From 2d005e523152ea6bdad1f6de9154c051f40bc87d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 25 Aug 2026 11:19:06 +0700 Subject: [PATCH 1/2] fix(app): order session messages by creation time, not ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 10 ++++ DIVERGENCES.md | 1 + .../context/global-sync/event-reducer.test.ts | 38 +++++++++++++ .../src/context/global-sync/event-reducer.ts | 13 ++--- .../app/src/context/server-session.test.ts | 5 +- packages/app/src/context/server-session.ts | 54 ++++++++++--------- packages/app/src/context/sync.tsx | 13 ++--- packages/app/src/utils/message-order.test.ts | 44 +++++++++++++++ packages/app/src/utils/message-order.ts | 32 +++++++++++ 9 files changed, 171 insertions(+), 39 deletions(-) create mode 100644 packages/app/src/utils/message-order.test.ts create mode 100644 packages/app/src/utils/message-order.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f521516258..6b99d06994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,16 @@ file is the human-readable summary of what changed and why. ### Sessions +- **Messages in older sessions show in the right order again** — in any + session started before August 14, 2026, a new message appeared at the top of + the conversation instead of the bottom, and anything that used "the latest + message" (like the agent color in the sidebar) could pick the oldest one + instead. Message IDs carry a timestamp that silently wraps around every ~26 + months, and August 14 was a wrap: IDs minted since then sort before IDs + minted before, and the app ordered messages by ID. It now orders them by + when they were created, the same way the server does, so old sessions + display correctly and the next wrap (October 2028) changes nothing. + - **CoDev uploads your sessions for analysis on its own — no hub required** — session transcripts used to reach the CoDev analysis backend only on machines with CoDev Hub installed, because the hub's launch wrapper did the diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 0c68ee3e2f..72774adadd 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -309,6 +309,7 @@ from the squash message first, or use a plain merge. | Home-directory crawl guard: `packages/core/src/filesystem/search.ts`, `packages/app/src/components/directory-picker-domain.ts`, `packages/desktop/src/main/index.ts`, `packages/core/test/filesystem/search.test.ts`, `packages/app/src/components/directory-picker-domain.test.ts` | search.ts: new exported `unindexableDirectory(directory)` (true for `Global.Path.home` — honoring `CODEV_TEST_HOME` — and filesystem roots) and a new `shallowLayer`; the layer chooser becomes `Layer.unwrap(Effect.gen(...))` reading `Location.Service` and picks `shallowLayer` for unindexable directories, fff/ripgrep otherwise. `shallowLayer.find` answers from one `readDirectoryEntries` of the location itself (direct children only, symlinks/specials dropped, same fuzzysort/empty-query/limit semantics as the index layers); its glob/grep are the on-demand ripgrep implementations, extracted from `ripgrepLayer` into a shared `ripgrepGlobGrep` effect (ripgrepLayer now spreads it — no behavior change there). directory-picker-domain.ts: `createDirectorySearch`'s bare-word branch (no `/`, `~`, or drive root in the input) matches direct children via the existing `file.list`-based `match()` when the base is the picker's home or a filesystem root, instead of `find.files({directory: base})`. desktop index.ts: the startup `process.chdir(homedir())` becomes `process.chdir(join(app.getPath("userData"), "cwd"))` (mkdir first, after `app.setPath("userData", ...)` so it lands under the channel's own folder; still inside try/catch, `homedir` import dropped). | Materializing the search service walks everything under its location — fff at creation, ripgrep as an eagerly forked scan. Rooted at `~` that walk opens files inside Downloads, Desktop, Documents and the photo/music library bundles, so macOS raised a permission prompt for each (confirmed in the TCC database: Desktop, Media Library, Downloads granted; Photos denied — the last two one second apart, one walk). The desktop app reached a home-rooted location two ways: the picker's bare-word search runs `find.files` against its `~` base, and any server request naming no directory falls back to `process.cwd()` (workspace-routing), which the sidecar inherited as `~` from the main process's chdir. Reproduced end to end against a decoy home: one directoryless `find/file` request read sentinel files inside `Downloads/` and `Photos Library.photoslibrary/`; with the guard the same request returns home's direct children and no sentinel is touched. | Keep all three pieces and take upstream's surrounding changes. If upstream reworks the search layers, preserve the invariant: no eager index (fff create or ripgrep scan) is ever built for the home directory or a filesystem root — `find` there must answer from a single readdir of the location itself. If upstream moves the desktop chdir, keep it pointed at an app-owned empty folder, never `homedir()` (and never leave it in `/`, which breaks ripgrep). The picker guard is belt-and-braces for older/remote servers; do not "simplify" it away because the server also guards. | | Prompt permission mode: `packages/app/src/components/prompt-input-v2.tsx`, `packages/app/src/context/permission.tsx`, `packages/app/src/pages/session/use-session-commands.tsx`, `packages/app/src/components/settings-general.tsx`, `packages/app/src/components/settings-v2/general.tsx`, `packages/app/src/i18n/{en,es,vi}.ts` | prompt-input-v2.tsx: the `modelControl` slot now carries a fragment — upstream's `` plus the fork-only ``, an Auto/Manual select built on session-ui's exported `PromptInputV2Select` (riding the model slot keeps it immediately right of the model select without a new slot in session-ui). It reads and writes the same client-side auto-accept state the `permissions.autoaccept` command toggles: a session takes an explicit override, a draft steers the directory default (`src/components/prompt-input/permission-mode.ts`, fork-only, holds that decision). A directory whose server config says `permission: "allow"` renders a non-interactive Auto with a tooltip, since such a server never asks. permission.tsx: `enableConfiguredDirectory` gains an `autoByDefault` parameter, passed as `sideForServerKey(activeServer()) === "work"`, so Work directories seed to auto-accept, and exposes `side()` so the consent gate agrees with it. All three paths that turn auto-accept ON — the select, the `permissions.autoaccept` command in use-session-commands.tsx, and the Settings switch in both general.tsx files — now await `useAutoAcceptConsent()` (fork-only `src/components/dialog-auto-accept.tsx`): on Code, once per app run, a confirm dialog focused on Cancel plus a warning toast, matching the TUI's `enableAutoApprove`; Work never asks, since auto is the default there that nobody picked. Turning it off never asks. i18n: the `prompt.permission.*` and `dialog.autoAccept.*` families. | Users asked to pick Auto/Manual per session, with Code defaulting to manual and Work to auto. The Work default used to come from the sandbox guest config (`permission: "allow"`, dropped in `packages/desktop/src/main/sandbox/egress.ts` — fork-only, untracked here): a server told to allow everything never emits `permission.asked`, so Manual had nothing to gate. Moving the default to the host makes both modes real on both sides while leaving the walk-away behaviour unchanged. | Keep the fork's fragment and re-apply upstream's changes to the `` inside it. The three enable paths each gained one `await consent(...)` line — keep them together, or the warning becomes theatre that one door skips. If upstream adds its own permission or mode control to the prompt toolbar, drop ours and re-point the Work default at whatever state theirs uses. `enableConfiguredDirectory`'s extra parameter is additive — if upstream rewrites that function, keep the `autoByDefault` branch or the Work side loses its auto default. | +| Message order across the ID wrap: `packages/app/src/context/{server-session,sync}.ts(x)`, `packages/app/src/context/global-sync/event-reducer.ts` (+ their tests) | Session message arrays are kept sorted by `(time.created, id)` — the fork-only `src/utils/message-order.ts` holds the comparator (`compareMessages`), a matching string key for `Binary.search` inserts (`messageOrderKey`), and a linear `messageIndexByID` for the membership/removal lookups that only have an ID in hand. `merge`/`reconcileFetched` gained an optional comparator (message call sites pass it; sessions/parts keep ID order); the local `cmpMessage` was replaced by the shared comparator. | Upstream's ID encoder keeps only 48 bits of `timestamp*4096`, so the time prefix wraps every 2^36 ms — last on 2026-08-14T11:19:55Z. Every ID minted before a wrap sorts lexically after every ID minted since, and the arrays were ID-ordered: an old session rendered each new bubble at the top, and every "latest message" read (`.at(-1)`) answered with its oldest. The server already pages by `(time_created, id)` (message-v2.ts); this aligns the client with it and stays correct across the next wrap (2028-10-18). Parts stay ID-ordered — one message's parts are minted together, on one side of a wrap. | Keep the comparator-driven ordering and the linear ID lookups; take upstream's surrounding changes. If upstream fixes the identifier encoding itself, keep this anyway — pre-wrap IDs are stored in every existing database, so ID order alone can never be trusted again. The TUI's `src/context/sync.tsx` still inserts by bare ID and has the same bug. | ## Known rebrand gaps diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index fb58fc4832..d75b323018 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -414,6 +414,44 @@ describe("applyDirectoryEvent", () => { expect(store.part.msg_2).toBeUndefined() }) + // The ID time field wraps every 2^36 ms (last on 2026-08-14), so a session + // can hold messages whose IDs sort against their age: the older message's + // ID here is lexically greater. Insertion must follow creation time, not ID. + test("inserts a new message after an older one whose wrapped ID sorts greater", () => { + const sessionID = "ses_1" + const older = { + ...userMessage("msg_ff9d36308001Jyuhn6zym7JpS5", sessionID), + time: { created: 100 }, + } as Message + const newer = { + ...userMessage("msg_036951f3d001nUpvUTuLP49eTl", sessionID), + time: { created: 200 }, + } as Message + const [store, setStore] = createStore(baseState({ message: { [sessionID]: [older] } })) + + applyDirectoryEvent({ + event: { type: "message.updated", properties: { info: newer } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.map((x) => x.id)).toEqual([older.id, newer.id]) + + applyDirectoryEvent({ + event: { type: "message.removed", properties: { sessionID, messageID: older.id } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.map((x) => x.id)).toEqual([newer.id]) + }) + test("upserts and prunes message parts", () => { const sessionID = "ses_1" const messageID = "msg_1" diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index b12df5eb55..a03a6256be 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,4 +1,5 @@ import { Binary } from "@opencode-ai/core/util/binary" +import { messageIndexByID, messageOrderKey } from "@/utils/message-order" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { Message, @@ -209,16 +210,16 @@ export function applyDirectoryEvent(input: { input.setStore("message", info.sessionID, [info]) break } - const result = Binary.search(messages, info.id, (m) => m.id) - if (result.found) { - input.setStore("message", info.sessionID, result.index, reconcile(info)) + const index = messageIndexByID(messages, info.id) + if (index >= 0) { + input.setStore("message", info.sessionID, index, reconcile(info)) break } input.setStore( "message", info.sessionID, produce((draft) => { - draft.splice(result.index, 0, info) + draft.splice(Binary.search(draft, messageOrderKey(info), messageOrderKey).index, 0, info) }), ) break @@ -229,8 +230,8 @@ export function applyDirectoryEvent(input: { produce((draft) => { const messages = draft.message[props.sessionID] if (messages) { - const result = Binary.search(messages, props.messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) + const index = messageIndexByID(messages, props.messageID) + if (index >= 0) messages.splice(index, 1) } const parts = draft.part[props.messageID] if (parts) { diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 5554928075..dfd1769b03 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1332,7 +1332,10 @@ describe("server session", () => { await store.sync("child", { force: true }) - expect(store.data.message.child).toEqual([boundary, older]) + // Time-ordered: older (created 1) sorts before boundary (created 2) even + // though its ID (msg_z) is lexically greater — the array follows the same + // creation-time order the refresh boundary uses. + expect(store.data.message.child).toEqual([older, boundary]) }) test("preserves a part update for a message being loaded from history", async () => { diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 5a892f7915..b1d91de842 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -14,12 +14,12 @@ import type { import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs" +import { compareMessages, messageIndexByID, messageOrderKey } from "@/utils/message-order" import { sessionNotFoundError } from "@/utils/server-errors" import { rootSession } from "@/utils/session-route" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const initialMessagePageSize = 20 const historyMessagePageSize = 200 @@ -65,17 +65,18 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { const part = new Map(page.part.map((item) => [item.id, item.part])) const observed: { messageID: string; parts: Part[] }[] = [] for (const item of items) { - const result = Binary.search(session, item.message.id, (message) => message.id) - if (!result.found) session.splice(result.index, 0, item.message) + const found = messageIndexByID(session, item.message.id) >= 0 + if (!found) + session.splice(Binary.search(session, messageOrderKey(item.message), messageOrderKey).index, 0, item.message) const current = part.get(item.message.id) - const confirmed = result.found + const confirmed = found ? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found) : [] - if (result.found) observed.push({ messageID: item.message.id, parts: confirmed }) + if (found) observed.push({ messageID: item.message.id, parts: confirmed }) part.set( item.message.id, merge( - result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []), + found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []), item.parts.filter((part) => !confirmed.includes(part)), ), ) @@ -98,10 +99,10 @@ function runInflight(map: Map>, key: string, task: () => P return promise } -function merge(a: readonly T[], b: readonly T[]) { +function merge(a: readonly T[], b: readonly T[], compare?: (x: T, y: T) => number) { const items = new Map(a.map((item) => [item.id, item] as const)) for (const item of b) items.set(item.id, item) - return [...items.values()].sort((x, y) => cmp(x.id, y.id)) + return [...items.values()].sort(compare ?? ((x, y) => cmp(x.id, y.id))) } function reconcileFetched( @@ -112,6 +113,7 @@ function reconcileFetched( retained?: ReadonlySet removed?: ReadonlySet preserveUnfetched?: boolean | ((item: T) => boolean) + compare?: (x: T, y: T) => number } = {}, ) { const result = new Map(fetched.map((item) => [item.id, item])) @@ -134,7 +136,7 @@ function reconcileFetched( if (!item) result.delete(id) } for (const id of options.removed ?? emptyIDs) result.delete(id) - return [...result.values()].sort((a, b) => cmp(a.id, b.id)) + return [...result.values()].sort(options.compare ?? ((a, b) => cmp(a.id, b.id))) } export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) { @@ -344,8 +346,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: if (!load) return // A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata. const messages = data.message[sessionID] - if (messages && Binary.search(messages, messageID, (message) => message.id).found) - load.retainedMessages.add(messageID) + if (messages && messageIndexByID(messages, messageID) >= 0) load.retainedMessages.add(messageID) const parts = load.touchedParts.get(messageID) if (parts) { parts.add(partID) @@ -368,16 +369,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: load.touchedParts.set(messageID, new Set(parts)) load.carriedDeltaParts.set(messageID, new Set(parts)) const messages = data.message[sessionID] - if (messages && Binary.search(messages, messageID, (message) => message.id).found) - load.retainedMessages.add(messageID) + if (messages && messageIndexByID(messages, messageID) >= 0) load.retainedMessages.add(messageID) } for (const [messageID, parts] of load.removedParts) { const touched = load.touchedParts.get(messageID) ?? new Set() parts.forEach((partID) => touched.add(partID)) load.touchedParts.set(messageID, touched) const messages = data.message[sessionID] - if (messages && Binary.search(messages, messageID, (message) => message.id).found) - load.retainedMessages.add(messageID) + if (messages && messageIndexByID(messages, messageID) >= 0) load.retainedMessages.add(messageID) } for (const [messageID, parts] of load.optimisticParts) { load.removedMessages.delete(messageID) @@ -476,7 +475,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: }) const items = (response.data ?? []).filter((item) => !!item?.info?.id) return { - session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), + session: items.map((item) => cleanMessage(item.info)).sort(compareMessages), part: items.map((item) => ({ id: item.info.id, part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), @@ -581,6 +580,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: retained: load?.retainedMessages, removed: load?.removedMessages, preserveUnfetched, + compare: compareMessages, }) batch(() => { const messageIDs = replaceMessages(sessionID, messages) @@ -620,7 +620,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: try { const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) const first = page.session.reduce( - (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest), + (oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest), undefined, ) if (generations.get(sessionID) !== active) return @@ -670,6 +670,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: session: merge( page.session, parents.map((parent) => parent.message), + compareMessages, ), part: merge( page.part, @@ -677,7 +678,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ), } const preserveUnfetched = - mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0))) + mode === "prepend" || + (!result.complete && (!first || ((message: Message) => compareMessages(message, first) < 0))) applyMessagePage( sessionID, result, @@ -816,12 +818,12 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: setData("message", info.sessionID, [info]) return } - const result = Binary.search(messages, info.id, (message) => message.id) - if (result.found) setData("message", info.sessionID, result.index, reconcile(info)) - if (!result.found) + const index = messageIndexByID(messages, info.id) + if (index >= 0) setData("message", info.sessionID, index, reconcile(info)) + if (index < 0) setData("message", info.sessionID, (value = []) => { const next = value.slice() - next.splice(result.index, 0, info) + next.splice(Binary.search(next, messageOrderKey(info), messageOrderKey).index, 0, info) return next }) return @@ -846,8 +848,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: produce((draft) => { const messages = draft.message[props.sessionID] if (messages) { - const result = Binary.search(messages, props.messageID, (message) => message.id) - if (result.found) messages.splice(result.index, 1) + const index = messageIndexByID(messages, props.messageID) + if (index >= 0) messages.splice(index, 1) } deleteMessageParts(draft, props.messageID) }), @@ -859,7 +861,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: if (SKIP_PARTS.has(part.type)) return const messages = data.message[part.sessionID] const load = messageLoads.get(part.sessionID) - const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found + const missing = !messages || messageIndexByID(messages, part.messageID) < 0 // Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan. if ( missing && @@ -1103,7 +1105,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) - setData("message", input.sessionID, (messages = []) => merge(messages, [input.message])) + setData("message", input.sessionID, (messages = []) => merge(messages, [input.message], compareMessages)) setData( "part_text_accum_delta", produce((draft) => { diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index b90f49ffd4..09dd25b76b 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,4 +1,5 @@ import { Binary } from "@opencode-ai/core/util/binary" +import { messageIndexByID, messageOrderKey } from "@/utils/message-order" import { createMemo } from "solid-js" import { useServerSync } from "./server-sync" import { useSDK } from "./sdk" @@ -67,9 +68,9 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) const confirmed: string[] = [] for (const item of items) { - const result = Binary.search(session, item.message.id, (message) => message.id) - const found = result.found - if (!found) session.splice(result.index, 0, item.message) + const found = messageIndexByID(session, item.message.id) >= 0 + if (!found) + session.splice(Binary.search(session, messageOrderKey(item.message), messageOrderKey).index, 0, item.message) const current = part.get(item.message.id) if (found && hasParts(current, item.parts)) { @@ -92,7 +93,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { const messages = draft.message[input.sessionID] if (messages) { - const result = Binary.search(messages, input.message.id, (m) => m.id) + const result = Binary.search(messages, messageOrderKey(input.message), messageOrderKey) messages.splice(result.index, 0, input.message) } else { draft.message[input.sessionID] = [input.message] @@ -103,8 +104,8 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { const messages = draft.message[input.sessionID] if (messages) { - const result = Binary.search(messages, input.messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) + const index = messageIndexByID(messages, input.messageID) + if (index >= 0) messages.splice(index, 1) } delete draft.part[input.messageID] } diff --git a/packages/app/src/utils/message-order.test.ts b/packages/app/src/utils/message-order.test.ts new file mode 100644 index 0000000000..44a0e31381 --- /dev/null +++ b/packages/app/src/utils/message-order.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { compareMessages, messageIndexByID, messageOrderKey } from "./message-order" + +// The ID time field wraps every 2^36 ms (last on 2026-08-14T11:19:55Z): IDs +// minted before a wrap start near "ff…" and sort lexically after IDs minted +// since, which start near "00…". These two are real shapes from an affected +// database — the pre-wrap message is OLDER but its ID is lexically GREATER. +const preWrap = { id: "msg_ff9d36308001Jyuhn6zym7JpS5", time: { created: Date.parse("2026-08-13T06:33:28Z") } } +const postWrap = { id: "msg_036951f3d001nUpvUTuLP49eTl", time: { created: Date.parse("2026-08-25T01:42:17Z") } } + +describe("compareMessages", () => { + test("orders by creation time across the ID wrap", () => { + expect(compareMessages(preWrap, postWrap)).toBeLessThan(0) + expect(compareMessages(postWrap, preWrap)).toBeGreaterThan(0) + }) + + test("breaks same-millisecond ties by ID", () => { + const a = { id: "msg_a", time: { created: 5 } } + const b = { id: "msg_b", time: { created: 5 } } + expect(compareMessages(a, b)).toBeLessThan(0) + expect(compareMessages(b, a)).toBeGreaterThan(0) + expect(compareMessages(a, { ...a })).toBe(0) + }) +}) + +describe("messageOrderKey", () => { + // Binary.search inserts by key while reconcile paths sort by comparator; a + // disagreement between the two would silently corrupt the array invariant. + test("key order agrees with compareMessages", () => { + const messages = [preWrap, postWrap, { id: "msg_a", time: { created: 5 } }, { id: "msg_b", time: { created: 5 } }] + const byCompare = [...messages].sort(compareMessages).map((m) => m.id) + const byKey = [...messages].sort((a, b) => (messageOrderKey(a) < messageOrderKey(b) ? -1 : 1)).map((m) => m.id) + expect(byKey).toEqual(byCompare) + }) +}) + +describe("messageIndexByID", () => { + test("finds by ID regardless of position in a time-ordered array", () => { + const messages = [preWrap, postWrap] + expect(messageIndexByID(messages, preWrap.id)).toBe(0) + expect(messageIndexByID(messages, postWrap.id)).toBe(1) + expect(messageIndexByID(messages, "msg_missing")).toBe(-1) + }) +}) diff --git a/packages/app/src/utils/message-order.ts b/packages/app/src/utils/message-order.ts new file mode 100644 index 0000000000..de4d720e97 --- /dev/null +++ b/packages/app/src/utils/message-order.ts @@ -0,0 +1,32 @@ +// Fork: the canonical display order for a session's messages — creation time, +// then ID. Message arrays used to be kept sorted by bare ID, which encodes +// only 48 bits of timestamp*4096 and so wraps every 2^36 ms (~795 days; last +// on 2026-08-14T11:19:55Z). Every ID minted before a wrap sorts lexically +// AFTER every ID minted since, so an ID-ordered old session rendered new +// bubbles at the top and answered "latest message" with its oldest. Time +// first matches the server's own ordering (message-v2.ts pages by +// time_created, id) and stays correct across wraps; the ID tiebreak keeps +// same-millisecond messages in mint order via the counter in the ID. +type MessageOrder = { id: string; time?: { created?: number } } + +export function compareMessages(a: MessageOrder, b: MessageOrder) { + return (a.time?.created ?? 0) - (b.time?.created ?? 0) || cmp(a.id, b.id) +} + +// The same order as compareMessages, as a single string key for Binary.search +// inserts. 15 digits of zero-padded milliseconds keeps string comparison equal +// to numeric comparison until the year 33658. +export function messageOrderKey(message: MessageOrder) { + return String(message.time?.created ?? 0).padStart(15, "0") + message.id +} + +// Membership and removal only have an ID in hand, and an ID alone cannot be +// binary-searched in a time-ordered array — a linear scan over one session's +// messages is the honest lookup. +export function messageIndexByID(messages: readonly { id: string }[], id: string) { + return messages.findIndex((message) => message.id === id) +} + +function cmp(a: string, b: string) { + return a < b ? -1 : a > b ? 1 : 0 +} From d12bfde48203935553af5884d1221aefe4ea1dee Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 25 Aug 2026 11:54:31 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(opencode):=20pre-wrap=20sessions=20answ?= =?UTF-8?q?er=20again=20=E2=80=94=20latest()=20goes=20chronological?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 20 ++++++---- DIVERGENCES.md | 2 +- packages/opencode/src/session/message-v2.ts | 33 +++++++++++----- packages/opencode/src/session/prompt.ts | 5 ++- .../opencode/test/session/message-v2.test.ts | 38 +++++++++++++++++++ 5 files changed, 79 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b99d06994..9025d81e9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,15 +163,19 @@ file is the human-readable summary of what changed and why. ### Sessions -- **Messages in older sessions show in the right order again** — in any - session started before August 14, 2026, a new message appeared at the top of - the conversation instead of the bottom, and anything that used "the latest - message" (like the agent color in the sidebar) could pick the oldest one - instead. Message IDs carry a timestamp that silently wraps around every ~26 +- **Sessions from before August 14, 2026 answer again, with messages in the + right order** — in any session started before that date, a new message + appeared at the top of the conversation instead of the bottom, and the + agent never replied at all: the session looked dead no matter what you + typed. Message IDs carry a timestamp that silently wraps around every ~26 months, and August 14 was a wrap: IDs minted since then sort before IDs - minted before, and the app ordered messages by ID. It now orders them by - when they were created, the same way the server does, so old sessions - display correctly and the next wrap (October 2028) changes nothing. + minted before. The app ordered messages by ID, so new bubbles landed on + top — and the server picked "the newest message" by ID too, so it decided + your fresh question had already been answered by a reply from before the + wrap and quietly did nothing. Both now go by when messages were actually + created, the way the message store itself already did, so old sessions + display correctly, respond again, and the next wrap (October 2028) changes + nothing. - **CoDev uploads your sessions for analysis on its own — no hub required** — session transcripts used to reach the CoDev analysis backend only on diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 72774adadd..7a0622df16 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -309,7 +309,7 @@ from the squash message first, or use a plain merge. | Home-directory crawl guard: `packages/core/src/filesystem/search.ts`, `packages/app/src/components/directory-picker-domain.ts`, `packages/desktop/src/main/index.ts`, `packages/core/test/filesystem/search.test.ts`, `packages/app/src/components/directory-picker-domain.test.ts` | search.ts: new exported `unindexableDirectory(directory)` (true for `Global.Path.home` — honoring `CODEV_TEST_HOME` — and filesystem roots) and a new `shallowLayer`; the layer chooser becomes `Layer.unwrap(Effect.gen(...))` reading `Location.Service` and picks `shallowLayer` for unindexable directories, fff/ripgrep otherwise. `shallowLayer.find` answers from one `readDirectoryEntries` of the location itself (direct children only, symlinks/specials dropped, same fuzzysort/empty-query/limit semantics as the index layers); its glob/grep are the on-demand ripgrep implementations, extracted from `ripgrepLayer` into a shared `ripgrepGlobGrep` effect (ripgrepLayer now spreads it — no behavior change there). directory-picker-domain.ts: `createDirectorySearch`'s bare-word branch (no `/`, `~`, or drive root in the input) matches direct children via the existing `file.list`-based `match()` when the base is the picker's home or a filesystem root, instead of `find.files({directory: base})`. desktop index.ts: the startup `process.chdir(homedir())` becomes `process.chdir(join(app.getPath("userData"), "cwd"))` (mkdir first, after `app.setPath("userData", ...)` so it lands under the channel's own folder; still inside try/catch, `homedir` import dropped). | Materializing the search service walks everything under its location — fff at creation, ripgrep as an eagerly forked scan. Rooted at `~` that walk opens files inside Downloads, Desktop, Documents and the photo/music library bundles, so macOS raised a permission prompt for each (confirmed in the TCC database: Desktop, Media Library, Downloads granted; Photos denied — the last two one second apart, one walk). The desktop app reached a home-rooted location two ways: the picker's bare-word search runs `find.files` against its `~` base, and any server request naming no directory falls back to `process.cwd()` (workspace-routing), which the sidecar inherited as `~` from the main process's chdir. Reproduced end to end against a decoy home: one directoryless `find/file` request read sentinel files inside `Downloads/` and `Photos Library.photoslibrary/`; with the guard the same request returns home's direct children and no sentinel is touched. | Keep all three pieces and take upstream's surrounding changes. If upstream reworks the search layers, preserve the invariant: no eager index (fff create or ripgrep scan) is ever built for the home directory or a filesystem root — `find` there must answer from a single readdir of the location itself. If upstream moves the desktop chdir, keep it pointed at an app-owned empty folder, never `homedir()` (and never leave it in `/`, which breaks ripgrep). The picker guard is belt-and-braces for older/remote servers; do not "simplify" it away because the server also guards. | | Prompt permission mode: `packages/app/src/components/prompt-input-v2.tsx`, `packages/app/src/context/permission.tsx`, `packages/app/src/pages/session/use-session-commands.tsx`, `packages/app/src/components/settings-general.tsx`, `packages/app/src/components/settings-v2/general.tsx`, `packages/app/src/i18n/{en,es,vi}.ts` | prompt-input-v2.tsx: the `modelControl` slot now carries a fragment — upstream's `` plus the fork-only ``, an Auto/Manual select built on session-ui's exported `PromptInputV2Select` (riding the model slot keeps it immediately right of the model select without a new slot in session-ui). It reads and writes the same client-side auto-accept state the `permissions.autoaccept` command toggles: a session takes an explicit override, a draft steers the directory default (`src/components/prompt-input/permission-mode.ts`, fork-only, holds that decision). A directory whose server config says `permission: "allow"` renders a non-interactive Auto with a tooltip, since such a server never asks. permission.tsx: `enableConfiguredDirectory` gains an `autoByDefault` parameter, passed as `sideForServerKey(activeServer()) === "work"`, so Work directories seed to auto-accept, and exposes `side()` so the consent gate agrees with it. All three paths that turn auto-accept ON — the select, the `permissions.autoaccept` command in use-session-commands.tsx, and the Settings switch in both general.tsx files — now await `useAutoAcceptConsent()` (fork-only `src/components/dialog-auto-accept.tsx`): on Code, once per app run, a confirm dialog focused on Cancel plus a warning toast, matching the TUI's `enableAutoApprove`; Work never asks, since auto is the default there that nobody picked. Turning it off never asks. i18n: the `prompt.permission.*` and `dialog.autoAccept.*` families. | Users asked to pick Auto/Manual per session, with Code defaulting to manual and Work to auto. The Work default used to come from the sandbox guest config (`permission: "allow"`, dropped in `packages/desktop/src/main/sandbox/egress.ts` — fork-only, untracked here): a server told to allow everything never emits `permission.asked`, so Manual had nothing to gate. Moving the default to the host makes both modes real on both sides while leaving the walk-away behaviour unchanged. | Keep the fork's fragment and re-apply upstream's changes to the `` inside it. The three enable paths each gained one `await consent(...)` line — keep them together, or the warning becomes theatre that one door skips. If upstream adds its own permission or mode control to the prompt toolbar, drop ours and re-point the Work default at whatever state theirs uses. `enableConfiguredDirectory`'s extra parameter is additive — if upstream rewrites that function, keep the `autoByDefault` branch or the Work side loses its auto default. | -| Message order across the ID wrap: `packages/app/src/context/{server-session,sync}.ts(x)`, `packages/app/src/context/global-sync/event-reducer.ts` (+ their tests) | Session message arrays are kept sorted by `(time.created, id)` — the fork-only `src/utils/message-order.ts` holds the comparator (`compareMessages`), a matching string key for `Binary.search` inserts (`messageOrderKey`), and a linear `messageIndexByID` for the membership/removal lookups that only have an ID in hand. `merge`/`reconcileFetched` gained an optional comparator (message call sites pass it; sessions/parts keep ID order); the local `cmpMessage` was replaced by the shared comparator. | Upstream's ID encoder keeps only 48 bits of `timestamp*4096`, so the time prefix wraps every 2^36 ms — last on 2026-08-14T11:19:55Z. Every ID minted before a wrap sorts lexically after every ID minted since, and the arrays were ID-ordered: an old session rendered each new bubble at the top, and every "latest message" read (`.at(-1)`) answered with its oldest. The server already pages by `(time_created, id)` (message-v2.ts); this aligns the client with it and stays correct across the next wrap (2028-10-18). Parts stay ID-ordered — one message's parts are minted together, on one side of a wrap. | Keep the comparator-driven ordering and the linear ID lookups; take upstream's surrounding changes. If upstream fixes the identifier encoding itself, keep this anyway — pre-wrap IDs are stored in every existing database, so ID order alone can never be trusted again. The TUI's `src/context/sync.tsx` still inserts by bare ID and has the same bug. | +| Message order across the ID wrap: `packages/app/src/context/{server-session,sync}.ts(x)`, `packages/app/src/context/global-sync/event-reducer.ts`, `packages/opencode/src/session/{message-v2,prompt}.ts` (+ their tests) | Session message arrays are kept sorted by `(time.created, id)` — the fork-only `src/utils/message-order.ts` holds the comparator (`compareMessages`), a matching string key for `Binary.search` inserts (`messageOrderKey`), and a linear `messageIndexByID` for the membership/removal lookups that only have an ID in hand. `merge`/`reconcileFetched` gained an optional comparator (message call sites pass it; sessions/parts keep ID order); the local `cmpMessage` was replaced by the shared comparator. Server side, `MessageV2.latest` derives its user/assistant/finished bindings via a new exported `chronological(a, b)` (time.created, then ID) instead of max ID, its `tasks` filter compares the same way, and the prompt loop's already-answered exit (`lastUser.id < lastAssistant.id`) uses the comparator. | Upstream's ID encoder keeps only 48 bits of `timestamp*4096`, so the time prefix wraps every 2^36 ms — last on 2026-08-14T11:19:55Z. Every ID minted before a wrap sorts lexically after every ID minted since, and the arrays were ID-ordered: an old session rendered each new bubble at the top, and every "latest message" read (`.at(-1)`) answered with its oldest — and worse, such sessions were DEAD: `MessageV2.latest` picked the pre-wrap turn as newest, so the V1 prompt loop promoted the new user message and then exited believing it was already answered, never running a turn. The server already pages by `(time_created, id)` (message-v2.ts); this aligns the client with it and stays correct across the next wrap (2028-10-18). Parts stay ID-ordered — one message's parts are minted together, on one side of a wrap. | Keep the comparator-driven ordering and the linear ID lookups; take upstream's surrounding changes. If upstream fixes the identifier encoding itself, keep this anyway — pre-wrap IDs are stored in every existing database, so ID order alone can never be trusted again. Known siblings left alone: the TUI's `src/context/sync.tsx` still inserts by bare ID, and `revert.ts`/`session.ts` still compare revert/copy boundary markers by bare ID (they only have an ID in hand). | ## Known rebrand gaps diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 4bd1720849..8c6534ca26 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -575,25 +575,40 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses return filterCompacted(yield* stream(sessionID)) }) +// MessageID's sortable time prefix keeps only 48 bits of timestamp*4096 and +// wraps every 2^36 ms — most recently on 2026-08-14T11:19:55Z — so bare-ID +// comparison misorders messages minted across a wrap: every pre-wrap ID sorts +// above every post-wrap one. Creation time is the real order; the ID's +// per-millisecond counter still breaks same-millisecond ties. +export function chronological( + a: { id: string; time: { created: number } }, + b: { id: string; time: { created: number } }, +) { + return a.time.created - b.time.created || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) +} + // filterCompacted reorders messages for model consumption // ([compaction-user, summary, ...retained tail..., continue-user]), so array -// position is not chronological. Derive each binding by max id (MessageID -// is monotonic via MessageID.ascending) so a pre-compaction overflowing tail -// assistant doesn't get mistaken for the most recent turn. tasks are -// compaction/subtask parts attached to user messages newer than the latest -// finished assistant — i.e. unprocessed work. +// position is not chronological. Derive each binding chronologically (NOT by +// max id — a session spanning an ID wrap holds pre-wrap IDs that sort above +// every newer one, which froze such sessions: latest() saw the pre-wrap turn +// as newest and the prompt loop exited believing the last user message was +// already answered) so a pre-compaction overflowing tail assistant doesn't +// get mistaken for the most recent turn. tasks are compaction/subtask parts +// attached to user messages newer than the latest finished assistant — i.e. +// unprocessed work. export function latest(msgs: WithParts[]) { let user: User | undefined let assistant: Assistant | undefined let finished: Assistant | undefined for (const msg of msgs) { const info = msg.info - if (info.role === "user" && (!user || info.id > user.id)) user = info - if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info - if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info + if (info.role === "user" && (!user || chronological(info, user) > 0)) user = info + if (info.role === "assistant" && (!assistant || chronological(info, assistant) > 0)) assistant = info + if (info.role === "assistant" && info.finish && (!finished || chronological(info, finished) > 0)) finished = info } const tasks = msgs.flatMap((m) => - finished && m.info.id <= finished.id + finished && chronological(m.info, finished) <= 0 ? [] : m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d9e9846e95..91d3f33cf0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1112,7 +1112,10 @@ const layer = Layer.effect( lastAssistant?.finish && !["tool-calls"].includes(lastAssistant.finish) && !hasToolCalls && - lastUser.id < lastAssistant.id + // Chronological, not by ID: across an ID wrap the pre-wrap + // assistant's ID is greater than every new user message's, which + // read as "already answered" and exited without running a turn. + MessageV2.chronological(lastUser, lastAssistant) < 0 ) { const orphan = lastAssistantMsg?.parts.find( (part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part), diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index d9c3c6146f..6d46450c5b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -112,6 +112,44 @@ function basePart(messageID: string, id: string) { } } +describe("session.message-v2.latest", () => { + const at = (info: SessionV1.User | SessionV1.Assistant, created: number) => ({ ...info, time: { created } }) + + // Real ID shapes from a session spanning the 2026-08-14 wrap of the ID time + // prefix: the pre-wrap turn's IDs (ff…) sort lexically above every newer ID + // (00…/03…). latest() must follow creation time, or the prompt loop reads + // the pre-wrap turn as the newest and exits without answering. + test("follows creation time across the ID wrap", () => { + const oldUser = at(userInfo("msg_ff9d36308001Jyuhn6zym7JpS5"), 100) + const oldAssistant = { + ...at(assistantInfo("msg_ff9d3633600174jOGarsMtPiLz", oldUser.id), 101), + finish: "stop", + } as SessionV1.Assistant + const newUser = at(userInfo("msg_036951f3d001nUpvUTuLP49eTl"), 200) + const result = MessageV2.latest([ + { info: oldUser, parts: [] }, + { info: oldAssistant, parts: [] }, + { info: newUser, parts: [] }, + ]) + expect(result.user?.id).toBe(newUser.id) + expect(result.assistant?.id).toBe(oldAssistant.id) + expect(result.finished?.id).toBe(oldAssistant.id) + // The loop's continue/exit decision: the newest user message is NOT yet + // answered by the finished assistant. + expect(MessageV2.chronological(result.user!, result.finished!)).toBeGreaterThan(0) + }) + + test("breaks same-millisecond ties by ID", () => { + const first = at(userInfo("msg_a"), 5) + const second = at(userInfo("msg_b"), 5) + const result = MessageV2.latest([ + { info: first, parts: [] }, + { info: second, parts: [] }, + ]) + expect(result.user?.id).toBe(second.id) + }) +}) + describe("session.message-v2.toModelMessage", () => { test("filters out messages with no parts", async () => { const input: SessionV1.WithParts[] = [