Skip to content

fix(voice): stop a confirmed interruption from resuming the interrupted speech - #2122

Closed
toubatbrian wants to merge 2 commits into
mainfrom
brian/fix-confirmed-interruption-resume
Closed

fix(voice): stop a confirmed interruption from resuming the interrupted speech#2122
toubatbrian wants to merge 2 commits into
mainfrom
brian/fix-confirmed-interruption-resume

Conversation

@toubatbrian

@toubatbrian toubatbrian commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Problem

When the adaptive model rules an overlap a genuine barge-in, the agent pauses — and then puts the
interrupted speech back on the wire, continuing from exactly where it stopped. Reported from a live
session: "when I interrupt after asking for a long story, it does pause, but then it continues the
audio from where it's been cut off."

Two independent defects produce that.

1. The sink gate is opened before the interruption reaches the sink.

ParticipantAudioOutput.captureFrame parks frames at a pause gate and races them against
interruptedFuture, which clearBuffer() resolves:

if (!this.playbackEnabledFuture.done) {
  this.audioSource.clearQueue();
  await Promise.race([this.playbackEnabledFuture.await, this.interruptedFuture.await]);
  if (this.interruptedFuture.done) {
    return;
  }
}

cancelSpeechPause interrupts the paused speech and then calls audio.resume() without calling
clearBuffer(). That resume is only meant to admit the next speech, but it also releases every
frame parked during the pause. Whether you hear the interrupted speech continue depends on which of
clearBuffer() (async, via the playout task) and resume() (sync, at the end of
cancelSpeechPause) wins — so it reproduces intermittently and needs no false-interruption timer,
which is why affected sessions show no agent_false_interruption event.

2. A confirmed interruption was left resumable.

onInterruption only parked the speech through interruptByAudioActivity, leaving the
false-interruption timer free to resume it after its timeout. That timer exists for overlaps nobody
has ruled on yet, so a confirmed verdict now ends the pause outright.

Runtime evidence

Instrumented barge-in run, consecutive events:

event key fields
_output.ts:pause gate closed, interruptedAlready: false, 1 pending segment
cancelSpeechPause:ungateSink still 1 pending segment
_output.ts:resume clearBufferAlreadyFired: false
captureFrame past the gate a frame parked during the pause reached the wire

Over that run 14 frames were parked: 13 dropped (clearBuffer won) and 1 released (resume won).

After the fix, across three runs every resume that actually opened a closed gate saw
clearBufferAlreadyFired: true, with zero violations and zero released frames.

Changes

  • cancelSpeechPause calls clearBuffer() before resume() when it interrupted the paused speech.
  • onInterruption commits the interruption instead of leaving it parked. Side benefit: the agent
    stops roughly 700ms sooner, since it no longer waits for the final transcript.

Test plan

  • New unit test asserts the clearBufferresume ordering; fails on main
    (expected [ 'resume' ] to deeply equal [ 'clearBuffer', 'resume' ]).
  • New unit test asserts a confirmed interruption is not resumable by the timer.
  • agents/src/voice + agents/src/inference/interruption: 557 passed.
  • cue-cli barge-in scenario, 3 iterations: agent stops and answers the new question.
  • Live confirmation that the interrupted story no longer resumes. The cue-cli runs never parked
    a frame at the gate post-fix, so they exercise the ordering invariant but not the release path.

…umable

A verdict of "this overlap really was a barge-in" left the speech parked by
interruptByAudioActivity, which the false-interruption timer is free to resume once its
timeout elapses. That timer is for overlaps nobody has ruled on yet, so end the pause
outright once the model has ruled. Also stops the agent ~700ms sooner, since committing
the interruption no longer waits on the final transcript.

Co-authored-by: Cursor <cursoragent@cursor.com>
@toubatbrian
toubatbrian requested a review from a team as a code owner July 26, 2026 06:37
@changeset-bot

changeset-bot Bot commented Jul 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3e10112

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-krisp Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-protoface Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@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: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

cancelSpeechPause() opened the sink's pause gate without first calling clearBuffer(), so frames
of the speech it had just interrupted — parked at that gate for the duration of the pause — were
released and the interrupted speech continued from exactly where it stopped.

Runtime evidence from a barge-in run: the sink gate opened with clearBufferAlreadyFired=false and
one parked frame then reached the wire. This path needs no false-interruption timer, which is why
affected sessions show the audio resuming with no agent_false_interruption event.

Co-authored-by: Cursor <cursoragent@cursor.com>
@toubatbrian toubatbrian changed the title fix(voice): commit a confirmed interruption instead of leaving it resumable fix(voice): stop a confirmed interruption from resuming the interrupted speech Jul 26, 2026
@toubatbrian

Copy link
Copy Markdown
Contributor Author

Do not merge this without #2124.

#2124 fixes a pre-existing bug where ParticipantAudioOutput's interrupt signal is level-triggered and only reset inside flush(), so a stale clearBuffer() from a previous turn makes every frame parked at the pause gate bail. This PR widens the exposure: on the path where a reply completes its playout while paused and the user's transcript then finalizes, main never calls clearBuffer() at all, whereas this PR does.

Measured with a real AgentSession and a real ParticipantAudioOutput, on the next reply:

flag left set after cancelSpeechPause next reply delivered
main no 20/20 frames
this PR yes 2/20 frames, 18 lost

That is a reply that stops dead after ~40ms of 400ms, which the session still commits to chat context as fully spoken and not interrupted.

This PR needs no changes. #2124 resolves it, and the two were verified together: all scenarios deliver 20/20 with both applied, and this PR's own behaviour is preserved — its parked frames are still dropped, identically with and without #2124. Either merge order is fine as long as both land together.

@toubatbrian

Copy link
Copy Markdown
Contributor Author

The first commit — onInterruption committing a confirmed interruption instead of leaving it parked — is carried into #2126.

Verified there against a real AgentSession and a real ParticipantAudioOutput, with only the model verdict synthesized:

origin/main: interrupted=false  deliveredAfterVerdict=35  falseInterruptionEvents=[true]
#2126:       interrupted=true   deliveredAfterVerdict=1   falseInterruptionEvents=[]

The second commit — the clearBuffer()-before-resume() ordering inside cancelSpeechPause() — is not carried over. Its test asserts a call sequence rather than any observable behaviour, and with #2124 landed alongside it the parked frames bail at the sink's segment-scoped gate anyway. It is not quite a no-op, though: in the reproduction above the branch delivers one frame (20ms) after the verdict rather than zero, because resume() lands about a millisecond ahead of the interrupted speech's clearBuffer(). That is worth a small standalone PR with its own evidence rather than riding along here.

#2126 also asks reviewers to confirm the product judgement this change makes: committing the verdict removes the STT final-transcript safety net, so a false positive from the interruption model now permanently ends the speech. Python's on_interruption behaves like the unfixed JS, so it is a framework-wide gap.

Closing in favour of #2126.

@toubatbrian

Copy link
Copy Markdown
Contributor Author

Redirect: half of this PR now lives in #2134 (stack 5/5, based on #2133). The other half is not being shipped.

KeptcancelSpeechPause() clears the sink before reopening its gate. That is parity with Python, not a divergence: _cancel_speech_pause (agent_activity.py:4230) awaits _wait_for_generation() before resume(), so the interrupted reply task has already run clear_buffer() by the time Python reopens the gate. JS races that same await against the interrupt (deliberately, per #1124), so it returns immediately and has to signal the sink explicitly.

Dropped — having onInterruption call cancelSpeechPause() to commit the verdict outright. @chenghao-mou pointed out that leaving the speech parked is by design, and Python agrees (on_interruption, agent_activity.py:2066-2076).

The cost of dropping it is measured in #2134: on the path where no STT final transcript arrives, the stack is identical to main — the whole tail of the interrupted reply goes back on the wire. So the 'agent pauses, then resumes mid-sentence' symptom this PR was opened for is only prevented by a transcript landing inside falseInterruptionTimeout. That is Python's contract, and accepting it is the explicit trade-off of merging the stack.

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.

1 participant