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/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/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>()), })), ) 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) + }) + }) +})