diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1bea9f52c3ec..afa42b67bb57 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -575,25 +575,33 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses return filterCompacted(yield* stream(sessionID)) }) +// Message IDs contain a truncated 48-bit timestamp prefix, so lexical order +// wraps every 2^36 milliseconds. Storage already orders by (time.created, id); +// use the same migration-safe ordering here, with id only breaking ties inside +// one millisecond. +export function compareChronological(a: Info, b: Info) { + return a.time.created - b.time.created || a.id.localeCompare(b.id) +} + // 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 by persisted creation +// time rather than lexical id order. 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 || compareChronological(info, user) > 0)) user = info + if (info.role === "assistant" && (!assistant || compareChronological(info, assistant) > 0)) assistant = info + if (info.role === "assistant" && info.finish && (!finished || compareChronological(info, finished) > 0)) + finished = info } const tasks = msgs.flatMap((m) => - finished && m.info.id <= finished.id + finished && compareChronological(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 eb116f6b960f..6f79eda00a37 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1112,7 +1112,7 @@ const layer = Layer.effect( lastAssistant?.finish && !["tool-calls"].includes(lastAssistant.finish) && !hasToolCalls && - lastUser.id < lastAssistant.id + MessageV2.compareChronological(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 9bb688aeddb0..5bf2b9fbb82c 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -61,12 +61,12 @@ const model: Provider.Model = { release_date: "2026-01-01", } -function userInfo(id: string): SessionV1.User { +function userInfo(id: string, created = 0): SessionV1.User { return { id, sessionID, role: "user", - time: { created: 0 }, + time: { created }, agent: "user", model: { providerID, modelID: ModelV2.ID.make("test") }, tools: {}, @@ -79,13 +79,14 @@ function assistantInfo( parentID: string, error?: SessionV1.Assistant["error"], meta?: { providerID: string; modelID: string }, + created = 0, ): SessionV1.Assistant { const infoModel = meta ?? { providerID: model.providerID, modelID: model.api.id } return { id, sessionID, role: "assistant", - time: { created: 0 }, + time: { created }, error, parentID, modelID: infoModel.modelID, @@ -1633,6 +1634,28 @@ describe("session.message-v2.latest", () => { expect(state.tasks).toEqual([]) }) + test("uses creation time across the 48-bit ascending-id rollover", () => { + const boundary = 2 ** 36 + const beforeID = "msg_fffffffff001AAAAAAAAAAAAAA" + const afterID = "msg_000000000001BBBBBBBBBBBBBB" + expect(afterID < beforeID).toBe(true) + + const before: SessionV1.WithParts = { + info: userInfo(beforeID, boundary - 1), + parts: [], + } + const after: SessionV1.WithParts = { + info: { ...assistantInfo(afterID, beforeID, undefined, undefined, boundary), finish: "stop" }, + parts: [], + } + + const state = MessageV2.latest([after, before]) + expect(String(state.user?.id)).toBe(beforeID) + expect(String(state.assistant?.id)).toBe(afterID) + expect(String(state.finished?.id)).toBe(afterID) + expect(MessageV2.compareChronological(before.info, after.info)).toBeLessThan(0) + }) + test("a fresh compaction-user newer than the latest summary surfaces in tasks", () => { const newCompactionUser: SessionV1.WithParts = { info: userInfo(NEW_COMPACTION_USER), diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index b67c982ebcd3..ed5cbe240d03 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -271,6 +271,33 @@ describe("MessageV2.page", () => { ), ) + it.instance("keeps storage order chronological across the 48-bit id rollover", () => + withSession(({ session }) => + Effect.gen(function* () { + const created = yield* session.create({}) + const boundary = 2 ** 36 + const ids = [MessageID.make("msg_fffffffff001AAAAAAAAAAAAAA"), MessageID.make("msg_000000000001BBBBBBBBBBBBBB")] + for (const [index, id] of ids.entries()) { + yield* session.updateMessage({ + id, + sessionID: created.id, + role: "user", + time: { created: boundary - 1 + index }, + agent: "test", + model: { providerID: "test", modelID: "test" }, + tools: {}, + mode: "", + } as unknown as SessionV1.Info) + } + + const result = yield* MessageV2.page({ sessionID: created.id, limit: 10 }) + expect(ids[1]! < ids[0]!).toBe(true) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + yield* session.remove(created.id) + }), + ), + ) + it.instance("does not return messages from other sessions", () => Effect.gen(function* () { const session = yield* SessionNs.Service