Summary
A user's own message sometimes renders twice — the second copy appearing after the agent's reply, not next to the first one. Reloading the page always fixes it.
This is not a regression of #794 (which added removeOptimisticUserEchoes). The reconciler is present and works; it is defeated whenever the browser's clock disagrees with the clock of the machine that writes the transcript.
Reproduced on 1.37.1; the responsible code is byte-identical in 1.37.2 (git diff v1.37.1..v1.37.2 -- src/stores/sessionMessageReconciliation.ts is empty).
Root cause
The chat pane renders one chronologically sorted list built from two different clocks:
- persisted transcript rows (
serverMessages) carry timestamps written by the machine running the provider CLI;
- optimistic and streaming rows (
realtimeMessages) carry new Date() from the browser.
removeOptimisticUserEchoes matches an optimistic row against its persisted copy by fingerprint (trimmed text + image count + file count), but only inside a time gate — src/stores/sessionMessageReconciliation.ts:67-73:
const serverTime = readMessageTime(serverMessage);
if (
serverTime === null
|| serverTime < localTime - LOCAL_USER_DEDUPE_CLOCK_SKEW_MS // 10_000
|| serverTime - localTime > dedupeWindow
) {
continue;
}
The persisted row is allowed to be at most 10 s older than the optimistic one. If the browser clock runs more than 10 s ahead of the server, the persisted row falls outside the gate, no match is claimed, and the optimistic row survives as a second user bubble. Because that row is stamped in the browser's future, computeMerged's chronological sort places it after the agent's reply — which is exactly what the bubble looks like from the user's side.
A reload drops realtimeMessages entirely and rebuilds from the transcript, so the duplicate disappears — matching the "not reproducible after refresh" symptom.
Why "sometimes": the skew has to exceed both the 10 s allowance and the duration of the turn. On a host without working time sync the offset free-runs, so the same deployment reproduces intermittently and then reliably as drift accumulates. On the host where this was found, timedatectl reported System clock synchronized: no with NTP inactive.
Reproduction
- Set the browser machine's clock ~1 minute ahead of the host running CloudCLI (or run the server on a host with no time sync and let it drift).
- Send any message in a Claude/Codex chat.
- The agent replies; the user's message then appears a second time below the reply.
- Reload → single bubble.
Same effect with a driven reconciler, no browser needed:
// browser 45s ahead; persisted copy written 800ms after submit on server time
removeOptimisticUserEchoes([persistedUserTurn], [browserStampedEcho])
// → [browserStampedEcho] (unmatched; renders as the duplicate)
Fix direction
Widening LOCAL_USER_DEDUPE_CLOCK_SKEW_MS would only paper over it — the two timelines still disagree, and rows would still sort into the wrong place. The root fix is to stop putting two clocks in one sorted list: measure the browser↔server offset from a response header and stamp every client-created chat row on the server's timeline.
Note for anyone implementing this: measuring off the HTTP Date header alone is not sufficient. nginx hides upstream Date/Server by default (proxy_hide_header) and substitutes its own, and the reverse-proxy template shipped in docs/nginx-subpath-template.conf has no proxy_pass_header Date. Behind a proxy whose clock is correct while the app host drifts — precisely the failing configuration — the measurement reads the proxy's clock and the fix silently no-ops. An app-set header (e.g. X-Server-Time) is what carries the clock that actually writes the transcripts.
PR follows.
Summary
A user's own message sometimes renders twice — the second copy appearing after the agent's reply, not next to the first one. Reloading the page always fixes it.
This is not a regression of #794 (which added
removeOptimisticUserEchoes). The reconciler is present and works; it is defeated whenever the browser's clock disagrees with the clock of the machine that writes the transcript.Reproduced on 1.37.1; the responsible code is byte-identical in 1.37.2 (
git diff v1.37.1..v1.37.2 -- src/stores/sessionMessageReconciliation.tsis empty).Root cause
The chat pane renders one chronologically sorted list built from two different clocks:
serverMessages) carry timestamps written by the machine running the provider CLI;realtimeMessages) carrynew Date()from the browser.removeOptimisticUserEchoesmatches an optimistic row against its persisted copy by fingerprint (trimmed text + image count + file count), but only inside a time gate —src/stores/sessionMessageReconciliation.ts:67-73:The persisted row is allowed to be at most 10 s older than the optimistic one. If the browser clock runs more than 10 s ahead of the server, the persisted row falls outside the gate, no match is claimed, and the optimistic row survives as a second user bubble. Because that row is stamped in the browser's future,
computeMerged's chronological sort places it after the agent's reply — which is exactly what the bubble looks like from the user's side.A reload drops
realtimeMessagesentirely and rebuilds from the transcript, so the duplicate disappears — matching the "not reproducible after refresh" symptom.Why "sometimes": the skew has to exceed both the 10 s allowance and the duration of the turn. On a host without working time sync the offset free-runs, so the same deployment reproduces intermittently and then reliably as drift accumulates. On the host where this was found,
timedatectlreportedSystem clock synchronized: nowith NTP inactive.Reproduction
Same effect with a driven reconciler, no browser needed:
Fix direction
Widening
LOCAL_USER_DEDUPE_CLOCK_SKEW_MSwould only paper over it — the two timelines still disagree, and rows would still sort into the wrong place. The root fix is to stop putting two clocks in one sorted list: measure the browser↔server offset from a response header and stamp every client-created chat row on the server's timeline.Note for anyone implementing this: measuring off the HTTP
Dateheader alone is not sufficient. nginx hides upstreamDate/Serverby default (proxy_hide_header) and substitutes its own, and the reverse-proxy template shipped indocs/nginx-subpath-template.confhas noproxy_pass_header Date. Behind a proxy whose clock is correct while the app host drifts — precisely the failing configuration — the measurement reads the proxy's clock and the fix silently no-ops. An app-set header (e.g.X-Server-Time) is what carries the clock that actually writes the transcripts.PR follows.