Files
openchamber/packages/ui/src/sync/__tests__/materialization.test.ts
T
Serhii Dziupin e3202df037 fix(sync): finalize tool parts orphaned by an interrupted turn after settlement
When a managed OpenCode process dies mid-turn (crash, health-check
restart), the persisted turn never settles: the trailing assistant
message has no time.completed and its tool parts stay pending/running
forever — the server never finalizes them (anomalyco/opencode#19023).
The existing settle-triggered tail refresh refetches the same stale
records, so the UI kept running tool timers and working styling
indefinitely (#2577).

Now, when a session is authoritatively settled (session.idle/
session.error event, or an authoritative status snapshot lowering a
previously busy session) and the trailing assistant message is still
unfinished with active tool parts and no pending question/permission,
the orphaned parts are finalized locally as error/"Interrupted" with
an end time — the same shape OpenCode itself writes for cancelled
tools. The mark is gated on an explicit idle status (absent status is
"unknown", never judged), never applies while busy (including
question/permission waits), and a later terminal event or refresh
supersedes it while a stale running refresh cannot regress it (the
reducer and materializer already preserve final statuses).

Fixes #2577
2026-08-05 14:15:03 +03:00

574 lines
18 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import {
getSessionMaterializationRequestKey,
getSessionMaterializationStatus,
getStaleRunningToolMessageID,
isSessionMaterializationStillNeeded,
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("getSessionMaterializationRequestKey", () => {
test("isolates the same directory and session identity across runtimes", () => {
expect(getSessionMaterializationRequestKey("runtime-a", "/repo", "ses_1"))
.not.toBe(getSessionMaterializationRequestKey("runtime-b", "/repo", "ses_1"))
})
})
describe("materializeSessionSnapshots", () => {
test("marks an empty successful page as materialized", () => {
const result = materializeSessionSnapshots(
{ message: {}, part: {} },
"ses_1",
[],
)
expect(result.message.ses_1).toEqual([])
expect(result.messagesChanged).toBe(true)
expect(getSessionMaterializationStatus(result, "ses_1")).toEqual({
hasMessages: true,
renderable: true,
missingPartMessageIDs: [],
})
})
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])
})
test("preserves state.time from existing part when snapshot drops it", () => {
const livePart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "completed", time: { start: 1000, end: 2000 } },
} as unknown as Part
const snapshotPart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "completed" },
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [livePart] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [snapshotPart] }],
)
const mergedPart = result.part.msg_1[0] as { state?: { time?: { start?: number; end?: number } } }
expect(mergedPart.state?.time?.start).toBe(1000)
expect(mergedPart.state?.time?.end).toBe(2000)
})
test("does not regress a locally interrupted tool (error + end) when a stale running snapshot arrives", () => {
// The #2577 mark writes status "error" + end time; a later stale refresh
// that still reports the part as running must not undo it.
const interruptedTool = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "error", error: "Interrupted", time: { start: 1000, end: 5000 } },
} as unknown as Part
const staleRunningTool = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "running", time: { start: 1000 } },
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [interruptedTool] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [staleRunningTool] }],
)
expect(result.part.msg_1[0]).toBe(interruptedTool)
expect(result.part.msg_1[0]).not.toBe(staleRunningTool)
expect((result.part.msg_1[0] as { state: { status: string } }).state.status).toBe("error")
})
test("does not regress a completed tool when a stale running snapshot arrives", () => {
const completedTool = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "completed", output: "done", time: { start: 1000, end: 2000 } },
} as unknown as Part
const staleRunningTool = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "running", time: { start: 1000 } },
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [completedTool] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [staleRunningTool] }],
)
expect(result.part).toBe(state.part)
expect(result.part.msg_1[0]).toBe(completedTool)
expect(getStaleRunningToolMessageID(result, "ses_1")).toBe(undefined)
})
test("preserves state.attachments from existing part when completed snapshot lacks them", () => {
const livePart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: {
status: "completed",
output: "done",
time: { start: 100, end: 200 },
attachments: [{ id: "att-1", type: "file", mime: "image/png", url: "data:image/png,..." }],
},
} as unknown as Part
const snapshotPart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "completed", output: "done", time: { start: 100, end: 200 } },
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [livePart] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [snapshotPart] }],
)
const mergedPart = result.part.msg_1[0] as { state?: { attachments?: Array<unknown> } }
expect(mergedPart.state?.attachments).toHaveLength(1)
expect((mergedPart.state?.attachments?.[0] as { id?: string })?.id).toBe("att-1")
})
test("preserves state.attachments during streaming merge when snapshot has no end time", () => {
const livePart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: {
status: "running",
time: { start: 100 },
attachments: [{ id: "att-1", type: "file", mime: "image/png", url: "data:image/png,..." }],
},
} as unknown as Part
const snapshotPart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "running", time: { start: 100 } },
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [livePart] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [snapshotPart] }],
)
const mergedPart = result.part.msg_1[0] as { state?: { attachments?: Array<unknown> } }
expect(mergedPart.state?.attachments).toHaveLength(1)
expect((mergedPart.state?.attachments?.[0] as { id?: string })?.id).toBe("att-1")
})
test("preserves both state.attachments and state.time.start during streaming merge when snapshot lacks both", () => {
const livePart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: {
status: "running",
time: { start: 100 },
attachments: [{ id: "att-1", type: "file", mime: "image/png", url: "data:image/png,..." }],
},
} as unknown as Part
const snapshotPart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "running" },
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [livePart] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [snapshotPart] }],
)
const mergedPart = result.part.msg_1[0] as { state?: { attachments?: Array<unknown>; time?: { start?: number; end?: number } } }
expect(mergedPart.state?.attachments).toHaveLength(1)
expect((mergedPart.state?.attachments?.[0] as { id?: string })?.id).toBe("att-1")
expect(mergedPart.state?.time?.start).toBe(100)
})
test("does not merge existing state.attachments when snapshot has its own", () => {
const livePart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: {
status: "completed",
output: "done",
time: { start: 100, end: 200 },
attachments: [{ id: "att-old", type: "file", mime: "image/png", url: "data:image/png,..." }],
},
} as unknown as Part
const snapshotPart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: {
status: "completed",
output: "done",
time: { start: 100, end: 200 },
attachments: [{ id: "att-new", type: "file", mime: "image/jpeg", url: "data:image/jpeg,..." }],
},
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [livePart] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [snapshotPart] }],
)
const mergedPart = result.part.msg_1[0] as { state?: { attachments?: Array<unknown> } }
expect(mergedPart.state?.attachments).toHaveLength(1)
expect((mergedPart.state?.attachments?.[0] as { id?: string })?.id).toBe("att-new")
})
test("treats empty state.attachments in completed snapshot as authoritative", () => {
const livePart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: {
status: "completed",
output: "done",
time: { start: 100, end: 200 },
attachments: [{ id: "att-old", type: "file", mime: "image/png", url: "data:image/png,..." }],
},
} as unknown as Part
const snapshotPart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: {
status: "completed",
output: "done",
time: { start: 100, end: 200 },
attachments: [],
},
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [livePart] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [snapshotPart] }],
)
const mergedPart = result.part.msg_1[0] as { state?: { attachments?: Array<unknown> } }
expect(mergedPart.state?.attachments).toEqual([])
})
test("treats empty state.attachments in streaming snapshot as authoritative", () => {
const livePart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: {
status: "running",
time: { start: 100 },
attachments: [{ id: "att-old", type: "file", mime: "image/png", url: "data:image/png,..." }],
},
} as unknown as Part
const snapshotPart = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "running", time: { start: 100 }, attachments: [] },
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [livePart] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [snapshotPart] }],
)
const mergedPart = result.part.msg_1[0] as { state?: { attachments?: Array<unknown> } }
expect(mergedPart.state?.attachments).toEqual([])
})
})
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: [],
})
})
})
describe("isSessionMaterializationStillNeeded", () => {
test("skips empty-assistant recovery after a part bucket arrives", () => {
const state = { message: { ses_1: [message("msg_1")] }, part: { msg_1: [part("prt_1", "msg_1")] } }
expect(isSessionMaterializationStillNeeded(state, "ses_1", {
reason: "empty-assistant-message",
messageID: "msg_1",
})).toBe(false)
})
test("treats an explicit empty part bucket as authoritative", () => {
const state = { message: { ses_1: [message("msg_1")] }, part: { msg_1: [] } }
expect(isSessionMaterializationStillNeeded(state, "ses_1", {
reason: "empty-assistant-message",
messageID: "msg_1",
})).toBe(false)
})
test("skips missing-message and missing-part recovery after ordered events repair state", () => {
const state = { message: { ses_1: [message("msg_1")] }, part: { msg_1: [part("prt_1", "msg_1")] } }
expect(isSessionMaterializationStillNeeded(state, "ses_1", {
reason: "missing-owning-message",
messageID: "msg_1",
})).toBe(false)
expect(isSessionMaterializationStillNeeded(state, "ses_1", {
reason: "missing-delta-part",
messageID: "msg_1",
partID: "prt_1",
})).toBe(false)
})
test("keeps recovery active while the requested entity is still missing", () => {
const state = { message: { ses_1: [message("msg_1")] }, part: {} }
expect(isSessionMaterializationStillNeeded(state, "ses_1", {
reason: "orphan-delta",
messageID: "msg_1",
partID: "prt_1",
})).toBe(true)
})
test("recovers a settled session whose trailing assistant still has a running tool", () => {
const runningTool = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
tool: "read",
state: { status: "running" },
} as Part
const state = { message: { ses_1: [message("msg_1")] }, part: { msg_1: [runningTool] } }
expect(getStaleRunningToolMessageID(state, "ses_1")).toBe("msg_1")
expect(isSessionMaterializationStillNeeded(state, "ses_1", {
reason: "settled-running-tool",
messageID: "msg_1",
})).toBe(true)
const completedState = {
...state,
part: { msg_1: [{ ...runningTool, state: { status: "completed" } } as Part] },
}
expect(getStaleRunningToolMessageID(completedState, "ses_1")).toBe(undefined)
expect(isSessionMaterializationStillNeeded(completedState, "ses_1", {
reason: "settled-running-tool",
messageID: "msg_1",
})).toBe(false)
})
test("does not recover an older running tool after a newer user turn", () => {
const state = {
message: { ses_1: [message("msg_1"), userMessage("msg_2")] },
part: {
msg_1: [{
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
tool: "read",
state: { status: "running" },
} as Part],
},
}
expect(getStaleRunningToolMessageID(state, "ses_1")).toBe(undefined)
})
})