fix(realtime): clamp interrupt truncation to received audio - #4122
Merged
seratch merged 1 commit intoAug 2, 2026
Merged
Conversation
seratch
approved these changes
Aug 2, 2026
seratch
enabled auto-merge (squash)
August 2, 2026 22:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
OpenAIRealtimeWebSocketModel._send_interrupt()can send aconversation.item.truncatewhoseaudio_end_msis larger than the audio duration the client actually received, which the Realtime API rejects. The value is now clamped to the received audio length, matching what the VAD (input_audio_buffer.speech_started) barge-in path already does.Affected component:
src/agents/realtime/openai_realtime.py— explicit interrupts (RealtimeSession.interrupt()/RealtimeModelSendInterrupt).Problem. #2370 reported that the SDK sends truncate events with an
audio_end_msbigger than the item's audio length, so the server answers with an error. #2374 fixed both interrupt paths by clampingtruncated_msto the received audio length. The follow-up refactor in #2385 restructured both paths so the truncate decision no longer depends on_ongoing_responsealone; the VAD path kepttruncated_ms = min(truncated_ms, max_audio_ms), but the_send_interrupt()path lost it while the surroundingelse:block was removed. Since then, whenever a response is still in progress, an explicit interrupt sends the raw elapsed time.This is easy to hit with the default timing. Without a custom
RealtimePlaybackTracker,elapsed_msis wall-clock time since the first audio delta for the item, whilemax_audio_msis the audio actually received. Any pause between deltas (model still generating, tool call, slow network) makes elapsed time outgrow the received audio, so the truncate is sent out of range.Minimal reproduction (no API key, no live request):
Current behavior: the truncate carries
audio_end_ms=5000for an item with 1000 ms of audio. The identical setup driven throughinput_audio_buffer.speech_startedsendsaudio_end_ms=1000(this is already asserted bytests/realtime/test_openai_realtime.py::test_speech_started_truncates_when_response_ongoing).Corrected behavior: both interrupt paths truncate at 1000 ms.
Root cause: the clamp removed from
_send_interrupt()in #2385 was never re-added, so only the VAD path boundstruncated_msbymax_audio_ms.Implementation: restore
truncated_ms = min(truncated_ms, max_audio_ms)inside the existing send branch of_send_interrupt(), guarded bymax_audio_ms is not Noneexactly as the VAD path does.Why this is minimal: four lines in one function. The decision of whether to send a truncate is unchanged (the
self._ongoing_response or max_audio_ms is None or truncated_ms < max_audio_mscondition is untouched), only the value is bounded. No public API, no new abstraction, no behavior change when the elapsed time is already within the received audio (the common case), and no change to the VAD path, the playback trackers, or response cancellation.Test plan
tests/realtime/test_playback_tracker.py:test_interrupt_clamps_truncate_to_received_audio_while_response_ongoing(new): default timing, wall clock 5 s past 1000 ms of received audio, expectsaudio_end_ms == 1000. Fails onmainwith5000 == 1000.test_interrupt_matches_speech_started_truncation_point(new): drives the same tracker state through_send_interrupt()and throughinput_audio_buffer.speech_startedand asserts both truncate at the same point. Fails onmainwithassert 5000 == 1000.test_interrupt_sends_truncate_when_ongoing_response(existing): still asserts that a truncate is sent while a response is ongoing; the expected value moves from2000to1000because the custom playback tracker reports 2000 ms for an item with only 1000 ms of received audio. That2000expectation was introduced in fix: #2370 send truncate events independent of response state #2385 alongside the dropped clamp.Execution modes covered: explicit interrupt with the default
ModelAudioTrackertiming, explicit interrupt with a customRealtimePlaybackTracker, and VADspeech_startedbarge-in. Cases where the elapsed time is below the received audio, where no audio state exists (max_audio_ms is None), and where playback is complete and the truncate is skipped are already covered by the existing tests in that file and intests/realtime/test_openai_realtime.py, and all still pass unchanged.No lifecycle, cancellation, or cleanup behavior changes: the tracker resets,
response.cancelhandling, and emittedaudio_interruptedevents are untouched.Commands run from the repository root on
fc084ae+ this change (Python 3.12.13, macOS 15.6 / darwin 24.6.0):The three focused tests were also run repeatedly to confirm determinism. Integration test profiles (
make integration-tests*) were not run: they require live provider credentials and are unrelated to this change.Non-goals: no change to the truncate/skip decision, to
RealtimePlaybackTracker, toModelAudioTracker, to response cancellation, or to the VAD path.Issue number
Follow-up to #2370 (the clamp added in #2374 was dropped from
_send_interrupt()by the refactor in #2385).Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PR