Repository navigation
@cloudflare/think continuation recovery can append duplicate assistant message #1876
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.1agents@0.17.3- Also present on current
cloudflare/agentsmainas of commit5ee3bf5.
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:
user
assistant // interrupted partial answer, then continued in-placeActual shape:
user
assistant // interrupted partial answer
assistant // continuation appended as a second assistant messageSuspected 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:
const accumulator = new StreamAccumulator({ messageId: crypto.randomUUID() });For continuation === true, it should initialize from the latest assistant message already present in this.messages:
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:
- Persists a user message.
- Persists an interrupted assistant message with id
a-continuation-accumulator. - Inserts interrupted stream chunks and a recovery fiber snapshot for that assistant.
- Triggers chat recovery continuation.
- 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:
main...advaitpaliwal:codex/think-continuation-accumulator
Patch contents:
packages/think/src/think.ts— initializeStreamAccumulatorfrom 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
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.tsAll three passed locally.
Activity
fix(think): extend the interrupted assistant message on recovery cont…
fix(think): extend the interrupted assistant message on recovery cont…