Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
Expand Down
58 changes: 58 additions & 0 deletions packages/app/src/utils/id-wrap-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test"
import { create } from "@opencode-ai/schema/identifier"

const WRAP_BOUNDARY = 1786706395136
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

describe("app identifier seam: 48-bit wrap", () => {
test("ascending ids stay lexicographically ascending across the wrap boundary", () => {
const preWrap = create(false, WRAP_BOUNDARY - 1)
const postWrap = create(false, WRAP_BOUNDARY + 1)
expect(preWrap < postWrap).toBe(true)
})

test("descending ids stay lexicographically descending across the wrap boundary", () => {
const preWrap = create(true, WRAP_BOUNDARY - 1)
const postWrap = create(true, WRAP_BOUNDARY + 1)
expect(postWrap < preWrap).toBe(true)
})

test("same-millisecond ids are strictly ascending and unique", () => {
const ids = Array.from({ length: 10 }, (_, index) => create(false, WRAP_BOUNDARY + 1000 + index))
for (let i = 1; i < ids.length; i++) {
expect(ids[i - 1] < ids[i]).toBe(true)
}
expect(new Set(ids).size).toBe(ids.length)
})

test("same-millisecond descending ids are strictly descending and unique", () => {
const ids = Array.from({ length: 10 }, (_, index) => create(true, WRAP_BOUNDARY + 2000 + index))
for (let i = 1; i < ids.length; i++) {
expect(ids[i - 1] > ids[i]).toBe(true)
}
expect(new Set(ids).size).toBe(ids.length)
})

test("id format is 12 hex chars plus 14 base62 chars", () => {
const id = create(false, WRAP_BOUNDARY + 1)
expect(id).toHaveLength(26)
expect(id.slice(0, 12)).toMatch(/^[0-9a-f]{12}$/)
for (const char of id.slice(12)) {
expect(chars).toContain(char)
}
})

test("ids stay ascending when the clock regresses (latch absorbs regression)", () => {
const first = create(false, WRAP_BOUNDARY + 3000)
const regressed = create(false, WRAP_BOUNDARY + 3000 - 50)
const later = create(false, WRAP_BOUNDARY + 3000 + 50)
expect(first < regressed).toBe(true)
expect(regressed < later).toBe(true)
})

test("new-scheme ids sort below historical pre-wrap ids (comparisons must be time-based)", () => {
const now = create(false, Date.now())
const historical = create(false, WRAP_BOUNDARY - 1)
expect(now < historical).toBe(true)
})
})
75 changes: 75 additions & 0 deletions packages/app/src/utils/id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test"
import { create } from "@opencode-ai/core/id/id"
import { Identifier } from "./id"

const prefixes = {
session: "ses",
message: "msg",
permission: "per",
user: "usr",
part: "prt",
pty: "pty",
} as const

function decodeTime(id: string): number {
const start = id.indexOf("_") + 1
return Number(BigInt("0x" + id.slice(start, start + 12)))
}

describe("Identifier", () => {
test("every prefix keeps the prefix_underscore_26-char shape", () => {
for (const prefix of ["session", "message", "permission", "user", "part", "pty"] as const) {
const ascending = Identifier.ascending(prefix)
const descending = Identifier.descending(prefix)
expect(ascending.startsWith(`${prefixes[prefix]}_`)).toBe(true)
expect(descending.startsWith(`${prefixes[prefix]}_`)).toBe(true)
for (const id of [ascending, descending]) {
expect(id).toHaveLength(prefixes[prefix].length + 1 + 26)
expect(id.slice(prefixes[prefix].length + 1, prefixes[prefix].length + 13)).toMatch(/^[0-9a-f]{12}$/)
}
}
})

test("ascending ids decode their time prefix back to raw wall-clock ms", () => {
// The legacy encoding shifted the millisecond value 12 bits and wrapped at
// 2026-08-14 (issue 271), so under it a live id no longer decodes near
// Date.now().
const before = Date.now()
const id = Identifier.ascending("message")
const after = Date.now()
const decoded = decodeTime(id)
expect(decoded).toBeGreaterThanOrEqual(before - 5000)
expect(decoded).toBeLessThanOrEqual(after + 5000)
})

test("app ids sort consistently with core ids for the same wall-clock", () => {
const appID = Identifier.ascending("message")
const coreID = create("msg", "ascending")
expect(Math.abs(decodeTime(appID) - decodeTime(coreID))).toBeLessThanOrEqual(5000)
})

test("same-millisecond bursts stay strictly ascending and unique", () => {
const ids = Array.from({ length: 20 }, () => Identifier.ascending("message"))
for (let index = 1; index < ids.length; index++) {
expect(ids[index - 1] < ids[index]).toBe(true)
}
expect(new Set(ids).size).toBe(ids.length)
})

test("descending ids stay strictly descending and unique", () => {
const ids = Array.from({ length: 20 }, () => Identifier.descending("session"))
for (let index = 1; index < ids.length; index++) {
expect(ids[index - 1] > ids[index]).toBe(true)
}
expect(new Set(ids).size).toBe(ids.length)
})

test("given id passes through when the prefix matches", () => {
expect(Identifier.ascending("session", "ses_abc123")).toBe("ses_abc123")
expect(Identifier.descending("message", "msg_abc123")).toBe("msg_abc123")
})

test("given id with a wrong prefix throws", () => {
expect(() => Identifier.ascending("message", "ses_abc123")).toThrow("ID ses_abc123 does not start with msg")
})
})
66 changes: 3 additions & 63 deletions packages/app/src/utils/id.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { create } from "@opencode-ai/schema/identifier"

const prefixes = {
session: "ses",
message: "msg",
Expand All @@ -7,10 +9,6 @@ const prefixes = {
pty: "pty",
} as const

const LENGTH = 26
let lastTimestamp = 0
let counter = 0

type Prefix = keyof typeof prefixes
export namespace Identifier {
export function ascending(prefix: Prefix, given?: string) {
Expand All @@ -24,7 +22,7 @@ export namespace Identifier {

function generateID(prefix: Prefix, descending: boolean, given?: string): string {
if (!given) {
return create(prefix, descending)
return prefixes[prefix] + "_" + create(descending)
}

if (!given.startsWith(prefixes[prefix])) {
Expand All @@ -33,61 +31,3 @@ function generateID(prefix: Prefix, descending: boolean, given?: string): string

return given
}

function create(prefix: Prefix, descending: boolean, timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()

if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}

counter += 1

let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)

if (descending) {
now = ~now
}

const timeBytes = new Uint8Array(6)
for (let i = 0; i < 6; i += 1) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}

return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
}

function bytesToHex(bytes: Uint8Array): string {
let hex = ""
for (let i = 0; i < bytes.length; i += 1) {
hex += bytes[i].toString(16).padStart(2, "0")
}
return hex
}

function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const bytes = getRandomBytes(length)
let result = ""
for (let i = 0; i < length; i += 1) {
result += chars[bytes[i] % 62]
}
return result
}

function getRandomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length)
const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined

if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
cryptoObj.getRandomValues(bytes)
return bytes
}

for (let i = 0; i < length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256)
}

return bytes
}
Loading