@@ -51,6 +51,7 @@ public class ConversationRelaySession
5151 private bool _utteranceOpen ;
5252 private bool _streamedThisTurn ;
5353 private bool _dropStream ;
54+ private bool _cutInMidReply ;
5455
5556 // The current turn's reply is in; the response watchdog says nothing more once it is set
5657 private bool _turnReplied ;
@@ -235,6 +236,7 @@ private async Task HandleMessageAsync(string json)
235236 if ( _isProcessing && _streamedThisTurn )
236237 {
237238 _dropStream = true ;
239+ _cutInMidReply = true ;
238240 }
239241 }
240242 var interruptedAfter = GetString ( root , "durationUntilInterruptMs" ) ;
@@ -316,6 +318,7 @@ private async Task ProcessPendingAsync()
316318 _utteranceOpen = false ;
317319 _streamedThisTurn = false ;
318320 _dropStream = false ;
321+ _cutInMidReply = false ;
319322 _turnReplied = false ;
320323 _turnStartedUtc = DateTime . UtcNow ;
321324 }
@@ -333,7 +336,8 @@ private async Task ProcessPendingAsync()
333336 OnStreamStarted = stream ? CloseUtteranceAsync : null ,
334337 // A line said before a slow tool ("Okay, that's everything I need") is spoken now, and the watchdog
335338 // counts the silence after it instead of waiting for the next response to close it
336- OnStreamEndedWithToolCall = stream ? CloseUtteranceAsync : null
339+ OnStreamEndedWithToolCall = stream ? CloseUtteranceAsync : null ,
340+ RewriteReply = stream ? MarkWhatTheCallerCutOff : null
337341 } ;
338342
339343 AssistantMessage ? reply ;
@@ -587,6 +591,40 @@ await SendIfAsync(() =>
587591 } , new { type = "text" , token = string . Empty , last = true } ) ;
588592 }
589593
594+ /// <summary>
595+ /// What the conversation history keeps of a reply the caller cut in on: the part that went out, and after it a
596+ /// mark naming the rest, so the next turn knows the caller never heard it. Every other reply is kept as it is.
597+ /// </summary>
598+ /// <remarks>
599+ /// Only a reply cut off while it was still being streamed can be marked: once the whole reply is out, Twilio
600+ /// holds the text it has yet to speak and a cut-in there leaves nothing of ours to go by.
601+ /// </remarks>
602+ private string MarkWhatTheCallerCutOff ( string content )
603+ {
604+ var final = content ? . Trim ( ) ?? string . Empty ;
605+ string sent ;
606+ lock ( _sync )
607+ {
608+ if ( ! _cutInMidReply || final . Length == 0 )
609+ {
610+ return content ;
611+ }
612+ sent = ( _utteranceOpen ? _streamSegment . ToString ( ) : _lastStreamSegment ) . Trim ( ) ;
613+ }
614+
615+ // The reply is not what was streamed (text set by a tool, or a reply rewritten after the model wrote it),
616+ // so where the caller cut in is not known
617+ if ( sent . Length == 0 || ! final . StartsWith ( sent , StringComparison . Ordinal ) )
618+ {
619+ return content ;
620+ }
621+
622+ var unheard = final . Substring ( sent . Length ) . Trim ( ) ;
623+ return unheard . Length == 0
624+ ? content
625+ : $ "{ sent } [the caller cut in here; they did not hear: \" { unheard } \" ]";
626+ }
627+
590628 /// <summary>
591629 /// The part of the final reply that was not streamed. Normally nothing; the whole reply when it is not
592630 /// what the model streamed last, e.g. text set by a tool, or a reply rewritten after the model wrote it.
0 commit comments