fix(chat): keep messages chronological across ID rollover

This commit is contained in:
Bohdan Triapitsyn
2026-08-14 16:53:05 +03:00
parent 7cf869d5eb
commit fe1f6130d6
23 changed files with 405 additions and 132 deletions
+1
View File
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
- **Chat:** new messages now remain at the end of the conversation instead of jumping before older messages after the message ID sequence rolls over; history loading, revert, and redo follow the same chronological order.
- **Stability:** a single internal error no longer shuts down the local server, which made the instance unreachable until it was restarted; the error is logged and the server keeps running.
- Browser: restoring or opening a dev server preview while connected to an instance over a relay or other non-standard address no longer crashes the app; the preview reports the tunnel as unavailable instead.
@@ -93,7 +93,8 @@ export const RevertedMessageDock: React.FC<RevertedMessageDockProps> = React.mem
if (!sessionId || restoringId) return;
setRestoringId(messageId);
try {
const nextMessage = userMessages.find((message) => message.id > messageId);
const messageIndex = userMessages.findIndex((message) => message.id === messageId);
const nextMessage = messageIndex >= 0 ? userMessages[messageIndex + 1] : undefined;
if (nextMessage) {
await revertToMessage(sessionId, nextMessage.id, { skipRedoPush: true });
} else {
@@ -86,4 +86,20 @@ describe('buildRevertedMessageDockState', () => {
expect(second).not.toBe(first);
expect(second.records).toHaveLength(1);
});
test('collects a post-rollover reverted tail by marker position', () => {
const before = message('msg_ffffffffffffBefore', 'user');
const marker = message('msg_000000000000Marker', 'user');
const after = message('msg_000000000001After', 'user');
const snapshot = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: marker.id } } as State['session'][number]],
message: { ses_1: [before, marker, after] },
}),
'ses_1',
);
expect(snapshot.records.map((record) => record.message.id)).toEqual([marker.id, after.id]);
});
});
@@ -1,5 +1,6 @@
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from '@/sync/types';
import { findMessageIndex } from '@/sync/message-ordering';
type RevertedMessageRecord = {
message: Message & { role: 'user' };
@@ -50,9 +51,14 @@ export const buildRevertedMessageDockState = (
}
const messages = state.message[sessionId] ?? [];
const revertIndex = findMessageIndex(messages, revertMessageID);
if (revertIndex < 0) {
return EMPTY_REVERTED_MESSAGE_DOCK_STATE;
}
const records: RevertedMessageRecord[] = [];
for (const message of messages) {
if (!isUserMessage(message) || message.id < revertMessageID) {
for (let index = revertIndex; index < messages.length; index += 1) {
const message = messages[index];
if (!isUserMessage(message)) {
continue;
}
records.push({
+3 -1
View File
@@ -182,8 +182,10 @@ Rules:
6. Message and part materialization preserves references for unchanged records and maintains direct message-to-parts lookup. Consumers subscribe to the selected session's records rather than broad message/part containers.
7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree.
8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work.
9. Transcript arrays are chronological by `message.time.created`, with message ID used only as a deterministic equal-time tie-breaker. Message IDs are identity and reconciliation keys, not chronology: OpenCode's fixed-width sortable timestamp prefix rolls over, so a newer `msg_000...` can follow an older `msg_fff...`. Fetch, pagination, materialization, optimistic insertion, events, reconnect inspection, rendering, and revert/undo/redo must preserve this contract.
10. Part arrays preserve authoritative response/event order. Part IDs are identity keys and have the same rollover limitation; identity lookup/removal must not require a part array to be lexically ID-sorted.
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication.
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering.
## Loading diagnostics
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2"
import type { Event, Part, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/sdk/v2/client"
import type { Event, Message, Part, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/sdk/v2/client"
import { applyDirectoryEvent } from "../event-reducer"
import { INITIAL_STATE, type State } from "../types"
@@ -65,6 +65,55 @@ function buildSession(title: string, time: Session["time"]): Session {
}
describe("applyDirectoryEvent", () => {
test("inserts post-rollover message events by creation time rather than ID", () => {
const legacy = {
id: "msg_ffffffffffffLegacy",
sessionID: "ses_1",
role: "user",
time: { created: 100 },
} as Message
const current = {
id: "msg_000000000000Current",
sessionID: "ses_1",
role: "assistant",
time: { created: 200 },
} as Message
const draft = state({ message: { ses_1: [legacy] } })
expect(applyDirectoryEvent(draft, {
type: "message.updated",
properties: { info: current },
} as Event)).toBe(true)
expect(draft.message.ses_1).toEqual([legacy, current])
})
test("preserves part event order across the part ID rollover", () => {
const legacyPart = {
id: "prt_ffffffffffffLegacy",
messageID: "msg_1",
sessionID: "ses_1",
type: "text",
text: "legacy",
} as Part
const currentPart = {
id: "prt_000000000000Current",
messageID: "msg_1",
sessionID: "ses_1",
type: "text",
text: "current",
} as Part
const draft = state({
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "assistant", time: { created: 1 } } as Message] },
part: { msg_1: [legacyPart] },
})
expect(applyDirectoryEvent(draft, {
type: "message.part.updated",
properties: { part: currentPart },
} as Event)).toBe(true)
expect(draft.part.msg_1).toEqual([legacyPart, currentPart])
})
test("returns typed materialization when delta arrives before parts", () => {
const result = applyDirectoryEvent(state(), deltaEvent())
+34 -25
View File
@@ -15,6 +15,11 @@ import { dropSessionCaches } from "./session-cache"
import { stripSessionDiffSnapshots } from "./sanitize"
import { syncDebug } from "./debug"
import { shouldSkipStaleSessionEvent } from "./session-event-freshness"
import {
compareMessagesChronologically,
findMessageIndex,
insertMessageChronologically,
} from "./message-ordering"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const DELTA_OVERLAP_FIELDS = ["text", "output"] as const
@@ -180,7 +185,7 @@ function hasMessage(draft: State, sessionID: string | undefined, messageID: stri
if (!sessionID) return false
const messages = draft.message[sessionID]
if (!messages) return false
return Binary.search(messages, messageID, (message) => message.id).found
return messages.some((message) => message.id === messageID)
}
export function reduceGlobalEvent(event: Event): GlobalEventResult {
@@ -353,21 +358,26 @@ export function applyDirectoryEvent(
draft.message[info.sessionID] = [info]
return true
}
const result = Binary.search(messages, info.id, (m) => m.id)
if (result.found) {
const messageIndex = findMessageIndex(messages, info.id)
if (messageIndex >= 0) {
// Skip message replacement if unchanged — preserves reference, avoids re-render
const existing = messages[result.index]
const existing = messages[messageIndex]
const unchanged = areMessageUpdateFieldsEqual(existing, info)
if (unchanged) {
syncDebug.reducer.messageUpdatedUnchanged(info.sessionID, info.id, info.role, (info as { finish?: unknown }).finish, (info.time as { completed?: number })?.completed)
return false
}
const next = [...messages]
next[result.index] = info
if (compareMessagesChronologically(existing, info) === 0) {
next[messageIndex] = info
} else {
next.splice(messageIndex, 1)
insertMessageChronologically(next, info)
}
draft.message[info.sessionID] = next
} else {
const next = [...messages]
next.splice(result.index, 0, info)
insertMessageChronologically(next, info)
draft.message[info.sessionID] = next
}
return true
@@ -378,9 +388,9 @@ export function applyDirectoryEvent(
const messages = draft.message[props.sessionID]
if (messages) {
const next = [...messages]
const result = Binary.search(next, props.messageID, (m) => m.id)
if (result.found) {
next.splice(result.index, 1)
const messageIndex = findMessageIndex(next, props.messageID)
if (messageIndex >= 0) {
next.splice(messageIndex, 1)
draft.message[props.sessionID] = next
}
}
@@ -411,14 +421,14 @@ export function applyDirectoryEvent(
: true
}
const next = [...parts]
const result = Binary.search(next, part.id, (p) => p.id)
if (result.found) {
const previous = next[result.index]
const partIndex = next.findIndex((candidate) => candidate.id === part.id)
if (partIndex >= 0) {
const previous = next[partIndex]
if (shouldPreserveExistingPart(previous, part)) {
return false
}
const dedupeFields = getUpdatedDeltaFields(previous, part)
next[result.index] = dedupeFields.length > 0
next[partIndex] = dedupeFields.length > 0
? { ...part, __dedupeNextDeltaFields: dedupeFields } as unknown as Part
: part
} else {
@@ -427,14 +437,13 @@ export function applyDirectoryEvent(
// always inserted first). Assistant messages never have optimistic parts,
// so this check is effectively free during streaming.
const hasOptimistic = next.length > 0 && !(next[0] as { sessionID?: string }).sessionID
const optimisticIdx = hasOptimistic && (part.type === "text" || part.type === "file")
const optimisticIndex = hasOptimistic && (part.type === "text" || part.type === "file")
? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID)
: -1
if (optimisticIdx >= 0) {
next.splice(optimisticIdx, 1)
if (optimisticIndex >= 0) {
next.splice(optimisticIndex, 1)
}
const insertResult = Binary.search(next, part.id, (p) => p.id)
next.splice(insertResult.index, 0, part)
next.push(part)
}
draft.part[messageID] = next
return missingOwningMessage
@@ -449,10 +458,10 @@ export function applyDirectoryEvent(
const props = event.properties as { messageID: string; partID: string }
const parts = draft.part[props.messageID]
if (!parts) return false
const result = Binary.search(parts, props.partID, (p) => p.id)
if (result.found) {
const partIndex = parts.findIndex((part) => part.id === props.partID)
if (partIndex >= 0) {
const next = [...parts]
next.splice(result.index, 1)
next.splice(partIndex, 1)
if (next.length === 0) {
delete draft.part[props.messageID]
} else {
@@ -479,21 +488,21 @@ export function applyDirectoryEvent(
materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
}
}
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) {
const partIndex = parts.findIndex((part) => part.id === props.partID)
if (partIndex < 0) {
syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID)
return {
changed: false,
materialization: { type: "incomplete-session-snapshot", reason: "missing-delta-part", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
}
}
const existing = parts[result.index] as Record<string, unknown>
const existing = parts[partIndex] as Record<string, unknown>
const existingValue = existing[props.field] as string | undefined
const dedupeFields = (existing as DedupeMetadata).__dedupeNextDeltaFields ?? []
const shouldDedupe = dedupeFields.includes(props.field)
// Create new Part object + new array so React detects the change
const next = [...parts]
next[result.index] = {
next[partIndex] = {
...existing,
[props.field]: shouldDedupe ? appendNonOverlappingDelta(existingValue, props.delta) : (existingValue ?? "") + props.delta,
__dedupeNextDeltaFields: dedupeFields.filter((field) => field !== props.field),
+11 -9
View File
@@ -1,8 +1,8 @@
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { mergeMessages } from "./optimistic"
import type { SessionMaterializationReason } from "./event-reducer"
import { sortMessagesChronologically } from "./message-ordering"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const STREAMING_PART_FIELDS = ["text", "output"] as const
const ACTIVE_TOOL_STATUSES = new Set(["pending", "running"])
const FINAL_TOOL_STATUSES = new Set(["completed", "error", "aborted", "failed", "timeout", "cancelled"])
@@ -93,10 +93,9 @@ export function getStaleRunningToolMessageID(
return undefined
}
function sortParts(parts: Part[], skipPartTypes: ReadonlySet<string>) {
function filterMaterializedParts(parts: Part[], skipPartTypes: ReadonlySet<string>): Part[] {
return parts
.filter((part) => !!part?.id && !skipPartTypes.has(part.type))
.sort((a, b) => cmp(a.id, b.id))
}
function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean {
@@ -252,7 +251,7 @@ function mergeMaterializedParts(
)
if (missingLiveParts.length === 0) return mergedParts
return [...mergedParts, ...missingLiveParts].sort((a, b) => cmp(a.id, b.id))
return [...mergedParts, ...missingLiveParts]
}
export function materializeSessionSnapshots(
@@ -262,10 +261,13 @@ export function materializeSessionSnapshots(
options: MaterializeSessionSnapshotsOptions = {},
): MaterializeSessionSnapshotsResult {
const skipPartTypes = options.skipPartTypes ?? new Set<string>()
const snapshots = records
.filter((record) => !!record?.info?.id)
.sort((left, right) => cmp(left.info.id, right.info.id))
const nextMessages = snapshots.map((record) => record.info)
const recordsByMessageID = new Map(
records
.filter((record) => !!record?.info?.id)
.map((record) => [record.info.id, record] as const),
)
const nextMessages = sortMessagesChronologically([...recordsByMessageID.values()].map((record) => record.info))
const snapshots = nextMessages.map((message) => recordsByMessageID.get(message.id)!)
const existingMessages = state.message[sessionID]
const currentMessages = existingMessages ?? []
const messages = mergeMessages(currentMessages, nextMessages)
@@ -283,7 +285,7 @@ export function materializeSessionSnapshots(
const existing = nextPartState[messageID]
const nextParts = mergeMaterializedParts(
existing,
sortParts(record.parts ?? [], skipPartTypes),
filterMaterializedParts(record.parts ?? [], skipPartTypes),
skipPartTypes,
isAssistant,
)
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import {
insertMessageChronologically,
messagesBefore,
messagesFrom,
sortMessagesChronologically,
} from "./message-ordering"
const message = (id: string, created: number): Message => ({
id,
sessionID: "session-a",
role: "user",
time: { created },
} as Message)
describe("message chronology", () => {
test("orders post-rollover IDs after legacy IDs by creation time", () => {
const legacy = message("msg_ffffffffffffLegacy", 100)
const current = message("msg_000000000000Current", 200)
expect(sortMessagesChronologically([current, legacy])).toEqual([legacy, current])
const messages = [legacy]
insertMessageChronologically(messages, current)
expect(messages).toEqual([legacy, current])
})
test("uses ID only as a deterministic equal-time tie breaker", () => {
const second = message("msg_b", 100)
const first = message("msg_a", 100)
expect(sortMessagesChronologically([second, first])).toEqual([first, second])
const messages = [second]
insertMessageChronologically(messages, first)
expect(messages).toEqual([first, second])
})
test("splits a revert branch by marker position instead of ID value", () => {
const before = message("msg_ffffBefore", 100)
const marker = message("msg_0000Marker", 200)
const after = message("msg_0001After", 300)
const messages = [before, marker, after]
expect(messagesBefore(messages, marker.id)).toEqual([before])
expect(messagesFrom(messages, marker.id)).toEqual([marker, after])
})
test("does not destructively split when the marker is not materialized", () => {
const messages = [message("msg_a", 100)]
expect(messagesBefore(messages, "missing")).toBe(messages)
expect(messagesFrom(messages, "missing")).toEqual([])
})
})
+57
View File
@@ -0,0 +1,57 @@
import type { Message } from "@opencode-ai/sdk/v2/client"
const getCreatedAt = (message: Message): number => {
const value = (message as { time?: { created?: unknown } }).time?.created
return typeof value === "number" && Number.isFinite(value) ? value : 0
}
/**
* Message IDs identify records; they are not chronology. OpenCode's sortable
* ID timestamp rolls over, so a newly created `msg_000...` can follow a legacy
* `msg_fff...`. Creation time is the authoritative transcript order, with ID
* used only to make equal timestamps deterministic.
*/
export const compareMessagesChronologically = (left: Message, right: Message): number => {
const createdAtDifference = getCreatedAt(left) - getCreatedAt(right)
if (createdAtDifference !== 0) return createdAtDifference
if (left.id < right.id) return -1
if (left.id > right.id) return 1
return 0
}
export const sortMessagesChronologically = <T extends Message>(messages: readonly T[]): T[] => (
[...messages].sort(compareMessagesChronologically)
)
export const findMessageIndex = (messages: readonly Message[], messageID: string): number => (
messages.findIndex((message) => message.id === messageID)
)
export const insertMessageChronologically = <T extends Message>(messages: T[], message: T): number => {
let low = 0
let high = messages.length
while (low < high) {
const middle = (low + high) >>> 1
if (compareMessagesChronologically(messages[middle], message) < 0) {
low = middle + 1
} else {
high = middle
}
}
messages.splice(low, 0, message)
return low
}
/** Return the messages before the marker's current array position. */
export const messagesBefore = <T extends Message>(messages: readonly T[], messageID?: string): T[] => {
if (!messageID) return messages as T[]
const index = findMessageIndex(messages, messageID)
return index < 0 ? messages as T[] : messages.slice(0, index)
}
/** Return the marker and all messages after its current array position. */
export const messagesFrom = <T extends Message>(messages: readonly T[], messageID?: string): T[] => {
if (!messageID) return []
const index = findMessageIndex(messages, messageID)
return index < 0 ? [] : messages.slice(index)
}
+37 -36
View File
@@ -1,10 +1,8 @@
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
import { sortMessagesChronologically } from "./message-ordering"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
function filterIdentifiedParts(parts: Part[]): Part[] {
return parts.filter((part) => !!part?.id)
}
export type OptimisticItem = {
@@ -19,22 +17,24 @@ export type MessagePage = {
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
const containsAllPartsByID = (currentParts: Part[] | undefined, requiredParts: Part[]) => {
if (!currentParts) return requiredParts.length === 0
const currentPartIDs = new Set(currentParts.map((part) => part.id))
return requiredParts.every((part) => currentPartIDs.has(part.id))
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
const mergeParts = (currentParts: Part[] | undefined, optimisticParts: Part[]) => {
if (!currentParts) return filterIdentifiedParts(optimisticParts)
const next = [...currentParts]
const partIDs = new Set(currentParts.map((part) => part.id))
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
for (const part of optimisticParts) {
if (partIDs.has(part.id)) continue
partIDs.add(part.id)
next.push(part)
changed = true
}
if (!changed) return parts
if (!changed) return currentParts
return next
}
@@ -42,46 +42,47 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const messageIDs = new Set(session.map((message) => message.id))
const partsByMessageID = new Map(page.part.map((item) => [item.id, filterIdentifiedParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const messageExists = messageIDs.has(item.message.id)
if (!messageExists) {
messageIDs.add(item.message.id)
session.push(item.message)
}
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
const currentParts = partsByMessageID.get(item.message.id)
if (messageExists && containsAllPartsByID(currentParts, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
partsByMessageID.set(item.message.id, mergeParts(currentParts, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()]
.sort((a, b) => cmp(a[0], b[0]))
.map(([id, part]) => ({ id, part })),
session: sortMessagesChronologically(session),
part: [...partsByMessageID].map(([id, part]) => ({ id, part })),
confirmed,
}
}
/** Merge two sorted message arrays by id, deduplicating.
* Preserves references from `a` for items that already exist avoids
/** Merge two chronologically sorted message arrays by identity, deduplicating.
* Preserves existing references for items that already exist avoids
* unnecessary React re-renders when prepending older history. */
export function mergeMessages<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const existing = new Map(a.map((item) => [item.id, item] as const))
export function mergeMessages<T extends Message>(existingMessages: readonly T[], incomingMessages: readonly T[]) {
const messagesByID = new Map(existingMessages.map((item) => [item.id, item] as const))
let changed = false
for (const item of b) {
if (!existing.has(item.id)) {
existing.set(item.id, item)
for (const item of incomingMessages) {
if (!messagesByID.has(item.id)) {
messagesByID.set(item.id, item)
changed = true
}
}
if (!changed) return a as T[]
return [...existing.values()].sort((x, y) => cmp(x.id, y.id))
if (!changed) return existingMessages as T[]
return sortMessagesChronologically([...messagesByID.values()])
}
+12 -12
View File
@@ -944,12 +944,12 @@ describe("optimisticSend target directory", () => {
})
test("commits the new branch locally and discards its optimistic shadow when sending after a revert", async () => {
const retainedMessage = { id: "msg_1", role: "user", sessionID: "session-reverted" } as Message
const revertedMessage = { id: "msg_2", role: "user", sessionID: "session-reverted" } as Message
const retainedMessage = { id: "msg_ffffffffffffRetained", role: "user", sessionID: "session-reverted", time: { created: 1 } } as Message
const revertedMessage = { id: "msg_000000000000Reverted", role: "user", sessionID: "session-reverted", time: { created: 2 } } as Message
const targetStore = createStore({}, {
session: [{ id: "session-reverted", revert: { messageID: "msg_2" } } as Session],
session: [{ id: "session-reverted", revert: { messageID: revertedMessage.id } } as Session],
message: { "session-reverted": [retainedMessage, revertedMessage] },
part: { msg_2: [{ id: "part_2", type: "text", text: "old branch" } as Part] },
part: { [revertedMessage.id]: [{ id: "part_2", type: "text", text: "old branch" } as Part] },
})
const childStores = createChildStores([["/target/project", targetStore]])
let optimisticMessage: Message | null = null
@@ -981,22 +981,22 @@ describe("optimisticSend target directory", () => {
expect(targetStore.getState().session[0].revert).toBe(undefined)
expect(targetStore.getState().message["session-reverted"].map((message) => message.id)).toEqual([
"msg_1",
retainedMessage.id,
(optimisticMessage as unknown as Message).id,
])
expect(targetStore.getState().part.msg_2).toBe(undefined)
expect(targetStore.getState().part[revertedMessage.id]).toBe(undefined)
expect(optimisticShadow.has(revertedMessage.id)).toBe(false)
expect(optimisticShadow.has((optimisticMessage as unknown as Message).id)).toBe(true)
})
test("restores the reverted branch when sending fails", async () => {
const retainedMessage = { id: "msg_1", role: "user", sessionID: "session-reverted" } as Message
const revertedMessage = { id: "msg_2", role: "user", sessionID: "session-reverted" } as Message
const retainedMessage = { id: "msg_ffffffffffffRetained", role: "user", sessionID: "session-reverted", time: { created: 1 } } as Message
const revertedMessage = { id: "msg_000000000000Reverted", role: "user", sessionID: "session-reverted", time: { created: 2 } } as Message
const revertedPart = { id: "part_2", type: "text", text: "old branch" } as Part
const targetStore = createStore({}, {
session: [{ id: "session-reverted", revert: { messageID: "msg_2" } } as Session],
session: [{ id: "session-reverted", revert: { messageID: revertedMessage.id } } as Session],
message: { "session-reverted": [retainedMessage, revertedMessage] },
part: { msg_2: [revertedPart] },
part: { [revertedMessage.id]: [revertedPart] },
})
const childStores = createChildStores([["/target/project", targetStore]])
@@ -1022,9 +1022,9 @@ describe("optimisticSend target directory", () => {
send: async () => { throw new Error("rejected") },
})).rejects.toThrow("rejected")
expect(targetStore.getState().session[0].revert?.messageID).toBe("msg_2")
expect(targetStore.getState().session[0].revert?.messageID).toBe(revertedMessage.id)
expect(targetStore.getState().message["session-reverted"]).toEqual([retainedMessage, revertedMessage])
expect(targetStore.getState().part.msg_2).toEqual([revertedPart])
expect(targetStore.getState().part[revertedMessage.id]).toEqual([revertedPart])
})
test("rolls back a captured send when the runtime changes after optimistic insert", async () => {
+9 -8
View File
@@ -33,6 +33,8 @@ import { getRuntimeKey } from "@/lib/runtime-switch"
import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error"
import { getStaleRunningToolMessageID } from "./materialization"
import { normalizePath } from "@/lib/pathNormalization"
import { mergeMessages } from "./optimistic"
import { messagesBefore, messagesFrom } from "./message-ordering"
const MESSAGE_REFETCH_LIMIT = 100
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
@@ -1230,9 +1232,10 @@ export async function unshareSession(sessionId: string): Promise<Session | null>
// Optimistic message send — insert user message before API call, rollback on error
// ---------------------------------------------------------------------------
// ID generator matching OpenCode's Identifier.ascending format.
// ID generator matching OpenCode's Identifier.ascending wire format.
// Uses BigInt(timestamp) * 0x1000 + counter, encoded as 6 hex bytes + random base62.
// This ensures client-generated IDs sort correctly with server-generated ones.
// The 6-byte prefix rolls over, so this value is identity only; transcript
// chronology is always derived from message.time.created.
let lastIdTimestamp = 0
let idCounter = 0
@@ -1308,9 +1311,8 @@ export async function optimisticSend(input: {
const stateBeforeSend = store.getState()
const sessionBeforeSend = stateBeforeSend.session.find((session) => session.id === input.sessionId)
const revertMessageID = sessionBeforeSend?.revert?.messageID
const revertedMessages = revertMessageID
? (stateBeforeSend.message[input.sessionId] ?? []).filter((message) => message.id >= revertMessageID)
: []
const messagesBeforeSend = stateBeforeSend.message[input.sessionId] ?? []
const revertedMessages = messagesFrom(messagesBeforeSend, revertMessageID)
const revertedParts = new Map(
revertedMessages.map((message) => [message.id, stateBeforeSend.part[message.id] ?? []] as const),
)
@@ -1321,7 +1323,7 @@ export async function optimisticSend(input: {
))
const message = {
...stateBeforeSend.message,
[input.sessionId]: (stateBeforeSend.message[input.sessionId] ?? []).filter((candidate) => candidate.id < revertMessageID),
[input.sessionId]: messagesBefore(messagesBeforeSend, revertMessageID),
}
const part = { ...stateBeforeSend.part }
for (const revertedMessage of revertedMessages) delete part[revertedMessage.id]
@@ -1439,8 +1441,7 @@ export async function optimisticSend(input: {
))
message = {
...rollbackState.message,
[input.sessionId]: [...(rollbackState.message[input.sessionId] ?? []), ...revertedMessages]
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
[input.sessionId]: mergeMessages(rollbackState.message[input.sessionId] ?? [], revertedMessages),
}
part = { ...rollbackState.part }
for (const [revertedMessageID, parts] of revertedParts) {
@@ -7,8 +7,8 @@ import {
startSessionLoadPerformanceEvent,
} from "./session-load-performance"
const createRecord = (sessionID: string, id = "msg_1") => ({
info: { id, sessionID, role: "user", time: { created: 1 } } as Message,
const createRecord = (sessionID: string, id = "msg_1", created = 1) => ({
info: { id, sessionID, role: "user", time: { created } } as Message,
parts: [{ id: `part_${id}`, messageID: id, sessionID, type: "text", text: "hello" }] as Part[],
})
@@ -65,8 +65,8 @@ describe("SessionMessageLoader", () => {
const { childStores, loader } = createLoader(async ({ sessionID, limit, before }) => {
calls.push({ limit, before })
return before
? response([createRecord(sessionID, "msg_older")])
: response([createRecord(sessionID, "msg_latest")], "older-cursor")
? response([createRecord(sessionID, "msg_older", 1)])
: response([createRecord(sessionID, "msg_latest", 2)], "older-cursor")
})
const target = { directory: "/repo", sessionID: "session-a" }
@@ -83,11 +83,35 @@ describe("SessionMessageLoader", () => {
{ limit: 100, before: "older-cursor" },
])
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]?.map((message) => message.id))
.toEqual(["msg_latest", "msg_older"].sort())
.toEqual(["msg_older", "msg_latest"])
loader.dispose()
childStores.disposeAll()
})
test("keeps a post-rollover tail after legacy messages for shared runtime identities", async () => {
const runtimes = ["web", "desktop", "vscode", "mobile"]
for (const runtimeKey of runtimes) {
const childStores = new ChildStoreManager()
const sdk = {
session: {
messages: async ({ sessionID }: { sessionID: string }) => response([
createRecord(sessionID, "msg_000000000000Current", 200),
createRecord(sessionID, "msg_ffffffffffffLegacy", 100),
]),
},
} as unknown as OpencodeClient
const loader = new SessionMessageLoader(childStores, { sdk, runtimeKey })
const target = { directory: `/repo-${runtimeKey}`, sessionID: "session-a" }
await loader.ensure(target)
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]?.map((message) => message.id))
.toEqual(["msg_ffffffffffffLegacy", "msg_000000000000Current"])
loader.dispose()
childStores.disposeAll()
}
})
test("loads every history page for an explicit complete-history request", async () => {
const calls: Array<{ before?: string }> = []
const { childStores, loader } = createLoader(async ({ sessionID, before }) => {
+11 -12
View File
@@ -1,8 +1,8 @@
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
import type { ChildStoreManager, DirectoryStore } from "./child-store"
import { Binary } from "./binary"
import { retry } from "./retry"
import { mergeOptimisticPage, type OptimisticItem } from "./optimistic"
import { findMessageIndex, insertMessageChronologically, sortMessagesChronologically } from "./message-ordering"
import { stripMessageDiffSnapshots } from "./sanitize"
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
import {
@@ -23,7 +23,6 @@ const CONSTRAINED_INITIAL_MESSAGE_PAGE_SIZE = 30
const HISTORY_MESSAGE_PAGE_SIZE = 100
const INITIAL_PAGE_EXPANSION_LIMITS = [100, 150] as const
const CONSTRAINED_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
const cmp = (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0
export type SessionMessageTarget = {
directory: string
@@ -109,9 +108,8 @@ const assertSdkSuccess = (result: {
throw error
}
const sortParts = (parts: Part[]): Part[] => parts
const filterIdentifiedParts = (parts: Part[]): Part[] => parts
.filter((part) => Boolean(part?.id))
.sort((left, right) => cmp(left.id, right.id))
const createDefaultState = (generation = 0): SessionMessageLoadState => ({
status: "idle",
@@ -341,15 +339,16 @@ export class SessionMessageLoader {
const target = this.normalizeTarget(input)
if (!target) return
const entry = this.getEntry(target)
entry.optimistic.set(input.message.id, { message: input.message, parts: sortParts(input.parts) })
entry.optimistic.set(input.message.id, { message: input.message, parts: filterIdentifiedParts(input.parts) })
const store = this.childStores.ensureChild(target.directory, { bootstrap: false })
const current = store.getState()
const messages = current.message[target.sessionID] ? [...current.message[target.sessionID]] : []
const result = Binary.search(messages, input.message.id, (message) => message.id)
if (!result.found) messages.splice(result.index, 0, input.message)
if (findMessageIndex(messages, input.message.id) < 0) {
insertMessageChronologically(messages, input.message)
}
store.setState({
message: { ...current.message, [target.sessionID]: messages },
part: { ...current.part, [input.message.id]: sortParts(input.parts) },
part: { ...current.part, [input.message.id]: filterIdentifiedParts(input.parts) },
})
}
@@ -601,12 +600,12 @@ export class SessionMessageLoader {
const records = result.data.filter((record: { info?: { id?: string } }) => Boolean(record?.info?.id))
recordCount = records.length
if (performance) performance.recordCount += recordCount
const session = records
.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info))
.sort((left: Message, right: Message) => cmp(left.id, right.id))
const session = sortMessagesChronologically(
records.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info)),
)
const partsByMessageID = new Map<string, Part[]>()
for (const record of records as Array<{ info: { id: string }; parts?: Part[] }>) {
partsByMessageID.set(record.info.id, sortParts(record.parts ?? []))
partsByMessageID.set(record.info.id, filterIdentifiedParts(record.parts ?? []))
}
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
finishPagePerformance("complete", { retryCount: Math.max(0, attempts - 1), recordCount })
@@ -4,12 +4,12 @@ import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import { buildSessionMessageRecordsSnapshot } from './sync-context';
import { INITIAL_STATE, type State } from './types';
const message = (id: string, role: 'user' | 'assistant', parentID?: string): Message => ({
const message = (id: string, role: 'user' | 'assistant', parentID?: string, created = 1): Message => ({
id,
role,
sessionID: 'ses_1',
...(parentID ? { parentID } : {}),
time: { created: 1 },
time: { created },
} as Message);
const textPart = (id: string, text: string): Part => ({
@@ -34,6 +34,22 @@ const state = (partial: Partial<State>): State => ({
});
describe('buildSessionMessageRecordsSnapshot', () => {
test('renders and reverts a rollover-spanning transcript by array chronology', () => {
const before = message('msg_ffffffffffffBefore', 'user', undefined, 100);
const marker = message('msg_000000000000Marker', 'user', undefined, 200);
const after = message('msg_000000000001After', 'assistant', marker.id, 300);
const snapshot = buildSessionMessageRecordsSnapshot(
state({
session: [{ id: 'ses_1', revert: { messageID: marker.id } } as State['session'][number]],
message: { ses_1: [before, marker, after] },
}),
'ses_1',
);
expect(snapshot.list.map((record) => record.info.id)).toEqual([before.id]);
});
test('only suspends part updates for the active streaming message', () => {
const user = message('user_1', 'user');
const assistant1 = message('assistant_1', 'assistant', 'user_1');
+4 -2
View File
@@ -1490,7 +1490,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const revertToId = currentSession?.revert?.messageID
let targetMessage: typeof messages[number] | undefined
if (revertToId) {
targetMessage = [...userMessages].reverse().find((m) => m.id < revertToId)
const revertIndex = userMessages.findIndex((message) => message.id === revertToId)
targetMessage = revertIndex > 0 ? userMessages[revertIndex - 1] : undefined
} else {
targetMessage = userMessages[userMessages.length - 1]
}
@@ -1537,7 +1538,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
await refetchSessionMessages(sessionId)
const messages = getSyncMessages(sessionId)
const userMessages = messages.filter((m) => m.role === "user")
const targetMessage = userMessages.find((m) => m.id > revertToId)
const revertIndex = userMessages.findIndex((message) => message.id === revertToId)
const targetMessage = revertIndex >= 0 ? userMessages[revertIndex + 1] : undefined
if (targetMessage) {
await get().revertToMessage(sessionId, targetMessage.id, { skipRedoPush: true })
+2 -1
View File
@@ -40,6 +40,7 @@ import { stripSessionDiffSnapshots } from "./sanitize"
import { applySessionEventToGlobalSessions } from "./session-event-router"
import { syncDebug } from "./debug"
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
import { messagesBefore } from "./message-ordering"
import { opencodeClient } from "@/lib/opencode/client"
import { usePermissionStore } from "@/stores/permissionStore"
import {
@@ -2995,7 +2996,7 @@ function getVisibleMessagesForSession(state: State, sessionID: string, previous?
return {
sourceMessages,
visibleMessages: revertMessageID ? sourceMessages.filter((message) => message.id < revertMessageID) : sourceMessages,
visibleMessages: messagesBefore(sourceMessages, revertMessageID),
revertMessageID,
}
}
+30 -5
View File
@@ -40,11 +40,11 @@ describe('shouldFetchSessionForRenderableSync', () => {
// already-committed messages (no re-render churn, no reference breaks).
// 3. mergeOptimisticPage + clearOptimistic is idempotent across commits.
function assistantMessage(id: string): Message {
return { id, sessionID: 'ses_1', role: 'assistant', time: { created: 1 } } as Message
function assistantMessage(id: string, created = 1): Message {
return { id, sessionID: 'ses_1', role: 'assistant', time: { created } } as Message
}
function userMessage(id: string): Message {
return { id, sessionID: 'ses_1', role: 'user', time: { created: 1 } } as Message
function userMessage(id: string, created = 1): Message {
return { id, sessionID: 'ses_1', role: 'user', time: { created } } as Message
}
function assistantMessageWithClientRole(id: string): Message {
// OpenCode sets clientRole on the wire; role may be absent.
@@ -79,11 +79,26 @@ describe('hasUserMessage', () => {
describe('incremental materialization of superset pages (#2084)', () => {
const SKIP_PARTS = new Set(['patch', 'step-start', 'step-finish'])
test('preserves authoritative part order across the part ID rollover', () => {
const msg = assistantMessage('msg_1')
const legacy = textPart('prt_ffffffffffffLegacy', msg.id)
const current = textPart('prt_000000000000Current', msg.id)
const result = materializeSessionSnapshots(
{ message: {}, part: {} },
'ses_1',
[{ info: msg, parts: [legacy, current] }],
{ skipPartTypes: SKIP_PARTS },
)
expect(result.part[msg.id]).toEqual([legacy, current])
})
test('preserves message references for already-committed messages', () => {
// Simulate expansion: commit 50 (assistant-only), then 100 (with user), then 150.
// Pages are supersets: the 100-page includes all 50 from the first page,
// the 150-page includes all 100 from the second.
// Messages are sorted by id in the store (mergeMessages uses cmp by id),
// Messages are chronological in the store,
// so look them up by id rather than positional index.
const a1 = assistantMessage('a_1')
const a2 = assistantMessage('a_2')
@@ -168,6 +183,16 @@ describe('incremental materialization of superset pages (#2084)', () => {
})
describe('mergeOptimisticPage idempotency across commits (#2084)', () => {
test('places a post-rollover optimistic message at the chronological tail', () => {
const legacy = userMessage('msg_ffffffffffffLegacy', 100)
const current = userMessage('msg_000000000000Current', 200)
const page = { session: [legacy], part: [], cursor: undefined, complete: true }
const merged = mergeOptimisticPage(page, [{ message: current, parts: [] }])
expect(merged.session).toEqual([legacy, current])
})
test('second call after clearOptimistic returns the page unchanged', () => {
// Simulate: first commit confirmed an optimistic item, clearOptimistic
// removed it; second commit (expansion) finds no optimistic items.
@@ -78,16 +78,16 @@ describe('buildUserMessageHistorySnapshot', () => {
});
test('excludes user messages hidden by session revert state', () => {
const beforeRevert = message('user_1', 'user');
const reverted = message('user_2', 'user');
const beforeRevert = message('msg_ffffffffffffBefore', 'user');
const reverted = message('msg_000000000000Reverted', 'user');
const snapshot = buildUserMessageHistorySnapshot(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_2' } } as State['session'][number]],
session: [{ id: 'ses_1', revert: { messageID: reverted.id } } as State['session'][number]],
message: { ses_1: [beforeRevert, reverted] },
part: {
user_1: [textPart('part_user_1', 'kept')],
user_2: [textPart('part_user_2', 'reverted')],
[beforeRevert.id]: [textPart('part_user_1', 'kept')],
[reverted.id]: [textPart('part_user_2', 'reverted')],
},
}),
'ses_1',
+4 -5
View File
@@ -1,5 +1,6 @@
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from './types';
import { messagesBefore } from './message-ordering';
type UserMessageHistoryRecord = {
message: Message;
@@ -62,14 +63,12 @@ export const buildUserMessageHistorySnapshot = (
const session = state.session.find((candidate) => candidate.id === sessionID);
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
const records: UserMessageHistoryRecord[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const visibleMessages = messagesBefore(messages, revertMessageID);
for (let index = visibleMessages.length - 1; index >= 0; index -= 1) {
const message = visibleMessages[index];
if (message.role !== 'user') {
continue;
}
if (revertMessageID && message.id >= revertMessageID) {
continue;
}
records.push({
message,
parts: state.part[message.id] ?? EMPTY_PARTS,
+4
View File
@@ -1,3 +1,7 @@
## [Unreleased]
- **Chat:** new messages now remain at the end of the conversation instead of jumping before older messages after the message ID sequence rolls over; history loading, revert, and redo follow the same chronological order.
## [1.18.3] - 2026-08-14
- Chat images: completed assistant replies now collect Markdown images into a compact gallery with thumbnails and full-screen previews, including workspace-local images across multi-root workspaces (thanks to @ChangeHow).
+4
View File
@@ -72,6 +72,10 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r
- Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract.
- Serializes reads and read-modify-write updates, persists a monotonic policy revision, and broadcasts the exact committed snapshot to every active OpenChamber webview. Permission replies remain foreground UI-owned because VS Code does not run the OpenChamber server runtime.
## Shared webview message ordering
Message and part ordering is owned by [`packages/ui/src/sync/DOCUMENTATION.md`](../../ui/src/sync/DOCUMENTATION.md#session-message-loading). The VS Code webview consumes that shared sync implementation; bridge and proxy runtimes pass OpenCode records through without adding runtime-specific ordering.
## Extension guideline
When adding new bridge route families: