WIP: fix(ai-chat) prevent duplicate assistant persistence on history round-trips#958
WIP: fix(ai-chat) prevent duplicate assistant persistence on history round-trips#958whoiskatrin wants to merge 4 commits intomainfrom
Conversation
🦋 Changeset detectedLatest commit: 10b657a 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 |
commit: |
|
/bonk review this PR |
Co-authored-by: whoiskatrin <whoiskatrin@users.noreply.github.com>
This comment was marked as outdated.
This comment was marked as outdated.
|
/bonk re-review this PR |
There was a problem hiding this comment.
Re-review: fix(ai-chat) prevent duplicate assistant persistence on history round-trips
Test results
All 174 tests pass across 22 test files on this branch (workers project), including:
- 4 tests in
merge-server-state.test.ts(2 existing + 2 new) - 21 tests in
client-tool-duplicate-message.test.ts(no regressions)
File-by-file analysis
.changeset/calm-pens-juggle.md — Correct package (@cloudflare/ai-chat), correct semver (patch), clear description. Good.
examples/playground/testing.md — Escapes a markdown glob (X-Agent-* → X-Agent-\*). Unrelated to the fix. Should be a separate commit.
package-lock.json — Peer dependency flag reshuffling. These "peer": true additions/removals look like lockfile regeneration noise from npm install. Not concerning, but worth confirming this wasn't an unintended dependency change.
Implementation review (packages/ai-chat/src/index.ts)
The refactor of _mergeIncomingWithServerState cleanly separates two concerns:
- Tool output merging (existing logic, now in
withMergedToolOutputs) - Assistant ID reconciliation (new
_reconcileAssistantIdsWithServerState)
This is a good structural improvement.
Reconciliation logic (lines 1042-1103)
The serverCursor approach is sound — it enforces positional ordering and prevents a single server message from matching multiple incoming messages with identical content. The algorithm:
- Fast path (line 1059): exact ID match advances cursor for any role
- Skip non-candidates (line 1068): non-assistant or tool-bearing messages fall through
- Content match (line 1091): scans forward from cursor for matching sanitized content
- Fallback (line 1101): unmatched messages pass through unchanged
Concern: content-match scan doesn't advance cursor on non-matches
In the content-matching loop (lines 1082-1099), when a server message is assistant + non-tool but its content doesn't match, the cursor is NOT advanced past it. This means a later incoming message could still match that skipped server message. This is intentional and correct — you don't want to burn a server message that might match a later incoming message. But it does mean the algorithm is O(n*m) in the worst case (n incoming × m server assistant messages). For typical chat histories this is fine, but worth noting.
Concern: double reconciliation for tool-bearing assistant messages
The flow is:
_mergeIncomingWithServerState→ merges tool outputs → calls_reconcileAssistantIdsWithServerStatepersistMessagesloop → calls_resolveMessageForToolMergeper message
Tool-bearing assistant messages are excluded from step 1's reconciliation (line 1070), so they hit _resolveMessageForToolMerge in step 2 as before. This is correct — no double processing. Good.
Nit: _assistantMessageContentKey calls _sanitizeMessageForPersistence redundantly
At line 1116, _assistantMessageContentKey sanitizes the message to compute a content key. But in persistMessages (line 936), the message is sanitized again for actual persistence. For the reconciliation path, the incoming message is sanitized once for key computation and once for storage — two sanitization passes. This is functionally correct (sanitization is idempotent) but costs extra allocations. Not a blocker, but could be optimized if this path becomes hot.
Test review (merge-server-state.test.ts)
Test: "reuses server assistant IDs for plain text messages to avoid duplicates" (lines 107-156) — Correctly reproduces #957: persists with server ID, re-persists same content with different client ID, asserts server ID is reused and no duplicate. Good.
Test: "matches repeated assistant text messages in order" (lines 158-219) — Tests the serverCursor ordering logic with two assistant messages having identical content. Verifies each maps to the correct server ID by position. This directly addresses the previous review's suggestion. Good.
Suggestions (non-blocking)
-
Move the
testing.mdchange to a separate commit — keeps the fix atomic. -
Add a brief inline comment explaining why the content-match loop doesn't skip non-assistant server messages — the
continueon line 1088 skips non-assistant and tool-bearing server messages but doesn't advance the cursor past them. A reader might wonder if non-assistant messages should also advance the cursor to maintain strict ordering. A comment like// Don't advance cursor: non-assistant messages don't participate in content matchingwould help. -
Consider whether
_reconcileAssistantIdsWithServerStateshould also handle the edge case where the client removes messages from history — if a client sends a shorter history than what's on the server, the cursor approach still works (unmatched server messages are simply not reused), but the orphaned server rows remain. This is existing behavior, not introduced by this PR, but worth documenting as a known limitation if it matters for #957.
Verdict
The fix is correct, well-tested, and well-scoped. The serverCursor approach for positional content matching is the right design choice. All existing tests pass — no regressions in tool merge paths or other persistence behavior. The second test (duplicate content ordering) was a good addition from the previous review's feedback.
Approve with minor suggestions — the optional feedback above can be addressed in follow-up or as part of removing the WIP status.
|
Re-review posted. Here's the summary: All 174 tests pass across 22 test files — no regressions. The fix is correct and well-tested. The Key observations:
Verdict: Approve with minor suggestions — ready to merge after removing WIP status. |
|
examples/playground/testing.md is annoying, but for some reason fails the CI |
Summary
WIP fix for #957.
This PR addresses duplicate assistant rows being persisted when the client resends full chat history with local assistant IDs that differ from server-persisted IDs.
Root cause
persistMessages()previously upserted bymessage.id.For plain-text assistant messages (no
toolCallId), when a client round-tripped history using a different local assistant ID, the server treated it as a new message and inserted a duplicate row.Tool messages were already protected by
_resolveMessageForToolMerge, but plain assistant text messages were not.What changed
1) Repro test added
packages/ai-chat/src/tests/merge-server-state.test.tsreuses server assistant IDs for plain text messages to avoid duplicates2) ID reconciliation for non-tool assistant messages
File:
packages/ai-chat/src/index.ts_mergeIncomingWithServerState()now:Added helpers:
_reconcileAssistantIdsWithServerState(...)_hasToolCallPart(...)_assistantMessageContentKey(...)Reconciliation behavior
For assistant messages without tool parts:
sanitized.parts) matches a server assistant message in sequence: replace incoming ID with server IDThis preserves current tool-flow logic and prevents duplicate inserts for plain assistant text round-trips.
Why this approach
Reviewer guide
Suggested review order
packages/ai-chat/src/tests/merge-server-state.test.tspackages/ai-chat/src/index.ts_mergeIncomingWithServerState()and then inspect new helper methods._resolveMessageForToolMergebehavior.What to validate
Commands used locally
cd packages/ai-chat npm run test:workers -- merge-server-state.test.ts npm run test:workers -- merge-server-state.test.ts client-tool-duplicate-message.test.tsTests