refactor: stabilize live chat sync materialization (#1132)

Canonicalize session message/part materialization across load, prefetch, reconnect, and recovery paths so OpenChamber restores session snapshots through one consistent merge flow.

Preserve live assistant streaming text when stale or delayed snapshots arrive, while still replacing optimistic user parts with confirmed server snapshots to avoid duplicated user messages.

Narrow recovery triggers to explicit incomplete snapshot signals instead of broad session-event fallbacks, reducing unnecessary session refetches during active streaming.

Keep turn windowing aligned with parented assistant replies and add regression coverage for materialization gaps, stale snapshot protection, optimistic user replacement, reconnect recovery, and turn grouping.
This commit is contained in:
Bohdan Triapitsyn
2026-05-07 18:57:44 +03:00
committed by GitHub
parent ff830d3812
commit e892346c6b
17 changed files with 725 additions and 285 deletions
+203
View File
@@ -0,0 +1,203 @@
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { mergeMessages } from "./optimistic"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const STREAMING_PART_FIELDS = ["text", "output"] as const
export type MaterializedMessageRecord = {
info: Message
parts: Part[]
}
export type MaterializedState = {
message: Record<string, Message[]>
part: Record<string, Part[]>
}
export type MaterializeSessionSnapshotsOptions = {
skipPartTypes?: ReadonlySet<string>
mode?: "merge" | "prepend"
}
export type MaterializeSessionSnapshotsResult = {
message: Record<string, Message[]>
part: Record<string, Part[]>
messages: Message[]
messagesChanged: boolean
partsChanged: boolean
}
export type SessionMaterializationStatus = {
hasMessages: boolean
renderable: boolean
missingPartMessageIDs: string[]
}
function sortParts(parts: Part[], skipPartTypes: ReadonlySet<string>) {
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 {
if (!left) return right.length === 0
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
const leftPart = left[index]
const rightPart = right[index]
if (!leftPart || !rightPart) return false
if (leftPart.id !== rightPart.id) return false
if (JSON.stringify(leftPart) !== JSON.stringify(rightPart)) return false
}
return true
}
function getPartEndTime(part: Part): number | undefined {
const stateEnd = (part as { state?: { time?: { end?: unknown } } }).state?.time?.end
if (typeof stateEnd === "number") {
return stateEnd
}
const timeEnd = (part as { time?: { end?: unknown } }).time?.end
return typeof timeEnd === "number" ? timeEnd : undefined
}
function getStringField(part: Part, field: "text" | "output"): string | undefined {
const value = (part as Record<string, unknown>)[field]
return typeof value === "string" ? value : undefined
}
function hasLiveStreamingField(part: Part): boolean {
if (getPartEndTime(part) !== undefined) return false
return STREAMING_PART_FIELDS.some((field) => {
const value = getStringField(part, field)
return typeof value === "string" && value.length > 0
})
}
function mergeMaterializedPart(existing: Part | undefined, next: Part): Part {
if (!existing || getPartEndTime(next) !== undefined) return next
let merged: Part = next
for (const field of STREAMING_PART_FIELDS) {
const existingValue = getStringField(existing, field)
if (!existingValue) continue
const nextValue = getStringField(next, field)
if (typeof nextValue === "string" && nextValue.length >= existingValue.length) continue
if (typeof nextValue === "string" && nextValue.length > 0 && !existingValue.startsWith(nextValue)) continue
if (merged === next) merged = { ...next }
const mergedRecord = merged as Record<string, unknown>
mergedRecord[field] = existingValue
}
return merged
}
function mergeMaterializedParts(
existing: Part[] | undefined,
nextParts: Part[],
skipPartTypes: ReadonlySet<string>,
preserveLiveStreamingParts: boolean,
): Part[] {
if (!existing || existing.length === 0) return nextParts
if (!preserveLiveStreamingParts) return nextParts
const existingByID = new Map(existing.map((part) => [part.id, part]))
let mergedParts = nextParts
let changed = false
for (let index = 0; index < nextParts.length; index += 1) {
const nextPart = nextParts[index]
const mergedPart = mergeMaterializedPart(existingByID.get(nextPart.id), nextPart)
if (mergedPart === nextPart) continue
if (!changed) mergedParts = [...nextParts]
mergedParts[index] = mergedPart
changed = true
}
const snapshotIDs = new Set(nextParts.map((part) => part.id))
const missingLiveParts = existing.filter(
(part) => !!part?.id && !snapshotIDs.has(part.id) && !skipPartTypes.has(part.type) && hasLiveStreamingField(part),
)
if (missingLiveParts.length === 0) return mergedParts
return [...mergedParts, ...missingLiveParts].sort((a, b) => cmp(a.id, b.id))
}
export function materializeSessionSnapshots(
state: MaterializedState,
sessionID: string,
records: MaterializedMessageRecord[],
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 currentMessages = state.message[sessionID] ?? []
const messages = mergeMessages(currentMessages, nextMessages)
const messagesChanged = messages !== currentMessages
let partsChanged = false
const nextPartState = { ...state.part }
const isPrepend = options.mode === "prepend"
for (const record of snapshots) {
const messageID = record.info.id
if (isPrepend && nextPartState[messageID]) continue
const existing = nextPartState[messageID]
const nextParts = mergeMaterializedParts(
existing,
sortParts(record.parts ?? [], skipPartTypes),
skipPartTypes,
record.info.role === "assistant",
)
if (haveEquivalentPartSnapshots(existing, nextParts)) continue
if (nextParts.length === 0) {
delete nextPartState[messageID]
} else {
nextPartState[messageID] = nextParts
}
partsChanged = true
}
return {
message: messagesChanged ? { ...state.message, [sessionID]: messages } : state.message,
part: partsChanged ? nextPartState : state.part,
messages,
messagesChanged,
partsChanged,
}
}
export function getSessionMaterializationStatus(
state: MaterializedState,
sessionID: string,
): SessionMaterializationStatus {
const messages = state.message[sessionID]
if (!messages) {
return { hasMessages: false, renderable: false, missingPartMessageIDs: [] }
}
const missingPartMessageIDs: string[] = []
for (const message of messages) {
if (message.role !== "assistant") continue
const parts = state.part[message.id]
if (!parts || parts.length === 0) {
missingPartMessageIDs.push(message.id)
}
}
return {
hasMessages: true,
renderable: missingPartMessageIDs.length === 0,
missingPartMessageIDs,
}
}