cloudflare / cloudflare/agents
Think: chat recovery completes an interrupted messenger turn, but the recovered answer is never delivered to the messenger thread
- Dominant language
- TypeScript
- Stars
- 5.6k
- Forks
- 711
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 53
Description
**Describe the bug**
When a messenger-originated turn is interrupted mid-stream and routed into Think's bounded chat recovery, the recovery completes the turn — the recovered assistant message is persisted to the session and broadcast to WebSocket connections — but it is never posted back to the messenger thread that asked the question.
The messenger user's experience: they get the interrupted apology (*"Sorry, my reply was interrupted. Please send your message again if you'd like me to retry."*), and then nothing. The answer exists in the transcript, invisible to them. Worse, the apology's instruction is misleading on this path: recovery **is** already retrying the turn, so a user who follows it and re-sends triggers a duplicate turn while the recovered answer stays undelivered.
The behavior is acknowledged in the source — `deliverMessengerReply`'s interrupted branch says so directly ([delivery.ts#L436-L443](https://github.com/cloudflare/agents/blob/c7abcf9908d8/packages/think/src/messengers/delivery.ts#L436-L443)):
```ts
if (callback.wasInterrupted()) {
// The model turn was interrupted and routed into bounded recovery; the
// recovered answer is produced later by a scheduled continuation and
// broadcast only to WebSocket connections, NOT to this one-shot messenger
// delivery. ...
```
So this is filed as a gap report rather than a surprise: for agents whose primary surface is a messenger, "broadcast only to WebSocket connections" means the recovered answer is delivered to no one. Every interruption that chat recovery successfully absorbs still presents to the messenger user as a dead turn.
**To Reproduce**
1. Set up a Think agent (`@cloudflare/think@0.15.1`) with a chat-sdk messenger (e.g. the Telegram example from the messengers docs). To make the interruption deterministic, lower the stream-stall watchdog (`streamStallTimeoutMs`) and give the agent a tool that takes longer than the watchdog to return.
2. DM the agent a question that streams some visible text and then calls the slow tool.
3. The watchdog interrupts the stream and routes the turn into bounded recovery (`_chatRecoveryContinue` is scheduled). `deliverMessengerReply` observes `callback.wasInterrupted()`, posts the interrupted apology to the thread, and checkpoints the messenger-reply fiber as `completed`.
4. The recovery callback fires and completes the turn: the full assistant answer is persisted and streamed to any connected WebSocket clients.
5. The messenger thread never receives the answer. The last thing the user sees is the apology inviting them to re-send — which, if followed, starts a second turn for a question the agent already answered.
The same end state is reachable through fiber recovery after a DO restart: `messengerReplyRecoveryMode` returns `"apologize"` for a snapshot at stage `"streaming"` ([delivery.ts#L289-L299](https://github.com/cloudflare/agents/blob/c7abcf9908d8/packages/think/src/messengers/delivery.ts#L289-L299)), so once the first visible token has streamed, the apology is the messenger runtime's final word — regardless of what chat recovery subsequently produces.
**Expected behavior**
An interrupted messenger turn that chat recovery completes should have its recovered answer delivered to the originating messenger thread. The apology should be reserved for the case where recovery is exhausted or skipped — i.e. the apology and the recovered answer should be alternatives, not both/neither.
**Screenshots**
N/A — the relevant artifacts are the posted apology, the missing thread reply, and the recovered assistant message visible in the session transcript.
**Version:**
| | |
|---|---|
| `@cloudflare/think` | 0.15.1 (latest) — behavior unchanged on `main` @ `c7abcf99` |
| `agents` | 0.20.1 |
| runtime | Cloudflare Workers + Durable Objects |
**Additional context**
Why neither recovery path can deliver the answer today
Think has two recovery mechanisms, and the messenger surface falls between them:
**Chat recovery** (`_chatRecoveryRetry` / `_chatRecoveryContinue`) re-runs the turn via `_retryLastUserTurn` / `continueLastTurn`. The context it restores is request-shaped only — `_applyRecoveredRequestContext` reinstates `lastClientTools` and `lastBody`, nothing else. `_activeMessengerContext` is unset and `bindActiveDeliverySurface` is never called on this path, so the recovered output has no route to the adapter. (On the original turn, both are set up by `deliverMessengerReply` → `chatWithMessengerContext`; recovery bypasses that wrapper entirely.)
**Fiber recovery** (`ThinkMessengerRuntime.handleFiberRecovery`, [chat-sdk.ts#L246-L295](https://github.com/cloudflare/agents/blob/c7abcf9908d8/packages/think/src/messengers/chat-sdk.ts#L246-L295)) *does* know how to rebuild the delivery surface — it revives the serialized thread with `reviveChatObject` and, for a snapshot at stage `"accepted"`, re-invokes the full answer path. But `messengerReplyRecoveryMode` maps stage `"streaming"` to `"apologize"`, so the one mechanism that can post to the thread deliberately declines to once any text has streamed, and resolves the fiber `completed`. From that point the thread is orphaned: chat recovery will finish the turn, and nothing is left holding a reference to where the answer should go.
The net effect is that the recovery guarantees compose to less than either promises alone: the turn is durably completed (chat recovery works) and the fiber is durably resolved (fiber recovery works), but the user-facing contract — a reply in the thread — is dropped in the seam between them.
Possible fix directions
Ordered by how much they change:
1. **Make chat recovery messenger-aware.** The messenger-reply fiber snapshot already carries everything needed to deliver later — the serialized event and thread that `handleFiberRecovery` proves can be revived. If the recovery scheduler recorded the originating fiber/thread alongside the incident (or resolved it from the fiber ledger when the recovered request id matches), the recovery completion could post the recovered assistant text through the adapter, mirroring the `"answer"` branch of `handleFiberRecovery`.
2. **Make the `"streaming"`-stage fiber outcome-aware instead of apologize-and-complete.** Rather than posting the apology immediately, park the fiber until the chat-recovery incident resolves: post the recovered text on `completed`, and only post the apology on `exhausted` / `skipped`. This also resolves the misleading "please send your message again" copy on the recovery path, and lines up with the direction discussed in #1842 (the apology as an un-checkpointed side effect).
3. **Minimal: expose a supported completion hook.** If neither belongs in core, a documented hook on recovery completion that surfaces the recovered assistant message plus the originating channel/messenger identity would let applications deliver the reply themselves. Today doing so requires overriding internals to detect a recovery turn and reconstruct the thread, which is not stable across releases.
Related issues (adjacent, but none cover delivery)
- #1842 — the interrupted apology re-posted on recovery (apology as un-checkpointed side effect). About the apology's durability, not about delivering the recovered answer.
- #1644 — `StreamCallback` abandoned on recovery-interrupted turns; the `wasInterrupted` signal this branch consumes came from there.
- #2042 / #1941 — stall-watchdog and pre-first-chunk recovery classification. Both are about whether recovery runs; this issue is about where its output goes when it does.
Fixing all of the above still leaves messenger users with silently dropped answers until recovery output can reach the thread — which is why this is filed separately.
Contributor guide
Assessment
This issue has not been assessed yet.