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
@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import type { Event, Part } from "@opencode-ai/sdk/v2/client"
import { applyDirectoryEvent } from "../event-reducer"
import { INITIAL_STATE, type State } from "../types"
function state(overrides: Partial<State> = {}): State {
return {
...INITIAL_STATE,
message: {},
part: {},
...overrides,
}
}
function deltaEvent(): Event {
return {
type: "message.part.delta",
properties: {
messageID: "msg_1",
partID: "prt_1",
field: "text",
delta: "hello",
},
} as Event
}
function partUpdatedEvent(): Event {
return {
type: "message.part.updated",
properties: {
part: {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "text",
text: "hello",
},
},
} as Event
}
describe("applyDirectoryEvent", () => {
test("returns typed materialization when delta arrives before parts", () => {
const result = applyDirectoryEvent(state(), deltaEvent())
expect(result).toEqual({
changed: false,
materialization: { type: "incomplete-session-snapshot", messageID: "msg_1", partID: "prt_1" },
})
})
test("returns typed materialization when delta part is missing", () => {
const result = applyDirectoryEvent(
state({ part: { msg_1: [{ id: "prt_2", messageID: "msg_1", type: "text", text: "" } as Part] } }),
deltaEvent(),
)
expect(result).toEqual({
changed: false,
materialization: { type: "incomplete-session-snapshot", messageID: "msg_1", partID: "prt_1" },
})
})
test("applies part update and requests materialization when owning message is absent", () => {
const draft = state()
const result = applyDirectoryEvent(draft, partUpdatedEvent())
expect(draft.part.msg_1.map((item) => item.id)).toEqual(["prt_1"])
expect(result).toEqual({
changed: true,
materialization: {
type: "incomplete-session-snapshot",
sessionID: "ses_1",
messageID: "msg_1",
partID: "prt_1",
},
})
})
test("applies part update without materialization when owning message exists", () => {
const draft = state({
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "assistant", time: { created: 1 } } as never] },
})
const result = applyDirectoryEvent(draft, partUpdatedEvent())
expect(draft.part.msg_1.map((item) => item.id)).toEqual(["prt_1"])
expect(result).toBe(true)
})
})
@@ -0,0 +1,137 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "../materialization"
function message(id: string, sessionID = "ses_1"): Message {
return { id, sessionID, role: "assistant", time: { created: 1 } } as Message
}
function userMessage(id: string, sessionID = "ses_1"): Message {
return { id, sessionID, role: "user", time: { created: 1 } } as Message
}
function part(id: string, messageID: string, type = "text", text = id): Part {
return { id, messageID, sessionID: "ses_1", type, text } as Part
}
describe("materializeSessionSnapshots", () => {
test("materializes messages and parts together", () => {
const result = materializeSessionSnapshots(
{ message: {}, part: {} },
"ses_1",
[{ info: message("msg_1"), parts: [part("prt_1", "msg_1")] }],
)
expect(result.message.ses_1.map((item) => item.id)).toEqual(["msg_1"])
expect(result.part.msg_1.map((item) => item.id)).toEqual(["prt_1"])
expect(result.messagesChanged).toBe(true)
expect(result.partsChanged).toBe(true)
})
test("preserves unchanged references", () => {
const existingMessage = message("msg_1")
const existingPart = part("prt_1", "msg_1")
const state = { message: { ses_1: [existingMessage] }, part: { msg_1: [existingPart] } }
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: existingMessage, parts: [existingPart] }],
)
expect(result.message).toBe(state.message)
expect(result.part).toBe(state.part)
expect(result.messagesChanged).toBe(false)
expect(result.partsChanged).toBe(false)
})
test("skips non-rendered part types", () => {
const result = materializeSessionSnapshots(
{ message: {}, part: {} },
"ses_1",
[{ info: message("msg_1"), parts: [part("prt_patch", "msg_1", "patch"), part("prt_text", "msg_1")] }],
{ skipPartTypes: new Set(["patch"]) },
)
expect(result.part.msg_1.map((item) => item.id)).toEqual(["prt_text"])
})
test("preserves newer live streaming text when a stale snapshot materializes", () => {
const livePart = part("prt_1", "msg_1", "text", "First chunk ")
const stalePart = part("prt_1", "msg_1", "text", "")
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [livePart] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [stalePart] }],
)
expect(result.part.msg_1[0]).toBe(livePart)
expect((result.part.msg_1[0] as { text?: string })?.text).toBe("First chunk ")
})
test("preserves live streaming parts omitted by a stale snapshot", () => {
const livePart = part("prt_1", "msg_1", "text", "First chunk ")
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [livePart] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [] }],
)
expect(result.part.msg_1[0]).toBe(livePart)
})
test("does not preserve omitted optimistic user text parts beside server snapshot parts", () => {
const optimisticPart = { id: "prt_optimistic", messageID: "msg_1", type: "text", text: "Hello" } as Part
const serverPart = part("prt_server", "msg_1", "text", "Hello")
const state = {
message: { ses_1: [userMessage("msg_1")] },
part: { msg_1: [optimisticPart] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: userMessage("msg_1"), parts: [serverPart] }],
)
expect(result.part.msg_1).toEqual([serverPart])
})
})
describe("getSessionMaterializationStatus", () => {
test("requires assistant parts for renderable cached state", () => {
const state = {
message: { ses_1: [message("msg_1")] },
part: {},
}
expect(getSessionMaterializationStatus(state, "ses_1")).toEqual({
hasMessages: true,
renderable: false,
missingPartMessageIDs: ["msg_1"],
})
})
test("treats user-only cached state as renderable", () => {
const state = {
message: { ses_1: [{ ...message("msg_1"), role: "user" } as Message] },
part: {},
}
expect(getSessionMaterializationStatus(state, "ses_1")).toEqual({
hasMessages: true,
renderable: true,
missingPartMessageIDs: [],
})
})
})