Skip to content

fix(ai-chat): prevent duplicate messages after tool calls and orphaned client IDs#1096

Merged
threepointone merged 2 commits into
mainfrom
fix/chat-duplicate-messages-1094
Mar 20, 2026
Merged

fix(ai-chat): prevent duplicate messages after tool calls and orphaned client IDs#1096
threepointone merged 2 commits into
mainfrom
fix/chat-duplicate-messages-1094

Conversation

@threepointone
Copy link
Copy Markdown
Contributor

@threepointone threepointone commented Mar 11, 2026

Summary

Fixes #1094 — temporary duplicate assistant messages after tool calls and orphaned client-generated IDs in persistent storage.

Problem

Primary: Duplicate messages after tool calls

After a streaming response completes with a tool call (e.g. needsApproval tools or client-side tools resolved via addToolOutput), the client briefly renders a duplicate assistant message with React "duplicate key" warnings.

Root cause: Two async paths race to add the same assistant message to client state:

  1. Transport streamReadableStream → AI SDK Chat class (commits asynchronously when fully processed)
  2. CF_AGENT_MESSAGE_UPDATED broadcast from _findAndUpdateToolPartonAgentMessagesetMessages updater (resolves synchronously against chatRef.current.messages)

Path 2 can arrive before Path 1 commits. The setMessages updater reads chatRef.current.messages which doesn't contain the streaming message yet. Both ID match and toolCallId fallback fail → the handler appends → duplicate. The duplicate resolves ~1s later when CF_AGENT_CHAT_MESSAGES (full list from persistMessages) reconciles state.

Secondary: Orphaned client IDs in persistent storage

Client-generated nanoid IDs (e.g. 7VfvgiOu19cSPzLS) were leaking into SQLite instead of server-stamped IDs (e.g. assistant_1773224943506_ehnrjoobz).

Root cause: _resolveMessageForToolMerge only reconciled message IDs when tool parts were in advanced states (output-available, output-error, approval-responded, approval-requested). It skipped input-available and input-streaming. Meanwhile, _reconcileAssistantIdsWithServerState explicitly delegates tool-bearing messages to _resolveMessageForToolMerge. So tool messages in input-available state fell through both reconciliation paths — client IDs persisted unchanged.

Fix

1. Client-side: don't append from CF_AGENT_MESSAGE_UPDATED (react.tsx)

Changed the "message not found" fallback from appending the message to returning prevMessages unchanged. CF_AGENT_MESSAGE_UPDATED is semantically an update operation, not an insert. If the message isn't in client state yet, it will arrive through:

  • The transport stream (same tab — sender)
  • CF_AGENT_USE_CHAT_RESPONSE chunks in onAgentMessage (cross-tab)
  • CF_AGENT_CHAT_MESSAGES from persistMessages (full reconciliation)

2. Server-side: broaden _resolveMessageForToolMerge ID reconciliation (index.ts)

Removed the state filter. Now any tool part with a toolCallId matching an existing server message triggers ID reconciliation, regardless of tool state. Tool call IDs are unique per conversation, so this is unconditionally safe.

3. Regression test (client-tool-duplicate-message.test.ts)

Added a test that persists an assistant message with a server-stamped ID, then persists the same message (same toolCallId, input-available state) with a client-generated ID. Verifies the server reconciles to the original server ID instead of creating a duplicate.

Known limitation

_findAndUpdateToolPart and the early-persist in _streamSSEReply write directly to SQLite via this.sql, bypassing user overrides of persistMessages. These are intentional fast-paths for immediate tool state updates (approval survival across page refresh, etc.). Users who override persistMessages for custom logic (external sync, validation) won't see these intermediate writes, but the state catches up on the next full persistMessages call (stream completion or next turn). Routing these through persistMessages would risk re-entrancy issues during active streams and add unnecessary serialization overhead for single-row updates.

Testing

  • 296/296 unit tests pass (was 295 — added 1 regression test)
  • npm run check clean (build, typecheck, oxlint, oxfmt, sherif, export checks)

Open with Devin

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 11, 2026

🦋 Changeset detected

Latest commit: a145778

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

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 3 additional findings.

Open in Devin Review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 11, 2026

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1096

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1096

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1096

hono-agents

npm i https://pkg.pr.new/hono-agents@1096

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1096

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1096

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1096

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1096

commit: a145778

…d client IDs

- CF_AGENT_MESSAGE_UPDATED handler no longer appends when message not found
  in client state, fixing race between transport stream and server broadcast
- _resolveMessageForToolMerge reconciles IDs by toolCallId regardless of tool
  state, preventing client nanoid IDs from leaking into persistent storage
- Add regression test for input-available state ID reconciliation

fixes #1094

Made-with: Cursor
@threepointone threepointone force-pushed the fix/chat-duplicate-messages-1094 branch from b5717ce to eab2f16 Compare March 20, 2026 17:00
Escape the underscore in .changeset to avoid unintended markdown emphasis for _resolveMessageForToolMerge. package-lock.json was regenerated/updated (likely by npm), adding peer/dev flags and small punctuation fixes across dependency entries.
@threepointone threepointone merged commit 0d0b7d3 into main Mar 20, 2026
2 checks passed
@threepointone threepointone deleted the fix/chat-duplicate-messages-1094 branch March 20, 2026 17:10
@github-actions github-actions Bot mentioned this pull request Mar 20, 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

Development

Successfully merging this pull request may close these issues.

useAgentChat: temporary duplicate messages after tool calls (CF_AGENT_MESSAGE_UPDATED races with stream)

2 participants