Replace the continuous heartbeat-based timer (UserSpeakingFrame/BotSpeakingFrame + asyncio.Event loop) with a simple one-shot timer that starts when BotStoppedSpeakingFrame is received and cancels on UserStartedSpeakingFrame or BotStartedSpeakingFrame. This eliminates false idle triggers caused by gaps between the user finishing speaking and the bot starting to speak (LLM/TTS latency). Guard the timer start with two conditions to prevent false triggers: - User turn in progress: during interruptions, BotStoppedSpeaking arrives while the user is still speaking mid-turn. - Function calls in progress: FunctionCallsStarted arrives before BotStoppedSpeaking because the bot speaks concurrently with the function call starting, so the timer must wait for the result and subsequent bot response.
253 lines
8.8 KiB
Python
253 lines
8.8 KiB
Python
#
|
|
# Copyright (c) 2024-2026, Daily
|
|
#
|
|
# SPDX-License-Identifier: BSD 2-Clause License
|
|
#
|
|
|
|
import asyncio
|
|
import unittest
|
|
import unittest.mock
|
|
|
|
from pipecat.frames.frames import (
|
|
BotStartedSpeakingFrame,
|
|
BotStoppedSpeakingFrame,
|
|
FunctionCallResultFrame,
|
|
FunctionCallsStartedFrame,
|
|
UserStartedSpeakingFrame,
|
|
)
|
|
from pipecat.turns.user_idle_controller import UserIdleController
|
|
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
|
|
|
USER_IDLE_TIMEOUT = 0.2
|
|
|
|
|
|
class TestUserIdleController(unittest.IsolatedAsyncioTestCase):
|
|
async def asyncSetUp(self):
|
|
self.task_manager = TaskManager()
|
|
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
|
|
|
async def test_idle_after_bot_stops_speaking(self):
|
|
"""Test that idle event fires after BotStoppedSpeakingFrame + timeout."""
|
|
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
|
|
await controller.setup(self.task_manager)
|
|
|
|
idle_triggered = False
|
|
|
|
@controller.event_handler("on_user_turn_idle")
|
|
async def on_user_turn_idle(controller):
|
|
nonlocal idle_triggered
|
|
idle_triggered = True
|
|
|
|
await controller.process_frame(BotStoppedSpeakingFrame())
|
|
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
|
|
|
|
self.assertTrue(idle_triggered)
|
|
|
|
await controller.cleanup()
|
|
|
|
async def test_user_speaking_cancels_timer(self):
|
|
"""Test that UserStartedSpeakingFrame cancels the idle timer."""
|
|
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
|
|
await controller.setup(self.task_manager)
|
|
|
|
idle_triggered = False
|
|
|
|
@controller.event_handler("on_user_turn_idle")
|
|
async def on_user_turn_idle(controller):
|
|
nonlocal idle_triggered
|
|
idle_triggered = True
|
|
|
|
await controller.process_frame(BotStoppedSpeakingFrame())
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
|
|
await controller.process_frame(UserStartedSpeakingFrame())
|
|
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
|
|
|
|
self.assertFalse(idle_triggered)
|
|
|
|
await controller.cleanup()
|
|
|
|
async def test_bot_speaking_cancels_timer(self):
|
|
"""Test that BotStartedSpeakingFrame cancels the idle timer."""
|
|
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
|
|
await controller.setup(self.task_manager)
|
|
|
|
idle_triggered = False
|
|
|
|
@controller.event_handler("on_user_turn_idle")
|
|
async def on_user_turn_idle(controller):
|
|
nonlocal idle_triggered
|
|
idle_triggered = True
|
|
|
|
await controller.process_frame(BotStoppedSpeakingFrame())
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
|
|
await controller.process_frame(BotStartedSpeakingFrame())
|
|
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
|
|
|
|
self.assertFalse(idle_triggered)
|
|
|
|
await controller.cleanup()
|
|
|
|
async def test_no_idle_before_bot_speaks(self):
|
|
"""Test that idle does not fire if no BotStoppedSpeakingFrame is received."""
|
|
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
|
|
await controller.setup(self.task_manager)
|
|
|
|
idle_triggered = False
|
|
|
|
@controller.event_handler("on_user_turn_idle")
|
|
async def on_user_turn_idle(controller):
|
|
nonlocal idle_triggered
|
|
idle_triggered = True
|
|
|
|
# Wait without any frames
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
|
|
|
|
self.assertFalse(idle_triggered)
|
|
|
|
await controller.cleanup()
|
|
|
|
async def test_interruption_no_false_trigger(self):
|
|
"""Test that BotStoppedSpeakingFrame during a user turn does not start the timer."""
|
|
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
|
|
await controller.setup(self.task_manager)
|
|
|
|
idle_triggered = False
|
|
|
|
@controller.event_handler("on_user_turn_idle")
|
|
async def on_user_turn_idle(controller):
|
|
nonlocal idle_triggered
|
|
idle_triggered = True
|
|
|
|
# User starts speaking (interruption)
|
|
await controller.process_frame(UserStartedSpeakingFrame())
|
|
# Bot stops speaking due to interruption
|
|
await controller.process_frame(BotStoppedSpeakingFrame())
|
|
|
|
# Wait - timer should NOT have started because user turn is in progress
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
|
|
|
|
self.assertFalse(idle_triggered)
|
|
|
|
await controller.cleanup()
|
|
|
|
async def test_idle_cycle(self):
|
|
"""Test that idle fires, then can fire again after another bot speaking cycle."""
|
|
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
|
|
await controller.setup(self.task_manager)
|
|
|
|
idle_count = 0
|
|
|
|
@controller.event_handler("on_user_turn_idle")
|
|
async def on_user_turn_idle(controller):
|
|
nonlocal idle_count
|
|
idle_count += 1
|
|
|
|
# First cycle: bot stops → idle fires
|
|
await controller.process_frame(BotStoppedSpeakingFrame())
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
|
|
self.assertEqual(idle_count, 1)
|
|
|
|
# Second cycle: bot starts → bot stops → idle fires again
|
|
await controller.process_frame(BotStartedSpeakingFrame())
|
|
await controller.process_frame(BotStoppedSpeakingFrame())
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
|
|
self.assertEqual(idle_count, 2)
|
|
|
|
await controller.cleanup()
|
|
|
|
async def test_cleanup_cancels_timer(self):
|
|
"""Test that cleanup cancels a pending idle timer."""
|
|
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
|
|
await controller.setup(self.task_manager)
|
|
|
|
idle_triggered = False
|
|
|
|
@controller.event_handler("on_user_turn_idle")
|
|
async def on_user_turn_idle(controller):
|
|
nonlocal idle_triggered
|
|
idle_triggered = True
|
|
|
|
await controller.process_frame(BotStoppedSpeakingFrame())
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
|
|
await controller.cleanup()
|
|
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
|
|
|
|
self.assertFalse(idle_triggered)
|
|
|
|
async def test_function_call_cancels_timer(self):
|
|
"""Test normal ordering: BotStopped starts timer, FunctionCallsStarted cancels it."""
|
|
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
|
|
await controller.setup(self.task_manager)
|
|
|
|
idle_triggered = False
|
|
|
|
@controller.event_handler("on_user_turn_idle")
|
|
async def on_user_turn_idle(controller):
|
|
nonlocal idle_triggered
|
|
idle_triggered = True
|
|
|
|
# Bot finishes speaking, timer starts
|
|
await controller.process_frame(BotStoppedSpeakingFrame())
|
|
# Function call starts shortly after, cancels the timer
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
|
|
await controller.process_frame(
|
|
FunctionCallsStartedFrame(function_calls=[unittest.mock.Mock()])
|
|
)
|
|
|
|
# Wait longer than timeout — should not fire
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
|
|
self.assertFalse(idle_triggered)
|
|
|
|
await controller.cleanup()
|
|
|
|
async def test_function_call_suppresses_timer(self):
|
|
"""Test race condition: FunctionCallsStarted arrives before BotStopped.
|
|
|
|
A race condition can cause FunctionCallsStarted to arrive before
|
|
BotStoppedSpeaking. The counter guard prevents the timer from starting
|
|
while a function call is in progress.
|
|
"""
|
|
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
|
|
await controller.setup(self.task_manager)
|
|
|
|
idle_triggered = False
|
|
|
|
@controller.event_handler("on_user_turn_idle")
|
|
async def on_user_turn_idle(controller):
|
|
nonlocal idle_triggered
|
|
idle_triggered = True
|
|
|
|
# LLM emits function call and "let me check" concurrently
|
|
await controller.process_frame(
|
|
FunctionCallsStartedFrame(function_calls=[unittest.mock.Mock()])
|
|
)
|
|
await controller.process_frame(BotStartedSpeakingFrame())
|
|
await controller.process_frame(BotStoppedSpeakingFrame())
|
|
|
|
# Wait longer than timeout — should not fire (function call in progress)
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
|
|
self.assertFalse(idle_triggered)
|
|
|
|
# Function call completes, bot speaks result
|
|
await controller.process_frame(
|
|
FunctionCallResultFrame(
|
|
function_name="test", tool_call_id="123", arguments={}, result="ok"
|
|
)
|
|
)
|
|
await controller.process_frame(BotStartedSpeakingFrame())
|
|
await controller.process_frame(BotStoppedSpeakingFrame())
|
|
|
|
# Now the timer should start and fire
|
|
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
|
|
self.assertTrue(idle_triggered)
|
|
|
|
await controller.cleanup()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|