test: cover fallback path and align with vocabulary refactor

Adds two tests for the strategy's transcripts-without-VAD fallback
path — one in default mode (both events fire with the aggregated
content) and one in delayed-transcript mode (only
``on_user_turn_message_finalized`` fires; no end-of-turn event is
emitted since no turn ever started in the controller).

Updates existing tests for the vocabulary refactor: assertions now
expect ``content=None`` (not ``""``) for the end-of-turn event in
delayed-transcript mode; comments and docstrings use the
standardized terms (end of turn, user message finalization,
pending-finalization timer, plural "transcripts").
This commit is contained in:
Paul Kompfner
2026-05-18 09:55:42 -04:00
parent 8330c3487d
commit ee1538d18e

View File

@@ -766,18 +766,18 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
async def test_no_wait_for_transcript_basic_flow(self):
"""``wait_for_transcript_to_end_user_turn=False`` splits the lifecycle:
- ``on_user_turn_stopped`` fires on the audible end of the turn,
with empty content (the transcript hasn't arrived yet).
- The late transcript is captured into ``_aggregation``.
- When the backstop timer fires (no further transcripts),
- ``on_user_turn_stopped`` fires at the end of turn with empty
content (no transcripts have arrived yet).
- Late transcripts are captured into ``_aggregation``.
- When the pending-finalization timer fires,
``on_user_turn_message_finalized`` fires with the populated
transcript and the user message lands in context.
message and the user message lands in context.
"""
from unittest.mock import patch
from pipecat.processors.aggregators import llm_response_universal
# Shrink the backstop so the test runs quickly.
# Shrink the timer so the test runs quickly.
with patch.object(llm_response_universal, "DEFAULT_TTFS_P99", TRANSCRIPTION_TIMEOUT):
context = LLMContext()
user_aggregator = LLMUserAggregator(
@@ -813,24 +813,25 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
# Let the user_speech_timeout fire so the strategy
# fires turn-stopped.
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
# Late transcript arrives after the audible stop.
# Late transcripts arrive after the end of turn (just
# one here for the basic case).
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
# Wait for the backstop to fire.
# Wait for the pending-finalization timer to fire.
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
]
await run_test(pipeline, frames_to_send=frames_to_send)
# Two events fired in order: audible stop first (empty),
# message finalized later (populated).
self.assertEqual(events, [("stopped", ""), ("finalized", "Hello!")])
# Two events fired in order: end of turn first (empty),
# user message finalization later (populated).
self.assertEqual(events, [("stopped", None), ("finalized", "Hello!")])
# Context contains the user message.
user_messages = [m for m in context.get_messages() if m.get("role") == "user"]
self.assertEqual([m["content"] for m in user_messages], ["Hello!"])
async def test_no_wait_for_transcript_backstop_no_transcript_arrives(self):
"""When no transcript arrives, the backstop timer still finalizes
the turn — ``on_user_turn_message_finalized`` fires with empty
async def test_no_wait_for_transcript_no_transcripts_arrive(self):
"""When no transcripts arrive, the pending-finalization timer
still runs — ``on_user_turn_message_finalized`` fires with empty
content and nothing is written to context.
"""
from unittest.mock import patch
@@ -871,22 +872,23 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
VADUserStoppedSpeakingFrame(),
# Strategy fires turn-stopped.
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
# Backstop fires without any transcript arriving.
# Pending-finalization timer fires without any transcripts.
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
]
await run_test(pipeline, frames_to_send=frames_to_send)
self.assertEqual(events, [("stopped", ""), ("finalized", "")])
self.assertEqual(events, [("stopped", None), ("finalized", "")])
# No user message added to context (empty aggregation).
user_messages = [m for m in context.get_messages() if m.get("role") == "user"]
self.assertEqual(user_messages, [])
async def test_no_wait_for_transcript_next_turn_force_flushes_previous(self):
"""If a new user turn starts while the previous turn's transcript
is still pending (precondition violation), the previous turn's
finalization fires before the new turn's start. Whatever
transcript was captured by then is what lands in context.
"""If a new user turn starts while the previous turn's
finalization is still pending (precondition violation), the
previous turn's finalization fires before the new turn's start.
Whatever transcripts were captured by then are what lands in
context.
"""
from unittest.mock import patch
@@ -895,7 +897,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
with patch.object(
llm_response_universal,
"DEFAULT_TTFS_P99",
TRANSCRIPTION_TIMEOUT * 10, # backstop should NOT fire during the test
TRANSCRIPTION_TIMEOUT * 10, # timer should NOT fire during the test
):
context = LLMContext()
user_aggregator = LLMUserAggregator(
@@ -934,23 +936,25 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
SleepFrame(),
VADUserStoppedSpeakingFrame(),
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
# Late transcript for turn 1 arrives.
# Late transcript for turn 1 arrives (just one here for
# simplicity).
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
SleepFrame(),
# Turn 2 starts before turn 1's backstop fires — precondition
# violation. The aggregator should force-flush turn 1 first.
# Turn 2 starts before turn 1's pending-finalization timer
# fires — precondition violation. The aggregator should
# force-flush turn 1 first.
VADUserStartedSpeakingFrame(),
SleepFrame(),
]
await run_test(pipeline, frames_to_send=frames_to_send)
# The sequence must show turn 1's audible stop and finalization
# firing before turn 2's start event.
# The sequence must show turn 1's end of turn and user message
# finalization firing before turn 2's start event.
self.assertEqual(
events,
[
("started", ""), # turn 1 starts
("stopped", ""), # turn 1 audible stop
("stopped", None), # turn 1 end of turn
("finalized", "Hello!"), # forced flush before turn 2 starts
("started", ""), # turn 2 starts
],
@@ -961,20 +965,20 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
async def test_no_wait_for_transcript_context_order_with_assistant_response(self):
"""End-to-end ordering test: with both aggregators, verify the user
message lands in context *before* the assistant message, even though
the user transcript arrives after the audible turn end.
message lands in context *before* the assistant message, even
though the user's transcripts arrive after the end of turn.
Correct ordering requires the user aggregator's deferred
Correct ordering requires the user aggregator's pending
``push_aggregation`` to run before the assistant aggregator's
``push_aggregation`` (which fires on ``LLMFullResponseEndFrame``).
The patched-short backstop plus the sleep between LLM start and end
make that constraint hold here.
The patched-short pending-finalization timer plus the sleep
between LLM start and end make that constraint hold here.
"""
from unittest.mock import patch
from pipecat.processors.aggregators import llm_response_universal
# Short backstop so the user flush fires while the assistant
# Short timer so the user flush fires while the assistant
# response is still streaming.
with patch.object(llm_response_universal, "DEFAULT_TTFS_P99", 0.05):
context = LLMContext()
@@ -998,17 +1002,19 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
VADUserStartedSpeakingFrame(),
SleepFrame(),
VADUserStoppedSpeakingFrame(),
# Strategy fires turn-stopped (audible).
# Strategy fires turn-stopped (end of turn).
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
# User transcript arrives after audible stop (the realtime
# service has finally emitted it).
# User transcripts arrive after end of turn (the realtime
# service has finally emitted them — just one here).
TranscriptionFrame(text="What's the weather?", user_id="", timestamp="now"),
# Bot starts responding. Ordering correctness depends on the
# user backstop firing before LLMFullResponseEndFrame below.
# Bot starts responding. Ordering correctness depends on
# the user's pending-finalization timer firing before
# LLMFullResponseEndFrame below.
LLMFullResponseStartFrame(),
LLMTextFrame("It's sunny."),
# Allow time for the user backstop to fire (flushing the
# user message to context) before the assistant turn ends.
# Allow time for the user's pending-finalization timer to
# fire (flushing the user message to context) before the
# assistant turn ends.
SleepFrame(sleep=0.1),
LLMFullResponseEndFrame(),
SleepFrame(),
@@ -1061,6 +1067,109 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
# Stop strategy: wait_for_transcript flipped to False.
self.assertFalse(stop._wait_for_transcript)
async def test_transcript_fallback_default_mode(self):
"""The strategy's fallback path (transcripts with no prior VAD)
triggers turn-stopped correctly in default mode, and the user
message lands in context with the aggregated content.
"""
context = LLMContext()
user_aggregator = LLMUserAggregator(
context,
params=LLMUserAggregatorParams(
user_turn_strategies=UserTurnStrategies(
stop=[
SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)
],
),
),
)
events: list[tuple[str, str]] = []
@user_aggregator.event_handler("on_user_turn_stopped")
async def on_stopped(aggregator, strategy, message):
events.append(("stopped", message.content))
@user_aggregator.event_handler("on_user_turn_message_finalized")
async def on_finalized(aggregator, strategy, message):
events.append(("finalized", message.content))
pipeline = Pipeline([user_aggregator])
# No VAD frames — fallback path: transcripts with no prior VAD
# (just one transcript here for simplicity).
frames_to_send = [
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
]
await run_test(pipeline, frames_to_send=frames_to_send)
# Both events fire with the aggregated content.
self.assertEqual(events, [("stopped", "Hello!"), ("finalized", "Hello!")])
user_messages = [m for m in context.get_messages() if m.get("role") == "user"]
self.assertEqual([m["content"] for m in user_messages], ["Hello!"])
async def test_transcript_fallback_no_wait_for_transcript_mode(self):
"""The strategy's fallback path still gets the user message into
context in delayed-transcript mode, even though no end-of-turn
event ever fires (the bundle drops
``TranscriptionUserTurnStartStrategy``, so a transcript-only flow
never starts a turn in the controller; the strategy's stop-fire
is dropped by the controller too).
At session end the aggregated text is flushed and
``on_user_turn_message_finalized`` fires with the content.
``on_user_turn_stopped`` doesn't fire — in delayed-transcript
mode it's reserved for the end-of-turn path.
"""
from unittest.mock import patch
from pipecat.processors.aggregators import llm_response_universal
with patch.object(llm_response_universal, "DEFAULT_TTFS_P99", TRANSCRIPTION_TIMEOUT):
context = LLMContext()
user_aggregator = LLMUserAggregator(
context,
params=LLMUserAggregatorParams(
user_turn_strategies=UserTurnStrategies(
stop=[
SpeechTimeoutUserTurnStopStrategy(
user_speech_timeout=TRANSCRIPTION_TIMEOUT
)
],
),
wait_for_transcript_to_end_user_turn=False,
),
)
events: list[tuple[str, str]] = []
@user_aggregator.event_handler("on_user_turn_stopped")
async def on_stopped(aggregator, strategy, message):
events.append(("stopped", message.content))
@user_aggregator.event_handler("on_user_turn_message_finalized")
async def on_finalized(aggregator, strategy, message):
events.append(("finalized", message.content))
pipeline = Pipeline([user_aggregator])
frames_to_send = [
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
# Wait for the strategy's fallback timer +
# pending-finalization timer.
SleepFrame(sleep=2 * TRANSCRIPTION_TIMEOUT + 0.1),
]
await run_test(pipeline, frames_to_send=frames_to_send)
# No end-of-turn event (no turn ever started in the controller).
# Only message_finalized fires, with the populated transcript.
self.assertEqual(events, [("finalized", "Hello!")])
user_messages = [m for m in context.get_messages() if m.get("role") == "user"]
self.assertEqual([m["content"] for m in user_messages], ["Hello!"])
class TestLLMAssistantAggregator(unittest.IsolatedAsyncioTestCase):
async def test_empty(self):