cloudflare / cloudflare/agents

withVoice: onCallEnd fires before in-flight turn settles, causing lost final turn on abrupt disconnect

Open
#1,849 0 comments 0 reactions 1 assignee Claimed by @cjol View on GitHub
bug
Dominant language
TypeScript
Stars
5.6k
Forks
711
Avg merge
1d 20h
Merged PRs (30d)
53

Description

`withVoice`'s call-end flow doesn't wait for an in-flight turn to finish before invoking `onCallEnd()`, so any state a user persists in `onCallEnd` (e.g. syncing the final turn to an external DB) can race with — and lose — the last turn when the call ends mid-response.

### Where this happens

`#handleEndCall` calls `onCallEnd(connection)` synchronously, with no `await` and no check on whether `#runPipeline` (the async `onTurn` → TTS pipeline) is still running for that connection:

```js
#handleEndCall(connection) {
this.#cm.cleanup(connection.id);
this.#releaseKeepAlive(connection.id);
this.#sendJSON(connection, { type: "status", status: "idle" });
this.onCallEnd(connection);
}
```

`#runPipeline` itself is invoked fire-and-forget from `onUtterance`:

```js
onUtterance: (transcript) => {
this.#sendJSON(connection, { type: "transcript_interim", text: "" });
this.#runPipeline(connection, transcript);
}
```

There's no exposed primitive (e.g. `isTurnActive()`, `awaitPendingTurn()`, or an `onCallEnd` that's guaranteed to fire only after the pipeline settles) to bridge these two. `onCallEnd`'s own default is an empty stub, so nothing in the framework enforces ordering here.

### Why it matters for telephony

For browser-based voice widgets this is rarely observable — the client itself sends `end_call` once it's satisfied with the final response, so there's an implicit "the turn is done" signal before disconnect.

For PSTN telephony adapters (Twilio/Telnyx/etc.), the caller can hang up at any instant, including mid-turn while `onTurn()`'s stream is still generating/synthesizing. In that case:
1. `onCallEnd` fires immediately (via the WS `close` handler forwarding to `onCallEnd`, since telephony calls don't send an explicit `end_call` message the way the browser client does).
2. Meanwhile the in-flight `onTurn()`/`#runPipeline` continues running (in a Durable Object, pending I/O keeps it alive) and is still writing its final turn state.
3. Any persistence a user does inside `onCallEnd` (e.g. flushing turns to Postgres) runs *before* the in-flight turn has finished writing, so the last turn is dropped.

### Repro sketch

```ts
class MyVoiceAgent extends withVoice(Agent) {
transcriber = new WorkersAIFluxSTT(this.env.AI)
tts = new WorkersAITTS(this.env.AI)

async onTurn(transcript: string, context: VoiceTurnContext) {
// Simulate a slow-ish response
return (async function* () {
yield "Thinking..."
await new Promise((r) => setTimeout(r, 2000))
yield " here is the final answer."
})()
}

async onCallEnd(connection: Connection) {
// If the caller hangs up while onTurn() above is still yielding,
// this runs before the turn/history write finishes.
await this.syncConversationToExternalStore()
}
}
```

Hang up mid-response (or simulate a WS close while `onTurn`'s generator hasn't finished draining) and the last turn is missing from whatever `onCallEnd` persists.

### Suggested fix

Something like tracking pending turn promises internally and having the mixin `await` them (with a sane timeout) before invoking `onCallEnd`, or exposing a hook that fires only once the pipeline has fully settled for a connection — e.g.:

```ts
protected onCallEnd(connection: Connection): void | Promise
// vs. something like
protected onCallSettled(connection: Connection): void | Promise // guaranteed after in-flight turn drains
```

Happy to put up a PR if a shape like this is welcome — happened to hit this building a Telnyx-based PSTN agent and worked around it in userland with a manually-threaded "settled" promise, but it'd be nice not to need to.

_Env: `@cloudflare/voice@0.3.2`_

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.