diff --git a/CHANGELOG.md b/CHANGELOG.md index f521516258..9025d81e9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,20 @@ file is the human-readable summary of what changed and why. ### Sessions +- **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. 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 machines with CoDev Hub installed, because the hub's launch wrapper did the diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 0c68ee3e2f..7a0622df16 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`, `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/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 +} 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[] = [