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.
91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import type { Message, Part, SessionStatus } from "@opencode-ai/sdk/v2/client"
|
|
import type { Session } from "@opencode-ai/sdk/v2"
|
|
import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
|
|
|
|
function createSession(id: string, overrides: Partial<Session> = {}): Session {
|
|
return {
|
|
id,
|
|
title: id,
|
|
time: { created: 1, updated: 1 },
|
|
version: "1",
|
|
...overrides,
|
|
} as Session
|
|
}
|
|
|
|
function createAssistantMessage(id: string, sessionID: string, completed?: number): Message {
|
|
return {
|
|
id,
|
|
sessionID,
|
|
role: "assistant",
|
|
time: completed ? { created: 1, updated: 1, completed } : { created: 1, updated: 1 },
|
|
parts: [],
|
|
} as unknown as Message
|
|
}
|
|
|
|
function createPart(id: string, messageID: string): Part {
|
|
return { id, messageID, sessionID: "active", type: "text", text: "done" } as Part
|
|
}
|
|
|
|
describe("getReconnectCandidateSessionIds", () => {
|
|
test("includes non-idle, incomplete assistant, and parent sessions", () => {
|
|
const busyStatus = { type: "busy" } as SessionStatus
|
|
|
|
expect(getReconnectCandidateSessionIds({
|
|
session: [
|
|
createSession("busy"),
|
|
createSession("child", { parentID: "parent" }),
|
|
createSession("parent"),
|
|
createSession("incomplete"),
|
|
],
|
|
session_status: { busy: busyStatus },
|
|
message: {
|
|
incomplete: [createAssistantMessage("m-1", "incomplete")],
|
|
},
|
|
}).sort()).toEqual(["busy", "incomplete", "parent"])
|
|
})
|
|
|
|
test("includes the currently viewed session even when it looks idle and complete", () => {
|
|
expect(getReconnectCandidateSessionIds({
|
|
session: [createSession("active")],
|
|
session_status: { active: { type: "idle" } as SessionStatus },
|
|
message: {
|
|
active: [createAssistantMessage("m-1", "active", 1)],
|
|
},
|
|
part: {
|
|
"m-1": [createPart("p-1", "m-1")],
|
|
},
|
|
}, {
|
|
directory: "/repo",
|
|
viewedSession: { directory: "/repo", sessionId: "active" },
|
|
}).sort()).toContain("active")
|
|
})
|
|
|
|
test("includes completed assistant sessions when the latest assistant parts are missing", () => {
|
|
expect(getReconnectCandidateSessionIds({
|
|
session: [createSession("blank")],
|
|
session_status: { blank: { type: "idle" } as SessionStatus },
|
|
message: {
|
|
blank: [createAssistantMessage("m-1", "blank", 1)],
|
|
},
|
|
part: {},
|
|
})).toEqual(["blank"])
|
|
})
|
|
|
|
test("does not include a viewed session from another directory", () => {
|
|
expect(getReconnectCandidateSessionIds({
|
|
session: [createSession("active")],
|
|
session_status: { active: { type: "idle" } as SessionStatus },
|
|
message: {
|
|
active: [createAssistantMessage("m-1", "active", 1)],
|
|
},
|
|
part: {
|
|
"m-1": [createPart("p-1", "m-1")],
|
|
},
|
|
}, {
|
|
directory: "/repo-a",
|
|
viewedSession: { directory: "/repo-b", sessionId: "active" },
|
|
}).sort()).not.toContain("active")
|
|
})
|
|
})
|