TranscriptionFrame queued behind the frame that starts a user turn is dropped by that turn's own interruption
Problem Statement
When a user turn starts, LLMUserAggregator broadcasts an InterruptionFrame:
# pipecat/processors/aggregators/llm_response_universal.py, LLMUserAggregator._on_user_turn_started
if params.enable_interruptions:
await self.broadcast_interruption()Every FrameProcessor handles that frame in _start_interruption, which flushes its queue and keeps only UninterruptibleFrame instances:
# pipecat/processors/frame_processor.py, FrameProcessor._start_interruption
# "Just flush non-uninterruptible frames from the queue; any uninterruptible
# ones will be kept and processed after the current frame finishes."
self.__reset_process_queue()TranscriptionFrame does not carry the mixin:
# pipecat/frames/frames.py
class TranscriptionFrame(TextFrame):So if a final transcript is sitting in a queue behind the frame that started the turn, it is deleted by the interruption of its own turn. The turn opens with no text (or with only part of what the user said), the LLM runs on that or never runs, and the user gets silence or a wrong answer until they repeat themselves.
The frame that triggers the turn is safe, it is being processed and not queued. The problem is anything from the user that is queued behind it at that moment.
When it happens
Whenever two user transcripts are in a queue at the same time, so both are already there when the sweep passes. Three shapes:
InterimTranscriptionFramethenTranscriptionFrame, withTranscriptionUserTurnStartStrategy(use_interim=True)(the default)TranscriptionFramethenTranscriptionFrame, with any setting,use_interim=Falseincluded- a speech
TranscriptionFrameand a keypad entry landing together.DTMFAggregatoremits its entry as a plainTranscriptionFrametoo, so when the user talks and presses a key around the same moment, whichever of the two reachesLLMUserAggregatorfirst starts the turn and the other one is swept. This one needs no batching from a single source, it is two independent producers (the STT service andDTMFAggregator) hitting the same window. In the repro below the keypad entry wins becauseInputDTMFFrameis aSystemFrameand jumps ahead of the queued speech transcript, so the speech is what gets lost.
For the first two shapes, SonioxSTTService does exactly this. In _receive_messages the token loop calls finalize_turn() for every <end> token inside the same websocket message, so a message with two endpoints (bunched results after a short network stall, or two short sentences) pushes two TranscriptionFrames back to back and the second one is lost. With the normal gap between interim and final everything is fine, which is why this is rare and easy to miss in production.
Repro (pipecat only, no custom code)
"""Stock pipecat only. Does a TranscriptionFrame queued behind the frame that
starts the user turn survive that turn's own interruption?"""
import asyncio
from pipecat.audio.dtmf.types import KeypadEntry
from pipecat.frames.frames import (
InputDTMFFrame,
InterimTranscriptionFrame,
InterruptionFrame,
TranscriptionFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.aggregators.dtmf_aggregator import DTMFAggregator
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMUserAggregator,
LLMUserAggregatorParams,
)
from pipecat.tests.utils import SleepFrame, run_test
from pipecat.turns.user_start import TranscriptionUserTurnStartStrategy
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
from pipecat.turns.user_turn_strategies import UserTurnStrategies
TS = "2026-01-01T00:00:00Z"
def aggregator(use_interim=True):
return LLMUserAggregator(
LLMContext(),
params=LLMUserAggregatorParams(
user_turn_strategies=UserTurnStrategies(
start=[TranscriptionUserTurnStartStrategy(use_interim=use_interim)],
stop=[SpeechTimeoutUserTurnStopStrategy(timeout=0.05)],
)
),
)
def interim(text):
return InterimTranscriptionFrame(user_id="u", text=text, timestamp=TS)
def final(text):
return TranscriptionFrame(user_id="u", text=text, timestamp=TS)
async def case(label, frames, dtmf=False, **kwargs):
agg = aggregator(**kwargs)
processor = Pipeline([DTMFAggregator(), agg]) if dtmf else agg
down, up = await run_test(processor, frames_to_send=frames)
committed = [m["content"] for m in agg.context.get_messages() if m.get("role") == "user"]
interrupted = any(isinstance(f, InterruptionFrame) for f in [*down, *up])
print(f"{label:40s} interrupted={interrupted!s:5s} committed={committed}")
async def main():
await case("interim, final (same tick)", [interim("what is"), final("what is my balance")])
await case("interim, 20ms gap, final", [interim("what is"), SleepFrame(sleep=0.02), final("what is my balance")])
await case("final, final (same tick)", [final("one"), final("two")])
await case("final, final, use_interim=False", [final("one"), final("two")], use_interim=False)
keys = [InputDTMFFrame(KeypadEntry.ONE), InputDTMFFrame(KeypadEntry.POUND)]
await case("speech final + keypad entry (same tick)", [final("what is my balance"), *keys], dtmf=True)
await case("speech final, 20ms gap, keypad entry", [final("what is my balance"), SleepFrame(sleep=0.02), *keys], dtmf=True)
asyncio.run(main())Output:
interim, final (same tick) interrupted=True committed=[]
interim, 20ms gap, final interrupted=True committed=['what is my balance']
final, final (same tick) interrupted=True committed=['one']
final, final, use_interim=False interrupted=True committed=['one']
speech final + keypad entry (same tick) interrupted=True committed=['DTMF: 1#']
speech final, 20ms gap, keypad entry interrupted=True committed=['what is my balance DTMF: 1#']The InterruptionFrame goes out in all six cases. The only thing that changes is whether the user's text was still in a queue when it did.
Proposed Solution
Make TranscriptionFrame uninterruptible:
@dataclass
class TranscriptionFrame(TextFrame, UninterruptibleFrame):Interruptions exist to drop the bot's pending output. The user's own words are never something an interruption should clear, so I don't see what would break. There is already a DataFrame carrying the mixin for the same reason, a result that arrived must not be lost to an in-flight interruption:
# pipecat/frames/frames.py
class FunctionCallResultFrame(DataFrame, UninterruptibleFrame):The one side effect I can think of is that a FrameProcessor in the middle of handling a TranscriptionFrame won't be cancelled by an interruption anymore, which seems right to me anyway.
Alternative Solutions
A small FrameProcessor right after the last producer of transcripts (in our pipeline a DTMF aggregator, otherwise right after the STT service) that re-emits every TranscriptionFrame as a subclass mixing in UninterruptibleFrame, copying the fields the same way FrameProcessor.broadcast_frame_instance does. Works, but I would like to delete it. - The workaround we use today
Additional Context
Environment
pipecat-ai==1.8.1- Python
3.14.4, macOS
Would you be willing to help implement this feature?
- Yes, I'd like to contribute
- No, I'm just suggesting
Source: pipecat-ai/pipecat