#5683·pipecat

TranscriptionFrame queued behind the frame that starts a user turn is dropped by that turn's own interruption

Author: omer-insaitCreated Sep 10, 2026Updated Sep 16, 2026
Labelsneeds-repro

Problem Statement

When a user turn starts, LLMUserAggregator broadcasts an InterruptionFrame:

python
# 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:

python
# 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:

python
# 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:

  • InterimTranscriptionFrame then TranscriptionFrame, with TranscriptionUserTurnStartStrategy(use_interim=True) (the default)
  • TranscriptionFrame then TranscriptionFrame, with any setting, use_interim=False included
  • a speech TranscriptionFrame and a keypad entry landing together. DTMFAggregator emits its entry as a plain TranscriptionFrame too, so when the user talks and presses a key around the same moment, whichever of the two reaches LLMUserAggregator first 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 and DTMFAggregator) hitting the same window. In the repro below the keypad entry wins because InputDTMFFrame is a SystemFrame and 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)

python
"""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:

python
@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:

python
# 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