Skip to content

fix: flush pending handoff data on streaming voice responses - #83

Open
ryanrishi wants to merge 2 commits into
mainfrom
fix/voice-streaming-handoff-flush
Open

fix: flush pending handoff data on streaming voice responses#83
ryanrishi wants to merge 2 commits into
mainfrom
fix/voice-streaming-handoff-flush

Conversation

@ryanrishi

Copy link
Copy Markdown
Member

Summary

VoiceChannel.sendStreamingResponse() never flushed session.pendingHandoffData, so the ConversationRelay end message was not sent when an app streamed its final response. The caller was not transferred and the handoffData payload — including the conversationSid apps 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 both sendResponse() and sendStreamingResponse().

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. pendingHandoffData stays on the session and the next sendResponse()/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 end message right after the last token, restoring the pre-streaming sequence without a hardcoded sendResponse().

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Refactoring

Checklist

  • Tests added/updated
  • Documentation updated
  • Tested E2E

Three tests in tests/voice-channel.test.ts, written to fail before the fix:

  • streaming sends the end message after the final {last: true} marker and clears pendingHandoffData
  • no end message when no handoff is pending
  • a pending handoff survives an interrupt and is flushed by the next response

TSDoc on sendStreamingResponse() now states the flush behavior. The contract documented in packages/tools/src/built-in/handoff.ts ("the voice channel automatically sends the WS end message with handoffData after 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.

Tip: Use the /sync-to-python-sdk skill in Claude Code to automatically generate and create a Python SDK PR from your changes.

  • Change is TypeScript-specific (no Python update needed)
  • Python SDK PR created:

Python is already correct — it has a single send_response() that branches on str vs AsyncGenerator with 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

`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>
Copilot AI review requested due to automatic review settings July 30, 2026 20:59

Copilot AI 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.

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 pendingHandoffData flushing into flushPendingHandoff() and invokes it from both sendResponse() and sendStreamingResponse().
  • 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.

Comment thread tests/voice-channel.test.ts
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>
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