fix(history,web): stop user messages rendering as duplicate bubbles - #12
Open
Antisophy wants to merge 3 commits into
Open
fix(history,web): stop user messages rendering as duplicate bubbles#12Antisophy wants to merge 3 commits into
Antisophy wants to merge 3 commits into
Conversation
The same user record can reach the reducer more than once. Overlapping history replays merge without a reset (the pendingHistoryReplies window keeps existing messages when a second replay begins mid-flight), and a live echo can already be present when its replayed copy arrives. Assistant content survives this because its blocks are keyed by item id; user messages were appended with a fresh id every time, so one prompt rendered as two full bubbles after switching between tasks. A user record's uuid is its identity, so an arriving user message whose uuid already exists in the transcript now updates that bubble in place (content, seq, source) instead of appending a second copy. The update never reapplies the pending presentation, so a re-delivered pending copy cannot regress a bubble that consumption has already upgraded. Records without a uuid (optimistic placeholders) are untouched, as is the placeholder displacement that runs before the guard.
The live queue tail emitted user_message/consumed with the echo's own uuid as both the identity and native_uuid, discarding the awaited enqueue anchor. Live that happens to work: the correlation nonce upgrades the optimistic bubble in place, which is the message's display. But the stored record is replay-broken by construction: replay strips the nonce, and no bubble ever carries the echo's uuid at that point in the stream, so on every reload the confirmation was a no-op and the pipeline-derived provisional survived beside its canonical echo. Every message rendered twice after any reload. Name the awaited anchor as the confirmation's identity with the echo's uuid as native_uuid, exactly as the replay pipeline already does. The live path is unchanged in effect (the nonce still upgrades the optimistic bubble; a uuid-less placeholder is never dropped, since no separate echo bubble renders live), and the stored record now resolves its provisional on replay. Histories recorded while the live path was miswired hold the bad confirmations. Those are recognizable (uuid equals native_uuid, nonce stripped by replay), and the queue is FIFO, so such a confirmation can only describe the oldest still-pending enqueue-emitted bubble: drop that one. A correctly named confirmation resolves directly and never reaches the fallback, so a genuinely queued later message with identical text is not touched.
Antisophy
force-pushed
the
fix/user-message-identity-dedup
branch
from
August 5, 2026 22:11
cbf1c36 to
bd3a216
Compare
A replayed history delivers a live-sent message in two forms: the unconfirmed placeholder (nonce-correlated, no uuid) and later the canonical echo. Between them sits the consumed confirmation, which upgrades the placeholder in place; that is correct live, where no echo bubble renders at all. But the upgrade clears the pending state, and the echo's displacement only accepted pending placeholders, so on replay the echo appended beside the upgraded bubble and the message rendered twice. On a replayed echo, also displace a uuid-less user bubble that is no longer pending: it is still the same message in placeholder form, and canonical messages always carry uuids so they are never displaced. The widening applies to replay echoes only; a live no-nonce send keeps the original pending-only rule, so two genuine identical sends still render as two bubbles, which is also what keeps the existing no-reset duplication baseline test passing. Observed end to end in a headless browser against a real task: the wire delivers the unconfirmed envelope and the seq-bearing echo exactly once each, and the previous reducer rendered them as two bubbles.
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.
Three cooperating defects made user messages render as duplicate bubbles after the queue-primary history rework; this PR fixes all three. Live-recorded histories were affected on every message when replayed.
1. The live consumed confirmation names the wrong identity
The live queue tail emitted
user_message/consumedwith the echo's own uuid as both the identity andnative_uuid, discarding the awaited enqueue anchor. Live that happens to work (the nonce correlation upgrades the optimistic bubble, which is the message's display), but the stored record is replay-broken by construction: replay strips the nonce, and no bubble carries the echo's uuid at that point in the stream, so on reload the confirmation was a no-op. The confirmation now carries the awaited anchor as its identity and the echo's uuid asnative_uuid, matching the replay pipeline. Histories already recorded with the misnamed shape are recognizable (uuid equalsnative_uuid), and the queue is FIFO, so the reducer resolves them against the oldest still-pending enqueue-emitted bubble; a correctly named confirmation never reaches that fallback.2. A replayed echo could not displace an upgraded placeholder
A replayed history delivers a live-sent message as the unconfirmed placeholder plus the canonical echo, with the consumed confirmation between them. The confirmation upgrades the placeholder in place (correct live, where no echo bubble renders at all), but the upgrade clears the pending state, and the echo's displacement only accepted pending placeholders, so the echo appended beside the upgraded bubble. On a replayed echo, a uuid-less user bubble is now displaceable whether or not it is still pending; canonical messages always carry uuids, so they are never displaced, and the widening applies to replay echoes only, so two genuine identical live sends still render as two bubbles. Observed end to end in a headless browser against a real task before and after: the wire delivers each form exactly once, and the fix takes the rendering from two bubbles to one.
3. No identity dedup on append
The same user record can also reach the reducer more than once (overlapping history replays merge without a reset). Assistant content survives this because its blocks are keyed by item id; user messages were appended fresh every time. An arriving user message whose uuid already exists now updates that bubble in place.
Tests
Reducer tests cover: the same record arriving twice, distinct records staying distinct, pending re-delivery not regressing an upgraded bubble, a nonce-correlated confirmation upgrading (not dropping) the optimistic placeholder, the misnamed-confirmation FIFO healing, a correctly named confirmation leaving a still-queued same-text bubble alone, and the full stored sequence (unconfirmed placeholder, consumed, echo) rendering as one bubble. The existing no-reset duplication baseline still passes, pinning that genuine identical sends keep separate bubbles.
nix flake check: full set built per commit; the handful of integration checks that failed under full parallel load passed at low parallelism each time, in different sets per run.