fix: flush pending handoff data on streaming voice responses - #83
Open
ryanrishi wants to merge 2 commits into
Open
fix: flush pending handoff data on streaming voice responses#83ryanrishi wants to merge 2 commits into
ryanrishi wants to merge 2 commits into
Conversation
`sendStreamingResponse()` sent the final `{last: true}` token marker and
returned without checking `session.pendingHandoffData`, so the
ConversationRelay `end` message was never sent when an app streamed its
final response. Callers were not transferred and the `handoffData` payload
never reached the `<Connect action>` URL. `sendResponse()` already flushed
it, so the two paths diverged for identical session state.
Extract the flush into `flushPendingHandoff()` and call it from both send
paths. An aborted stream leaves the handoff pending so the next response
delivers it, rather than ending the call mid-utterance.
The Python SDK has a single `send_response()` that branches on str vs
AsyncGenerator with the flush after both branches, so this restores
cross-SDK parity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes a behavioral gap in the Voice channel where sendStreamingResponse() did not flush session.pendingHandoffData, preventing ConversationRelay from receiving the end message (and associated handoffData) when the final assistant response was streamed.
Changes:
- Extracts shared
pendingHandoffDataflushing intoflushPendingHandoff()and invokes it from bothsendResponse()andsendStreamingResponse(). - Ensures streaming responses flush the handoff only after a non-aborted stream completes (aborted streams intentionally keep the handoff pending).
- Adds/extends Vitest coverage for streaming handoff flush behavior and the interrupt/abort path.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
packages/core/src/channels/voice.ts |
Introduces flushPendingHandoff() and unifies handoff flushing across non-streaming and streaming send paths. |
tests/voice-channel.test.ts |
Adds streaming and interrupt-path tests to verify end message behavior and persistence/flush of pending handoff data. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Without this, a regression that dropped the final `{token: "", last: true}`
marker would leave finalMarkerIndex at -1 and the ordering assertion would
hold trivially.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ryanrouleau
approved these changes
Aug 6, 2026
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
VoiceChannel.sendStreamingResponse()never flushedsession.pendingHandoffData, so the ConversationRelayendmessage was not sent when an app streamed its final response. The caller was not transferred and thehandoffDatapayload — including theconversationSidapps read at the<Connect action>URL — never arrived.sendResponse()already flushed it, so the two send paths behaved differently for identical session state.Reported by a customer who hit it after adopting streaming; their workaround was a hardcoded
sendResponse()before the call ended.The flush is now
flushPendingHandoff(), called from bothsendResponse()andsendStreamingResponse().Interrupt behavior
An aborted stream deliberately does not flush. The response was cut short, so ending the call there would drop the caller mid-utterance.
pendingHandoffDatastays on the session and the nextsendResponse()/sendStreamingResponse()delivers it — no payload is lost. A test covers this path.Streaming and tool-call ordering
The customer also observed that with streaming, the handoff tool runs after the text has been streamed. That is inherent to streaming LLM APIs — tool calls arrive at the end of a completion — and is not addressed here. The resolution is in the app's agent loop: execute the tool, submit the result, then stream the model's follow-up as a separate turn. With this fix that second call delivers the
endmessage right after the last token, restoring the pre-streaming sequence without a hardcodedsendResponse().Type of Change
Checklist
Three tests in
tests/voice-channel.test.ts, written to fail before the fix:endmessage after the final{last: true}marker and clearspendingHandoffDataendmessage when no handoff is pendingTSDoc on
sendStreamingResponse()now states the flush behavior. The contract documented inpackages/tools/src/built-in/handoff.ts("the voice channel automatically sends the WSendmessage withhandoffDataafter the LLM's final response is delivered") is unqualified and now holds for both paths.SDK Parity
This is the TypeScript SDK. If this change affects shared functionality, ensure the Python SDK is updated as well.
Python is already correct — it has a single
send_response()that branches onstrvsAsyncGeneratorwith the flush placed after both branches (src/tac/channels/voice/channel.py:819-834). This was a TypeScript-only divergence introduced by splitting the two paths into separate public methods.🤖 Generated with Claude Code