fix(ai-chat): prevent duplicate messages after tool calls and orphaned client IDs#1096
Merged
Merged
Conversation
🦋 Changeset detectedLatest commit: a145778 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
whoiskatrin
approved these changes
Mar 20, 2026
…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
b5717ce to
eab2f16
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
needsApprovaltools or client-side tools resolved viaaddToolOutput), 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:
ReadableStream→ AI SDKChatclass (commits asynchronously when fully processed)CF_AGENT_MESSAGE_UPDATEDbroadcast from_findAndUpdateToolPart→onAgentMessage→setMessagesupdater (resolves synchronously againstchatRef.current.messages)Path 2 can arrive before Path 1 commits. The
setMessagesupdater readschatRef.current.messageswhich doesn't contain the streaming message yet. Both ID match andtoolCallIdfallback fail → the handler appends → duplicate. The duplicate resolves ~1s later whenCF_AGENT_CHAT_MESSAGES(full list frompersistMessages) 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:
_resolveMessageForToolMergeonly reconciled message IDs when tool parts were in advanced states (output-available,output-error,approval-responded,approval-requested). It skippedinput-availableandinput-streaming. Meanwhile,_reconcileAssistantIdsWithServerStateexplicitly delegates tool-bearing messages to_resolveMessageForToolMerge. So tool messages ininput-availablestate 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
prevMessagesunchanged.CF_AGENT_MESSAGE_UPDATEDis semantically an update operation, not an insert. If the message isn't in client state yet, it will arrive through:CF_AGENT_USE_CHAT_RESPONSEchunks inonAgentMessage(cross-tab)CF_AGENT_CHAT_MESSAGESfrompersistMessages(full reconciliation)2. Server-side: broaden
_resolveMessageForToolMergeID reconciliation (index.ts)Removed the state filter. Now any tool part with a
toolCallIdmatching 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-availablestate) with a client-generated ID. Verifies the server reconciles to the original server ID instead of creating a duplicate.Known limitation
_findAndUpdateToolPartand the early-persist in_streamSSEReplywrite directly to SQLite viathis.sql, bypassing user overrides ofpersistMessages. These are intentional fast-paths for immediate tool state updates (approval survival across page refresh, etc.). Users who overridepersistMessagesfor custom logic (external sync, validation) won't see these intermediate writes, but the state catches up on the next fullpersistMessagescall (stream completion or next turn). Routing these throughpersistMessageswould risk re-entrancy issues during active streams and add unnecessary serialization overhead for single-row updates.Testing
npm run checkclean (build, typecheck, oxlint, oxfmt, sherif, export checks)