Skip to content

fix(ai-chat): fix regenerate stale messages, transport trigger, and identical-content reconciliation - #1014

Merged
threepointone merged 2 commits into
mainfrom
fix/regenerate-stale-messages
Feb 28, 2026
Merged

fix(ai-chat): fix regenerate stale messages, transport trigger, and identical-content reconciliation#1014
threepointone merged 2 commits into
mainfrom
fix/regenerate-stale-messages

Conversation

@threepointone

@threepointone threepointone commented Feb 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #1012, fixes #1008

Three bugs in @cloudflare/ai-chat caused stale/duplicate assistant messages and broken regeneration:

  1. Transport drops trigger field — server never knew if a request was submit vs regenerate
  2. persistMessages never deletes stale rows — regenerated messages persisted as ghosts
  3. Content-based reconciliation mismatches identical text — two "Sure" messages could get the same server ID

Bug 1: Transport drops trigger field (#1012)

Root cause: WebSocketChatTransport.sendMessages serialized the body without trigger. The AI SDK passes trigger ("submit-message" or "regenerate-message") but it was silently dropped.

Fix: Added trigger: options.trigger to the serialized body in ws-chat-transport.ts.

Cleanup: On the server, trigger is destructured out of the parsed body alongside messages and clientTools, so it does not leak into options.body in onChatMessage.

Bug 2: persistMessages never deletes stale rows (#1012)

Root cause: persistMessages only performed upserts. When regenerate() removed the last assistant message from the client array, the old DB row stayed and reappeared on reload — causing Anthropic 400 errors and phantom messages.

Fix: Added an internal _deleteStaleRows option to persistMessages. The CF_AGENT_USE_CHAT_REQUEST handler passes { _deleteStaleRows: true }, deleting DB rows whose IDs are absent from the post-merge message set.

Why _deleteStaleRows lives inside persistMessages

The delete logic must use post-merge message IDs. _mergeIncomingWithServerState can remap client assistant IDs to server IDs. If reconciliation ran externally with raw client IDs, it would incorrectly delete remapped messages.

Why the option is named _deleteStaleRows

The underscore prefix + @internal JSDoc signal internal use only. Only the chat-request handler passes it. All other callers use the default (upsert-only).

Why CF_AGENT_USE_CHAT_REQUEST always reconciles

The transport always sends the full message array — it's the source of truth regardless of submit vs regenerate. Gating on trigger value would be fragile.

Bug 3: Content-based reconciliation mismatches identical text (#1008)

Root cause: _reconcileAssistantIdsWithServerState used a single-pass cursor for both exact-ID and content-based matching. When an exact-ID match jumped the cursor forward (e.g. client had a server ID from the wrong position after state drift), it skipped server messages that a later incoming message needed for content matching. With identical text like "Sure" or "I understand", the second message would match the wrong server message or fail to match entirely, creating duplicate rows.

Fix: Rewrote with a two-pass approach:

  • Pass 1: Resolve all exact-ID matches, claiming server indices into a Set<number>.
  • Pass 2: Content-based matching for remaining non-tool assistant messages, scanning only unclaimed server indices left-to-right.

This ensures exact-ID matches cannot interfere with content matching, and each server message is claimed at most once.


Files changed

File What changed
packages/ai-chat/src/ws-chat-transport.ts +1 line: include trigger in body payload
packages/ai-chat/src/index.ts Strip trigger from customBody; add _deleteStaleRows to persistMessages; rewrite _reconcileAssistantIdsWithServerState with two-pass approach
packages/ai-chat/src/tests/chat-persistence.test.ts Updated to include assistant message in second request (real AI SDK behavior)
packages/ai-chat/src/tests/regenerate-message.test.ts New — 7 tests for stale row deletion and trigger stripping
packages/ai-chat/src/tests/ws-transport-trigger.test.ts New — 3 unit tests for transport trigger field
packages/ai-chat/src/tests/reconcile-identical-content.test.ts New — 4 tests for identical-content reconciliation (#1008)
.changeset/fix-regenerate-stale-messages.md Patch changeset

Test coverage

14 new tests across 3 new files + 1 updated file:

Regenerate / stale rows (7 tests):

  • E2E: truncated array deletes stale assistant, new one created
  • CF_AGENT_CHAT_MESSAGES does NOT delete (backward compat)
  • Direct _deleteStaleRows flag deletes stale rows
  • No-op when sets match
  • trigger stripped from options.body (alone and with custom fields)

Transport trigger (3 tests):

  • submit-message trigger in body
  • regenerate-message trigger in body
  • trigger coexists with prepareBody fields

Identical-content reconciliation (4 tests):

  • Two "Sure" messages → distinct server IDs
  • Mixed exact-ID + content matches without cursor jumping
  • Exact-ID at wrong position does not steal another slot
  • Three "I understand" messages → all get unique server IDs

Notes for reviewers

  • All 229 tests pass (29 test files). The 2 previously-failing wait-mcp-connections.test.ts tests now pass as well.
  • The _deleteStaleRows loop does N+1 queries (1 SELECT + N DELETEs). Fine for typical conversation sizes; batchable later if needed.
  • persistMessages is public and overridable. The new optional third parameter is backward-compatible.
  • The two-pass reconciliation removes the serverCursor entirely. Performance is still O(n·m) worst case (same as before), but the constant factor is slightly higher due to the Set lookups. Negligible for typical conversation sizes.

… transport

Two bugs caused regenerate() to leave stale assistant messages in SQLite:

1. WebSocketChatTransport.sendMessages omitted the trigger field from the
   body payload, so the server never knew whether a request was a submit
   or a regenerate. Fixed by adding trigger: options.trigger to the
   serialized body. On the server, trigger is destructured out of the
   parsed body (like messages and clientTools) so it does not leak into
   options.body in onChatMessage.

2. persistMessages only performed upserts (INSERT ON CONFLICT UPDATE),
   never deletes. When regenerate() removed the last assistant message
   from the client array, the old row stayed in SQLite and reappeared
   on reload — causing Anthropic 400 errors and phantom messages.
   Fixed by adding an internal _deleteStaleRows option that the
   CF_AGENT_USE_CHAT_REQUEST handler passes. It deletes DB rows whose
   IDs are absent from the post-merge message set. The default behavior
   (upsert-only) is unchanged for all other callers.

Closes #1012
@changeset-bot

changeset-bot Bot commented Feb 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3dc616c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/ai-chat Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Feb 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/agents@1014
npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/ai-chat@1014
npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/codemode@1014
npm i https://pkg.pr.new/cloudflare/agents/hono-agents@1014

commit: 3dc616c

… messages

Rewrites _reconcileAssistantIdsWithServerState with a two-pass approach
to fix #1008.

The old single-pass cursor advanced on both exact-ID and content matches.
When an exact-ID match jumped the cursor forward (e.g. client had a server
ID from the wrong position after state drift), it skipped server messages
that a later incoming message needed for content matching. With identical
assistant text like 'Sure' or 'I understand', the second message would
match the wrong server message or fail to match entirely, creating
duplicate rows.

Pass 1: resolve all exact-ID matches, claiming server indices into a Set.
Pass 2: content-based matching for remaining non-tool assistant messages,
scanning only unclaimed server indices left-to-right.

This ensures exact-ID matches cannot interfere with content matching,
and each server message is claimed at most once.

Closes #1008
@threepointone threepointone changed the title fix(ai-chat): delete stale messages on regenerate, include trigger in transport fix(ai-chat): fix regenerate stale messages, transport trigger, and identical-content reconciliation Feb 27, 2026
@threepointone
threepointone merged commit 74a3815 into main Feb 28, 2026
4 checks passed
@threepointone
threepointone deleted the fix/regenerate-stale-messages branch February 28, 2026 06:08
@github-actions github-actions Bot mentioned this pull request Feb 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant