fix(chat): keep messages chronological across ID rollover
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()])
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user