fix(app): reconcile v2 session projections

This commit is contained in:
Brendan Allan 2026-08-04 13:46:53 +08:00 committed by Brendan Allan
parent c849b1f0f7
commit 6a5b8af665
5 changed files with 67 additions and 3 deletions

View file

@ -45,6 +45,10 @@ describe("timeline fixture validation", () => {
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
})
test("uses the projected tool ID as its call ID", () => {
expect(toolPart("call_1", "read", "running", {})).toMatchObject({ id: "call_1", callID: "call_1" })
})
})
if (false) {

View file

@ -265,6 +265,34 @@ describe("server session", () => {
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.history.more("root")).toBe(false)
})
test("replaces stale current projections on complete refreshes", async () => {
const first = { id: "msg_1", type: "user", text: "first", time: { created: 1 } } as const
const second = { id: "msg_2", type: "user", text: "second", time: { created: 2 } } as const
const pages = [
{ data: [first], cursor: { previous: null, next: null } },
{ data: [second], cursor: { previous: null, next: null } },
{ data: [], cursor: { previous: null, next: null } },
]
const messageApi = {
list: async () => pages.shift()!,
} as unknown as MessageApi
const sessionApi = { get: async () => session("root") } as unknown as SessionApi
const store = createServerSession({} as OpencodeClient, sessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(store.data.session_message.root.map((message) => message.id)).toEqual([first.id])
await store.sync("root", { force: true })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([second.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([second.id])
await store.sync("root", { force: true })
expect(store.data.session_message.root).toEqual([])
expect(store.data.message.root).toEqual([])
})
test("extends a current page to include the user for split assistant turns", async () => {

View file

@ -563,7 +563,7 @@ export function createServerSession(
sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true,
cursor: response.cursor.next ?? undefined,
complete: response.data.length === 0,
complete: !response.cursor.next,
}
}
const response = await (options?.retry ?? retry)(() => {
@ -683,7 +683,14 @@ export function createServerSession(
? (() => {
const incoming = new Map(page.source.map((message) => [message.id, message]))
const existing = data.session_message[sessionID] ?? []
const current = existing.filter((message) => !incoming.has(message.id))
const boundary = Math.min(...page.source.map((message) => message.time.created))
const current = existing.filter(
(message) =>
!incoming.has(message.id) &&
(page.sourceMode === "older" ||
load?.touchedSource.has(message.id) ||
(!page.complete && message.time.created < boundary)),
)
const live = new Map(existing.map((message) => [message.id, message]))
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),

View file

@ -3,6 +3,31 @@ import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "./session-message"
describe("normalizeSessionMessages", () => {
test("keeps attachments without inventing an empty text part", () => {
const source = [
{
id: "msg_1",
type: "user",
text: "",
files: [
{
data: "aGVsbG8=",
mime: "text/plain",
name: "note.txt",
source: { type: "inline" },
},
],
agents: [{ name: "review" }],
time: { created: 1 },
},
] satisfies SessionMessageInfo[]
const result = normalizeSessionMessages("ses_1", source)
expect(result.messages).toHaveLength(1)
expect(result.parts.get("msg_1")?.map((part) => part.type)).toEqual(["file", "agent"])
})
test("projects current turns into stable legacy rendering records", () => {
const source = [
{ id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },

View file

@ -196,7 +196,7 @@ function userMessage(
function userParts(sessionID: string, message: SessionMessageUser): Part[] {
return [
textPart(sessionID, message.id, 0, message.text),
...(message.text ? [textPart(sessionID, message.id, 0, message.text)] : []),
...(message.files ?? []).map(
(file, index): FilePart => ({
id: `${message.id}:file:${index}`,