Skip to content

fix(duplex): keep late transcripts on the audio they describe - #7264

Merged
longcw merged 2 commits into
mainfrom
longc/duplex-late-transcript
Sep 14, 2026
Merged

longcw merged 2 commits into
mainfrom
longc/duplex-late-transcript

Conversation

@longcw

@longcw longcw commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Problem: DuplexRealtimeAdapter attaches queued transcript fragments only inside the frame handler, while the gate is open, so a fragment that arrives after the last accepted frame stays queued. The next utterance takes it as its first fragment and sets its time anchor from it, or the 3 s unclaimed timeout emits it under a new id with an error log.

Fix: _close_burst attaches due fragments before it closes the burst, so words that arrive between the last frame and the close still join it. A fragment whose span, by the last anchored burst's anchor, sits on audio that burst already played is emitted at once as a text-only generation under a fresh id, the shape the unclaimed path already produces, so it can no longer anchor the next burst.

Fixes #7227. Supersedes #7244.

Context for reviewing and coding agents

How to see it on main

With _FakeDuplexModel, push three speech frames, emit one transcript delta, then close audio_ch: the generation's text stream ends empty and the delta is still in session._fragments. Second order: let a burst with an anchored fragment close, then emit a delta whose start_ms lies inside that burst's span: on main the next speech burst's transcript begins with it.

Why a text-only generation instead of holding text_ch open

The framework builds the assistant chat item from the text stream alone and skips it when the stream closed empty (agent_activity.py:4537), and update_chat_ctx replaces the adapter's own context copy, so text recorded only in the adapter never reaches session.history or the client. Holding the stream open for a grace delays the speech handle on every turn and still loses text later than the grace. _on_generation_created schedules the text-only generation as a normal speech handle, so it plays after the burst that is speaking and never overlaps it.

Why a burst without an anchor is left alone

The span clock and the adapter's audio clock share no origin; the anchor from a burst's first fragment is the only mapping between them. A burst that never received a fragment cannot tell its own late text from the next utterance's leading text, so that case keeps the current behaviour: the next burst or the unclaimed timeout takes it.

Blast radius

_open_burst no longer assigns self._burst; the frame handler assigns it, and the unclaimed path, the late path and the function-only generation close their burst directly. _close_burst keeps its five callers: the frame handler, the idle timer, the segment task's exit, reconnect and aclose.

The adapter attached queued transcript fragments only inside the frame
handler while the gate was open. A fragment arriving after the last
accepted frame stayed queued: the next utterance took it as its first
fragment and anchored on it, or the unclaimed timeout emitted it under a
new id 3 s later.

_close_burst now attaches due fragments before closing. A fragment whose
span sits on audio the last anchored burst already played is emitted at
once as a text-only generation, so it can no longer anchor the next
burst.

Fixes #7227
@longcw
longcw requested a review from a team as a code owner September 14, 2026 05:19
devin-ai-integration[bot]

This comment was marked as resolved.

_open_burst resolved a pending generate_reply for any burst that
carried a message. A text-only generation for words the model already
spoke could claim the request, and the speech that followed was then
emitted as a turn of the model's own.

_open_burst takes the kind of burst it opens: speech, text or tool.
Only speech is the reply asked for.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

break
late += 1
if late:
burst = self._open_burst(kind="text")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Late transcripts follow newer replies

During generate_reply, _open_burst(kind="text") queues late text behind the pending requested handle. _scheduling_task keeps that handle current until its speech finishes. The old transcript therefore displays after the newer reply.

Learn more

A requested reply owns the scheduler's current speech handle before it calls RealtimeSession.generate_reply in _realtime_reply_task. A non-user-initiated generation creates another normal speech handle in _on_generation_created. The scheduler cannot authorize that queued handle until the requested handle receives and finishes its speech. Marking historical text as non-user-initiated preserves reply correlation, but routes it through this blocked queue.

Example: A request is waiting when " there." arrives for the previous "Hello" burst. The model then speaks "How can I help?" as the requested reply. The room receives "How can I help?" before the older " there." transcript.

Recommended fix: Preserve the pending reply for the next speech while delivering historical text before that reply. Add an explicit historical-text distinction to the generation contract and handle it without placing a normal speech handle behind the current requested handle, or provide equivalent scheduler ordering.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will fix it separately

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DuplexRealtimeAdapter loses delayed transcripts or attaches them to the next audio burst

2 participants