Repository navigation
fix(app): order session messages by creation time, not ID - #324
Conversation
Added: the deadness fix (ca1ac16) — same wrap, server sideOrdering the display fixed the bubbles but not the second symptom: pre-wrap sessions were dead — typing anything promoted a user message and then nothing happened, ever. Root cause
Fix
Verified
Also noted in |
The ID encoder keeps only 48 bits of timestamp*4096, so the sortable time prefix wraps every 2^36 ms — most recently on 2026-08-14T11:19:55Z. Every ID minted before a wrap sorts lexically after every ID minted since, and the app kept each session's messages sorted by bare ID: in a session older than the wrap, each new bubble rendered at the top of the conversation, and every "latest message" read answered with the oldest. Message arrays now hold the order the server already pages in — (time.created, id) — via a shared comparator and a matching Binary.search key (fork-only src/utils/message-order.ts). Membership and removal lookups, which only have an ID in hand, scan linearly; an ID alone cannot be binary-searched in a time-ordered array. Parts stay ID-ordered: one message's parts are minted together, on one side of any wrap. Verified against an affected database: the pre-wrap session renders its old turn first again, and a newly sent message lands at the bottom. The next wrap (2028-10-18) changes nothing. The TUI's sync store still inserts by bare ID and shares the bug; left for a follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ogical Ordering the display fixed the bubbles but not the deadness: in a session older than the 2026-08-14 ID wrap, typing anything promoted a user message and then nothing happened. MessageV2.latest derived its user/assistant/ finished bindings by max ID — "MessageID is monotonic via MessageID.ascending", said the comment — so the pre-wrap turn (ff9d…) beat every new message (03…): the prompt loop saw a finished assistant "newer" than the latest user message, concluded the question was already answered, and exited without running a turn. latest() now derives its bindings via an exported chronological(a, b) — time.created, then ID for same-millisecond ties — the tasks filter compares the same way, and the loop's already-answered exit uses the comparator instead of lastUser.id < lastAssistant.id. Verified live against the dead session from the report: after the fix, a new prompt gets an assistant reply again (2s turn), landing at the bottom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ca1ac16 to
d12bfde
Compare
In any session started before August 14, 2026, sending a message put the new bubble at the top of the conversation instead of the bottom (screenshot in the report: a stack of "hi" bubbles above a turn from two weeks earlier).
Root cause: the ID timestamp wraps
Every ID (
msg_…,ses_…,prt_…) encodes its creation time as 6 bytes oftimestamp*4096— but that value needs 53 bits in 2026, and the encoder keeps only the low 48 (packages/schema/src/identifier.ts, same inpackages/opencode/src/id/id.ts). The sortable time prefix therefore wraps every 2^36 ms ≈ 26 months, most recently at 2026-08-14T11:19:55Z.Verified directly against the affected database: re-encoding each message's stored
time_createdreproduces its ID prefix —msg_ff9d3630…ff9d362c…msg_00902e38…00902e1f…msg_036951f3…036951ca…So every ID minted before the wrap (
ff…) sorts lexically after every ID minted since (00…) — and the app kept each session's message array sorted by bare ID. In pre-wrap sessions, each new message inserted at index 0, and every "latest message" read (.at(-1)— sidebar agent color, context usage, followups) answered with the oldest message. Upstream is affected identically; theirdevas of today still has the 6-byte encoder.Fix
Message arrays now hold the order the server already pages in —
(time.created, id)(message-v2.tsorders bytime_created, id; the file even already had acmpMessagecomparator with exactly this order for pagination boundaries, while the arrays ignored it).src/utils/message-order.ts:compareMessages, a matching string key forBinary.searchinserts (messageOrderKey— the test pins that key order and comparator order agree), and linearmessageIndexByIDfor membership/removal lookups, which only have an ID in hand and cannot binary-search a time-ordered array.server-session.ts,sync.tsx,global-sync/event-reducer.ts: fetch sorts, page merges, optimistic adds, and event inserts use the comparator; membership checks and removals go linear (bounded by one session's message count).merge/reconcileFetchedgained an optional comparator — sessions, parts, and permissions keep their ID order untouched. Parts stay ID-ordered deliberately: one message's parts are minted together, on one side of any wrap.Deliberately not touched: the ID encoder itself. Widening it would diverge a wire-visible format from upstream, and it wouldn't help — pre-wrap IDs are already stored in every database, so ID order alone can never be trusted again. This ordering stays correct across the next wrap (2028-10-18) with the encoder as it is.
One test expectation updated (
server-session.test.ts"uses message creation time for incomplete refresh boundaries"): it used IDs whose lexical order contradicts their times to prove the boundary logic followed time while the array followed IDs — the array now follows time too, which is the point.Verified
message-order.test.tsandevent-reducer.test.ts).pwdturn renders first, the "hi" bubbles follow in order, and a newly sent message lands at the bottom — both the fetch path and the live event-insert path.Known sibling
The TUI's
src/context/sync.tsxinserts by bare ID and has the same bug in old sessions; left for a follow-up to keep this change reviewable.🤖 Generated with Claude Code