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
+40 -5
View File
@@ -101,6 +101,23 @@ export type GlobalEventResult = {
project: Project
} | null
export type DirectoryEventResult = boolean | {
changed: boolean
materialization: {
type: "incomplete-session-snapshot"
sessionID?: string
messageID: string
partID?: string
}
}
function hasMessage(draft: State, sessionID: string | undefined, messageID: string): boolean {
if (!sessionID) return false
const messages = draft.message[sessionID]
if (!messages) return false
return Binary.search(messages, messageID, (message) => message.id).found
}
export function reduceGlobalEvent(event: Event): GlobalEventResult {
if (event.type === "global.disposed" || event.type === "server.connected") {
return { type: "refresh" }
@@ -135,7 +152,7 @@ export function applyDirectoryEvent(
onLoadLsp?: () => void
onSetSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
},
): boolean {
): DirectoryEventResult {
switch (event.type) {
case "server.instance.disposed": {
callbacks?.onRefresh?.("")
@@ -269,11 +286,18 @@ export function applyDirectoryEvent(
return false
}
const messageID = (part as { messageID: string }).messageID
const sessionID = (part as { sessionID?: string }).sessionID
const missingOwningMessage = !hasMessage(draft, sessionID, messageID)
const parts = draft.part[messageID]
if (!parts) {
syncDebug.reducer.partUpdatedNoExistingParts(messageID, part.id, part.type)
draft.part[messageID] = [part]
return true
return missingOwningMessage
? {
changed: true,
materialization: { type: "incomplete-session-snapshot", sessionID, messageID, partID: part.id },
}
: true
}
const next = [...parts]
const result = Binary.search(next, part.id, (p) => p.id)
@@ -302,7 +326,12 @@ export function applyDirectoryEvent(
next.splice(insertResult.index, 0, part)
}
draft.part[messageID] = next
return true
return missingOwningMessage
? {
changed: true,
materialization: { type: "incomplete-session-snapshot", sessionID, messageID, partID: part.id },
}
: true
}
case "message.part.removed": {
@@ -333,12 +362,18 @@ export function applyDirectoryEvent(
const parts = draft.part[props.messageID]
if (!parts) {
syncDebug.reducer.partDeltaNoParts(props.messageID, props.partID)
return false
return {
changed: false,
materialization: { type: "incomplete-session-snapshot", messageID: props.messageID, partID: props.partID },
}
}
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) {
syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID)
return false
return {
changed: false,
materialization: { type: "incomplete-session-snapshot", messageID: props.messageID, partID: props.partID },
}
}
const existing = parts[result.index] as Record<string, unknown>
const existingValue = existing[props.field] as string | undefined