Skip to content

feat(twilio): keep a reply the caller cut off as the part they heard - #1467

Merged
yileicn merged 1 commit into
SciSharp:masterfrom
yuyixg:features/conversation-relay-interrupt-mark
Oct 29, 2026
Merged

yileicn merged 1 commit into
SciSharp:masterfrom
yuyixg:features/conversation-relay-interrupt-mark

Conversation

@yuyixg

@yuyixg yuyixg commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

The problem

ConversationRelay stops playback where the caller cuts in, but the whole reply still went into the conversation history. The next turn then believed the question had been asked in full, so the part the caller never heard was never asked again.

What this does

A reply cut off while it was still being streamed is kept in the history as the text that actually went out, with the rest named in a mark:

Let me [the caller cut in here; they did not hear: "check on that."]
  • AgentTurnCallbacks.RewriteReply is the flow's last say over what the history keeps of the reply.
  • TwilioConversationProcessor calls it from the onResponseReceived callback. ConversationService.SendMessage appends the dialog after that callback, so the message changed there is the one stored. AssistantMessage.Content keeps the reply as the model wrote it, so what is spoken does not change and the mark can never reach TTS.
  • ConversationRelaySession.MarkWhatTheCallerCutOff builds it from _streamSegment / _lastStreamSegment, which hold exactly what was sent to Twilio. A reply that does not start with what was streamed (text set by a tool, or a reply rewritten after the model wrote it) is left as it is, as GetUnstreamedText already does for the same case.
  • The cut-in is tracked with its own _cutInMidReply flag rather than _dropStream, which FailAsync also sets: an orphaned turn that timed out still reaches the callback when it finishes, and must not be recorded as something the caller interrupted.

What it does not cover

Only a reply cut off mid-stream can be marked. Once the whole reply is out, Twilio holds the text it has yet to speak, and a cut-in there leaves nothing on our side to go by; placing that cut would mean estimating from durationUntilInterruptMs and rewriting a message that is already stored.

Testing

The behaviour is covered downstream, where the relay session is driven with a fake WebSocket: a reply cut in on mid-stream is stored as the heard part plus the mark, and a reply the caller lets finish is stored unchanged. There is no test project here that covers the Twilio plugin.

🤖 Generated with Claude Code

ConversationRelay stops playback where the caller cuts in, but the whole reply
still went into the conversation history, so the next turn believed the question
had been asked in full and never came back to it.

The relay session now gets the last say over what the history keeps: for a reply
cut off while it was still being streamed it returns the text that actually went
out, with the rest named in a mark. The dialog is appended after that callback,
so the stored message is the marked one, while what is spoken is unchanged.

Only a reply cut off mid-stream can be marked; once the whole reply is out,
Twilio holds the text it has yet to speak and a cut-in there leaves nothing of
ours to go by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown
Contributor

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@yileicn

yileicn commented Oct 29, 2026

Copy link
Copy Markdown
Member

reviewed

@yileicn
yileicn merged commit b3ebbfb into SciSharp:master Oct 29, 2026
4 checks passed
@yileicn
yileicn requested a balanced review from Copilot November 2, 2026 00:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Interrupt handling can misclassify timed-out replies as caller-interrupted and incorrectly treat queued speech the caller never heard as delivered in conversation history.

2 open issues
What changed in this PR

This PR adds history rewriting to the Twilio conversation flow so interrupted streaming replies can record an unsent remainder without changing the text passed to speech playback.

Changes:

  • Adds a RewriteReply callback for adjusting stored replies.
  • Applies history rewrites while preserving the original spoken response.
  • Tracks mid-reply interruptions and builds annotations from streamed text.
File Description
src/​Plugins/​BotSharp.Plugin.Twilio/​Services/​TwilioConversationProcessor.cs Applies history rewrites while preserving spoken content.
src/​Plugins/​BotSharp.Plugin.Twilio/​Services/​ConversationRelaySession.cs Tracks interruptions and annotates unsent reply text.
src/​Plugins/​BotSharp.Plugin.Twilio/​Models/​AgentTurnCallbacks.cs Defines the optional reply-rewriting callback.

Review effort: Balanced

@@ -235,6 +236,7 @@ private async Task HandleMessageAsync(string json)
if (_isProcessing && _streamedThisTurn)

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.

Fixed in #1474: an interrupt no longer marks the turn once its stream was already dropped (the turn timed out and the apology is playing) or the session ended, so the orphaned reply is stored as it is.

🤖 Addressed by Claude Code

{
return content;
}
sent = (_utteranceOpen ? _streamSegment.ToString() : _lastStreamSegment).Trim();

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.

Fixed in #1474: the interrupt's utteranceUntilInterrupt is kept, and the mark cuts there when it is the start of what went out; when it is missing or doesn't match, the cut falls back to where the sending stopped (and that is logged).

🤖 Addressed by Claude Code

yileicn pushed a commit that referenced this pull request Nov 2, 2026
…er a timeout

Addresses the review on #1467.

- Text goes out faster than it is spoken, so what was sent when the caller
  cut in is more than they heard. The interrupt's utteranceUntilInterrupt
  says where playback stopped; the history mark now cuts there when it is
  the start of what went out, and where the sending stopped otherwise.
- An interrupt once the stream was already dropped (the turn timed out and
  the apology is playing) or the session ended no longer marks the
  orphaned turn's reply as cut in on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

4 participants