Fix stale state in user turn stop strategies between turns

Reset stop strategies at turn start (not just turn stop) so that late
transcriptions arriving between turns do not leave stale _text that
causes premature stops on the next turn. Also cancel pending timeout
tasks in reset() for both SpeechTimeout and TurnAnalyzer strategies.
This commit is contained in:
Mark Backman
2026-03-17 11:31:08 -04:00
parent f54b3c6884
commit 5000b040dd
5 changed files with 122 additions and 0 deletions

View File

@@ -15,6 +15,7 @@ from pipecat.frames.frames import (
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.turns.user_start import VADUserTurnStartStrategy
from pipecat.turns.user_start.min_words_user_turn_start_strategy import (
MinWordsUserTurnStartStrategy,
)
@@ -199,6 +200,73 @@ class TestUserTurnController(unittest.IsolatedAsyncioTestCase):
self.assertTrue(should_stop)
self.assertTrue(timeout)
async def test_late_transcription_between_turns_no_premature_stop(self):
"""Test that a late transcription arriving between turns does not cause a premature stop.
Reproduces the bug from issue #4053: after turn 1 completes and reset()
clears state, a late TranscriptionFrame sets _text to stale content. On
the next turn, that stale _text gates a premature turn stop via timeout(0)
before the current turn's transcript arrives.
Uses only VADUserTurnStartStrategy (no TranscriptionUserTurnStartStrategy)
so the late transcription doesn't trigger a spurious turn start.
"""
controller = UserTurnController(
user_turn_strategies=UserTurnStrategies(
start=[VADUserTurnStartStrategy()],
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
)
await controller.setup(self.task_manager)
start_count = 0
stop_count = 0
@controller.event_handler("on_user_turn_started")
async def on_user_turn_started(controller, strategy, params):
nonlocal start_count
start_count += 1
@controller.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(controller, strategy, params):
nonlocal stop_count
stop_count += 1
# === Turn 1: S-T-E ===
await controller.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(start_count, 1)
await controller.process_frame(
TranscriptionFrame(text="Hello!", user_id="", timestamp="now")
)
await controller.process_frame(VADUserStoppedSpeakingFrame())
await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1)
self.assertEqual(stop_count, 1)
# === Between turns: late transcription arrives ===
# This sets _text on the stop strategy while _user_turn is False.
await controller.process_frame(
TranscriptionFrame(text="Hello!", user_id="", timestamp="now")
)
# === Turn 2: S-T-E (transcription arrives during turn) ===
# The fix resets stop strategies at turn start, clearing stale _text.
await controller.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(start_count, 2)
await controller.process_frame(
TranscriptionFrame(text="How are you?", user_id="", timestamp="now")
)
await controller.process_frame(VADUserStoppedSpeakingFrame())
# Wait for user_speech_timeout to elapse — should get turn 2 stop
await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1)
self.assertEqual(stop_count, 2)
if __name__ == "__main__":
unittest.main()