* test(sync): preserve question bootstrap and reducer invariants Port the main-independent test assets from the superseded hybrid V2 question PRs (#3266/#3267/#3288) onto current main's V1-only architecture: - listPendingQuestions: unscoped + per-directory fan-out, merge with id-dedupe (first wins), failure throws instead of empty success, malformed-item skip, true-empty success (client.questions.test.ts) - question reducer invariants: idempotent upsert-by-id, replace-not- first-wins, removal by session/request pair, duplicate-terminal no-op, late-asked re-registration (no tombstone) - bootstrap deferred-phase question merge: signature-based replace and disappearance-deletion, in-flight-change preservation (stale guard), retry-then-merge on transient failure Tests only: zero production changes, zero V2 SDK calls, zero fallback logic, zero raw V2 event aliases. * test(questions): clarify V1 merge id-filter test title --------- Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com>
453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import type { Session } from "@opencode-ai/sdk/v2"
|
|
import type { Event, Message, Part, PermissionRequest, QuestionRequest, SessionStatus } 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: {},
|
|
session_status: {},
|
|
...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
|
|
}
|
|
|
|
function topLevelSessionOnlyPartUpdatedEvent(): Event {
|
|
return {
|
|
type: "message.part.updated",
|
|
properties: {
|
|
sessionID: "ses_1",
|
|
part: {
|
|
id: "prt_1",
|
|
messageID: "msg_1",
|
|
type: "text",
|
|
text: "hello",
|
|
},
|
|
},
|
|
} as Event
|
|
}
|
|
|
|
function buildSession(title: string, time: Session["time"]): Session {
|
|
return {
|
|
id: "ses_1",
|
|
title,
|
|
time,
|
|
} as Session
|
|
}
|
|
|
|
describe("applyDirectoryEvent", () => {
|
|
test("inserts post-rollover message events by creation time rather than ID", () => {
|
|
const legacy = {
|
|
id: "msg_ffffffffffffLegacy",
|
|
sessionID: "ses_1",
|
|
role: "user",
|
|
time: { created: 100 },
|
|
} as Message
|
|
const current = {
|
|
id: "msg_000000000000Current",
|
|
sessionID: "ses_1",
|
|
role: "assistant",
|
|
time: { created: 200 },
|
|
} as Message
|
|
const draft = state({ message: { ses_1: [legacy] } })
|
|
|
|
expect(applyDirectoryEvent(draft, {
|
|
type: "message.updated",
|
|
properties: { info: current },
|
|
} as Event)).toBe(true)
|
|
expect(draft.message.ses_1).toEqual([legacy, current])
|
|
})
|
|
|
|
test("preserves part event order across the part ID rollover", () => {
|
|
const legacyPart = {
|
|
id: "prt_ffffffffffffLegacy",
|
|
messageID: "msg_1",
|
|
sessionID: "ses_1",
|
|
type: "text",
|
|
text: "legacy",
|
|
} as Part
|
|
const currentPart = {
|
|
id: "prt_000000000000Current",
|
|
messageID: "msg_1",
|
|
sessionID: "ses_1",
|
|
type: "text",
|
|
text: "current",
|
|
} as Part
|
|
const draft = state({
|
|
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "assistant", time: { created: 1 } } as Message] },
|
|
part: { msg_1: [legacyPart] },
|
|
})
|
|
|
|
expect(applyDirectoryEvent(draft, {
|
|
type: "message.part.updated",
|
|
properties: { part: currentPart },
|
|
} as Event)).toBe(true)
|
|
expect(draft.part.msg_1).toEqual([legacyPart, currentPart])
|
|
})
|
|
|
|
test("replaces an optimistic user part in place instead of appending it", () => {
|
|
const optimisticText = { id: "prt_optimistic_text", messageID: "msg_1", type: "text", text: "hi" } as Part
|
|
const optimisticFile = { id: "prt_optimistic_file", messageID: "msg_1", type: "file", filename: "a.png" } as Part
|
|
const serverText = { id: "prt_server_text", messageID: "msg_1", sessionID: "ses_1", type: "text", text: "hi" } as Part
|
|
const draft = state({
|
|
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "user", time: { created: 1 } } as Message] },
|
|
part: { msg_1: [optimisticText, optimisticFile] },
|
|
})
|
|
|
|
expect(applyDirectoryEvent(draft, {
|
|
type: "message.part.updated",
|
|
properties: { part: serverText },
|
|
} as Event)).toBe(true)
|
|
expect(draft.part.msg_1).toEqual([serverText, optimisticFile])
|
|
|
|
// The file echo follows the text echo; it must claim the optimistic file
|
|
// even though the first slot now holds a server part.
|
|
const serverFile = { id: "prt_server_file", messageID: "msg_1", sessionID: "ses_1", type: "file", filename: "a.png" } as Part
|
|
expect(applyDirectoryEvent(draft, {
|
|
type: "message.part.updated",
|
|
properties: { part: serverFile },
|
|
} as Event)).toBe(true)
|
|
expect(draft.part.msg_1).toEqual([serverText, serverFile])
|
|
})
|
|
|
|
test("returns typed materialization when delta arrives before parts", () => {
|
|
const result = applyDirectoryEvent(state(), deltaEvent())
|
|
|
|
expect(result).toEqual({
|
|
changed: false,
|
|
materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", 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", reason: "missing-delta-part", 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",
|
|
reason: "missing-owning-message",
|
|
sessionID: "ses_1",
|
|
messageID: "msg_1",
|
|
partID: "prt_1",
|
|
},
|
|
})
|
|
})
|
|
|
|
test("uses top-level session id and part message id for part update materialization", () => {
|
|
const draft = state()
|
|
const result = applyDirectoryEvent(draft, topLevelSessionOnlyPartUpdatedEvent())
|
|
|
|
expect(draft.part.msg_1.map((item) => item.id)).toEqual(["prt_1"])
|
|
expect(result).toEqual({
|
|
changed: true,
|
|
materialization: {
|
|
type: "incomplete-session-snapshot",
|
|
reason: "missing-owning-message",
|
|
sessionID: "ses_1",
|
|
messageID: "msg_1",
|
|
partID: "prt_1",
|
|
},
|
|
})
|
|
})
|
|
|
|
test("uses top-level session id for delta materialization", () => {
|
|
const result = applyDirectoryEvent(state(), {
|
|
type: "message.part.delta",
|
|
properties: {
|
|
sessionID: "ses_1",
|
|
messageID: "msg_1",
|
|
partID: "prt_1",
|
|
field: "text",
|
|
delta: "hello",
|
|
},
|
|
} as Event)
|
|
|
|
expect(result).toEqual({
|
|
changed: false,
|
|
materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", sessionID: "ses_1", messageID: "msg_1", partID: "prt_1" },
|
|
})
|
|
})
|
|
|
|
test("skips stale session.updated events so a newer title survives", () => {
|
|
const draft = state({ session: [buildSession("New Title", { created: 1, updated: 20 })] })
|
|
|
|
const result = applyDirectoryEvent(draft, {
|
|
type: "session.updated",
|
|
properties: {
|
|
info: buildSession("Old Title", { created: 1, updated: 10 }),
|
|
},
|
|
} as Event)
|
|
|
|
expect(result).toBe(false)
|
|
expect(draft.session[0]?.title).toBe("New Title")
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
test("skips duplicate session status events", () => {
|
|
const draft = state()
|
|
const busyStatus = { type: "busy" } as SessionStatus
|
|
const event = {
|
|
type: "session.status",
|
|
properties: { sessionID: "ses_1", status: busyStatus },
|
|
} as Event
|
|
|
|
expect(applyDirectoryEvent(draft, event)).toBe(true)
|
|
const statusRef = draft.session_status.ses_1
|
|
|
|
expect(applyDirectoryEvent(draft, event)).toBe(false)
|
|
expect(draft.session_status.ses_1).toBe(statusRef)
|
|
})
|
|
|
|
test("skips duplicate session idle events", () => {
|
|
const draft = state()
|
|
const event = {
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_1" },
|
|
} as Event
|
|
|
|
expect(applyDirectoryEvent(draft, event)).toBe(true)
|
|
const statusRef = draft.session_status.ses_1
|
|
|
|
expect(applyDirectoryEvent(draft, event)).toBe(false)
|
|
expect(draft.session_status.ses_1).toBe(statusRef)
|
|
})
|
|
|
|
test("skips duplicate session error idle-state events", () => {
|
|
const draft = state()
|
|
const event = {
|
|
type: "session.error",
|
|
properties: { sessionID: "ses_1" },
|
|
} as Event
|
|
|
|
expect(applyDirectoryEvent(draft, event)).toBe(true)
|
|
const statusRef = draft.session_status.ses_1
|
|
|
|
expect(applyDirectoryEvent(draft, event)).toBe(false)
|
|
expect(draft.session_status.ses_1).toBe(statusRef)
|
|
})
|
|
|
|
test("detects retry status metadata changes", () => {
|
|
const draft = state({
|
|
session_status: {
|
|
ses_1: { type: "retry", attempt: 1, message: "rate limited", next: 10 } as SessionStatus,
|
|
},
|
|
})
|
|
|
|
const event = {
|
|
type: "session.status",
|
|
properties: {
|
|
sessionID: "ses_1",
|
|
status: { type: "retry", attempt: 2, message: "rate limited", next: 20 } as SessionStatus,
|
|
},
|
|
} as Event
|
|
|
|
expect(applyDirectoryEvent(draft, event)).toBe(true)
|
|
expect((draft.session_status.ses_1 as Extract<SessionStatus, { type: "retry" }>).attempt).toBe(2)
|
|
})
|
|
|
|
test("updates permission request arrays immutably", () => {
|
|
const initialPermissions = [
|
|
{ id: "perm_1", sessionID: "ses_1" } as PermissionRequest,
|
|
]
|
|
const draft = state({ permission: { ses_1: initialPermissions } })
|
|
|
|
applyDirectoryEvent(draft, {
|
|
type: "permission.asked",
|
|
properties: { id: "perm_2", sessionID: "ses_1" } as PermissionRequest,
|
|
} as Event)
|
|
|
|
expect(draft.permission.ses_1).not.toBe(initialPermissions)
|
|
expect(draft.permission.ses_1.map((item) => item.id)).toEqual(["perm_1", "perm_2"])
|
|
|
|
const afterAsk = draft.permission.ses_1
|
|
applyDirectoryEvent(draft, {
|
|
type: "permission.replied",
|
|
properties: { sessionID: "ses_1", requestID: "perm_1" },
|
|
} as Event)
|
|
|
|
expect(draft.permission.ses_1).not.toBe(afterAsk)
|
|
expect(draft.permission.ses_1.map((item) => item.id)).toEqual(["perm_2"])
|
|
})
|
|
|
|
test("updates question request arrays immutably", () => {
|
|
const initialQuestions = [
|
|
{ id: "ques_1", sessionID: "ses_1" } as QuestionRequest,
|
|
]
|
|
const draft = state({ question: { ses_1: initialQuestions } })
|
|
|
|
applyDirectoryEvent(draft, {
|
|
type: "question.asked",
|
|
properties: { id: "ques_2", sessionID: "ses_1" } as QuestionRequest,
|
|
} as Event)
|
|
|
|
expect(draft.question.ses_1).not.toBe(initialQuestions)
|
|
expect(draft.question.ses_1.map((item) => item.id)).toEqual(["ques_1", "ques_2"])
|
|
|
|
const afterAsk = draft.question.ses_1
|
|
applyDirectoryEvent(draft, {
|
|
type: "question.replied",
|
|
properties: { sessionID: "ses_1", requestID: "ques_1" },
|
|
} as Event)
|
|
|
|
expect(draft.question.ses_1).not.toBe(afterAsk)
|
|
expect(draft.question.ses_1.map((item) => item.id)).toEqual(["ques_2"])
|
|
|
|
const afterReply = draft.question.ses_1
|
|
applyDirectoryEvent(draft, {
|
|
type: "question.rejected",
|
|
properties: { sessionID: "ses_1", requestID: "ques_2" },
|
|
} as Event)
|
|
|
|
expect(draft.question.ses_1).not.toBe(afterReply)
|
|
expect(draft.question.ses_1).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("question reducer invariants (main contract)", () => {
|
|
const questionRequest = (id: string, sessionID = "ses_1"): QuestionRequest => ({
|
|
id,
|
|
sessionID,
|
|
questions: [],
|
|
})
|
|
|
|
const askedEvent = (id: string, sessionID = "ses_1"): Event => ({
|
|
id: `evt_${id}`,
|
|
type: "question.asked",
|
|
properties: questionRequest(id, sessionID),
|
|
})
|
|
|
|
const repliedEvent = (requestID: string, sessionID = "ses_1"): Event => ({
|
|
id: `evt_${requestID}`,
|
|
type: "question.replied",
|
|
properties: { sessionID, requestID, answers: [] },
|
|
})
|
|
|
|
const rejectedEvent = (requestID: string, sessionID = "ses_1"): Event => ({
|
|
id: `evt_${requestID}`,
|
|
type: "question.rejected",
|
|
properties: { sessionID, requestID },
|
|
})
|
|
|
|
test("question.asked is an idempotent upsert-by-id — replaying does not duplicate", () => {
|
|
const draft = state({ question: { ses_1: [questionRequest("ques_1")] } })
|
|
|
|
expect(applyDirectoryEvent(draft, askedEvent("ques_1"))).toBe(true)
|
|
expect(applyDirectoryEvent(draft, askedEvent("ques_1"))).toBe(true)
|
|
|
|
expect(draft.question.ses_1).toHaveLength(1)
|
|
expect(draft.question.ses_1[0]?.id).toBe("ques_1")
|
|
})
|
|
|
|
test("question.asked replaces the stored record in place (not first-wins)", () => {
|
|
const draft = state({ question: { ses_1: [questionRequest("ques_1")] } })
|
|
const replacement: QuestionRequest = {
|
|
id: "ques_1",
|
|
sessionID: "ses_1",
|
|
questions: [
|
|
{ question: "updated?", header: "Build", options: [{ label: "Yes", description: "Go" }] },
|
|
],
|
|
}
|
|
|
|
expect(applyDirectoryEvent(draft, {
|
|
id: "evt_ques_1",
|
|
type: "question.asked",
|
|
properties: replacement,
|
|
})).toBe(true)
|
|
|
|
expect(draft.question.ses_1).toHaveLength(1)
|
|
expect(draft.question.ses_1[0]).toEqual(replacement)
|
|
})
|
|
|
|
test("question.replied and question.rejected remove exactly the matching request; unknown removal is a no-op returning false", () => {
|
|
const draft = state({
|
|
question: { ses_1: [questionRequest("ques_1"), questionRequest("ques_2")] },
|
|
})
|
|
|
|
expect(applyDirectoryEvent(draft, repliedEvent("ques_1"))).toBe(true)
|
|
expect(draft.question.ses_1.map((q) => q.id)).toEqual(["ques_2"])
|
|
|
|
expect(applyDirectoryEvent(draft, rejectedEvent("ques_2"))).toBe(true)
|
|
expect(draft.question.ses_1).toEqual([])
|
|
|
|
// Removal for an unknown request is a safe no-op.
|
|
expect(applyDirectoryEvent(draft, repliedEvent("ques_missing"))).toBe(false)
|
|
expect(applyDirectoryEvent(draft, rejectedEvent("ques_missing"))).toBe(false)
|
|
expect(draft.question.ses_1).toEqual([])
|
|
})
|
|
|
|
test("a duplicate terminal event after removal is a safe no-op", () => {
|
|
const draft = state({ question: { ses_1: [questionRequest("ques_1")] } })
|
|
|
|
expect(applyDirectoryEvent(draft, repliedEvent("ques_1"))).toBe(true)
|
|
expect(draft.question.ses_1).toEqual([])
|
|
|
|
// Replayed terminal event: no error, no duplicate, no state change.
|
|
expect(applyDirectoryEvent(draft, repliedEvent("ques_1"))).toBe(false)
|
|
expect(draft.question.ses_1).toEqual([])
|
|
})
|
|
|
|
test("a late question.asked after a terminal event re-registers the request (no tombstone)", () => {
|
|
const draft = state({ question: { ses_1: [questionRequest("ques_1")] } })
|
|
|
|
expect(applyDirectoryEvent(draft, repliedEvent("ques_1"))).toBe(true)
|
|
expect(draft.question.ses_1).toEqual([])
|
|
|
|
// Ordered-stream replay: a late asked re-inserts; there is no tombstone.
|
|
expect(applyDirectoryEvent(draft, askedEvent("ques_1"))).toBe(true)
|
|
expect(draft.question.ses_1.map((q) => q.id)).toEqual(["ques_1"])
|
|
})
|
|
})
|