cloudflare / cloudflare/agents

onChatRecovery is never invoked when turn is ended by stream-stall watchdog, leaving SSE stream open indefinitely

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

Description

# Title
`onChatRecovery` is never invoked when turn is ended by stream-stall watchdog, leaving SSE stream open indefinitely

# Description

**Describe the bug**
When a chat turn is terminated by the stream-stall watchdog, the recovery engine opens an incident, schedules `_chatRecoveryContinue`, and emits `chat:recovery:skipped` without ever calling the agent's `onChatRecovery` hook.

Additionally, the server-side `chat()` promise never settles, so neither `onDone` nor `onError` fires, leaving the client's SSE response (`text/event-stream`) open indefinitely after receiving the initial `start` frame (measured up to 180s before timing out).

Note: `onChatRecovery` works as expected on Durable Object eviction / deploy interruptions.

**To Reproduce**
Steps to reproduce the behavior:

1. Configure a low stall threshold in `beforeTurn`:
```ts
return { chatStreamStallTimeoutMs: 300 };
```
2. POST a normal chat turn and read the SSE body using a bounded reader.
3. Observe that only 1 frame (`data: {"type":"start",...}`) is emitted.
4. The stream remains open indefinitely, `onChatRecovery` is never invoked, and `wrangler tail` logs show `chat:recovery:skipped` with `attempt: 1, maxAttempts: 10, recoveryKind: "continue"`.

**Expected behavior**
Per the documented lifecycle in `ChatRecoveryEngine.handleChatFiberRecovery` (step 3 & 5):
1. An interrupted turn with remaining budget should invoke `onChatRecovery`.
2. Active streams should be closed/completed via `completeRecoveredStream(streamId)` to prevent client hangs.

**Screenshots**
N/A (See Wrangler tail JSON trace in additional context).

**Version:**
* `agents`: 0.20.1 (also reproduced on 0.16.2)
* `@cloudflare/think`: 0.15.1 (also reproduced on 0.9.1)

**Additional context**

Wrangler Tail JSON Output

```json
{
"wallTime": 271464,
"cpuTime": 9,
"executionModel": "durableObject",
"outcome": "ok",
"entrypoint": "ChatAgentThink",
"diagnosticsChannelEvents": [
{
"channel": "agents:schedule",
"message": {
"type": "schedule:execute",
"agent": "ChatAgentThink",
"payload": { "callback": "_chatRecoveryContinue", "id": "5u-O5CfPM" }
}
},
{
"channel": "agents:chat",
"message": {
"type": "chat:recovery:skipped",
"agent": "ChatAgentThink",
"payload": {
"incidentId": "b5cca0e0-fde5-4ca1-b1bf-18fae2646a7d:m_1785782647096",
"requestId": "b5cca0e0-fde5-4ca1-b1bf-18fae2646a7d",
"attempt": 1,
"maxAttempts": 10,
"recoveryKind": "continue"
}
}
}
],
"logs": []
}
```

**Source Trace:**
In `agents/dist/chat/index.js`, `invokeOnChatRecovery` is only reachable inside `handleChatFiberRecovery`. The stall path arrives via the scheduled `_chatRecoveryContinue` callback and reaches `chat:recovery:skipped` without entering `handleChatFiberRecovery`.

**Environment details:**
* Runtime: Cloudflare Workers
* Compatibility Date: `2026-02-01`
* Flags: `nodejs_compat`
* Architecture: Turn delegated to DO from an Astro API route; DO returns SSE stream.

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.