Redesign UserIdleController to use BotStoppedSpeakingFrame

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.
This commit is contained in:
Mark Backman
2026-02-14 08:22:33 -05:00
parent 73cb96bf66
commit 012ef41ff4
5 changed files with 277 additions and 207 deletions

View File

@@ -6,12 +6,13 @@
import asyncio
import unittest
import unittest.mock
from pipecat.frames.frames import (
BotSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
UserSpeakingFrame,
UserStartedSpeakingFrame,
)
from pipecat.turns.user_idle_controller import UserIdleController
@@ -25,8 +26,8 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase):
self.task_manager = TaskManager()
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
async def test_basic_idle_detection(self):
"""Test that idle event is triggered after timeout when no activity."""
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)
@@ -37,18 +38,16 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase):
nonlocal idle_triggered
idle_triggered = True
# Start conversation
await controller.process_frame(UserStartedSpeakingFrame())
await controller.process_frame(BotStoppedSpeakingFrame())
# Wait for idle timeout
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertTrue(idle_triggered)
await controller.cleanup()
async def test_user_speaking_resets_idle_timer(self):
"""Test that continuous UserSpeakingFrame frames reset the idle timer."""
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)
@@ -59,20 +58,18 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase):
nonlocal idle_triggered
idle_triggered = True
# Start conversation
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
await controller.process_frame(UserStartedSpeakingFrame())
# Send UserSpeakingFrame continuously to reset timer
for _ in range(5):
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.5) # 50% of timeout period
await controller.process_frame(UserSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_bot_speaking_resets_idle_timer(self):
"""Test that BotSpeakingFrame frames reset the idle timer."""
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)
@@ -83,102 +80,61 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase):
nonlocal idle_triggered
idle_triggered = True
# Start conversation
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())
# Bot speaking should reset timer
for _ in range(5):
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.6) # 60% of timeout
await controller.process_frame(BotSpeakingFrame())
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_function_call_prevents_idle(self):
"""Test that function calls in progress prevent idle event."""
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
# Start conversation
await controller.process_frame(UserStartedSpeakingFrame())
# Start function call
await controller.process_frame(FunctionCallsStartedFrame(function_calls=[]))
# Wait longer than idle timeout
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
# Should not trigger idle because function call is in progress
self.assertFalse(idle_triggered)
# Complete function call
await controller.process_frame(
FunctionCallResultFrame(
function_name="test",
tool_call_id="123",
arguments={},
result=None,
run_llm=False,
)
)
# Now idle should trigger
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertTrue(idle_triggered)
await controller.cleanup()
async def test_no_idle_before_conversation_starts(self):
"""Test that idle monitoring doesn't start before first conversation activity."""
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 starting conversation
# 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_starts_with_bot_speaking(self):
"""Test that monitoring starts with BotSpeakingFrame, not just user speech."""
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
# Start conversation with bot speaking
await controller.process_frame(BotSpeakingFrame())
# Wait for idle timeout
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertTrue(idle_triggered)
await controller.cleanup()
async def test_multiple_idle_events(self):
"""Test that idle event can trigger multiple times."""
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)
@@ -189,29 +145,105 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase):
nonlocal idle_count
idle_count += 1
# Start conversation
await controller.process_frame(UserStartedSpeakingFrame())
# First idle
# First cycle: bot stops → idle fires
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
first_count = idle_count
self.assertGreaterEqual(first_count, 1)
self.assertEqual(idle_count, 1)
# Second idle
# 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)
second_count = idle_count
self.assertGreater(second_count, first_count)
self.assertEqual(idle_count, 2)
# User activity resets timer
await controller.process_frame(UserSpeakingFrame())
await controller.cleanup()
# Give a moment for the timer to reset
await asyncio.sleep(0.1)
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()
# Third idle
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
third_count = idle_count
self.assertGreater(third_count, second_count)
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()