FE CORE: fix duplicate + lost messages in history/realtime merge#1492
Conversation
Client→viewer duplicate: a CLIENT user message sent from the Tauri client renders TWICE in the tickets viewer (assistant reply once). Root cause is a missing backend signal, not a double publish: the AI path persists the user MESSAGE_REQUEST row with lastChunkStreamSeq=null (DIRECT/SYSTEM rows persist the chunk seq). So the persisted user row contributes 0 to maxPersistedStreamSeq → optStartSeq never covers the user chunk → JetStream replays it → a fresh user- synthetic is minted that neither seq-coverage (history max never reaches its streamSeq) nor wall-clock (replay has a fresh timestamp) can drop, and the merge's content-fallback was assistant-only. The assistant replay collapses via that fallback; the user one survives — 'user twice, Fae once'. mergeHistoryWithRealtime now also collapses a user- synthetic against an identical-text persisted user twin that is NOT the trailing history row — a user row followed by another message is a completed turn, so the synthetic is provably its replay. A trailing (or absent) twin is left alone so a just-sent message's live echo survives. +3 tests. This is a targeted workaround; the durable fix is the backend stamping lastChunkStreamSeq on user rows (see PR discussion) — that also stops the replay at the source.
…reload/stop) Messages the user already saw vanished from the thread on ANY merge recompute after a page reload — triggered by a Stop from another client, a new incoming message, or a reconnect. Affected all three surfaces (openframe-chat, tickets, mingo). Root cause: mergeHistoryWithRealtime dropped a live synthetic via the per-message seq-coverage test 'historyMaxStreamSeq >= m.streamSeq', using a SINGLE global max over all persisted rows as a proxy for 'this synthetic's turn is in history'. That proxy is false when persistence is async/out-of-order or the turn was interrupted: a later or OTHER-ROLE row (e.g. a user MESSAGE_REQUEST) pushes the global max past a synthetic whose own turn isn't in the snapshot, so it's dropped with no twin — and JetStream won't replay it (seq already consumed). The synthetic was only protected while it held the single streamingMessageId exemption, which any recompute strips (stop flips streaming off; a new turn steals the pointer; earlier post-reload synthetics were never exempt at all). Fix: decide coverage against a PER-ROLE max computed from history rows that carry their own streamSeq — a synthetic is covered only when a persisted row of ITS OWN role actually reached its seq. A role with no seq-stamped persisted row (e.g. user rows the backend leaves unstamped) yields 0, so seq-coverage doesn't apply and the synthetic falls through to the content / wall-clock rules rather than being dropped by an unrelated row. Fully backward-compatible: when NO history row is seq-stamped (legacy hosts) the previous global behaviour is unchanged. - history-merge.ts: per-role coverage max + gate; doc updates. +4 tests. - process-historical-messages.ts: carry lastChunkStreamSeq → the processed message's streamSeq (assistant turns take the MAX across the grouped rows; user/system/direct via their row). Both processors. - message.types.ts: HistoricalMessage.lastChunkStreamSeq + ProcessedMessage.streamSeq. Hosts activate it by feeding lastChunkStreamSeq into HistoricalMessage and mapping the processed streamSeq onto the rendered message (done in openframe-chat automatically via node spread; tickets/mingo via explicit map — those consumer edits ride separate PRs). Lib: type-check clean, 277 tests (+4).
|
Warning Review limit reached
Next review available in: 8 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughHistorical stream sequence metadata is propagated into processed chat messages. History/realtime merging now uses role-scoped sequence coverage, positional user synthetic deduplication, and richer trailing assistant adoption, with expanded merge tests. ChangesChat history and realtime merging
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant HistoricalMessage
participant processHistoricalMessages
participant mergeHistoryWithRealtime
participant RealtimeMessages
HistoricalMessage->>processHistoricalMessages: lastChunkStreamSeq
processHistoricalMessages->>mergeHistoryWithRealtime: processed messages with streamSeq
RealtimeMessages->>mergeHistoryWithRealtime: synthetic and trailing assistant messages
mergeHistoryWithRealtime->>mergeHistoryWithRealtime: apply role-scoped coverage and positional deduplication
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
… fallback The per-role seq fix still lost/duplicated USER messages because the backend persists user MESSAGE_REQUEST rows with lastChunkStreamSeq=null, so the 'user' role's per-role max is always 0. Two consequences: - coverageMax==0 fell back to the GLOBAL (cross-role) historyCoversRealtime, so an assistant row's seq covered and DROPPED a live user message whose own row was not yet persisted (reload+stop loss). - the findIndex content-fallback matched a later identical-text turn against an earlier turn's row: repeated 'continue' dropped the newer one (loss), and a trailing twin was never deduped (start-of-conversation duplicate). Fix: dedupe 'user-' synthetics purely by content, matched POSITIONALLY (k-th synthetic <-> k-th persisted user row) — drops a replay/echo once its row is in the snapshot, keeps a later same-text turn whose row hasn't landed. And in the per-role regime never fall back to the global seq: a role with no same-role seq >= the synthetic is treated as not-covered (kept), not dropped by another role. direct-/system- (role user, but seq-stamped) still use seq coverage via the user- prefix gate. Backend stamping user-row seq remains the durable fix.
…(post-reload tool turns) After a mid-stream reload the trailing assistant is a persisted Mongo-id row. The consumer chunk processors ADOPT it and append later chunks IN PLACE, so the store's same-id copy accumulates more than history has persisted yet (a later tool, a tool's terminal state), and no streaming exemption is set for those in-place tool updates. mergeHistoryWithRealtime then dropped the richer realtime copy on 'if (processedIds.has(m.id)) return false' and reverted the bubble to history's stale segments: 3 tools collapse to 2, a cancelled/failed tool shows as still pending (STOP does no local segment fix-up, so the failed state lives only in the realtime copy until the backend persists it). Fix: generalise the approval-batch pin to any trailing assistant — when a same-id realtime twin is strictly richer than the persisted trailing (more segments, or an equal count but a higher consumed streamSeq), pin it and drop history's copy. Self-heals once persistence catches up (twin no longer richer). +3 tests. All 41 merge tests + type-check green.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@openframe-frontend-core/src/components/chat/utils/history-merge.ts`:
- Around line 163-183: Update
openframe-frontend-core/src/components/chat/utils/history-merge.ts lines 163-183
to track sequence coverage without treating historyMaxSeqByRole maxima as proof
that intermediate turns persisted; preserve the legacy global behavior when
anyHistoryRowSeq is false. Update the synthetic-dropping logic at lines 346-373
to require either an exact persisted sequence correlation or a verified gap-free
backend watermark before removing a synthetic. Add coverage in
openframe-frontend-core/src/components/chat/utils/__tests__/history-merge.test.ts
lines 527-590 for a missing assistant sequence 100 followed by persisted
assistant sequence 200, ensuring the synthetic for 100 is retained.
- Around line 276-293: The positional text-based deduplication in
openframe-frontend-core/src/components/chat/utils/history-merge.ts lines 276-293
and 326-345 must remain stable when earlier synthetics disappear; avoid
occurrence identity derived only from the currently present synthetic list,
preserve ambiguous repeated messages unless durable request metadata correlates
them, and update the related test in
openframe-frontend-core/src/components/chat/utils/__tests__/history-merge.test.ts
lines 270-287 to rerun with only the second synthetic remaining.
In
`@openframe-frontend-core/src/components/chat/utils/process-historical-messages.ts`:
- Around line 123-129: Ensure both processor flush paths unconditionally reset
assistant identity, timestamp, ID, and stream sequence state after
accumulator.resetSegments(), including empty flushes. Apply the change at
openframe-frontend-core/src/components/chat/utils/process-historical-messages.ts
lines 123-129 and 533-539, covering both the standard and error-aware
processors.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 36550f53-d390-4eea-a4e3-dfcb5bdbe413
📒 Files selected for processing (4)
openframe-frontend-core/src/components/chat/types/message.types.tsopenframe-frontend-core/src/components/chat/utils/__tests__/history-merge.test.tsopenframe-frontend-core/src/components/chat/utils/history-merge.tsopenframe-frontend-core/src/components/chat/utils/process-historical-messages.ts
…ry flush flushAssistantMessage cleared currentAssistantId/timestamp/lastAssistantId/ currentAssistantStreamSeq only inside the 'if (hasContent())' push-block. An assistant turn that renders nothing (e.g. its only data is an approval excluded by displayApprovalTypes → escalated) produces an EMPTY flush that left that state set, so the NEXT assistant turn inherited the stale id/timestamp and a Math.max-inflated streamSeq. An over-high persisted assistant seq then over-covers synthetics in mergeHistoryWithRealtime (message loss). Reset the grouping state unconditionally on flush; resetSegments stays in the push-block. Applied to both processHistoricalMessages and processHistoricalMessagesWithErrors. +1 test. (CodeRabbit PR #1492 finding.)
…ry flush flushAssistantMessage cleared currentAssistantId/timestamp/lastAssistantId/ currentAssistantStreamSeq only inside the 'if (hasContent())' push-block. An assistant turn that renders nothing (e.g. its only data is an approval excluded by displayApprovalTypes → escalated) produces an EMPTY flush that left that state set, so the NEXT assistant turn inherited the stale id/timestamp and a Math.max-inflated streamSeq. An over-high persisted assistant seq then over-covers synthetics in mergeHistoryWithRealtime (message loss). Reset the grouping state unconditionally on flush; resetSegments stays in the push-block. Applied to both processHistoricalMessages and processHistoricalMessagesWithErrors. +1 test. (CodeRabbit PR #1492 finding.)
…story Positional content dedup dropped a NEW user message that repeats an earlier turn's text (e.g. sending 'continue' again during a long tool run, then reload): its only 'twin' was the OLDER identical-text turn, so it was treated as that turn's replay and lost. User rows carry no persisted seq, but the synthetic does — so disambiguate new-send vs replay/echo without one: - history persisted PAST the synthetic's streamSeq -> own row is in the snapshot -> replay -> drop. - twin is the trailing history row (just-sent, no reply yet) -> echo -> drop. - else history is BEHIND this fresh seq and the twin is an older completed turn -> NEW repeat -> keep (fixes the loss). Legacy transport (no seq) keeps the positional-only dedup. +3 tests, 44 merge tests + type-check green.
Two focused, related fixes to
mergeHistoryWithRealtimethat stop user-visible message corruption in the realtime chat thread. Split out of the larger realtime-chat hotfix (#1488) so they can be reviewed and merged on their own. Backward-compatible; lib type-check clean, 39 merge/processor tests (all green, +7 new).1. Duplicate user message (
dedupe replayed user MESSAGE_REQUEST)A CLIENT user message sent from the Tauri client rendered twice in the tickets/mingo viewer (assistant reply once). Root cause is a missing backend signal, not a double publish: the AI path persists the user
MESSAGE_REQUESTrow withlastChunkStreamSeq = null(DIRECT/SYSTEM rows persist it), so the persisted user row contributes 0 tomaxPersistedStreamSeq→optStartSeqnever covers the user chunk → JetStream replays it → a freshuser-synthetic is minted that neither seq-coverage nor wall-clock can drop, and the merge's content-fallback was assistant-only.Fix: the merge also collapses a
user-synthetic against an identical-text persisted user twin that is not the trailing history row (a user row followed by another message is a completed turn → the synthetic is provably its replay; a trailing/absent twin is left alone so a just-sent message survives).2. Message loss on reload / stop / any recompute (
per-role seq coverage)Messages the user already saw vanished from the thread on ANY merge recompute after a page reload — triggered by a Stop from another client, a new incoming message, or a reconnect. Affected all three surfaces (openframe-chat, tickets, mingo).
Root cause: the per-message seq-coverage test
historyMaxStreamSeq >= m.streamSeqused a single global max over all persisted rows as a proxy for "this synthetic's turn is in history". That proxy is false when persistence is async/out-of-order or the turn was interrupted: a later or other-role row (e.g. a userMESSAGE_REQUEST) pushes the global max past a synthetic whose own turn isn't in the snapshot, so it's dropped with no twin — and JetStream won't replay it (seq already consumed). The synthetic was only protected while it held the singlestreamingMessageIdexemption, which any recompute strips.Fix: coverage is now decided against the same-role persisted max (computed from history rows that carry a per-row
streamSeq, threaded through the processors fromlastChunkStreamSeq). A synthetic is dropped only when a persisted row of its own role actually reached its seq. Fully backward-compatible: when no history row is seq-stamped (legacy hosts) the previous global behaviour is kept unchanged (anyHistoryRowSeqgate).Consumer wiring (separate PRs to their repos)
To activate #2, hosts stamp
HistoricalMessage.lastChunkStreamSeq(→ processedstreamSeq) onto history rows:use-historical-messages.ts) and mingo (use-mingo-dialog-selection.ts) — two-line passthroughs; ship in their own PRs after this lib version publishes.Backend follow-up (recommended, not required)
The durable fix for #1 (and it also removes the replay behind #2) is the backend stamping
lastChunkStreamSeqon the userMESSAGE_REQUESTrow, mirroring the DIRECT/SYSTEM path (MessageProcessingService.handleDirectMessage). Detailed in #1488.Summary by CodeRabbit