From 048c6fcd1e528e9415907c22165cb17f22dbed8c Mon Sep 17 00:00:00 2001 From: lightrabbit Date: Thu, 16 Apr 2026 19:16:22 +0000 Subject: [PATCH 1/2] fix: add Identifier.ascendingAfter to guarantee child ID ordering When frontend and backend clocks are out of sync, the assistant message ID\ngenerated by the backend could end up smaller than the parent user message\nID, causing the assistant message to appear above the user message in the\nUI timeline. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/id/id.ts | 16 ++++++++++++++++ packages/opencode/src/session/schema.ts | 1 + 2 files changed, 17 insertions(+) diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 46c210fa5d2b..cbe9be834ab9 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -83,4 +83,20 @@ export function timestamp(id: string): number { return Number(encoded / BigInt(0x1000)) } +export function ascendingAfter(prefix: keyof typeof prefixes, afterID: string): string { + const pre = prefixes[prefix] + const afterHex = afterID.slice(pre.length + 1, pre.length + 13) + const afterEncoded = BigInt("0x" + afterHex) + const nowFull = BigInt(Date.now()) * BigInt(0x1000) + const nowEncoded = nowFull & ((BigInt(1) << BigInt(48)) - BigInt(1)) + const encoded = nowEncoded > afterEncoded ? nowEncoded + BigInt(1) : afterEncoded + BigInt(1) + + const timeBytes = Buffer.alloc(6) + for (let i = 0; i < 6; i++) { + timeBytes[i] = Number((encoded >> BigInt(40 - 8 * i)) & BigInt(0xff)) + } + + return pre + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) +} + export * as Identifier from "./id" diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index efed280c98c1..79d99a9ef791 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -19,6 +19,7 @@ export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.sche Schema.brand("MessageID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), + ascendingAfter: (afterID: string) => s.make(Identifier.ascendingAfter("message", afterID)), zod: Identifier.schema("message").pipe(z.custom>()), })), ) From 2e297fd1f2f7bc30d8d4d54461842c5371520531 Mon Sep 17 00:00:00 2001 From: lightrabbit Date: Thu, 16 Apr 2026 19:16:38 +0000 Subject: [PATCH 2/2] fix: use ascendingAfter for all assistant message creation Replace MessageID.ascending() with MessageID.ascendingAfter(parentID) at\nevery site where an assistant message is created, ensuring the assistant\nmessage ID is always strictly greater than its parent user message ID.\n\nAdds 6 unit tests covering normal case, same-millisecond, clock skew\n(300ms and 5s), uniqueness, and non-interference with ascending(). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/src/session/prompt.ts | 6 +-- packages/opencode/test/id/identifier.test.ts | 53 ++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/id/identifier.test.ts diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 212f5fdbab82..deb4699f2625 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -218,7 +218,7 @@ When constructing the summary, try to stick to this template: const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true }) const ctx = yield* InstanceState.context const msg: MessageV2.Assistant = { - id: MessageID.ascending(), + id: MessageID.ascendingAfter(input.parentID), role: "assistant", parentID: input.parentID, sessionID: input.sessionID, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9faa618788f8..a6b321f85c21 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -538,7 +538,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const { task: taskTool } = yield* registry.named() const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ - id: MessageID.ascending(), + id: MessageID.ascendingAfter(lastUser.id), role: "assistant", parentID: lastUser.id, sessionID, @@ -753,7 +753,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.updatePart(userPart) const msg: MessageV2.Assistant = { - id: MessageID.ascending(), + id: MessageID.ascendingAfter(userMsg.id), sessionID: input.sessionID, parentID: userMsg.id, mode: input.agent, @@ -1406,7 +1406,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the msgs = yield* insertReminders({ messages: msgs, agent, session }) const msg: MessageV2.Assistant = { - id: MessageID.ascending(), + id: MessageID.ascendingAfter(lastUser.id), parentID: lastUser.id, role: "assistant", mode: agent.name, diff --git a/packages/opencode/test/id/identifier.test.ts b/packages/opencode/test/id/identifier.test.ts new file mode 100644 index 000000000000..db8603804d05 --- /dev/null +++ b/packages/opencode/test/id/identifier.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { Identifier } from "../../src/id/id" + +describe("Identifier", () => { + describe("ascendingAfter", () => { + test("generates ID strictly greater than afterID", () => { + const parentId = Identifier.ascending("message") + const childId = Identifier.ascendingAfter("message", parentId) + expect(childId > parentId).toBe(true) + expect(childId.startsWith("msg_")).toBe(true) + }) + + test("handles same-millisecond parent ID", () => { + const now = Date.now() + const parentId = Identifier.create("msg", "ascending", now) + const childId = Identifier.ascendingAfter("message", parentId) + expect(childId > parentId).toBe(true) + }) + + test("handles clock skew: frontend 300ms ahead", () => { + const frontendTs = Date.now() + 300 + const parentId = Identifier.create("msg", "ascending", frontendTs) + const childId = Identifier.ascendingAfter("message", parentId) + expect(childId > parentId).toBe(true) + }) + + test("handles extreme clock skew: frontend 5s ahead", () => { + const futureTs = Date.now() + 5000 + const parentId = Identifier.create("msg", "ascending", futureTs) + const childId = Identifier.ascendingAfter("message", parentId) + expect(childId > parentId).toBe(true) + }) + + test("produces unique IDs on repeated calls", () => { + const parentId = Identifier.ascending("message") + const ids = new Set() + for (let i = 0; i < 100; i++) { + ids.add(Identifier.ascendingAfter("message", parentId)) + } + expect(ids.size).toBe(100) + for (const id of ids) { + expect(id > parentId).toBe(true) + } + }) + + test("does not interfere with ascending() monotonicity", () => { + const before = Identifier.ascending("message") + Identifier.ascendingAfter("message", before) + const after = Identifier.ascending("message") + expect(after > before).toBe(true) + }) + }) +})