@@ -15,6 +15,8 @@ export type RealtimeTalkConversationState = {
1515 userEntryAwaitingFinal : boolean ;
1616 userEntryAwaitingFinalStartedAtMs : number | null ;
1717 assistantEntryId : string | null ;
18+ assistantEntryAwaitingFinalId : string | null ;
19+ assistantEntryAwaitingFinalStartedAtMs : number | null ;
1820} ;
1921
2022export type RealtimeTalkTranscriptUpdate = {
@@ -26,6 +28,10 @@ export type RealtimeTalkTranscriptUpdate = {
2628
2729const MAX_CONVERSATION_ENTRIES = 60 ;
2830const USER_FINAL_REWRITE_GRACE_MS = 1_500 ;
31+ // A final assistant transcript can land after a user transcript already closed
32+ // the streamed assistant bubble. Within this window we rewrite that bubble
33+ // instead of inserting a duplicate; past it the final is treated as a new turn.
34+ const ASSISTANT_FINAL_REWRITE_GRACE_MS = 2_000 ;
2935
3036export function createRealtimeTalkConversationState ( ) : RealtimeTalkConversationState {
3137 return {
@@ -35,6 +41,8 @@ export function createRealtimeTalkConversationState(): RealtimeTalkConversationS
3541 userEntryAwaitingFinal : false ,
3642 userEntryAwaitingFinalStartedAtMs : null ,
3743 assistantEntryId : null ,
44+ assistantEntryAwaitingFinalId : null ,
45+ assistantEntryAwaitingFinalStartedAtMs : null ,
3846 } ;
3947}
4048
@@ -49,10 +57,13 @@ export function updateRealtimeTalkConversation(
4957 const nowMs = update . nowMs ?? Date . now ( ) ;
5058 if ( update . role === "assistant" ) {
5159 const preparedState = finishRealtimeConversationEntry ( state , "user" , nowMs ) ;
60+ const assistantEntryId =
61+ preparedState . assistantEntryId ??
62+ resolveLateFinalAssistantEntryId ( preparedState , text , update . final , nowMs ) ;
5263 return upsertRealtimeConversationEntry (
5364 preparedState ,
5465 update . role ,
55- preparedState . assistantEntryId ,
66+ assistantEntryId ,
5667 text ,
5768 update . final ,
5869 nowMs ,
@@ -139,7 +150,12 @@ function rememberRealtimeConversationEntry(
139150 userEntryAwaitingFinalStartedAtMs : null ,
140151 } ;
141152 }
142- return { ...state , assistantEntryId : isFinal ? null : entryId } ;
153+ return {
154+ ...state ,
155+ assistantEntryId : isFinal ? null : entryId ,
156+ assistantEntryAwaitingFinalId : null ,
157+ assistantEntryAwaitingFinalStartedAtMs : null ,
158+ } ;
143159}
144160
145161export function finishRealtimeConversationEntry (
@@ -162,7 +178,50 @@ export function finishRealtimeConversationEntry(
162178 userEntryAwaitingFinalStartedAtMs : nowMs ,
163179 } ;
164180 }
165- return { ...state , entries, assistantEntryId : null } ;
181+ return {
182+ ...state ,
183+ entries,
184+ assistantEntryId : null ,
185+ assistantEntryAwaitingFinalId : entryId ,
186+ assistantEntryAwaitingFinalStartedAtMs : nowMs ,
187+ } ;
188+ }
189+
190+ // Reattach a late final assistant transcript to the bubble its deltas already
191+ // filled, when a user transcript closed it moments earlier. Outside the grace
192+ // window, or when the text is not the same utterance, return null so the caller
193+ // starts a fresh bubble and never folds the next assistant turn into this one.
194+ function resolveLateFinalAssistantEntryId (
195+ state : RealtimeTalkConversationState ,
196+ incoming : string ,
197+ isFinal : boolean ,
198+ nowMs : number ,
199+ ) : string | null {
200+ if ( ! isFinal || state . assistantEntryAwaitingFinalId === null ) {
201+ return null ;
202+ }
203+ const elapsed =
204+ state . assistantEntryAwaitingFinalStartedAtMs === null
205+ ? Number . POSITIVE_INFINITY
206+ : nowMs - state . assistantEntryAwaitingFinalStartedAtMs ;
207+ if ( elapsed > ASSISTANT_FINAL_REWRITE_GRACE_MS ) {
208+ return null ;
209+ }
210+ const entry = state . entries . find (
211+ ( candidate ) => candidate . id === state . assistantEntryAwaitingFinalId ,
212+ ) ;
213+ if ( ! entry || entry . role !== "assistant" ) {
214+ return null ;
215+ }
216+ const existing = entry . text ;
217+ if (
218+ incoming === existing ||
219+ incoming . startsWith ( existing ) ||
220+ looksLikeTranscriptReplacement ( existing , incoming )
221+ ) {
222+ return entry . id ;
223+ }
224+ return null ;
166225}
167226
168227function shouldStartNewRealtimeUserEntry (
0 commit comments