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 "")