1946 lines
77 KiB
Python
1946 lines
77 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import time
|
||
from collections.abc import AsyncIterable
|
||
from types import SimpleNamespace
|
||
from unittest.mock import MagicMock, Mock, patch
|
||
|
||
import pytest
|
||
|
||
from livekit.agents import (
|
||
NOT_GIVEN,
|
||
Agent,
|
||
AgentFalseInterruptionEvent,
|
||
AgentStateChangedEvent,
|
||
ConversationItemAddedEvent,
|
||
FlushSentinel,
|
||
LanguageCode,
|
||
MetricsCollectedEvent,
|
||
ModelSettings,
|
||
NotGivenOr,
|
||
TurnHandlingOptions,
|
||
UserInputTranscribedEvent,
|
||
UserStateChangedEvent,
|
||
function_tool,
|
||
inference,
|
||
vad,
|
||
)
|
||
from livekit.agents.llm import (
|
||
FunctionTool,
|
||
FunctionToolCall,
|
||
InputTranscriptionCompleted,
|
||
RawFunctionTool,
|
||
ToolContext,
|
||
ToolFlag,
|
||
Toolset,
|
||
)
|
||
from livekit.agents.llm.chat_context import ChatContext, ChatMessage
|
||
from livekit.agents.stt import SpeechData, SpeechEvent, SpeechEventType
|
||
from livekit.agents.utils import aio
|
||
from livekit.agents.voice.agent_activity import AgentActivity
|
||
from livekit.agents.voice.audio_recognition import AudioRecognition, _EndOfTurnInfo
|
||
from livekit.agents.voice.endpointing import BaseEndpointing
|
||
from livekit.agents.voice.events import FunctionToolsExecutedEvent
|
||
from livekit.agents.voice.io import PlaybackFinishedEvent
|
||
|
||
from .fake_session import FakeActions, create_session, run_session
|
||
|
||
pytestmark = [pytest.mark.unit, pytest.mark.virtual_time, pytest.mark.no_concurrent]
|
||
|
||
|
||
class MyAgent(Agent):
|
||
def __init__(
|
||
self,
|
||
*,
|
||
generate_reply_on_enter: bool = False,
|
||
say_on_user_turn_completed: bool = False,
|
||
on_user_turn_completed_delay: float = 0.0,
|
||
turn_handling: NotGivenOr[TurnHandlingOptions] = NOT_GIVEN,
|
||
) -> None:
|
||
super().__init__(
|
||
instructions=("You are a helpful assistant."),
|
||
turn_handling=turn_handling,
|
||
)
|
||
self.generate_reply_on_enter = generate_reply_on_enter
|
||
self.say_on_user_turn_completed = say_on_user_turn_completed
|
||
self.on_user_turn_completed_delay = on_user_turn_completed_delay
|
||
|
||
self._close_session_task: asyncio.Task[None] | None = None
|
||
|
||
async def on_enter(self) -> None:
|
||
if self.generate_reply_on_enter:
|
||
self.session.generate_reply(instructions="instructions:say hello to the user")
|
||
|
||
@function_tool
|
||
async def get_weather(self, location: str) -> str:
|
||
"""
|
||
Called when the user asks about the weather.
|
||
|
||
Args:
|
||
location: The location to get the weather for
|
||
"""
|
||
return f"The weather in {location} is sunny today."
|
||
|
||
@function_tool
|
||
async def goodbye(self) -> None:
|
||
await self.session.generate_reply(instructions="instructions:say goodbye to the user")
|
||
self._close_session_task = asyncio.create_task(self.session.aclose())
|
||
|
||
async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatMessage) -> None:
|
||
if self.say_on_user_turn_completed:
|
||
self.session.say("session.say from on_user_turn_completed")
|
||
|
||
if self.on_user_turn_completed_delay > 0.0:
|
||
await asyncio.sleep(self.on_user_turn_completed_delay)
|
||
|
||
|
||
SESSION_TIMEOUT = 60.0
|
||
|
||
|
||
def test_realtime_user_input_transcription_preserves_item_id() -> None:
|
||
captured_events: list[UserInputTranscribedEvent] = []
|
||
|
||
class DummySession:
|
||
def _user_input_transcribed(self, ev: UserInputTranscribedEvent) -> None:
|
||
captured_events.append(ev)
|
||
|
||
activity = object.__new__(AgentActivity)
|
||
activity._session = DummySession()
|
||
|
||
AgentActivity._on_input_audio_transcription_completed(
|
||
activity,
|
||
InputTranscriptionCompleted(
|
||
item_id="item_123",
|
||
transcript="hello",
|
||
is_final=False,
|
||
),
|
||
)
|
||
|
||
assert len(captured_events) == 1
|
||
assert captured_events[0].transcript == "hello"
|
||
assert captured_events[0].is_final is False
|
||
assert captured_events[0].item_id == "item_123"
|
||
|
||
|
||
async def test_events_and_metrics() -> None:
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Hello, how are you?", stt_delay=0.2) # EOU at 2.5+0.5=3.0s
|
||
actions.add_llm("I'm doing well, thank you!", ttft=0.1, duration=0.3)
|
||
actions.add_tts(
|
||
2.0, ttfb=0.2, duration=0.3
|
||
) # audio playout starts at 3.0+0.3+0.2=3.5s, ends at 5.5s
|
||
|
||
session = create_session(actions, speed_factor=speed)
|
||
agent = MyAgent()
|
||
|
||
user_state_events: list[UserStateChangedEvent] = []
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
metrics_events: list[MetricsCollectedEvent] = []
|
||
conversation_events: list[ConversationItemAddedEvent] = []
|
||
user_transcription_events: list[UserInputTranscribedEvent] = []
|
||
|
||
session.on("user_state_changed", user_state_events.append)
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.on("metrics_collected", metrics_events.append)
|
||
session.on("conversation_item_added", conversation_events.append)
|
||
session.on("user_input_transcribed", user_transcription_events.append)
|
||
|
||
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
# conversation_item_added
|
||
assert len(conversation_events) == 3
|
||
assert conversation_events[0].item.type == "agent_handoff"
|
||
assert conversation_events[1].item.type == "message"
|
||
assert conversation_events[1].item.role == "user"
|
||
assert conversation_events[1].item.text_content == "Hello, how are you?"
|
||
check_timestamp(conversation_events[1].created_at - t_origin, 3.0, speed_factor=speed)
|
||
assert conversation_events[2].item.type == "message"
|
||
assert conversation_events[2].item.role == "assistant"
|
||
assert conversation_events[2].item.text_content == "I'm doing well, thank you!"
|
||
check_timestamp(conversation_events[2].created_at - t_origin, 5.5, speed_factor=speed)
|
||
|
||
# user_input_transcribed
|
||
assert len(user_transcription_events) >= 1
|
||
assert user_transcription_events[-1].transcript == "Hello, how are you?"
|
||
assert user_transcription_events[-1].is_final is True
|
||
check_timestamp(user_transcription_events[-1].created_at - t_origin, 2.7, speed_factor=speed)
|
||
|
||
# user_state_changed
|
||
assert len(user_state_events) == 2
|
||
check_timestamp(user_state_events[0].created_at - t_origin, 0.5, speed_factor=speed)
|
||
assert user_state_events[0].new_state == "speaking"
|
||
check_timestamp(user_state_events[1].created_at - t_origin, 3.0, speed_factor=speed)
|
||
assert user_state_events[1].new_state == "listening"
|
||
|
||
# agent_state_changed
|
||
assert len(agent_state_events) == 4
|
||
assert agent_state_events[0].old_state == "initializing"
|
||
assert agent_state_events[0].new_state == "listening"
|
||
assert agent_state_events[1].new_state == "thinking"
|
||
check_timestamp(agent_state_events[1].created_at - t_origin, 3.0, speed_factor=speed)
|
||
assert agent_state_events[2].new_state == "speaking"
|
||
check_timestamp(agent_state_events[2].created_at - t_origin, 3.5, speed_factor=speed)
|
||
assert agent_state_events[3].new_state == "listening"
|
||
check_timestamp(agent_state_events[3].created_at - t_origin, 5.5, speed_factor=speed)
|
||
|
||
# metrics
|
||
metrics_events = [ev for ev in metrics_events if ev.metrics.type != "vad_metrics"]
|
||
assert len(metrics_events) == 3
|
||
assert metrics_events[0].metrics.type == "eou_metrics"
|
||
check_timestamp(metrics_events[0].metrics.end_of_utterance_delay, 0.5, speed_factor=speed)
|
||
check_timestamp(metrics_events[0].metrics.transcription_delay, 0.2, speed_factor=speed)
|
||
assert metrics_events[1].metrics.type == "llm_metrics"
|
||
check_timestamp(metrics_events[1].metrics.ttft, 0.1, speed_factor=speed)
|
||
check_timestamp(metrics_events[1].metrics.duration, 0.3, speed_factor=speed)
|
||
assert metrics_events[2].metrics.type == "tts_metrics"
|
||
check_timestamp(metrics_events[2].metrics.ttfb, 0.2, speed_factor=speed)
|
||
check_timestamp(metrics_events[2].metrics.audio_duration, 2.0, speed_factor=speed)
|
||
|
||
|
||
async def test_tts_node_ttfb_excludes_upstream_latency() -> None:
|
||
# the LLM stream stays open for its full duration and the fake TTS only starts
|
||
# synthesizing once its input is flushed. tts_node_ttfb must anchor on the text
|
||
# being handed to the TTS provider (~2.0s in), not on the first LLM token (~0.1s in),
|
||
# otherwise the LLM streaming time is misattributed to the TTS
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Hello, how are you?", stt_delay=0.2)
|
||
actions.add_llm("I'm doing well, thank you!", ttft=0.1, duration=2.0)
|
||
actions.add_tts(1.0, ttfb=0.2, duration=0.3)
|
||
|
||
session = create_session(actions, speed_factor=speed)
|
||
agent = MyAgent()
|
||
|
||
conversation_events: list[ConversationItemAddedEvent] = []
|
||
session.on("conversation_item_added", conversation_events.append)
|
||
|
||
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
assistant_messages = [
|
||
ev.item
|
||
for ev in conversation_events
|
||
if ev.item.type == "message" and ev.item.role == "assistant"
|
||
]
|
||
assert len(assistant_messages) == 1
|
||
metrics = assistant_messages[0].metrics
|
||
assert "tts_node_ttfb" in metrics
|
||
check_timestamp(metrics["tts_node_ttfb"], 0.2, speed_factor=speed)
|
||
|
||
|
||
async def test_tool_call() -> None:
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "What's the weather in Tokyo?")
|
||
actions.add_llm(
|
||
content="Let me check the weather for you.",
|
||
tool_calls=[
|
||
FunctionToolCall(name="get_weather", arguments='{"location": "Tokyo"}', call_id="1")
|
||
],
|
||
)
|
||
actions.add_tts(2.0) # audio for the content alongside the tool call
|
||
actions.add_llm(
|
||
content="The weather in Tokyo is sunny today.",
|
||
input="The weather in Tokyo is sunny today.",
|
||
)
|
||
actions.add_tts(3.0) # audio for the tool response
|
||
|
||
session = create_session(actions, speed_factor=speed)
|
||
agent = MyAgent()
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
tool_executed_events: list[FunctionToolsExecutedEvent] = []
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.on("function_tools_executed", tool_executed_events.append)
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
assert len(playback_finished_events) == 2
|
||
check_timestamp(playback_finished_events[0].playback_position, 2.0, speed_factor=speed)
|
||
check_timestamp(playback_finished_events[1].playback_position, 3.0, speed_factor=speed)
|
||
|
||
assert len(agent_state_events) == 6
|
||
assert agent_state_events[0].old_state == "initializing"
|
||
assert agent_state_events[0].new_state == "listening"
|
||
assert agent_state_events[1].new_state == "thinking"
|
||
assert agent_state_events[2].new_state == "speaking"
|
||
assert (
|
||
agent_state_events[3].new_state == "thinking"
|
||
) # from speaking to thinking when tool call is executed
|
||
check_timestamp(agent_state_events[3].created_at - t_origin, 5.5, speed_factor=speed)
|
||
assert agent_state_events[4].new_state == "speaking"
|
||
assert agent_state_events[5].new_state == "listening"
|
||
|
||
assert len(tool_executed_events) == 1
|
||
assert tool_executed_events[0].function_calls[0].name == "get_weather"
|
||
assert tool_executed_events[0].function_calls[0].arguments == '{"location": "Tokyo"}'
|
||
assert tool_executed_events[0].function_calls[0].call_id == "1"
|
||
|
||
# chat context
|
||
chat_ctx_items = agent.chat_ctx.items
|
||
assert len(chat_ctx_items) == 7
|
||
assert chat_ctx_items[0].type == "message"
|
||
assert chat_ctx_items[0].role == "system"
|
||
assert chat_ctx_items[1].type == "agent_config_update"
|
||
assert chat_ctx_items[2].type == "message"
|
||
assert chat_ctx_items[2].role == "user"
|
||
assert chat_ctx_items[2].text_content == "What's the weather in Tokyo?"
|
||
assert chat_ctx_items[3].type == "message"
|
||
assert chat_ctx_items[3].role == "assistant"
|
||
assert chat_ctx_items[3].text_content == "Let me check the weather for you."
|
||
assert chat_ctx_items[4].type == "function_call"
|
||
assert chat_ctx_items[4].name == "get_weather"
|
||
assert chat_ctx_items[5].type == "function_call_output"
|
||
assert chat_ctx_items[5].output == "The weather in Tokyo is sunny today."
|
||
assert chat_ctx_items[6].type == "message"
|
||
assert chat_ctx_items[6].role == "assistant"
|
||
assert chat_ctx_items[6].text_content == "The weather in Tokyo is sunny today."
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"resume_false_interruption, expected_interruption_time",
|
||
[
|
||
(False, 5.5), # when vad event, 5 + 0.5
|
||
(True, 5.5), # pause/resume is disabled for fake audio output
|
||
],
|
||
)
|
||
async def test_interruption(
|
||
resume_false_interruption: bool, expected_interruption_time: float
|
||
) -> None:
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
|
||
actions.add_llm("Here is a long story for you ... the end.")
|
||
actions.add_tts(10.0) # playout starts at 3.5s
|
||
actions.add_user_speech(5.0, 6.0, "Stop!", stt_delay=0.2)
|
||
# interrupted at 5.5s, min_interruption_duration=0.5
|
||
|
||
session = create_session(
|
||
actions,
|
||
speed_factor=speed,
|
||
turn_handling={"interruption": {"resume_false_interruption": resume_false_interruption}},
|
||
)
|
||
agent = MyAgent()
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
chat_ctx_items = agent.chat_ctx.items
|
||
assert len(chat_ctx_items) == 5
|
||
assert chat_ctx_items[1].type == "agent_config_update"
|
||
assert chat_ctx_items[3].type == "message"
|
||
assert chat_ctx_items[3].role == "assistant"
|
||
assert chat_ctx_items[3].interrupted is True
|
||
|
||
assert len(agent_state_events) == 6
|
||
assert agent_state_events[0].old_state == "initializing"
|
||
assert agent_state_events[0].new_state == "listening"
|
||
assert agent_state_events[1].new_state == "thinking"
|
||
assert agent_state_events[2].new_state == "speaking"
|
||
assert agent_state_events[3].new_state == "listening"
|
||
check_timestamp(
|
||
agent_state_events[3].created_at - t_origin, expected_interruption_time, speed_factor=speed
|
||
)
|
||
assert agent_state_events[4].new_state == "thinking"
|
||
check_timestamp(agent_state_events[4].created_at - t_origin, 6.5, speed_factor=speed)
|
||
assert agent_state_events[5].new_state == "listening"
|
||
check_timestamp(agent_state_events[5].created_at - t_origin, 6.5, speed_factor=speed)
|
||
|
||
assert len(playback_finished_events) == 1
|
||
assert playback_finished_events[0].interrupted is True
|
||
if not resume_false_interruption:
|
||
# fake audio output doesn't support pause/resume
|
||
check_timestamp(playback_finished_events[0].playback_position, 2.0, speed_factor=speed)
|
||
|
||
|
||
async def test_interruption_options() -> None:
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
|
||
actions.add_llm("Here is a long story for you ... the end.")
|
||
actions.add_tts(5.0) # playout starts at 3.5s
|
||
actions.add_user_speech(5.0, 6.0, "Stop!")
|
||
actions.add_user_speech(6.5, 7.5, "ok, stop!", stt_delay=0.0)
|
||
# it should interrupt at 7.5s after stt, playback position is 4.0s
|
||
|
||
# test min_interruption_words
|
||
session = create_session(
|
||
actions,
|
||
speed_factor=speed,
|
||
turn_handling={"interruption": {"min_words": 3}},
|
||
)
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
await asyncio.wait_for(run_session(session, MyAgent()), timeout=SESSION_TIMEOUT)
|
||
|
||
assert len(playback_finished_events) == 1
|
||
assert playback_finished_events[0].interrupted is True
|
||
check_timestamp(playback_finished_events[0].playback_position, 4.0, speed_factor=speed)
|
||
|
||
# test allow_interruptions=False
|
||
session = create_session(
|
||
actions,
|
||
speed_factor=speed,
|
||
turn_handling={"interruption": {"enabled": False}},
|
||
)
|
||
playback_finished_events.clear()
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
await asyncio.wait_for(run_session(session, MyAgent()), timeout=SESSION_TIMEOUT)
|
||
|
||
assert len(playback_finished_events) == 1
|
||
assert playback_finished_events[0].interrupted is False
|
||
check_timestamp(playback_finished_events[0].playback_position, 5.0, speed_factor=speed)
|
||
|
||
|
||
async def test_interruption_by_text_input() -> None:
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
|
||
actions.add_llm("Here is a long story for you ... the end.")
|
||
actions.add_tts(15.0)
|
||
actions.add_llm("Ok, I'll stop now.", input="stop from text input")
|
||
actions.add_tts(2.0)
|
||
|
||
session = create_session(actions, speed_factor=speed)
|
||
agent = MyAgent()
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
def fake_text_input() -> None:
|
||
session.interrupt()
|
||
session.generate_reply(user_input="stop from text input")
|
||
|
||
asyncio.get_event_loop().call_later(5 / speed, fake_text_input)
|
||
|
||
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
assert len(playback_finished_events) == 2
|
||
assert playback_finished_events[0].interrupted is True
|
||
|
||
assert len(agent_state_events) == 6
|
||
assert agent_state_events[0].old_state == "initializing"
|
||
assert agent_state_events[0].new_state == "listening"
|
||
assert agent_state_events[1].new_state == "thinking"
|
||
assert agent_state_events[2].new_state == "speaking"
|
||
# interrupted by text while speaking -> straight to thinking for the new reply
|
||
assert agent_state_events[3].new_state == "thinking"
|
||
assert agent_state_events[4].new_state == "speaking"
|
||
assert agent_state_events[5].new_state == "listening"
|
||
|
||
chat_ctx_items = agent.chat_ctx.items
|
||
assert len(chat_ctx_items) == 6
|
||
assert chat_ctx_items[0].type == "message"
|
||
assert chat_ctx_items[0].role == "system"
|
||
assert chat_ctx_items[1].type == "agent_config_update"
|
||
assert chat_ctx_items[2].type == "message"
|
||
assert chat_ctx_items[2].role == "user"
|
||
assert chat_ctx_items[2].text_content == "Tell me a story."
|
||
assert chat_ctx_items[3].type == "message"
|
||
assert chat_ctx_items[3].role == "assistant"
|
||
assert chat_ctx_items[3].interrupted is True # assistant message should be before text input
|
||
assert chat_ctx_items[4].type == "message"
|
||
assert chat_ctx_items[4].role == "user"
|
||
assert chat_ctx_items[4].text_content == "stop from text input"
|
||
assert chat_ctx_items[5].type == "message"
|
||
assert chat_ctx_items[5].role == "assistant"
|
||
assert chat_ctx_items[5].text_content == "Ok, I'll stop now."
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"resume_false_interruption, expected_interruption_time",
|
||
[
|
||
(False, 3.5), # 3 + 0.5
|
||
(True, 3.5), # pause/resume is disabled for fake audio output
|
||
],
|
||
)
|
||
async def test_interruption_before_speaking(
|
||
resume_false_interruption: bool, expected_interruption_time: float
|
||
) -> None:
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
|
||
actions.add_llm("Here is a long story for you ... the end.", duration=1.0)
|
||
actions.add_tts(10.0)
|
||
actions.add_user_speech(3.0, 4.0, "Stop!", stt_delay=0.2)
|
||
|
||
session = create_session(
|
||
actions,
|
||
speed_factor=speed,
|
||
turn_handling={"interruption": {"resume_false_interruption": resume_false_interruption}},
|
||
)
|
||
agent = MyAgent()
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
assert len(agent_state_events) == 5
|
||
assert agent_state_events[0].old_state == "initializing"
|
||
assert agent_state_events[0].new_state == "listening"
|
||
assert agent_state_events[1].new_state == "thinking" # without speaking state
|
||
assert agent_state_events[2].new_state == "listening"
|
||
check_timestamp(
|
||
agent_state_events[2].created_at - t_origin, expected_interruption_time, speed_factor=speed
|
||
) # interrupted at 3.5s
|
||
assert agent_state_events[3].new_state == "thinking"
|
||
assert agent_state_events[4].new_state == "listening"
|
||
|
||
assert len(playback_finished_events) == 0
|
||
|
||
assert len(agent.chat_ctx.items) == 4
|
||
assert agent.chat_ctx.items[0].type == "message"
|
||
assert agent.chat_ctx.items[0].role == "system"
|
||
assert agent.chat_ctx.items[1].type == "agent_config_update"
|
||
assert agent.chat_ctx.items[2].type == "message"
|
||
assert agent.chat_ctx.items[2].role == "user"
|
||
assert agent.chat_ctx.items[2].text_content == "Tell me a story."
|
||
# before we insert an empty assistant message with interrupted=True
|
||
# now we ignore it when the text is empty
|
||
assert agent.chat_ctx.items[3].type == "message"
|
||
assert agent.chat_ctx.items[3].role == "user"
|
||
assert agent.chat_ctx.items[3].text_content == "Stop!"
|
||
|
||
|
||
async def test_interrupt_before_speaking_with_pausable_audio() -> None:
|
||
"""
|
||
Regression test for https://github.com/livekit/agents/issues/5509
|
||
User turn starting while the agent is ``thinking`` must pause the
|
||
pausable output so the stale reply never promotes to ``speaking``.
|
||
"""
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
|
||
actions.add_llm("Here is a long story for you ... the end.", duration=1.0)
|
||
actions.add_tts(10.0)
|
||
actions.add_user_speech(3.0, 4.0, "Stop!", stt_delay=0.2)
|
||
|
||
session = create_session(actions, speed_factor=speed, can_pause_audio=True)
|
||
agent = MyAgent()
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
# core assertion: the stale reply never promotes to "speaking"
|
||
assert not any(ev.new_state == "speaking" for ev in agent_state_events), (
|
||
"stale reply should have been paused before the first frame reached the transport"
|
||
)
|
||
|
||
# state sequence mirrors test_interruption_before_speaking (can_pause=False variant),
|
||
# proving the pause path is observationally equivalent to the interrupt path
|
||
assert len(agent_state_events) == 5
|
||
assert agent_state_events[0].old_state == "initializing"
|
||
assert agent_state_events[0].new_state == "listening"
|
||
assert agent_state_events[1].new_state == "thinking"
|
||
assert agent_state_events[2].new_state == "listening"
|
||
check_timestamp(agent_state_events[2].created_at - t_origin, 3.5, speed_factor=speed)
|
||
|
||
# nothing audible reached the transport — the pause cleanup emits a single
|
||
# playback_finished with interrupted=True and playback_position=0
|
||
assert len(playback_finished_events) == 1
|
||
assert playback_finished_events[0].interrupted is True
|
||
assert playback_finished_events[0].playback_position == 0.0
|
||
|
||
# stale assistant reply is dropped; chat_ctx holds user turn 1 and (after
|
||
# the on_final_transcript commit) user turn 2
|
||
user_messages = [
|
||
item for item in agent.chat_ctx.items if item.type == "message" and item.role == "user"
|
||
]
|
||
assert [m.text_content for m in user_messages] == ["Tell me a story.", "Stop!"]
|
||
assert not any(
|
||
item.type == "message" and item.role == "assistant" for item in agent.chat_ctx.items
|
||
)
|
||
|
||
|
||
async def test_false_interruption_before_speaking_resumes() -> None:
|
||
"""
|
||
Brief VAD-only noise during ``thinking`` must pause then resume on VAD EOS,
|
||
letting the stale reply play through normally.
|
||
"""
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
|
||
actions.add_llm("Here is a short reply.", ttft=0.05, duration=0.05)
|
||
actions.add_tts(5.0, ttfb=0.05, duration=0.05)
|
||
# brief VAD-only noise — same shape as the can_pause=False test, different capability
|
||
actions.add_user_speech(3.0, 3.3, "")
|
||
|
||
session = create_session(actions, speed_factor=speed, can_pause_audio=True)
|
||
agent = MyAgent()
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
# the agent resumes and speaks the reply after the false interruption clears
|
||
speaking_events = [ev for ev in agent_state_events if ev.new_state == "speaking"]
|
||
assert len(speaking_events) == 1
|
||
|
||
# playout was postponed: the noise ran 3.0–3.3s, so "speaking" should fire at
|
||
# ~3.8s (resume on VAD EOS=3.3s + 0.5s min_silence_duration)
|
||
check_timestamp(speaking_events[0].created_at - t_origin, 3.8, speed_factor=speed)
|
||
|
||
# the reply plays to completion (not interrupted); playback_position covers the
|
||
# full audio duration
|
||
assert len(playback_finished_events) == 1
|
||
assert playback_finished_events[0].interrupted is False
|
||
check_timestamp(playback_finished_events[0].playback_position, 5.0, speed_factor=speed)
|
||
|
||
|
||
async def test_generate_reply() -> None:
|
||
"""
|
||
Test `generate_reply` in `on_enter` and tool call, `say` in `on_user_turn_completed`
|
||
"""
|
||
speed = 1
|
||
|
||
actions = FakeActions()
|
||
# llm and tts response for generate_reply() and say()
|
||
actions.add_llm("What can I do for you!", input="instructions:say hello to the user")
|
||
actions.add_tts(2.0)
|
||
actions.add_llm("Goodbye! have a nice day!", input="instructions:say goodbye to the user")
|
||
actions.add_tts(3.0)
|
||
actions.add_tts(1.0, ttfb=0, input="session.say from on_user_turn_completed")
|
||
# user speech
|
||
actions.add_user_speech(3.0, 4.0, "bye")
|
||
actions.add_llm(
|
||
content="",
|
||
tool_calls=[FunctionToolCall(name="goodbye", arguments="", call_id="1")],
|
||
)
|
||
# tool started at 4.5(EOU) + 1.0(session.say)
|
||
# tool finished at 5.5 + 0.5 (second LLM + TTS) + 3 (audio)
|
||
|
||
session = create_session(actions, speed_factor=speed)
|
||
agent = MyAgent(generate_reply_on_enter=True, say_on_user_turn_completed=True)
|
||
|
||
conversation_events: list[ConversationItemAddedEvent] = []
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
tool_executed_events: list[FunctionToolsExecutedEvent] = []
|
||
session.on("conversation_item_added", conversation_events.append)
|
||
session.on("function_tools_executed", tool_executed_events.append)
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
# playback_finished
|
||
assert len(playback_finished_events) == 3
|
||
assert playback_finished_events[0].interrupted is False
|
||
check_timestamp(playback_finished_events[0].playback_position, 2.0, speed_factor=speed)
|
||
assert playback_finished_events[1].interrupted is False
|
||
check_timestamp(playback_finished_events[1].playback_position, 1.0, speed_factor=speed)
|
||
assert playback_finished_events[2].interrupted is False
|
||
check_timestamp(playback_finished_events[2].playback_position, 3.0, speed_factor=speed)
|
||
|
||
# function_tools_executed
|
||
assert len(tool_executed_events) == 1
|
||
assert tool_executed_events[0].function_calls[0].name == "goodbye"
|
||
|
||
# conversation_item_added
|
||
assert len(conversation_events) == 5
|
||
assert conversation_events[0].item.type == "agent_handoff"
|
||
assert conversation_events[1].item.type == "message"
|
||
assert conversation_events[1].item.role == "assistant"
|
||
assert conversation_events[1].item.text_content == "What can I do for you!"
|
||
check_timestamp(conversation_events[1].created_at - t_origin, 2.5, speed_factor=speed)
|
||
assert conversation_events[2].item.type == "message"
|
||
assert conversation_events[2].item.role == "user"
|
||
assert conversation_events[2].item.text_content == "bye"
|
||
check_timestamp(conversation_events[2].created_at - t_origin, 4.5, speed_factor=speed)
|
||
assert conversation_events[3].item.type == "message"
|
||
assert conversation_events[3].item.role == "assistant"
|
||
assert conversation_events[3].item.text_content == "session.say from on_user_turn_completed"
|
||
check_timestamp(
|
||
conversation_events[3].created_at - t_origin, 5.5, speed_factor=speed, max_abs_diff=1.0
|
||
)
|
||
assert conversation_events[4].item.type == "message"
|
||
assert conversation_events[4].item.role == "assistant"
|
||
assert conversation_events[4].item.text_content == "Goodbye! have a nice day!"
|
||
check_timestamp(
|
||
conversation_events[4].created_at - t_origin, 9.0, speed_factor=speed, max_abs_diff=1.0
|
||
)
|
||
|
||
# chat context
|
||
assert len(agent.chat_ctx.items) == 8
|
||
assert agent.chat_ctx.items[0].type == "message"
|
||
assert agent.chat_ctx.items[0].role == "system"
|
||
assert agent.chat_ctx.items[1].type == "agent_config_update"
|
||
assert agent.chat_ctx.items[2].type == "message"
|
||
assert agent.chat_ctx.items[2].role == "assistant"
|
||
assert agent.chat_ctx.items[2].text_content == "What can I do for you!"
|
||
assert agent.chat_ctx.items[3].type == "message"
|
||
assert agent.chat_ctx.items[3].role == "user"
|
||
assert agent.chat_ctx.items[3].text_content == "bye"
|
||
assert agent.chat_ctx.items[4].type == "message"
|
||
assert agent.chat_ctx.items[4].role == "assistant"
|
||
assert agent.chat_ctx.items[4].text_content == "session.say from on_user_turn_completed"
|
||
assert agent.chat_ctx.items[5].type == "function_call"
|
||
assert agent.chat_ctx.items[6].type == "message"
|
||
assert agent.chat_ctx.items[6].role == "assistant"
|
||
assert agent.chat_ctx.items[6].text_content == "Goodbye! have a nice day!"
|
||
assert agent.chat_ctx.items[7].type == "function_call_output"
|
||
|
||
|
||
async def test_on_enter_hides_ignore_on_enter_tools() -> None:
|
||
"""IGNORE_ON_ENTER tools (bare + toolset-nested) are hidden inside on_enter, restored after."""
|
||
|
||
class _Toolset(Toolset):
|
||
def __init__(self) -> None:
|
||
end_call = function_tool(
|
||
self._end_call,
|
||
name="end_call",
|
||
description="ends the call",
|
||
flags=ToolFlag.IGNORE_ON_ENTER,
|
||
)
|
||
keep = function_tool(self._keep, name="keep", description="a normal tool")
|
||
super().__init__(id="ts", tools=[end_call, keep])
|
||
|
||
async def _end_call(self) -> None: ...
|
||
|
||
async def _keep(self) -> None: ...
|
||
|
||
@function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
|
||
async def bare_ignored() -> None:
|
||
"""A bare flagged tool."""
|
||
|
||
class _Agent(Agent):
|
||
def __init__(self) -> None:
|
||
super().__init__(instructions="you are helpful", tools=[_Toolset(), bare_ignored])
|
||
|
||
async def on_enter(self) -> None:
|
||
self.session.generate_reply(instructions="instructions:say hello to the user")
|
||
|
||
actions = FakeActions()
|
||
actions.add_llm("Hello!", input="instructions:say hello to the user")
|
||
actions.add_tts(1.0)
|
||
actions.add_user_speech(2.0, 3.0, "hi there")
|
||
actions.add_llm("How can I help?")
|
||
actions.add_tts(1.0)
|
||
|
||
session = create_session(actions)
|
||
|
||
captured: list[set[str]] = []
|
||
orig_chat = session.llm.chat
|
||
|
||
def _recording_chat(*, chat_ctx, tools=None, **kwargs): # type: ignore[no-untyped-def]
|
||
captured.append(
|
||
{t.info.name for t in (tools or []) if isinstance(t, (FunctionTool, RawFunctionTool))}
|
||
)
|
||
return orig_chat(chat_ctx=chat_ctx, tools=tools, **kwargs)
|
||
|
||
session.llm.chat = _recording_chat # type: ignore[method-assign]
|
||
|
||
await asyncio.wait_for(run_session(session, _Agent()), timeout=SESSION_TIMEOUT)
|
||
|
||
assert len(captured) == 2
|
||
# on_enter reply: flagged tools hidden, normal tool still offered
|
||
assert captured[0] == {"keep"}
|
||
# a later (non-on_enter) turn sees every tool again
|
||
assert captured[1] == {"keep", "end_call", "bare_ignored"}
|
||
|
||
|
||
async def test_on_enter_hides_tools_in_nested_tool_reply() -> None:
|
||
"""When an on_enter reply calls a tool, the tool-response follow-up (a nested speech task)
|
||
also hides the flagged tools — proving the on_enter contextvar reaches nested tasks."""
|
||
|
||
class _Toolset(Toolset):
|
||
def __init__(self) -> None:
|
||
end_call = function_tool(
|
||
self._end_call, name="end_call", description="ends", flags=ToolFlag.IGNORE_ON_ENTER
|
||
)
|
||
keep = function_tool(self._keep, name="keep", description="a normal tool")
|
||
super().__init__(id="ts", tools=[end_call, keep])
|
||
|
||
async def _end_call(self) -> None: ...
|
||
|
||
async def _keep(self) -> str:
|
||
return "kept"
|
||
|
||
@function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
|
||
async def bare_ignored() -> None:
|
||
"""A bare flagged tool."""
|
||
|
||
class _Agent(Agent):
|
||
def __init__(self) -> None:
|
||
super().__init__(instructions="you are helpful", tools=[_Toolset(), bare_ignored])
|
||
|
||
async def on_enter(self) -> None:
|
||
self.session.generate_reply(instructions="instructions:say hello to the user")
|
||
|
||
actions = FakeActions()
|
||
# on_enter reply calls the visible `keep` tool instead of speaking, spawning a follow-up
|
||
actions.add_llm(
|
||
"",
|
||
tool_calls=[FunctionToolCall(name="keep", arguments="{}", call_id="1")],
|
||
input="instructions:say hello to the user",
|
||
)
|
||
# tool-response follow-up (keyed on the `keep` return value)
|
||
actions.add_llm("Hello there!", input="kept")
|
||
actions.add_tts(1.0)
|
||
# a user turn ends the run and confirms tools are restored afterwards
|
||
actions.add_user_speech(4.0, 5.0, "hi there")
|
||
actions.add_llm("How can I help?")
|
||
actions.add_tts(1.0)
|
||
|
||
session = create_session(actions)
|
||
|
||
captured: list[set[str]] = []
|
||
orig_chat = session.llm.chat
|
||
|
||
def _recording_chat(*, chat_ctx, tools=None, **kwargs): # type: ignore[no-untyped-def]
|
||
captured.append(
|
||
{t.info.name for t in (tools or []) if isinstance(t, (FunctionTool, RawFunctionTool))}
|
||
)
|
||
return orig_chat(chat_ctx=chat_ctx, tools=tools, **kwargs)
|
||
|
||
session.llm.chat = _recording_chat # type: ignore[method-assign]
|
||
|
||
await asyncio.wait_for(run_session(session, _Agent()), timeout=SESSION_TIMEOUT)
|
||
|
||
assert len(captured) == 3
|
||
greeting, tool_reply, user_turn = captured
|
||
# both the greeting reply and its nested tool-response follow-up hide the flagged tools
|
||
assert greeting == {"keep"}
|
||
assert tool_reply == {"keep"}
|
||
# the later (non-on_enter) user turn sees every tool again
|
||
assert user_turn == {"keep", "end_call", "bare_ignored"}
|
||
|
||
|
||
def test_on_enter_ignored_tools() -> None:
|
||
"""_on_enter_ignored_tools returns flagged tools only inside this agent/session's on_enter."""
|
||
from livekit.agents.voice.agent_activity import _OnEnterContextVar, _OnEnterData
|
||
|
||
class _Toolset(Toolset):
|
||
def __init__(self) -> None:
|
||
end_call = function_tool(
|
||
self._end_call, name="end_call", description="ends", flags=ToolFlag.IGNORE_ON_ENTER
|
||
)
|
||
ts_keep = function_tool(self._keep, name="ts_keep", description="normal")
|
||
super().__init__(id="ts", tools=[end_call, ts_keep])
|
||
|
||
async def _end_call(self) -> None: ...
|
||
|
||
async def _keep(self) -> None: ...
|
||
|
||
@function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
|
||
async def bare_ignored() -> None:
|
||
"""A flagged bare tool."""
|
||
|
||
@function_tool
|
||
async def bare_keep() -> None:
|
||
"""A normal bare tool."""
|
||
|
||
activity = object.__new__(AgentActivity)
|
||
activity._agent = object() # type: ignore[assignment]
|
||
activity._session = object() # type: ignore[assignment]
|
||
tool_ctx = ToolContext([_Toolset(), bare_ignored, bare_keep])
|
||
|
||
# outside on_enter: nothing is ignored
|
||
assert activity._on_enter_ignored_tools(tool_ctx) == []
|
||
|
||
# inside this agent/session's on_enter: flagged tools (bare + nested) are returned
|
||
tk = _OnEnterContextVar.set(_OnEnterData(session=activity._session, agent=activity._agent))
|
||
try:
|
||
ignored = {t.info.name for t in activity._on_enter_ignored_tools(tool_ctx)}
|
||
finally:
|
||
_OnEnterContextVar.reset(tk)
|
||
assert ignored == {"end_call", "bare_ignored"}
|
||
|
||
# a different agent's on_enter must not leak in
|
||
tk = _OnEnterContextVar.set(_OnEnterData(session=activity._session, agent=object()))
|
||
try:
|
||
assert activity._on_enter_ignored_tools(tool_ctx) == []
|
||
finally:
|
||
_OnEnterContextVar.reset(tk)
|
||
|
||
|
||
async def test_aec_warmup() -> None:
|
||
"""AEC warmup should block audio-activity-based interruptions during the warmup window.
|
||
|
||
Without warmup, VAD-based interruption fires at 4.0 + 0.5 = 4.5s.
|
||
With warmup (3.0s from speaking at 3.5s, expires at 6.5s), the VAD path is blocked.
|
||
The interruption is delayed to 5.5s (EOU: speech end 5.0 + 0.5 endpointing delay)
|
||
because FakeSTT is timer-based and still produces transcripts during warmup.
|
||
"""
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
|
||
actions.add_llm("Here is a long story for you ... the end.")
|
||
actions.add_tts(15.0) # playout starts at 3.5s
|
||
# user speaks at 4.0-5.0s — within warmup window (3.5 + 3.0 = 6.5s expiry)
|
||
# without warmup: VAD interruption at 4.0 + 0.5 = 4.5s
|
||
# with warmup: VAD blocked, falls through to EOU at 5.0 + 0.5 = 5.5s
|
||
actions.add_user_speech(4.0, 5.0, "Stop!", stt_delay=0.2)
|
||
|
||
session = create_session(
|
||
actions,
|
||
speed_factor=speed,
|
||
extra_kwargs={"aec_warmup_duration": 3.0},
|
||
)
|
||
agent = MyAgent()
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
assert len(playback_finished_events) == 1
|
||
assert playback_finished_events[0].interrupted is True
|
||
|
||
assert agent_state_events[0].new_state == "listening"
|
||
assert agent_state_events[1].new_state == "thinking"
|
||
assert agent_state_events[2].new_state == "speaking"
|
||
# interruption delayed to 5.5s (EOU), not 4.5s (VAD was blocked by warmup)
|
||
speaking_to_listening = next(e for e in agent_state_events[3:] if e.new_state == "listening")
|
||
check_timestamp(speaking_to_listening.created_at - t_origin, 5.5, speed_factor=speed)
|
||
|
||
|
||
async def test_start_boundary_does_not_block_vad_interruption() -> None:
|
||
"""backchannel boundary should not interfere with VAD-based interruption when adaptive
|
||
detection is not active. The cooldown timer runs but has no effect on the VAD path.
|
||
|
||
This validates that the backchannel_boundary config is properly handled and doesn't
|
||
regress normal interruption behavior.
|
||
"""
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Tell me a story.")
|
||
actions.add_llm("Here is a long story for you ... the end.")
|
||
actions.add_tts(15.0) # playout starts at ~3.5s
|
||
# user speaks at 4.0-5.0s — within the 1s warmup window (3.5 + 1.0 = 4.5s expiry)
|
||
# VAD interruption at 4.0 + 0.5 = 4.5s (warmup does NOT block VAD)
|
||
actions.add_user_speech(4.0, 5.0, "Stop!", stt_delay=0.2)
|
||
|
||
session = create_session(
|
||
actions,
|
||
speed_factor=speed,
|
||
extra_kwargs={"aec_warmup_duration": None},
|
||
)
|
||
agent = MyAgent()
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
t_origin = await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
assert len(playback_finished_events) == 1
|
||
assert playback_finished_events[0].interrupted is True
|
||
|
||
assert agent_state_events[0].new_state == "listening"
|
||
assert agent_state_events[1].new_state == "thinking"
|
||
assert agent_state_events[2].new_state == "speaking"
|
||
# VAD interruption fires normally at ~4.5s (warmup doesn't block VAD path)
|
||
speaking_to_listening = next(e for e in agent_state_events[3:] if e.new_state == "listening")
|
||
check_timestamp(speaking_to_listening.created_at - t_origin, 4.5, speed_factor=speed)
|
||
|
||
|
||
async def test_backchannel_boundary_suppresses_start_boundary_backchannel() -> None:
|
||
actions = FakeActions()
|
||
session = create_session(
|
||
actions,
|
||
turn_handling={"interruption": {"backchannel_boundary": (0.05, 0.0)}},
|
||
)
|
||
hooks = _TestRecognitionHooks()
|
||
recognition = AudioRecognition(
|
||
session,
|
||
hooks=hooks,
|
||
endpointing=BaseEndpointing(min_delay=0.1, max_delay=1.0),
|
||
stt=None,
|
||
vad=None,
|
||
using_default_vad=False,
|
||
interruption_detection=None,
|
||
turn_detection="vad",
|
||
)
|
||
|
||
try:
|
||
recognition._on_start_of_agent_speech(started_at=time.time())
|
||
# backchannels during the cooldown are dropped (they are a no-op anyway,
|
||
# but this guards against the gate firing on `on_interruption`)
|
||
await recognition._on_overlap_speech_event(_backchannel_event())
|
||
assert hooks.interruptions == []
|
||
|
||
# a real interruption during the cooldown must still fire
|
||
await recognition._on_overlap_speech_event(_interruption_event())
|
||
assert len(hooks.interruptions) == 1
|
||
|
||
# after cooldown, both event types behave normally
|
||
await asyncio.sleep(0.06)
|
||
await recognition._on_overlap_speech_event(_backchannel_event())
|
||
await recognition._on_overlap_speech_event(_interruption_event())
|
||
assert len(hooks.interruptions) == 2
|
||
finally:
|
||
await _close_test_session(session)
|
||
|
||
|
||
async def _make_stt_eos_recognition() -> AudioRecognition:
|
||
return AudioRecognition(
|
||
create_session(FakeActions()),
|
||
hooks=_TestRecognitionHooks(),
|
||
endpointing=BaseEndpointing(min_delay=0.0, max_delay=0.0),
|
||
stt=None,
|
||
vad=None,
|
||
using_default_vad=False,
|
||
interruption_detection=None,
|
||
turn_detection="stt",
|
||
)
|
||
|
||
|
||
async def test_stt_eos_resets_active_vad_stream_without_restarting_vad() -> None:
|
||
recognition = await _make_stt_eos_recognition()
|
||
recognition._speaking = True
|
||
recognition._vad_speech_started = True
|
||
recognition._vad = MagicMock()
|
||
resettable_stream = MagicMock()
|
||
recognition._vad_stream = resettable_stream
|
||
|
||
try:
|
||
with patch.object(recognition, "_update_vad") as update_vad:
|
||
await recognition._on_stt_event(SpeechEvent(type=SpeechEventType.END_OF_SPEECH))
|
||
|
||
resettable_stream.flush.assert_called_once_with()
|
||
update_vad.assert_not_called()
|
||
assert recognition._vad_stream is resettable_stream
|
||
finally:
|
||
if recognition._end_of_turn_task is not None:
|
||
await aio.cancel_and_wait(recognition._end_of_turn_task)
|
||
await _close_test_session(recognition._session)
|
||
|
||
|
||
async def test_stt_eos_falls_back_to_update_vad_when_no_active_stream() -> None:
|
||
recognition = await _make_stt_eos_recognition()
|
||
recognition._speaking = True
|
||
recognition._vad_speech_started = True
|
||
recognition._vad = MagicMock()
|
||
recognition._vad_stream = None
|
||
|
||
try:
|
||
with patch.object(recognition, "_update_vad") as update_vad:
|
||
await recognition._on_stt_event(SpeechEvent(type=SpeechEventType.END_OF_SPEECH))
|
||
|
||
update_vad.assert_called_once_with(recognition._vad)
|
||
finally:
|
||
if recognition._end_of_turn_task is not None:
|
||
await aio.cancel_and_wait(recognition._end_of_turn_task)
|
||
await _close_test_session(recognition._session)
|
||
|
||
|
||
async def test_backchannel_boundary_releases_end_boundary_transcript() -> None:
|
||
actions = FakeActions()
|
||
session = create_session(
|
||
actions,
|
||
turn_handling={"interruption": {"backchannel_boundary": (0.0, 0.5)}},
|
||
)
|
||
recognition = AudioRecognition(
|
||
session,
|
||
hooks=_TestRecognitionHooks(),
|
||
endpointing=BaseEndpointing(min_delay=0.1, max_delay=1.0),
|
||
stt=None,
|
||
vad=None,
|
||
using_default_vad=False,
|
||
interruption_detection=None,
|
||
turn_detection="vad",
|
||
)
|
||
recognition._interruption_enabled = True
|
||
recognition._interruption_ch = aio.Chan[inference.InterruptionDataFrameType]()
|
||
input_started_at = time.time() - 10.0
|
||
# the input anchor lives on the STT pipeline (see _STTPipeline.input_started_at)
|
||
recognition._stt_pipeline = SimpleNamespace(input_started_at=input_started_at) # type: ignore[assignment]
|
||
|
||
try:
|
||
# the agent speaks for a couple of seconds so the held transcript still lands
|
||
# after the agent-speech start (the lower bound of the ignore window)
|
||
recognition._on_start_of_agent_speech(started_at=time.time() - 2.0)
|
||
speech_ended_at = time.time()
|
||
recognition._on_end_of_agent_speech(ignore_user_transcript_until=speech_ended_at)
|
||
|
||
assert not recognition._should_hold_stt_event(
|
||
_final_transcript_event(
|
||
text="near the boundary",
|
||
start_time=speech_ended_at - input_started_at - 0.25,
|
||
end_time=speech_ended_at - input_started_at,
|
||
)
|
||
)
|
||
assert recognition._should_hold_stt_event(
|
||
_final_transcript_event(
|
||
text="before the boundary",
|
||
start_time=speech_ended_at - input_started_at - 0.75,
|
||
end_time=speech_ended_at - input_started_at - 0.5,
|
||
)
|
||
)
|
||
finally:
|
||
recognition._interruption_ch.close()
|
||
await _close_test_session(session)
|
||
|
||
|
||
async def test_interruption_detection_error_is_not_session_error() -> None:
|
||
actions = FakeActions()
|
||
session = create_session(actions)
|
||
activity = AgentActivity(MyAgent(), session)
|
||
fallback = Mock()
|
||
activity._fallback_to_vad_interruption = fallback
|
||
error_events: list[object] = []
|
||
session.on("error", error_events.append)
|
||
|
||
try:
|
||
recoverable = inference.InterruptionDetectionError(
|
||
label="test",
|
||
error=RuntimeError("temporary failure"),
|
||
recoverable=True,
|
||
)
|
||
activity._on_error(recoverable)
|
||
|
||
unrecoverable = inference.InterruptionDetectionError(
|
||
label="test",
|
||
error=RuntimeError("adaptive unavailable"),
|
||
recoverable=False,
|
||
)
|
||
activity._on_error(unrecoverable)
|
||
|
||
assert error_events == []
|
||
fallback.assert_called_once_with(unrecoverable)
|
||
finally:
|
||
await _close_test_session(session)
|
||
|
||
|
||
async def test_vad_fallback_uses_next_vad_inference_event(
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
actions = FakeActions()
|
||
session = create_session(actions)
|
||
activity = AgentActivity(MyAgent(), session)
|
||
error = inference.InterruptionDetectionError(
|
||
label="test",
|
||
error=RuntimeError("adaptive unavailable"),
|
||
recoverable=False,
|
||
)
|
||
|
||
audio_recognition = MagicMock()
|
||
current_speech = MagicMock()
|
||
current_speech.interrupted = False
|
||
current_speech.allow_interruptions = True
|
||
|
||
activity._audio_recognition = audio_recognition
|
||
activity._current_speech = current_speech
|
||
activity._interruption_detection_enabled = True
|
||
activity._interruption_by_audio_activity_enabled = False
|
||
activity._default_interruption_by_audio_activity_enabled = True
|
||
|
||
caplog.set_level(logging.INFO, logger="livekit.agents")
|
||
|
||
try:
|
||
activity._fallback_to_vad_interruption(error)
|
||
|
||
audio_recognition._update_interruption_detection.assert_called_once_with(None)
|
||
current_speech.interrupt.assert_not_called()
|
||
assert activity._interruption_detection_enabled is False
|
||
assert activity._interruption_by_audio_activity_enabled is True
|
||
|
||
activity.on_vad_inference_done(
|
||
vad.VADEvent(
|
||
type=vad.VADEventType.INFERENCE_DONE,
|
||
samples_index=0,
|
||
timestamp=time.time(),
|
||
speech_duration=session.options.interruption["min_duration"] - 0.01,
|
||
silence_duration=0.0,
|
||
speaking=True,
|
||
)
|
||
)
|
||
current_speech.interrupt.assert_not_called()
|
||
|
||
activity.on_vad_inference_done(
|
||
vad.VADEvent(
|
||
type=vad.VADEventType.INFERENCE_DONE,
|
||
samples_index=0,
|
||
timestamp=time.time(),
|
||
speech_duration=session.options.interruption["min_duration"],
|
||
silence_duration=0.0,
|
||
speaking=True,
|
||
)
|
||
)
|
||
current_speech.interrupt.assert_called_once_with()
|
||
assert any(
|
||
record.levelno == logging.INFO
|
||
and "falling back to VAD-based interruption" in record.message
|
||
for record in caplog.records
|
||
)
|
||
assert not [record for record in caplog.records if record.levelno >= logging.WARNING]
|
||
finally:
|
||
await _close_test_session(session)
|
||
|
||
|
||
async def test_force_flush_held_transcripts_emits_buffered_events() -> None:
|
||
actions = FakeActions()
|
||
session = create_session(actions)
|
||
hooks = _TestRecognitionHooks()
|
||
recognition = AudioRecognition(
|
||
session,
|
||
hooks=hooks,
|
||
endpointing=BaseEndpointing(min_delay=0.1, max_delay=1.0),
|
||
stt=None,
|
||
vad=None,
|
||
using_default_vad=False,
|
||
interruption_detection=None,
|
||
turn_detection="manual",
|
||
)
|
||
recognition._transcript_buffer.append(
|
||
_final_transcript_event(text="held transcript", start_time=0.0, end_time=1.0)
|
||
)
|
||
|
||
try:
|
||
await recognition._flush_held_transcripts(cooldown=0.0, force=True)
|
||
|
||
assert hooks.final_transcripts == ["held transcript"]
|
||
assert not recognition._transcript_buffer
|
||
finally:
|
||
await _close_test_session(session)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"preemptive_generation, expected_latency",
|
||
[
|
||
({"preemptive_tts": True}, 0.7),
|
||
({"preemptive_tts": False}, 0.8),
|
||
({"enabled": False}, 1.1),
|
||
],
|
||
)
|
||
async def test_preemptive_generation(preemptive_generation: dict, expected_latency: float) -> None:
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.0, "Hello, how are you?", stt_delay=0.1)
|
||
actions.add_llm("I'm doing great, thank you!", ttft=0.1, duration=0.3)
|
||
actions.add_tts(3.0, ttfb=0.3)
|
||
# preemptive_generation with TTS enabled: e2e latency is 0.1+0.3+0.3=0.7s
|
||
# preemptive_generation without TTS enabled: e2e latency is max(0.1+0.3, 0.5)+0.3=0.8s
|
||
# preemptive_generation disabled: e2e latency is 0.5+0.3+0.3=1.1s
|
||
|
||
session = create_session(
|
||
actions,
|
||
speed_factor=speed,
|
||
turn_handling={"preemptive_generation": preemptive_generation},
|
||
)
|
||
agent = MyAgent()
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
user_state_events: list[UserStateChangedEvent] = []
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.on("user_state_changed", user_state_events.append)
|
||
|
||
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
assert len(user_state_events) == 2
|
||
assert user_state_events[0].old_state == "listening"
|
||
assert user_state_events[0].new_state == "speaking"
|
||
assert user_state_events[1].new_state == "listening"
|
||
t_user_stop_speaking = user_state_events[1].created_at
|
||
|
||
assert len(agent_state_events) == 4
|
||
assert agent_state_events[0].old_state == "initializing"
|
||
assert agent_state_events[0].new_state == "listening"
|
||
assert agent_state_events[1].new_state == "thinking"
|
||
assert agent_state_events[2].new_state == "speaking"
|
||
t_agent_start_speaking = agent_state_events[2].created_at
|
||
check_timestamp(
|
||
t_agent_start_speaking - t_user_stop_speaking,
|
||
t_target=expected_latency,
|
||
speed_factor=speed,
|
||
max_abs_diff=0.2,
|
||
)
|
||
assert agent_state_events[3].new_state == "listening"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"session_preemptive, agent_preemptive, expected_latency",
|
||
[
|
||
# agent disables what the session enabled -> no preemptive generation (1.1s)
|
||
({"preemptive_tts": True}, {"enabled": False}, 1.1),
|
||
# agent enables (with TTS) what the session disabled -> fully preemptive (0.7s)
|
||
({"enabled": False}, {"enabled": True, "preemptive_tts": True}, 0.7),
|
||
],
|
||
)
|
||
async def test_preemptive_generation_on_agent(
|
||
session_preemptive: dict, agent_preemptive: dict, expected_latency: float
|
||
) -> None:
|
||
# preemptive generation set on the agent must override the session value
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.0, "Hello, how are you?", stt_delay=0.1)
|
||
actions.add_llm("I'm doing great, thank you!", ttft=0.1, duration=0.3)
|
||
actions.add_tts(3.0, ttfb=0.3)
|
||
# preemptive_generation with TTS enabled: e2e latency is 0.1+0.3+0.3=0.7s
|
||
# preemptive_generation disabled: e2e latency is 0.5+0.3+0.3=1.1s
|
||
|
||
session = create_session(
|
||
actions,
|
||
speed_factor=speed,
|
||
turn_handling={"preemptive_generation": session_preemptive},
|
||
)
|
||
agent = MyAgent(turn_handling={"preemptive_generation": agent_preemptive})
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
user_state_events: list[UserStateChangedEvent] = []
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.on("user_state_changed", user_state_events.append)
|
||
|
||
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
t_user_stop_speaking = user_state_events[1].created_at
|
||
t_agent_start_speaking = agent_state_events[2].created_at
|
||
check_timestamp(
|
||
t_agent_start_speaking - t_user_stop_speaking,
|
||
t_target=expected_latency,
|
||
speed_factor=speed,
|
||
max_abs_diff=0.2,
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"preemptive_generation, on_user_turn_completed_delay",
|
||
[
|
||
(False, 0.0),
|
||
(False, 2.0),
|
||
(True, 0.0),
|
||
(True, 2.0),
|
||
],
|
||
)
|
||
async def test_interrupt_during_on_user_turn_completed(
|
||
preemptive_generation: bool, on_user_turn_completed_delay: float
|
||
) -> None:
|
||
"""
|
||
Test interrupt during preemptive generation and on_user_turn_completed.
|
||
"""
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.0, "Tell me a story", stt_delay=0.2)
|
||
actions.add_llm("Here is a story for you...", ttft=0.1, duration=0.3)
|
||
actions.add_tts(10.0, ttfb=1.0) # latency after end of turn: 1.3s
|
||
actions.add_user_speech(2.6, 3.2, "about a firefighter.") # interrupt before speaking
|
||
actions.add_llm("Here is a story about a firefighter...", ttft=0.1, duration=0.3)
|
||
actions.add_tts(10.0, ttfb=0.3)
|
||
|
||
session = create_session(
|
||
actions,
|
||
speed_factor=speed,
|
||
turn_handling={"preemptive_generation": {"enabled": preemptive_generation}},
|
||
)
|
||
agent = MyAgent(on_user_turn_completed_delay=on_user_turn_completed_delay / speed)
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
conversation_events: list[ConversationItemAddedEvent] = []
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.on("conversation_item_added", conversation_events.append)
|
||
|
||
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
assert agent_state_events[0].old_state == "initializing"
|
||
assert agent_state_events[0].new_state == "listening"
|
||
if on_user_turn_completed_delay == 0.0:
|
||
# on_user_turn_completed already committed before interrupting
|
||
assert len(agent_state_events) == 6
|
||
assert agent_state_events[1].new_state == "thinking"
|
||
assert agent_state_events[2].new_state == "listening"
|
||
assert agent_state_events[3].new_state == "thinking"
|
||
assert agent_state_events[4].new_state == "speaking"
|
||
assert agent_state_events[5].new_state == "listening"
|
||
else:
|
||
assert len(agent_state_events) == 4
|
||
assert agent_state_events[1].new_state == "thinking"
|
||
assert agent_state_events[2].new_state == "speaking"
|
||
assert agent_state_events[3].new_state == "listening"
|
||
|
||
assert len(conversation_events) == 4
|
||
assert conversation_events[0].item.type == "agent_handoff"
|
||
assert conversation_events[1].item.type == "message"
|
||
assert conversation_events[1].item.role == "user"
|
||
assert conversation_events[1].item.text_content == "Tell me a story"
|
||
assert conversation_events[2].item.type == "message"
|
||
assert conversation_events[2].item.role == "user"
|
||
assert conversation_events[2].item.text_content == "about a firefighter."
|
||
assert conversation_events[3].item.type == "message"
|
||
assert conversation_events[3].item.role == "assistant"
|
||
assert conversation_events[3].item.text_content == "Here is a story about a firefighter..."
|
||
|
||
|
||
async def test_unknown_function_call() -> None:
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Check the weather")
|
||
actions.add_llm(
|
||
content="",
|
||
tool_calls=[
|
||
FunctionToolCall(
|
||
name="nonexistent_tool", arguments='{"location": "Tokyo"}', call_id="1"
|
||
)
|
||
],
|
||
)
|
||
actions.add_llm(
|
||
content="I don't have access to that function.",
|
||
input="Unknown function: nonexistent_tool",
|
||
)
|
||
actions.add_tts(2.0)
|
||
|
||
session = create_session(actions, speed_factor=speed)
|
||
agent = MyAgent()
|
||
|
||
tool_executed_events: list[FunctionToolsExecutedEvent] = []
|
||
session.on("function_tools_executed", tool_executed_events.append)
|
||
|
||
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
assert len(tool_executed_events) == 1
|
||
assert tool_executed_events[0].function_calls[0].name == "nonexistent_tool"
|
||
assert tool_executed_events[0].function_call_outputs[0].is_error is True
|
||
assert "Unknown function" in tool_executed_events[0].function_call_outputs[0].output
|
||
|
||
chat_ctx_items = agent.chat_ctx.items
|
||
error_outputs = [
|
||
item
|
||
for item in chat_ctx_items
|
||
if item.type == "function_call_output" and item.is_error is True
|
||
]
|
||
assert len(error_outputs) == 1
|
||
assert "Unknown function: nonexistent_tool" in error_outputs[0].output
|
||
|
||
|
||
async def test_invalid_tool_arguments_surface_as_tool_error() -> None:
|
||
"""When the LLM emits a tool call with invalid arguments (missing required
|
||
field, wrong type, malformed JSON, etc.), the faulty turn must NOT be
|
||
stripped from the conversation. Instead the schema error is wrapped in a
|
||
ToolError so the model receives a descriptive message and can self-correct
|
||
on the next turn."""
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "What's the weather?")
|
||
# get_weather requires `location: str` — emit a call with no args so it
|
||
# fails pydantic validation.
|
||
actions.add_llm(
|
||
content="",
|
||
tool_calls=[
|
||
FunctionToolCall(name="get_weather", arguments="{}", call_id="1"),
|
||
],
|
||
)
|
||
|
||
session = create_session(actions, speed_factor=speed)
|
||
agent = MyAgent()
|
||
|
||
tool_executed_events: list[FunctionToolsExecutedEvent] = []
|
||
session.on("function_tools_executed", tool_executed_events.append)
|
||
|
||
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
# Event was emitted with both the call AND a non-None output (i.e., not stripped).
|
||
assert len(tool_executed_events) == 1
|
||
ev = tool_executed_events[0]
|
||
assert len(ev.function_calls) == 1
|
||
assert ev.function_calls[0].name == "get_weather"
|
||
assert ev.function_call_outputs[0] is not None
|
||
output = ev.function_call_outputs[0]
|
||
assert output.is_error is True
|
||
|
||
# The model must see a descriptive, schema-specific error — NOT the generic
|
||
# "An internal error occurred" string we reserve for unexpected exceptions.
|
||
assert "An internal error occurred" not in output.output
|
||
assert "get_weather" in output.output
|
||
# Pydantic validation error references the missing field.
|
||
assert "location" in output.output
|
||
|
||
# The faulty call AND its error output must both end up in chat history so
|
||
# the LLM can see what it did wrong on the next turn (not stripped).
|
||
items = agent.chat_ctx.items
|
||
function_calls = [i for i in items if i.type == "function_call"]
|
||
function_call_outputs = [i for i in items if i.type == "function_call_output"]
|
||
assert len(function_calls) == 1
|
||
assert function_calls[0].name == "get_weather"
|
||
assert function_calls[0].call_id == "1"
|
||
assert len(function_call_outputs) == 1
|
||
assert function_call_outputs[0].call_id == "1"
|
||
assert function_call_outputs[0].is_error is True
|
||
|
||
|
||
async def test_tool_internal_exception_returns_generic_error() -> None:
|
||
"""When a tool body raises a non-ToolError exception, the model receives
|
||
the generic "An internal error occurred" message so we don't leak internal
|
||
details. Validation-error path is tested separately."""
|
||
|
||
class _BrokenToolAgent(Agent):
|
||
def __init__(self) -> None:
|
||
super().__init__(instructions="You are a helpful assistant.")
|
||
|
||
@function_tool
|
||
async def get_weather(self, location: str) -> str:
|
||
"""Always blows up."""
|
||
raise RuntimeError("kaboom: secret database password leaked")
|
||
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "What's the weather in Tokyo?")
|
||
actions.add_llm(
|
||
content="",
|
||
tool_calls=[
|
||
FunctionToolCall(name="get_weather", arguments='{"location": "Tokyo"}', call_id="1"),
|
||
],
|
||
)
|
||
|
||
session = create_session(actions, speed_factor=speed)
|
||
agent = _BrokenToolAgent()
|
||
|
||
tool_executed_events: list[FunctionToolsExecutedEvent] = []
|
||
session.on("function_tools_executed", tool_executed_events.append)
|
||
|
||
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
assert len(tool_executed_events) == 1
|
||
output = tool_executed_events[0].function_call_outputs[0]
|
||
assert output is not None
|
||
assert output.is_error is True
|
||
# Generic message — the RuntimeError details must NOT leak to the model.
|
||
assert output.output == "An internal error occurred"
|
||
assert "kaboom" not in output.output
|
||
assert "secret" not in output.output
|
||
|
||
|
||
# helpers
|
||
|
||
|
||
class _TestRecognitionHooks:
|
||
def __init__(self) -> None:
|
||
self.interruptions: list[inference.OverlappingSpeechEvent] = []
|
||
self.final_transcripts: list[str] = []
|
||
|
||
def on_interruption(self, ev: inference.OverlappingSpeechEvent) -> None:
|
||
self.interruptions.append(ev)
|
||
|
||
def on_start_of_speech(self, ev: object, speech_start_time: float) -> None:
|
||
pass
|
||
|
||
def on_vad_inference_done(self, ev: object) -> None:
|
||
pass
|
||
|
||
def on_end_of_speech(self, ev: object) -> None:
|
||
pass
|
||
|
||
def on_interim_transcript(self, ev: SpeechEvent, *, speaking: bool | None) -> None:
|
||
pass
|
||
|
||
def on_final_transcript(self, ev: SpeechEvent, *, speaking: bool | None = None) -> None:
|
||
self.final_transcripts.append(ev.alternatives[0].text)
|
||
|
||
def on_end_of_turn(self, info: _EndOfTurnInfo) -> bool:
|
||
return True
|
||
|
||
def on_preemptive_generation(self, info: object) -> None:
|
||
pass
|
||
|
||
def retrieve_chat_ctx(self) -> ChatContext:
|
||
return ChatContext.empty()
|
||
|
||
|
||
def _interruption_event() -> inference.OverlappingSpeechEvent:
|
||
return inference.OverlappingSpeechEvent(
|
||
type="overlapping_speech",
|
||
is_interruption=True,
|
||
overlap_started_at=time.time(),
|
||
detected_at=time.time(),
|
||
)
|
||
|
||
|
||
def _backchannel_event() -> inference.OverlappingSpeechEvent:
|
||
return inference.OverlappingSpeechEvent(
|
||
type="overlapping_speech",
|
||
is_interruption=False,
|
||
overlap_started_at=time.time(),
|
||
detected_at=time.time(),
|
||
)
|
||
|
||
|
||
def _final_transcript_event(*, text: str, start_time: float, end_time: float) -> SpeechEvent:
|
||
return SpeechEvent(
|
||
type=SpeechEventType.FINAL_TRANSCRIPT,
|
||
alternatives=[
|
||
SpeechData(
|
||
text=text,
|
||
language=LanguageCode(""),
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
)
|
||
],
|
||
)
|
||
|
||
|
||
async def _close_test_session(session: object) -> None:
|
||
await session.aclose()
|
||
audio_output = session.output.audio
|
||
synchronizer = getattr(audio_output, "_synchronizer", None)
|
||
if synchronizer is not None:
|
||
await synchronizer.aclose()
|
||
|
||
|
||
def check_timestamp(
|
||
t_event: float,
|
||
t_target: float,
|
||
*,
|
||
speed_factor: float = 1.0,
|
||
max_abs_diff: float = 0.75,
|
||
min_real_time_diff: float = 0.3,
|
||
) -> None:
|
||
"""
|
||
Check if the event timestamp is within the target timestamp +/- max_abs_diff.
|
||
The event timestamp is scaled by the speed factor.
|
||
|
||
``max_abs_diff`` is expressed in scaled time. A real-time floor of
|
||
``min_real_time_diff`` (wallclock seconds) is also applied so high
|
||
``speed_factor`` values don't compress the effective tolerance below the
|
||
scheduling-jitter noise floor on CI runners — without this, the real-time
|
||
tolerance is ``max_abs_diff / speed_factor``, which at speed=5 is only
|
||
150 ms and routinely flakes.
|
||
"""
|
||
t_event_scaled = t_event * speed_factor
|
||
effective_diff = max(max_abs_diff, min_real_time_diff * speed_factor)
|
||
print(
|
||
f"check_timestamp: t_event={t_event_scaled} (real {t_event:.3f}s), "
|
||
f"t_target={t_target}, effective_diff={effective_diff} "
|
||
f"(max_abs_diff={max_abs_diff}, min_real_time_diff={min_real_time_diff})"
|
||
)
|
||
assert abs(t_event_scaled - t_target) <= effective_diff, (
|
||
f"event timestamp {t_event_scaled} is not within {effective_diff} of target {t_target} "
|
||
f"(real-time tolerance {effective_diff / speed_factor:.3f}s)"
|
||
)
|
||
|
||
|
||
async def test_silent_tool_call_pause_state_does_not_leak_into_tool_reply() -> None:
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.1, 0.2, "What's the weather in Tokyo?", stt_delay=0.05)
|
||
|
||
# Silent tool-call step: no spoken preamble/audio before the function call.
|
||
actions.add_llm(
|
||
content="",
|
||
tool_calls=[
|
||
FunctionToolCall(
|
||
name="get_weather",
|
||
arguments='{"location": "Tokyo"}',
|
||
call_id="1",
|
||
)
|
||
],
|
||
ttft=0.05,
|
||
duration=1.0,
|
||
)
|
||
|
||
# VAD-only speech starts during the silent tool-call generation and remains
|
||
# active after the tool reply starts.
|
||
actions.add_user_speech(0.85, 2.0, "", stt_delay=0.05)
|
||
|
||
actions.add_llm(
|
||
content="The weather in Tokyo is sunny today.",
|
||
input="The weather in Tokyo is sunny today.",
|
||
ttft=0.0,
|
||
duration=0.0,
|
||
)
|
||
actions.add_tts(0.5, ttfb=0.0, duration=0.0)
|
||
|
||
session = create_session(
|
||
actions,
|
||
speed_factor=speed,
|
||
can_pause_audio=True,
|
||
turn_handling={"interruption": {"false_interruption_timeout": 0.2 / speed}},
|
||
)
|
||
agent = MyAgent()
|
||
|
||
agent_state_events: list[AgentStateChangedEvent] = []
|
||
false_interruption_events: list[AgentFalseInterruptionEvent] = []
|
||
|
||
session.on("agent_state_changed", agent_state_events.append)
|
||
session.on("agent_false_interruption", false_interruption_events.append)
|
||
|
||
await asyncio.wait_for(
|
||
run_session(session, agent, drain_delay=0.8 / speed),
|
||
timeout=SESSION_TIMEOUT,
|
||
)
|
||
|
||
transitions = [(ev.old_state, ev.new_state) for ev in agent_state_events]
|
||
silent_step_finished = transitions.index(("speaking", "listening"))
|
||
|
||
# Before the fix this is ("listening", "thinking") because the pause state
|
||
# captured during the silent tool-call step leaks into the tool reply.
|
||
assert transitions[silent_step_finished + 1] == ("listening", "speaking")
|
||
assert false_interruption_events
|
||
assert false_interruption_events[-1].resumed is True
|
||
|
||
|
||
async def test_default_vad_is_auto_provisioned() -> None:
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
session = AgentSession()
|
||
try:
|
||
assert session.vad is not None
|
||
assert session._using_default_vad is True
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_explicit_vad_none_opts_out() -> None:
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
session = AgentSession(vad=None)
|
||
try:
|
||
assert session.vad is None
|
||
assert session._using_default_vad is False
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_user_supplied_vad_clears_default_flag() -> None:
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
from .fake_vad import FakeVAD
|
||
|
||
user_vad = FakeVAD(fake_user_speeches=[])
|
||
|
||
session = AgentSession(vad=user_vad)
|
||
try:
|
||
assert session.vad is user_vad
|
||
assert session._using_default_vad is False
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_default_turn_detection_builds_default_eot() -> None:
|
||
"""No turn_detection given → session auto-provisions a default TurnDetector."""
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
from livekit.agents.voice.turn import _StreamingTurnDetector
|
||
|
||
session = AgentSession()
|
||
try:
|
||
assert isinstance(session.turn_detection, _StreamingTurnDetector)
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_turn_detection_none_opts_out() -> None:
|
||
"""Explicit None opts out of turn detection (no default detector built)."""
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
session = AgentSession(turn_handling={"turn_detection": None})
|
||
try:
|
||
assert session.turn_detection is None
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_user_supplied_turn_detector_passes_through() -> None:
|
||
from livekit.agents import inference
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
user_detector = inference.TurnDetector(version="v1-mini")
|
||
session = AgentSession(turn_handling={"turn_detection": user_detector})
|
||
try:
|
||
assert session.turn_detection is user_detector
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_streaming_detector_uses_streaming_endpointing_defaults() -> None:
|
||
"""Default session → streaming detector → tighter 0.3/2.5 endpointing defaults."""
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
session = AgentSession()
|
||
try:
|
||
assert session._opts.endpointing["min_delay"] == 0.3
|
||
assert session._opts.endpointing["max_delay"] == 2.5
|
||
assert session._opts.endpointing_overrides == {}
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_non_streaming_detector_uses_legacy_endpointing_defaults() -> None:
|
||
"""A non-streaming mode keeps the legacy 0.5/3.0 defaults."""
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
session = AgentSession(turn_handling={"turn_detection": "vad"})
|
||
try:
|
||
assert session._opts.endpointing["min_delay"] == 0.5
|
||
assert session._opts.endpointing["max_delay"] == 3.0
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_explicit_endpointing_overrides_streaming_default_per_key() -> None:
|
||
"""An explicit delay is honored; the unset one still gets the streaming default."""
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
session = AgentSession(turn_handling={"endpointing": {"min_delay": 0.4}})
|
||
try:
|
||
assert session._opts.endpointing["min_delay"] == 0.4
|
||
assert session._opts.endpointing["max_delay"] == 2.5
|
||
assert session._opts.endpointing_overrides == {"min_delay": 0.4}
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_user_streaming_detector_uses_streaming_defaults() -> None:
|
||
"""A user-constructed streaming detector also triggers the streaming defaults."""
|
||
from livekit.agents import inference
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
session = AgentSession(
|
||
turn_handling={"turn_detection": inference.TurnDetector(version="v1-mini")}
|
||
)
|
||
try:
|
||
assert session._opts.endpointing["min_delay"] == 0.3
|
||
assert session._opts.endpointing["max_delay"] == 2.5
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_deprecated_turn_detection_vad_uses_legacy_defaults() -> None:
|
||
"""Deprecated turn_detection arg + no delays → legacy defaults (non-streaming)."""
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
session = AgentSession(turn_detection="vad")
|
||
try:
|
||
assert session._opts.endpointing["min_delay"] == 0.5
|
||
assert session._opts.endpointing["max_delay"] == 3.0
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_agent_turn_detection_override_resolves_endpointing_per_activity() -> None:
|
||
"""endpointing_opts uses the activity's resolved detector, not just the session's."""
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
from .fake_vad import FakeVAD
|
||
|
||
# session default → streaming detector; provide VAD so it validates
|
||
session = AgentSession(vad=FakeVAD(fake_user_speeches=[]))
|
||
try:
|
||
streaming_activity = AgentActivity(Agent(instructions="test"), session)
|
||
assert streaming_activity.endpointing_opts["min_delay"] == 0.3
|
||
assert streaming_activity.endpointing_opts["max_delay"] == 2.5
|
||
|
||
# an agent overriding to VAD falls back to legacy defaults for this activity
|
||
vad_activity = AgentActivity(Agent(instructions="test", turn_detection="vad"), session)
|
||
assert vad_activity.endpointing_opts["min_delay"] == 0.5
|
||
assert vad_activity.endpointing_opts["max_delay"] == 3.0
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
async def test_runtime_endpointing_opts_survive_handoff() -> None:
|
||
"""update_options changes are recorded as overrides, so a new activity keeps them."""
|
||
from livekit.agents.voice.agent_session import AgentSession
|
||
|
||
from .fake_vad import FakeVAD
|
||
|
||
session = AgentSession(vad=FakeVAD(fake_user_speeches=[]))
|
||
try:
|
||
session.update_options(endpointing_opts={"mode": "dynamic", "alpha": 0.5, "min_delay": 0.4})
|
||
|
||
# a fresh activity (as built on agent handoff) re-resolves from overrides
|
||
activity = AgentActivity(Agent(instructions="test"), session)
|
||
assert activity.endpointing_opts["mode"] == "dynamic"
|
||
assert activity.endpointing_opts["alpha"] == 0.5
|
||
assert activity.endpointing_opts["min_delay"] == 0.4
|
||
# untouched key still gets the streaming default
|
||
assert activity.endpointing_opts["max_delay"] == 2.5
|
||
finally:
|
||
await session.aclose()
|
||
|
||
|
||
class FlushMultiSegmentAgent(Agent):
|
||
"""Agent whose llm_node flushes the reply into two segments via FlushSentinel."""
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__(instructions="You are a helpful assistant.")
|
||
|
||
async def llm_node(
|
||
self,
|
||
chat_ctx: ChatContext,
|
||
tools: list,
|
||
model_settings: ModelSettings,
|
||
) -> AsyncIterable[str | FlushSentinel]:
|
||
yield "Hello there. "
|
||
yield FlushSentinel()
|
||
yield "How are you?"
|
||
|
||
|
||
async def test_pipeline_multi_segment_flush() -> None:
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Hello, how are you?", stt_delay=0.2)
|
||
# the agent's llm_node injects a FlushSentinel, splitting the reply into two
|
||
# segments; register a TTS response keyed by each segment's text
|
||
actions.add_tts(1.0, input="Hello there. ", ttfb=0.1, duration=0.1)
|
||
actions.add_tts(1.0, input="How are you?", ttfb=0.1, duration=0.1)
|
||
|
||
session = create_session(actions, speed_factor=speed)
|
||
agent = FlushMultiSegmentAgent()
|
||
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
await asyncio.wait_for(run_session(session, agent), timeout=SESSION_TIMEOUT)
|
||
|
||
# each FlushSentinel-delimited segment plays out independently
|
||
assert len(playback_finished_events) == 2
|
||
assert all(not ev.interrupted for ev in playback_finished_events)
|
||
|
||
# but both segments join into a single assistant message
|
||
assistant_msgs = [
|
||
it for it in agent.chat_ctx.items if it.type == "message" and it.role == "assistant"
|
||
]
|
||
assert len(assistant_msgs) == 1
|
||
assert assistant_msgs[0].text_content == "Hello there. How are you?"
|
||
|
||
|
||
async def test_pipeline_multi_segment_interrupted() -> None:
|
||
speed = 1
|
||
actions = FakeActions()
|
||
actions.add_user_speech(0.5, 2.5, "Hello, how are you?", stt_delay=0.2)
|
||
# long first segment so the interrupt lands while it is still playing
|
||
actions.add_tts(15.0, input="Hello there. ", ttfb=0.1, duration=0.1)
|
||
actions.add_tts(1.0, input="How are you?", ttfb=0.1, duration=0.1)
|
||
|
||
session = create_session(actions, speed_factor=speed)
|
||
agent = FlushMultiSegmentAgent()
|
||
|
||
playback_finished_events: list[PlaybackFinishedEvent] = []
|
||
session.output.audio.on("playback_finished", playback_finished_events.append)
|
||
|
||
asyncio.get_event_loop().call_later(5 / speed, session.interrupt)
|
||
|
||
await asyncio.wait_for(run_session(session, agent, drain_delay=0.5), timeout=SESSION_TIMEOUT)
|
||
|
||
# interrupted during the first segment: only that segment plays, the second
|
||
# segment is never forwarded
|
||
assert len(playback_finished_events) == 1
|
||
assert playback_finished_events[0].interrupted is True
|
||
|
||
assistant_msgs = [
|
||
it for it in agent.chat_ctx.items if it.type == "message" and it.role == "assistant"
|
||
]
|
||
assert len(assistant_msgs) == 1
|
||
assert assistant_msgs[0].interrupted is True
|
||
assert "How are you?" not in (assistant_msgs[0].text_content or "")
|