Align vocabulary around wait_for_transcript_to_end_user_turn=False

Reframe comments, docstrings, identifiers, changelog, and example
around a single explanation of the option: (1) turn strategies do not
consider user transcripts, letting the user turn end sooner, and (2)
the aggregator gathers user transcripts on its own after the turn
ends via a simple timer, then emits `on_user_turn_message_finalized`
with the new user context message.

The mechanism is generic, so internal aggregator vocabulary stays
generic ("transcript-gather", "after the user turn ends"); the
public-facing param docstring is the one place that explains the
"local turn detection drives a realtime service" use case. The stop
strategies' `wait_for_transcript` flag is pointed at as something
that's "usually flipped indirectly" by the aggregator param rather
than something to pair with it.

Renames internal state to match: `_expect_delayed_transcripts` →
`_aggregator_gathers_transcripts`, `_pending_finalization_*` →
`_transcript_gather_*`, `_finalize_delayed_user_message` →
`_finalize_user_message`, etc.
This commit is contained in:
Paul Kompfner
2026-05-18 10:18:22 -04:00
parent ee1538d18e
commit 797d09a1d5
6 changed files with 246 additions and 222 deletions

View File

@@ -767,11 +767,12 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
"""``wait_for_transcript_to_end_user_turn=False`` splits the lifecycle:
- ``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,
content (the aggregator hasn't gathered transcripts yet).
- Transcripts arriving after the end of turn are captured into
``_aggregation``.
- When the transcript-gather timer fires,
``on_user_turn_message_finalized`` fires with the populated
message and the user message lands in context.
user context message.
"""
from unittest.mock import patch
@@ -813,10 +814,10 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
# Let the user_speech_timeout fire so the strategy
# fires turn-stopped.
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
# Late transcripts arrive after the end of turn (just
# one here for the basic case).
# Transcripts arrive after the end of turn (just one
# here for the basic case).
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
# Wait for the pending-finalization timer to fire.
# Wait for the transcript-gather timer to fire.
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
]
await run_test(pipeline, frames_to_send=frames_to_send)
@@ -830,8 +831,8 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual([m["content"] for m in user_messages], ["Hello!"])
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
"""When no transcripts arrive, the transcript-gather timer still
runs — ``on_user_turn_message_finalized`` fires with empty
content and nothing is written to context.
"""
from unittest.mock import patch
@@ -940,7 +941,7 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
# simplicity).
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
SleepFrame(),
# Turn 2 starts before turn 1's pending-finalization timer
# Turn 2 starts before turn 1's transcript-gather timer
# fires — precondition violation. The aggregator should
# force-flush turn 1 first.
VADUserStartedSpeakingFrame(),
@@ -968,10 +969,10 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
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 pending
Correct ordering requires the user aggregator's deferred
``push_aggregation`` to run before the assistant aggregator's
``push_aggregation`` (which fires on ``LLMFullResponseEndFrame``).
The patched-short pending-finalization timer plus the sleep
The patched-short transcript-gather timer plus the sleep
between LLM start and end make that constraint hold here.
"""
from unittest.mock import patch
@@ -1008,13 +1009,13 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
# 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's pending-finalization timer firing before
# the user's transcript-gather timer firing before
# LLMFullResponseEndFrame below.
LLMFullResponseStartFrame(),
LLMTextFrame("It's sunny."),
# Allow time for the user's pending-finalization timer to
# fire (flushing the user message to context) before the
# assistant turn ends.
# Allow time for the user's transcript-gather timer to
# fire (flushing the user message to context) before
# the assistant turn ends.
SleepFrame(sleep=0.1),
LLMFullResponseEndFrame(),
SleepFrame(),
@@ -1112,16 +1113,17 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
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).
context when ``wait_for_transcript_to_end_user_turn=False``,
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.
``on_user_turn_stopped`` doesn't fire — when the aggregator
gathers transcripts after the turn ends, it's reserved for
the end-of-turn path.
"""
from unittest.mock import patch
@@ -1157,8 +1159,9 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
frames_to_send = [
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
# Wait for the strategy's fallback timer +
# pending-finalization timer.
# Wait long enough that the strategy's fallback timer
# has elapsed (its stop-fire is dropped by the
# controller, since no turn ever started).
SleepFrame(sleep=2 * TRANSCRIPTION_TIMEOUT + 0.1),
]
await run_test(pipeline, frames_to_send=frames_to_send)