Skip to content
Closed
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
26 changes: 17 additions & 9 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
29 changes: 26 additions & 3 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/test/session/messages-pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading