The server echoes a sent message part by part. The reducer replaced an optimistic part with its server echo only while the FIRST part of the message was still optimistic, so once the text echo had landed the file echo no longer qualified and was appended instead: an attached image showed twice until the next page fetch rewrote the parts. Any remaining optimistic part of the same type is now a replacement candidate. The scan only runs when a part with a new id arrives, which is once per part during assistant streaming. Testing: reducer test extended past the first echo to the file echo; ui type-check and lint; verified the event order against a live OpenCode (message, text part, file part within ~70ms of the accepted request).
358 lines
11 KiB
TypeScript
358 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])
|
|
|
|
// 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([])
|
|
})
|
|
})
|