Skip to content

fix(voice): propagate hard tail receive errors - #851

Open
allin2 wants to merge 6 commits into
langwatch:mainfrom
allin2:fix/voice-tail-receive-errors
Open

fix(voice): propagate hard tail receive errors#851
allin2 wants to merge 6 commits into
langwatch:mainfrom
allin2:fix/voice-tail-receive-errors

Conversation

@allin2

@allin2 allin2 commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Why

The TypeScript voice drain treated every error from its tail-silence receiveAudio call as a normal end of turn. Transport failures and adapter defects were therefore silently converted into truncated-but-successful agent turns, which is the worst failure mode for a test framework: the scenario keeps running and asserts against a turn that never happened. It is what hid the #697 P0 through CI, five rounds of automated review, and the original human reproduction.

Closes #756

What changed

  • Distinguish expected receive deadlines from hard receive failures, propagating the latter unchanged.
  • Use one internal ReceiveTimeoutError across the six built-in voice adapters, while accepting any error named TimeoutError from a custom adapter.
  • Label first_chunk_timeout only on an actual deadline. A transport that dies before the first chunk is no longer traced as an agent that stayed quiet. This matches Python, and the comment above that catch already claimed the behaviour.
  • Classify by error name alone, so the DOMException from AbortSignal.timeout() qualifies on any runtime and a doubly loaded module cannot defeat instanceof.
  • Apply the same rule to _drainSpokenTurn, the user-simulator's own drain loop, which had the identical defect.
  • Split "the stream ended" from "the stream broke" in Pipecat and ElevenLabs, so narrowing the catch does not fail runs in which nothing went wrong.
  • Document both turn-ending signals on VoiceAgentAdapter.receiveAudio: a TimeoutError-named rejection, or an empty chunk for a terminal condition that is not a failure.
  • Add specs/voice-drain-error-propagation.feature.

How it works

The drain catches only errors recognized as receive deadlines. Built-in adapters create the same internal timeout type at their existing deadline sites; errors from sockets, transports, configuration, assertions, or other adapter code bypass that check and keep their original message and stack.

An adapter that ends a turn without failing returns an empty chunk instead. That is this codebase's existing end-of-stream signal (#648, #646, #695, #849), and it is what keeps a deliberate agent hangup (#839) a clean conclusion rather than a failed run.

What narrowing the catch exposed

Three live paths were only safe because the drain absorbed everything. Each is a genuine defect the catch was hiding, and each would have started failing real runs the moment this landed.

Pipecat rejected every receive once its socket closed. Verified against the production call() wrapper: a bot that speaks and then hangs up failed with PipecatAgentAdapter: socket closed, no audio available, where it previously produced a normal turn. It was the last adapter still rejecting; ElevenLabs, OpenAI Realtime and Twilio had all converged on the terminal chunk already. It now resolves a parked receive with the terminal chunk on close, keeps returning it once the stream has ended, and wakes a parked receive on disconnect() the same way. A socket ERROR is recorded on the inbox instead, so it keeps failing every later receive with the underlying message and cause.

ElevenLabs resolved parked receives with the terminal chunk on a session ERROR, so a broken session read as a clean end of turn and the drain reported the partial audio as complete. Those now reject and the failure is recorded, so every later receive keeps failing. The clean path is unchanged and now also covers the case where the tail probe lands after the close rather than during it, which used to hit the isConnected() guard and throw "not connected". Harmless while the drain swallowed everything, fatal once it does not, and it would have failed exactly the hangups #839 went out of its way to allow.

_drainSpokenTurn absorbed every rejection from its own receiveAudio, so a socket close or server error handed back the audio collected so far as a complete spoken user line.

Tests

specs/voice-drain-error-propagation.feature binds three suites.

drain-tail-error-propagation.test.ts asserts outcomes rather than "did not raise":

  • the caller receives the adapter's own error object, with its class, custom fields and stack
  • no agent messages are produced, so a run cannot score the truncated turn
  • the voice.audio.receive span is ERROR and is not labelled tail_silence
  • the drain stops at the failure instead of swallowing and retrying
  • a deadline still closes the turn and keeps every chunk collected so far
  • the shared ReceiveTimeoutError, a custom TimeoutError-named error, and AbortSignal.timeout()'s DOMException all end the turn cleanly
  • a thrown non-Error value propagates instead of being read as a deadline
  • a deliberate agent hangup stays a clean terminal_chunk end of turn with agentHungUp still set

receive-timeout-contract.test.ts covers both halves of the adapter contract, faked at the network-client boundary with no adapter privates touched. It drives all six adapters to their real deadlines and asserts the rejection classifies as a receive timeout. This is the guard that matters most: an adapter that rejects its deadline with a plain Error is read as a hard failure, so a normal end of turn aborts the run, and no per-adapter test notices because each asserts its own message rather than the class. It then covers the other half, that a stream which ENDS is an end of turn: the terminal chunk on close, on a later probe after the close, and on a real call() whose stream ends mid-drain, with a socket error still failing every receive.

Every entry was verified red by reverting its own adapter.

voice-spans.test.ts gains the A4-negative, which was previously Python-only. openai-realtime-speak-user-turn.test.ts gains a server error mid-utterance that must reach the caller.

Two span tests in openai-realtime-spans.test.ts were ending their turn by pushing a synthetic server error event, precisely because the loop swallowed everything. They push an empty audio delta instead, which is the real end-of-stream signal and does not go green if the propagation breaks.

Test plan

  • pnpm exec vitest run — 96 files passed, 1 skipped; 1113 passed, 4 skipped.
  • pnpm build:all, pnpm smoke:dist, pnpm lint:all, pnpm lint:lib, pnpm typecheck:all all clean.

Anything surprising?

This PR had never actually run CI. It comes from a fork, so javascript-ci, python-ci and docs-ci sat at action_required on every push since the branch was opened, while the check list showed green because the only checks that did run are the firefighting ones that skip themselves. Approved now.

Separately, eslint . reports pre-existing import-order errors across the test tree. CI does not gate on them; extending the lint gate to tests is tracked in #565.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c00d9af-abfc-4d1c-a00c-e3b9e9be1f4a

📥 Commits

Reviewing files that changed from the base of the PR and between 19beb66 and 331e22f.

📒 Files selected for processing (2)
  • javascript/src/voice/adapters/elevenlabs.ts
  • javascript/src/voice/adapters/pipecat.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • javascript/src/voice/adapters/elevenlabs.ts
  • javascript/src/voice/adapters/pipecat.ts

Included review availability: Your plan includes up to 4 reviews per rolling hour; 2 remain after this review.


Walkthrough

This change standardizes receive timeout errors across voice adapters. Voice draining now propagates hard failures and records separate timeout and error telemetry. Tests cover adapter contracts, transport failures, timeout variants, and terminal chunks.

Changes

Voice drain timeout classification

Layer / File(s) Summary
Timeout error contract and adapter producers
javascript/src/voice/receive-timeout-error.ts, javascript/src/voice/adapters/*
Adds ReceiveTimeoutError and isReceiveTimeoutError. Adapters use the typed error for receive deadlines.
Adapter failure and terminal-chunk state
javascript/src/voice/adapters/elevenlabs.ts, javascript/src/voice/adapters/pipecat.ts
Adapters retain transport failures, reject failed receives, and return empty terminal chunks after clean closure.
Drain error handling and receive contract
javascript/src/voice/adapter.runtime.ts, javascript/src/voice/adapter.ts
Recognized timeouts end turns cleanly. Other receive failures propagate with their original details.
Timeout, hard-error, and terminal-chunk validation
javascript/src/voice/__tests__/*, javascript/src/voice/adapters/__tests__/*, specs/voice-drain-error-propagation.feature
Tests cover timeout variants, hard errors, telemetry, collected audio, adapter deadlines, server failures, and terminal chunks.

Sequence Diagram(s)

sequenceDiagram
  participant DrainAgentResponse
  participant VoiceAdapter
  participant TimeoutClassifier
  participant VoiceTelemetry
  DrainAgentResponse->>VoiceAdapter: call receiveAudio(timeout)
  VoiceAdapter-->>DrainAgentResponse: return timeout or receive failure
  DrainAgentResponse->>TimeoutClassifier: classify failure
  TimeoutClassifier-->>DrainAgentResponse: return timeout or hard-error classification
  DrainAgentResponse->>VoiceTelemetry: record termination or error
Loading

Possibly related PRs

Suggested labels: review: deep

Suggested reviewers: rogeriochaves

Poem

A rabbit guards each audio gate,
Timeouts end; hard errors wait.
Empty chunks close turns with care,
Failed streams keep their causes there.
Spans record each signal true.

Merge Risk: ⚪ Minimal · up to 331e2

The change propagates genuine voice receive failures while preserving expected timeout and terminal-end behavior; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: propagating hard errors from tail receive operations.
Description check ✅ Passed The description directly explains the problem, implementation, affected adapters, tests, and validation results.
Linked Issues check ✅ Passed The changes satisfy issue #756 by distinguishing timeouts from hard failures and propagating non-timeout errors across voice drains and adapters.
Out of Scope Changes check ✅ Passed The adapter changes, contract documentation, acceptance scenarios, and regression tests are directly related to the linked issue objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@rogeriochaves
rogeriochaves force-pushed the fix/voice-tail-receive-errors branch from a9b8748 to 7a97f5a Compare August 12, 2026 11:58
@rogeriochaves
rogeriochaves marked this pull request as ready for review August 12, 2026 11:58

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
javascript/src/voice/adapters/elevenlabs.ts (1)

829-838: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Reject parked receives when the session reports an error.

Line 829 correctly classifies an elapsed deadline. It does not cover onSessionError() at Lines 570-581. That handler calls drainPendingWaiters(), which resolves the active receiveAudio() with an empty chunk. drainInner() then records terminal_chunk and returns a successful truncated turn.

Split the paths. In onSessionError(), reject each pending receive with the original err. Keep empty chunks for clean terminal conditions only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@javascript/src/voice/adapters/elevenlabs.ts` around lines 829 - 838, Update
onSessionError() to reject every pending receive waiter with the original
session error instead of resolving it with an empty chunk through
drainPendingWaiters(). Preserve empty-chunk resolution for clean terminal
conditions, and keep the receiveAudio timeout handling unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@javascript/src/voice/adapters/__tests__/receive-timeout-contract.test.ts`:
- Around line 71-119: Expand the shared ADAPTERS contract coverage to include
the composable, OpenAI Realtime, and Twilio adapters, configuring each to
establish its connection and park receiveAudio(DEADLINE_S) with appropriate
teardown. Add success and timeout/failure assertions for every newly covered
adapter, preserving the existing contract behavior and test structure.

In `@javascript/src/voice/adapters/openai-realtime.ts`:
- Line 37: Update _drainSpokenTurn so its receiveAudio error handling catches
only errors identified by isReceiveTimeoutError, preserving the timeout behavior
while rethrowing socket closures and all other producer failures instead of
returning partial or empty audio as a successful turn. Reuse the imported
ReceiveTimeoutError-related helper or established timeout check already present
in the module.

---

Outside diff comments:
In `@javascript/src/voice/adapters/elevenlabs.ts`:
- Around line 829-838: Update onSessionError() to reject every pending receive
waiter with the original session error instead of resolving it with an empty
chunk through drainPendingWaiters(). Preserve empty-chunk resolution for clean
terminal conditions, and keep the receiveAudio timeout handling unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9578f829-5707-4298-a168-4c5d9646c282

📥 Commits

Reviewing files that changed from the base of the PR and between f26508b and 7a97f5a.

📒 Files selected for processing (13)
  • javascript/src/voice/__tests__/drain-tail-error-propagation.test.ts
  • javascript/src/voice/__tests__/voice-spans.test.ts
  • javascript/src/voice/adapter.runtime.ts
  • javascript/src/voice/adapter.ts
  • javascript/src/voice/adapters/__tests__/receive-timeout-contract.test.ts
  • javascript/src/voice/adapters/composable.ts
  • javascript/src/voice/adapters/elevenlabs.ts
  • javascript/src/voice/adapters/gemini-live.ts
  • javascript/src/voice/adapters/openai-realtime.ts
  • javascript/src/voice/adapters/pipecat.ts
  • javascript/src/voice/adapters/twilio.ts
  • javascript/src/voice/receive-timeout-error.ts
  • specs/voice-drain-error-propagation.feature

Comment thread javascript/src/voice/adapters/openai-realtime.ts Outdated
@rogeriochaves

Copy link
Copy Markdown
Contributor

On the outside-diff note about elevenlabs.ts onSessionError(): valid, fixed in 4733cf1.

It resolved parked receives with the empty terminal chunk, so a broken session read as a clean end of turn and the drain reported the partial audio as a complete one. Parked receives are now rejected with the underlying error, and the failure is recorded so every later receive keeps failing rather than the next probe finding a quiet socket. elevenlabs.test.ts asserted the old behaviour, so that test now pins the new one.

The clean path stays as it was, deliberately: a session that ENDS still resolves with the terminal chunk, and now keeps returning it once the stream has ended. That last part matters beyond this comment. The tail probe usually lands after the close rather than during it, and it used to hit isConnected() and throw "not connected". Harmless while the drain swallowed everything, fatal once it does not, and it would have failed exactly the agent hangups #839 went out of its way to allow.

Same split applied to Pipecat in 9438a94, which had the same problem and was the last adapter still rejecting on a closed stream.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🧹 Nitpick comments (1)
javascript/src/voice/adapters/pipecat.ts (1)

408-423: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the public receiveAudio contract with JSDoc.

Document timeout, non-empty audio results, zero-length terminal chunks, ReceiveTimeoutError, and propagated transport or session failures.

  • javascript/src/voice/adapters/pipecat.ts#L408-L423: add JSDoc for Pipecat clean-close, timeout, and socket-failure behavior.
  • javascript/src/voice/adapters/elevenlabs.ts#L826-L902: add JSDoc for ElevenLabs clean-end, timeout, and session-failure behavior.

As per coding guidelines: “Use JSDoc comments for all public APIs in TypeScript” and “Document error conditions in code documentation.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@javascript/src/voice/adapters/pipecat.ts` around lines 408 - 423, Document
the public receiveAudio contract with JSDoc at
javascript/src/voice/adapters/pipecat.ts:408-423 and
javascript/src/voice/adapters/elevenlabs.ts:826-902, covering the timeout
parameter, non-empty audio results, zero-length chunks for clean stream/session
termination, ReceiveTimeoutError on timeout, and propagation of transport or
session failures. Update both receiveAudio implementations’ documentation; no
behavior changes are required.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@javascript/src/voice/adapters/__tests__/receive-timeout-contract.test.ts`:
- Around line 344-367: Strengthen the assertion in the real call test around
adapter.call(makeAgentInput()) so it verifies the returned AgentReturnTypes
contains a non-empty audio result matching the emitted payload, rather than only
checking that messages is truthy. Keep the test focused on retaining audio sent
before the stream closes.
- Around line 327-342: The test “still fails every receive after a socket error”
must also cover an in-flight receive: start and retain adapter.receiveAudio(30)
before emitting the socket error, then assert that promise rejects with an Error
containing “ECONNRESET” and is not a receive-timeout error. Preserve the
existing assertions for subsequent receives after the socket error.

In `@javascript/src/voice/adapters/pipecat.ts`:
- Around line 579-582: Preserve the original transport error by assigning err
directly to inbox.failure in javascript/src/voice/adapters/pipecat.ts lines
579-582, rather than creating a wrapper Error. Apply the same change to
sessionFailure in javascript/src/voice/adapters/elevenlabs.ts lines 598-601 so
subsequent receive calls retain the original error identity, message, and stack.

---

Nitpick comments:
In `@javascript/src/voice/adapters/pipecat.ts`:
- Around line 408-423: Document the public receiveAudio contract with JSDoc at
javascript/src/voice/adapters/pipecat.ts:408-423 and
javascript/src/voice/adapters/elevenlabs.ts:826-902, covering the timeout
parameter, non-empty audio results, zero-length chunks for clean stream/session
termination, ReceiveTimeoutError on timeout, and propagation of transport or
session failures. Update both receiveAudio implementations’ documentation; no
behavior changes are required.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 44167138-1ccb-4c25-b341-b291aab3a6b1

📥 Commits

Reviewing files that changed from the base of the PR and between 7a97f5a and 4733cf1.

📒 Files selected for processing (6)
  • javascript/src/voice/adapters/__tests__/elevenlabs.test.ts
  • javascript/src/voice/adapters/__tests__/openai-realtime-spans.test.ts
  • javascript/src/voice/adapters/__tests__/receive-timeout-contract.test.ts
  • javascript/src/voice/adapters/elevenlabs.ts
  • javascript/src/voice/adapters/openai-realtime.ts
  • javascript/src/voice/adapters/pipecat.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • javascript/src/voice/adapters/openai-realtime.ts

Comment thread javascript/src/voice/adapters/pipecat.ts Outdated
@rogeriochaves

Copy link
Copy Markdown
Contributor

Worth flagging separately: this PR had never actually run CI.

It comes from a fork, so javascript-ci, python-ci and docs-ci all sat at action_required waiting for a maintainer to approve the workflow run. That has been the state on every push since the branch was opened. The check list still showed green, because the only checks that DID run are the firefighting/preflight ones that skip themselves, so nothing was red and nothing looked wrong.

Approved now, and the real jobs are running. Anyone reviewing a fork PR here should check gh api repos/langwatch/scenario/actions/runs --jq ".workflow_runs[] | select(.conclusion==\"action_required\")" before reading a green check list as a passing build.

@rogeriochaves

Copy link
Copy Markdown
Contributor

Status: CI is green for real now (javascript-complete, python-complete, docs-ci all pass) and CodeRabbit has completed its review. Waiting on two things before this can land.

  1. fix(voice): align the JS response timeout with Python and split the two receiveAudio timeouts #895 fix(voice): align the JS response timeout with Python and split the two receiveAudio timeouts should merge first. It rewrites the same ElevenLabs deadline site this touches, splitting one rejection into an idle one and a ceiling one, and both of its new sites build a plain Error. Rebasing this on top without converting them to ReceiveTimeoutError would make every ElevenLabs tail-silence probe a hard failure. receive-timeout-contract.test.ts catches exactly that, so the rebase will go red until it is done rather than landing the break quietly.
  2. An approving review.

Happy to do the rebase the moment #895 is in.

@langwatch-agent langwatch-agent added hound-checked Triaged by the pr-hound agent at the current head SHA ci-green Latest run of every check is passing (checks API, not the legacy commit-status index) labels Aug 12, 2026

@langwatch-agent langwatch-agent 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.

No findings from static review of the external contribution. I checked the changed receive/error paths and their tests; I did not execute contributor code or install dependencies.

LangWatch-Review: verdict=clean sha=a1f14942acf1007fb6961ba0600cb94abe7751b3 p0=0 p1=0 p2=0 p3=0

allin2 and others added 5 commits August 16, 2026 12:35
…k mislabel

The tail-drain fix rested on one assertion that a hard error rejects with a
given message. That leaves the two things worth proving untested: that the
error reaches the caller intact rather than as a truncated turn, and that the
built-in adapters still produce a rejection the drain reads as a deadline.

The second gap is the dangerous one. An adapter that rejects its deadline with
a plain Error is now read as a hard failure, so a normal end of turn aborts the
run, and nothing in the suite notices because each adapter test asserts its own
message rather than the class.

- Add specs/voice-drain-error-propagation.feature.
- Assert semantics on the tail split: the original error object reaches the
  caller with its class and fields, no agent messages are produced, the receive
  span is ERROR and not labelled tail_silence, a deadline still keeps the audio
  collected so far, and a deliberate agent hangup stays a clean end of turn.
- Add an adapter contract test driving ElevenLabs, Pipecat and Gemini Live to
  their real deadlines, faked at the network-client boundary.
- Label first_chunk_timeout only on an actual deadline, so a transport that
  dies before the first chunk is no longer traced as a quiet agent. Matches
  Python, and the comment above the catch already claimed this behaviour.
- Classify by error name alone, so the DOMException from AbortSignal.timeout()
  qualifies on any runtime and a doubly loaded module cannot defeat instanceof.
- Document both turn-ending signals on receiveAudio: a TimeoutError-named
  rejection, or an empty chunk for a terminal condition that is not a failure.
Narrowing the drain's catch turned an ordinary end of call into a failed run.
Pipecat rejected every receive once its socket closed, and the broad catch used
to absorb that. Now it propagates, so a bot that hangs up after speaking fails
the scenario with "socket closed, no audio available".

ElevenLabs (langwatch#648), OpenAI Realtime (langwatch#646) and Twilio (langwatch#695) all converged on
the same answer: a stream that has ended yields the empty terminal chunk, and
the shared drain exits cleanly. Pipecat was the last one still rejecting.

- Resolve a parked receive with the terminal chunk on socket close, and keep
  returning it once the stream has ended.
- Wake a parked receive on disconnect() the same way, matching langwatch#849.
- Record a socket ERROR on the inbox so it keeps failing every later receive,
  carrying the underlying message and cause. A broken transport is not an end
  of turn however many times it is asked.
- Cover all four paths, including a real call() whose stream ends mid-drain.
…ceive paths

Three gaps found in review, all the same shape as langwatch#756.

`_drainSpokenTurn` is a second drain loop, on the user-simulator side, and it
absorbed every rejection from its own `receiveAudio`. A socket close or a server
error handed back the audio collected so far as a complete spoken user line. It
now breaks only on a receive deadline. Two span tests were ending their turn by
pushing a synthetic server `error` event, which is exactly the rejection that
must now propagate; they push an empty audio delta instead, which is the real
end-of-stream signal and does not go green if the propagation breaks.

The ElevenLabs session-error handler resolved parked receives with the empty
terminal chunk, so a broken session read as a clean end of turn. It now rejects
them, and records the failure so every later receive keeps failing. A clean
session end still resolves with the terminal chunk, and keeps doing so once the
stream has ended, which is the ordinary shape of an agent hangup (langwatch#839).

The contract table covered three adapters. It now covers all six, so no timeout
producer can drift back to a plain Error unnoticed. Each entry verified red
against its own adapter.
The narrowed catch in `_drainSpokenTurn` had no direct coverage: the two
existing tests end their turn on the idle deadline, which passes either way.
This one pushes an error after the first audio delta and requires it to reach
the caller, so the simulator can never hand back half a sentence as the user's
real line.

Records the rule as AC7 of the spec: it is per drain loop, not per adapter, and
langwatch#623 adds more of them as agent-initiated turns reach the other adapters.
@allin2
allin2 force-pushed the fix/voice-tail-receive-errors branch from a1f1494 to 19beb66 Compare August 16, 2026 04:43
@allin2
allin2 force-pushed the fix/voice-tail-receive-errors branch from 19beb66 to 331e22f Compare August 16, 2026 04:45
@allin2

allin2 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

@rogeriochaves I rebased this PR onto the latest main (c9b58bb), which includes #895, and resolved the ElevenLabs overlap as suggested. Both the sliding idle deadline and the absolute hard ceiling now reject with ReceiveTimeoutError, so normal ElevenLabs tail silence remains a clean turn boundary.

I also addressed the remaining review findings:

  • an in-flight Pipecat receiveAudio() now has regression coverage for immediate ECONNRESET propagation;
  • Pipecat and ElevenLabs preserve the original transport/session error object, including identity, message, stack, and provider-specific fields;
  • the real Pipecat call() test now verifies a non-empty assistant audio result against the decoded and resampled emitted payload;
  • both public receiveAudio() implementations document audio, terminal-chunk, timeout, and hard-failure behavior.

Local validation with Node 22.23.1:

  • targeted timeout-contract + ElevenLabs tests: 64 passed;
  • complete voice suite: 499 passed, 1 skipped;
  • complete JavaScript suite with coverage: 1,138 passed, 4 skipped;
  • typecheck, lint:lib, changed-file lint, CJS/ESM/DTS build, and dist smoke: passed.

The full-tree eslint . command still reports the pre-existing test/example lint baseline noted in #565; all files changed by this PR are clean.

Could you please re-review the updated head (331e22f)?

@langwatch-agent

Copy link
Copy Markdown
Contributor

Human Review Brief

Mode: Targeted Review. Closes #756, and the body names the cost precisely: this failure mode hid a P0 through CI, five rounds of automated review, and the original human reproduction. That is the argument for the change, and it is a good one.

Decisions being ratified

  1. A hard receive failure is no longer an end of turn. Silent truncation into a successful turn is the worst outcome for a test framework, because the scenario keeps running and asserts against a turn that never happened. Approving this makes "errors propagate unless proven to be a deadline" the rule.
  2. Classification is by error name, not instanceof. That is deliberate: the DOMException from AbortSignal.timeout() qualifies on any runtime, and a doubly loaded module cannot defeat it. It also means any custom adapter throwing something named TimeoutError is treated as a deadline, which is now a documented extension point rather than an accident.
  3. first_chunk_timeout is labelled only on an actual deadline. A transport that dies before the first chunk stops being traced as a quiet agent. This aligns TypeScript with Python and with what the comment above the catch already claimed.

Must Check

  • The six built-in adapters against the one internal ReceiveTimeoutError. Confirm each genuinely throws it on a deadline rather than something else that used to be swallowed, otherwise a real deadline now propagates as a hard failure and turns passing scenarios red.
  • _drainSpoke... and any other drain path. The body says the same rule is applied there. Confirm there is no third drain that kept the old behaviour, since one surviving swallow reproduces the entire bug.
  • Name-based classification and minifiers. If any consumer bundles with a minifier that mangles error class names, name matching degrades silently. Worth one sentence either way.

Ask Author

Does an adapter that throws a plain Error on a deadline now fail the turn? If so, that is a breaking change for custom adapters and belongs in release notes.

Production risk

Low for production, high for trust: this changes which scenarios pass. Expect previously green suites to surface real failures, which is the point, and should be said out loud when it ships.

@langwatch-agent langwatch-agent added the review: targeted PR Hound review mode label Aug 17, 2026

@langwatch-agent langwatch-agent 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.

No additional actionable correctness, security, or integrity finding in the external diff. The shared timeout discriminator is applied to both drain loops, while clean close and hard transport-error paths remain distinct in the adapters. I performed static review only; contributor code and dependencies were not executed.

LangWatch-Review: verdict=clean sha=331e22f8c007abd469ecd751bbbe3a96a531a2c9 p0=0 p1=0 p2=0 p3=0

@langwatch-agent langwatch-agent 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.

External static review: no blocking concern found in the current diff. I did not execute branch code, install dependencies, or run contributor-provided scripts. Residual risk: runtime behavior remains covered by the repository CI.

LangWatch-Review: verdict=clean sha=331e22f8c007abd469ecd751bbbe3a96a531a2c9 p0=0 p1=0 p2=0 p3=0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-green Latest run of every check is passing (checks API, not the legacy commit-status index) hound-checked Triaged by the pr-hound agent at the current head SHA review: targeted PR Hound review mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

voice: drainAgentResponse silently swallows hard errors from the tail-silence receiveAudio

4 participants