The server echo of an optimistic user message carries different part ids, so the reducer dropped the optimistic part and appended the server one at the end, and MessageBody keyed user parts by part id. The key change remounted the text subtree (blank frame, markdown re-parse, truncation state reset) and the append reordered text against file parts. Replace the optimistic part in place and key user parts positionally.
349 lines
11 KiB
TypeScript
349 lines
11 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])
|
|
})
|
|
|
|
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([])
|
|
})
|
|
})
|