Skip to content

feat(twilio): drop a reply not yet started when the caller goes on talking - #1470

Merged
yileicn merged 2 commits into
SciSharp:masterfrom
yuyixg:features/conversation-relay-supersede
Oct 30, 2026
Merged

yileicn merged 2 commits into
SciSharp:masterfrom
yuyixg:features/conversation-relay-supersede

Conversation

@yuyixg

@yuyixg yuyixg commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Problem

ConversationRelay ends the caller's turn on a pause. A caller who stops mid-thought ("Tomorrow." ... "Pretty open, I'm around most of the day.") has the first half sent to the agent on its own. That turn always ran to the end: its reply was spoken, and the rest of what the caller said waited for the next turn and arrived as a cut-in. On a live call this sounds like the agent answering before the caller has finished.

Change

When more caller speech comes in while the current turn has neither said anything (a streamed piece, a filler, an indication) nor called a tool, ConversationRelaySession cancels that turn and answers what the caller said in the next one.

  • The dropped turn's words are already in the history; its reply is stored marked as never said ([the caller went on talking before anything was said], or ... they did not hear: "..." when the model had written something), so the next turn reads both halves.
  • A transfer or hang-up the dropped turn asked for still goes ahead. Keypad input never drops a turn.
  • A turn the flow keeps uninterrupted (twilio_relay_keep_uninterrupted) is never dropped: such a flow has a line the caller must hear coming (a confirmation number) and may fetch it in code from a conversation hook, where no tool call shows. The state is read on the turn only — at its start through the new AgentTurnCallbacks.OnTurnStarted, as each response starts streaming, with each piece and with the reply — and the session keeps the last value across turns, so the last turn's answer holds until the new one has read it.
  • Cancelling uses IConversationCancellationService, which the chat providers already honor while streaming: TwilioConversationProcessor registers each streamed turn, and AgentTurnCallbacks.Cancel / OnCancel connect the session to it (a cancel that arrives before the turn could take it is applied as soon as it can be). So this only applies with ConversationRelay:StreamReplies on.
  • RewriteForHistory now also offers an empty reply to RewriteReply, so a cancelled turn with no text can still be marked.

Known gap

A tool the model asked for in the moment before the cancel still runs; its result is kept, and the reply after it is dropped.

Tests

The session is covered by the relay session test suite in our downstream app (7 new cases): a reply not started yet is dropped and marked (with and without text written); a reply already going out, a turn that called a tool, a turn that does not stream and a turn the flow keeps uninterrupted are not taken back; and the last turn's keep-uninterrupted holds until the new turn has read it. That suite passes 24/24 on five runs in a row.

🤖 Generated with Claude Code

…lking

ConversationRelay ends the caller's turn on a pause, so a caller who
stops mid-thought ("Tomorrow." ... "Pretty open, I'm around most of the
day.") has the first half sent to the agent on its own. Until now that
turn always ran to the end: its reply was spoken, and the rest of what
the caller said was kept for the next turn, arriving as a cut-in.

Now, when more caller speech comes in while a turn has neither said
anything (a streamed piece, a filler, an indication) nor called a tool,
the session cancels it and answers what the caller said in the next
turn. The turn's words are already in the history, and its reply is
stored marked as never said ("[the caller went on talking before
anything was said]"), so the next turn reads both halves. A transfer or
hang-up the dropped turn asked for still goes ahead; keypad input never
drops a turn.

Cancelling goes through IConversationCancellationService, which the chat
providers already honor while streaming: TwilioConversationProcessor
registers each streamed turn there, and AgentTurnCallbacks.Cancel /
OnCancel connect the session to it, including a cancel that comes before
the turn could take it. So it only applies with StreamReplies on.

Known gap: a tool the model asked for in the moment before the cancel
still runs; its result is kept, and the reply after it is dropped.

Co-Authored-By: Claude Opus 5.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 →

A turn is dropped only when it has neither said anything nor called a
tool, but a tool call shows only when the model asks for it. A flow can
also call one in code: a phone flow that fetches the confirmation number
from a conversation hook, once the model has ended its reply, does that
with nothing to show for it, and the caller going on talking meanwhile
dropped the turn with the number in it.

Such a flow already keeps the agent's words uninterrupted while that
line is coming up (twilio_relay_keep_uninterrupted), so a turn it keeps
uninterrupted is no longer dropped.

The state is read on the turn only, which alone may touch the
conversation's states: at its start through the new
AgentTurnCallbacks.OnTurnStarted (called once the states are loaded),
as each response starts streaming and with each piece, and with the
reply. The session keeps the last value across turns, so in the moment
before a turn has read it the last turn's answer holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yileicn

yileicn commented Oct 30, 2026

Copy link
Copy Markdown
Member

reviewed

@yileicn
yileicn merged commit c459b0b into SciSharp:master Oct 30, 2026
4 checks passed
@yileicn
yileicn requested a balanced review from Copilot November 2, 2026 00:48

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

Unresolved cancellation and supersession races can cancel a later turn, discard replies after tool side effects, and speak superseded content.

6 open issues
What changed in this PR

This PR lets Twilio ConversationRelay cancel a pending reply when the caller resumes speaking, so the next turn can answer their combined input.

Changes:

  • Track speech, tool calls, keypad input, and uninterrupted-flow state to determine cancellation eligibility.
  • Connect turn callbacks to conversation streaming cancellation.
  • Mark dropped replies—including empty replies—as unheard in conversation history.
File Description
src/​Plugins/​BotSharp.Plugin.Twilio/​Services/​TwilioConversationProcessor.cs Registers streaming cancellation, notifies turn start, and permits empty-reply history rewrites.
src/​Plugins/​BotSharp.Plugin.Twilio/​Services/​ConversationRelaySession.cs Tracks turn eligibility, drops pending replies, and records unheard responses.
src/​Plugins/​BotSharp.Plugin.Twilio/​Models/​AgentTurnCallbacks.cs Adds turn-start notification and cancellation callbacks with deferred delivery.

Review effort: Balanced

OnStreamStarted = stream ? StartStreamAsync : null,
// A line said before a slow tool ("Okay, that's everything I need") is spoken now, and the watchdog
// counts the silence after it instead of waiting for the next response to close it
OnStreamEndedWithToolCall = stream ? CloseUtteranceBeforeToolAsync : null,
if (cancellation != null)
{
cancellation.RegisterConversation(message.ConversationId);
callbacks!.OnCancel(() => cancellation.CancelStreaming(message.ConversationId));
Comment on lines +327 to +328
return _settings.ConversationRelay.StreamReplies && _turnCallbacks != null && !_turnFromKeypad
&& !_turnReplied && !_superseded && !_streamedThisTurn && !_spokeThisTurn && !_toolCalledThisTurn
lock (_sync)
{
superseded = _superseded;
_keptUninterrupted = reply.KeepUninterrupted;
continue;
}

await SpeakAsync(GetUnstreamedText(reply.Content), keepUninterrupted: reply.KeepUninterrupted);
string sent;
lock (_sync)
{
if (_superseded)
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.

3 participants