Skip to content

@cloudflare/think continuation recovery can append duplicate assistant message #1876

Closed

Description

@advaitpaliwal

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:

user
assistant // interrupted partial answer, then continued in-place

Actual shape:

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:

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:

  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:

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

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.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions