Skip to content

FE CORE: fix duplicate + lost messages in history/realtime merge#1492

Merged
pavlo-flamingo merged 7 commits into
mainfrom
hotfix/fe-core-history-merge-dedup-loss
Jul 17, 2026
Merged

FE CORE: fix duplicate + lost messages in history/realtime merge#1492
pavlo-flamingo merged 7 commits into
mainfrom
hotfix/fe-core-history-merge-dedup-loss

Conversation

@pavlo-flamingo

@pavlo-flamingo pavlo-flamingo commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Two focused, related fixes to mergeHistoryWithRealtime that 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_REQUEST row with lastChunkStreamSeq = null (DIRECT/SYSTEM rows persist it), so the persisted user row contributes 0 to maxPersistedStreamSeqoptStartSeq never covers the user chunk → JetStream replays it → a fresh user- 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.streamSeq used 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.

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 from lastChunkStreamSeq). 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 (anyHistoryRowSeq gate).

Consumer wiring (separate PRs to their repos)

To activate #2, hosts stamp HistoricalMessage.lastChunkStreamSeq (→ processed streamSeq) onto history rows:

  • openframe-chat — automatic (already passes the raw node through; no code change needed).
  • openframe-frontend tickets (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 lastChunkStreamSeq on the user MESSAGE_REQUEST row, mirroring the DIRECT/SYSTEM path (MessageProcessingService.handleDirectMessage). Detailed in #1488.

Summary by CodeRabbit

  • Bug Fixes
    • Improved chat history and realtime message merging for more accurate deduplication.
    • Prevented valid user and assistant messages from being incorrectly removed during synchronization.
    • Preserved richer assistant and tool responses when historical and realtime versions overlap.
    • Improved handling of delayed persistence and partially streamed messages.
  • Tests
    • Added comprehensive coverage for message sequencing, deduplication, and tool-turn scenarios.

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).
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pavlo-flamingo, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dcd3cb6b-8665-48ac-bedf-bd568e852a4d

📥 Commits

Reviewing files that changed from the base of the PR and between 8292ded and 88cba09.

📒 Files selected for processing (4)
  • openframe-frontend-core/src/components/chat/utils/__tests__/history-merge.test.ts
  • openframe-frontend-core/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts
  • openframe-frontend-core/src/components/chat/utils/history-merge.ts
  • openframe-frontend-core/src/components/chat/utils/process-historical-messages.ts
📝 Walkthrough

Walkthrough

Historical 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.

Changes

Chat history and realtime merging

Layer / File(s) Summary
Stream sequence propagation
openframe-frontend-core/src/components/chat/types/message.types.ts, openframe-frontend-core/src/components/chat/utils/process-historical-messages.ts
Historical sequence metadata is carried onto system, user, and assembled assistant processed messages in both processing paths.
Role-scoped merge filtering
openframe-frontend-core/src/components/chat/utils/history-merge.ts
Merge coverage is scoped by message role when row sequences exist, with fallback behavior, positional user synthetic deduplication, and richer trailing assistant adoption.
Merge behavior validation
openframe-frontend-core/src/components/chat/utils/__tests__/history-merge.test.ts
Tests cover replay retention, persistence lag, per-role sequence coverage, and adopted trailing tool turns.

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
Loading

Possibly related PRs

Suggested reviewers: oleksandr-blip

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: fixing duplicate and lost messages in the history/realtime merge.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hotfix/fe-core-history-merge-dedup-loss

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

… 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between afd4fc2 and 8292ded.

📒 Files selected for processing (4)
  • openframe-frontend-core/src/components/chat/types/message.types.ts
  • openframe-frontend-core/src/components/chat/utils/__tests__/history-merge.test.ts
  • openframe-frontend-core/src/components/chat/utils/history-merge.ts
  • openframe-frontend-core/src/components/chat/utils/process-historical-messages.ts

Comment thread openframe-frontend-core/src/components/chat/utils/history-merge.ts
Comment thread openframe-frontend-core/src/components/chat/utils/history-merge.ts
Comment thread openframe-frontend-core/src/components/chat/utils/process-historical-messages.ts Outdated
…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.)
pavlo-flamingo added a commit that referenced this pull request Jul 17, 2026
…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.
@pavlo-flamingo
pavlo-flamingo enabled auto-merge (squash) July 17, 2026 21:53
@pavlo-flamingo
pavlo-flamingo merged commit bfd3e96 into main Jul 17, 2026
7 checks passed
@pavlo-flamingo
pavlo-flamingo deleted the hotfix/fe-core-history-merge-dedup-loss branch July 17, 2026 21:55
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.

2 participants