cloudflare / cloudflare/agents
Think: `onChatResponse` never fires if the DO resets between persisting the assistant message and running the hook
- Dominant language
- TypeScript
- Stars
- 5.6k
- Forks
- 711
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 53
Description
**Describe the bug**
Sequence in `packages/think/src/think.ts`:
1. `_persistAssistantMessageWithCutover(...)` succeeds (~L12901).
2. Durable Object resets.
3. `_fireResponseHook({ status: "completed" })` (~L12923) never runs.
4. On wake, recovery sees a terminal stream. `_dispatchRecoveredThinkTurn` marks the submission `completed` and the incident `skipped` with reason `stream_terminal` (~L15283). It does not call `_fireResponseHook`.
Result: the assistant message is persisted, `inspectSubmission()` returns `completed`, and `onChatResponse` is never called for that turn.
`docs/think/lifecycle-hooks.md` line 721 states: "Fires for all turn completion paths that persist an assistant message". This path persists an assistant message and does not fire the hook.
**To Reproduce**
1. Subclass `Think`. Add `console.log` in `onChatResponse`.
2. `runTurn({ mode: "submit", ... })` where a tool returns a large result. Our assistant message was ~6.6 MB. Agents SDK message compaction then caused "Internal error in Durable Object storage caused object to be reset". Cloudflare ray/ref: `657f8bbdk2qtnlm7e9s1a8b`.
3. The reset occurred between step 1 and step 3 above.
4. After wake: `inspectSubmission(id).status === "completed"`, `session.getLatestLeaf()` is the finished assistant message, `onChatResponse` log line absent.
**Expected behavior**
One of:
- A. In the `stream_terminal` branch, when the assistant leaf is persisted, call `_fireResponseHook` with that message, `status: "completed"`, and `recovered: true` on `ChatResponseResult`. Exactly one hook call per turn.
- B. Document that `onChatResponse` is not durable and must not contain delivery side effects.
A matches the current docs. The messenger path (`enqueueReply`) already replays delivery through a managed fiber with `accepted` / `streaming` / `completed` stashes. A custom channel that delivers from `onChatResponse` has no equivalent.
**Impact**
We deliver the reply to the user over SMS from `onChatResponse`. The user received nothing. The transcript shows a complete answer.
**Workaround**
An app-owned alarm re-checks each `completed` submission: read the assistant child via `session.getBranches(userMessageId)`, deliver under the same idempotency key. No-op if already delivered.
**Version:**
`@cloudflare/think` 0.16.0 observed. Same code on `main` (0.18.0).
**Additional context**
Not a duplicate of #1997. #1997: persist fails, turn reports `completed`. This issue: persist succeeds, process dies before the hook.
Contributor guide
Research direction
Start in packages/think/src/think.ts around _persistAssistantMessageWithCutover (~L12901), _fireResponseHook (~L12923), and _dispatchRecoveredThinkTurn (~L15283), then read docs/think/lifecycle-hooks.md around line 721. Trace the stream_terminal recovery path and compare it with the messenger enqueueReply replay behavior. Done means the documented completion behavior is consistent and a recovered turn invokes onChatResponse at most once, or the docs explicitly define its non-durable behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend, distributed-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100