cloudflare / cloudflare/agents
@cloudflare/think continuation recovery can append duplicate assistant message
- Dominant language
- TypeScript
- Stars
- 5.6k
- Forks
- 711
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 53
Description
## Summary
`@cloudflare/think` recovery continuation can append a second assistant message instead of continuing the interrupted assistant message.
In `Think._streamResult`, a recovery continuation currently creates a fresh `StreamAccumulator` with a new `crypto.randomUUID()` message id. When the interrupted turn already has a persisted assistant message and recovery continues that same logical assistant turn, the accumulator should reuse that assistant message id/parts/metadata. Otherwise the recovered transcript can contain two assistant messages for one user turn.
This is distinct from #1691. That issue covered a normal new assistant response being merged into a previous assistant message in `@cloudflare/ai-chat`. This issue is the opposite shape in `@cloudflare/think`: a continuation of the same assistant turn can become a duplicate assistant row.
## Observed version
- `@cloudflare/think@0.12.1`
- `agents@0.17.3`
- Also present on current `cloudflare/agents` `main` as of commit `5ee3bf5`.
## Why this matters
This is a recovery correctness issue. A Durable Object reset, deploy churn, isolate restart, or stream recovery path should preserve one logical assistant turn. Appending a fresh assistant message for a continuation changes both the visible transcript and later model context.
Expected transcript shape:
```ts
user
assistant // interrupted partial answer, then continued in-place
```
Actual shape:
```ts
user
assistant // interrupted partial answer
assistant // continuation appended as a second assistant message
```
## Suspected cause
In `packages/think/src/think.ts`, the continuation path activates pending continuation state, but the stream accumulator is initialized with a fresh message id:
```ts
const accumulator = new StreamAccumulator({ messageId: crypto.randomUUID() });
```
For `continuation === true`, it should initialize from the latest assistant message already present in `this.messages`:
```ts
const continuationAssistant = continuation
? [...this.messages].reverse().find((message) => message.role === "assistant")
: undefined;
const accumulator = new StreamAccumulator({
messageId: continuationAssistant?.id ?? crypto.randomUUID(),
continuation,
existingParts: continuationAssistant?.parts,
existingMetadata: continuationAssistant?.metadata
});
```
## Reproduction shape
A regression test that reproduces this is in the patch branch below. It:
1. Persists a user message.
2. Persists an interrupted assistant message with id `a-continuation-accumulator`.
3. Inserts interrupted stream chunks and a recovery fiber snapshot for that assistant.
4. Triggers chat recovery continuation.
5. Asserts there is exactly one assistant message and that it keeps id `a-continuation-accumulator`.
## Patch branch
Because this repo README currently says external PRs are not being accepted, I am filing this issue first and linking the patch branch instead of treating a PR as the durable contribution path.
Compare branch:
https://github.com/cloudflare/agents/compare/main...advaitpaliwal:codex/think-continuation-accumulator
Patch contents:
- `packages/think/src/think.ts` — initialize `StreamAccumulator` from the latest assistant message during continuation.
- `packages/think/src/tests/run-turn-recovery.test.ts` — regression test for duplicate assistant prevention.
- `.changeset/think-continuation-accumulator.md` — patch changeset for `@cloudflare/think`.
## Local validation on patch branch
```sh
pnpm --filter @cloudflare/think exec vitest --run -c src/tests/vitest.config.ts src/tests/run-turn-recovery.test.ts
pnpm exec oxfmt --check packages/think/src/think.ts packages/think/src/tests/run-turn-recovery.test.ts .changeset/think-continuation-accumulator.md
pnpm exec oxlint packages/think/src/think.ts packages/think/src/tests/run-turn-recovery.test.ts
```
All three passed locally.
Contributor guide
Assessment
This issue has not been assessed yet.