cloudflare / cloudflare/agents

Continuation replay duplicates already-rendered assistant parts after reconnect

Open
#1,951 3 comments 1 reaction 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

**Describe the bug**

A WebSocket reconnect during an active continuation stream can duplicate content that the client already rendered.

This happens with a single legitimate resume handshake and full-buffer replay; it does not require the duplicate-`STREAM_RESUMING` / double-ACK condition fixed by #1742. The duplicate content is appended inside the existing assistant message and may disappear later when an authoritative message update replaces the temporary client state.

**Relationship to #1733 / #1742**

This is adjacent to, but distinct from #1733:

- **#1733 mechanism:** two `STREAM_RESUMING` notifications caused two ACKs and two complete buffer replays; the second replay stacked a duplicate text part.
- **This mechanism:** two notifications may still arrive, but the client sends only one ACK and receives one complete replay. That single replay overlaps continuation content already rendered live before reconnect.

The captured frame sequence for this case contains exactly one `STREAM_RESUME_ACK` and one replay batch, so #1742's ACK deduplication is working. #1742 makes non-continuation replay idempotent, but deliberately excludes continuation replay from the whole-message reset to preserve completed pre-continuation steps. This report covers that remaining continuation-specific gap.

Both React paths appear affected:

- Transport-owned resumes snapshot the existing trailing assistant and feed the replayed continuation chunks into AI SDK `useChat`.
- The fallback observer initializes its continuation accumulator from the assistant's current parts, then applies the complete replay buffer.

**To Reproduce**

1. Render chat with `useAgentChat` and resumable streaming enabled.
2. Start a turn that enters a continuation after at least one completed step/tool call.
3. Allow part of the active continuation to render.
4. Reconnect the socket while that continuation is still streaming. With an async `useAgent` query, the default five-minute cache refresh is one deterministic way to trigger this; forcing a reconnect also works.
5. Complete one `STREAM_RESUMING -> STREAM_RESUME_ACK` handshake and replay the stored continuation buffer.

Minimal state-machine shape:

```text
Current assistant parts:
"before continuation"
"already streamed"

Replayed frames:
start (replay=true, continuation=true, no messageId)
text-start
text-delta "already streamed"
replayComplete

Actual assistant parts:
"before continuation"
"already streamed"
"already streamed"
```

The same result reproduces through AI SDK React's `Chat.resumeStream()` when the current assistant already contains the live continuation suffix.

**Expected behavior**

Stream replay should be idempotent. The continuation suffix already rendered before reconnect should appear once, while all assistant parts completed before the continuation remain intact.

**Screenshots**

Not included because the reducer-level reproduction is deterministic, but a recording can be provided if useful.

**Version:**

- `agents@0.17.3` and `agents@0.17.4`
- `@cloudflare/ai-chat@0.9.3`
- Reproduced with `@ai-sdk/react@3.0.219` / `ai@6.0.217`

**Additional context**

The protocol currently creates a gap between message identity and replay identity:

- Continuation `start` chunks intentionally have `messageId` removed before storage/broadcast: https://github.com/cloudflare/agents/blob/03cdc828c0bc3c6bb1d9aa636bb46ceb00a4e0ea/packages/ai-chat/src/index.ts#L6227-L6284
- The React replay reset requires a matching `start.messageId` and explicitly excludes continuation replays so it does not erase pre-continuation parts: https://github.com/cloudflare/agents/blob/03cdc828c0bc3c6bb1d9aa636bb46ceb00a4e0ea/packages/agents/src/chat/react.tsx#L2025-L2082
- The fallback accumulator seeds continuation replay from the assistant's current parts, which can already include live bytes from this same continuation: https://github.com/cloudflare/agents/blob/03cdc828c0bc3c6bb1d9aa636bb46ceb00a4e0ea/packages/agents/src/chat/broadcast-state.ts#L84-L139

The existing continuation replay regression test seeds only pre-continuation content. It does not include a continuation suffix that was rendered live before reconnect, so it misses the overlap: https://github.com/cloudflare/agents/blob/03cdc828c0bc3c6bb1d9aa636bb46ceb00a4e0ea/packages/agents/src/chat/__tests__/broadcast-state.test.ts#L552-L598

A possible fix is to preserve a per-request pre-continuation baseline and restore it before applying the complete replay, or introduce an explicit segment/chunk identity so replay can replace or deduplicate only the current continuation suffix. Clearing the entire assistant message would lose completed pre-continuation steps.

Regression coverage should exercise both the transport-owned and fallback paths with:

```text
pre-continuation parts + already-rendered continuation suffix
-> reconnect
-> one ACK
-> continuation replay starting without messageId
-> suffix appears exactly once
```

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.