From 4eb1b37d9be3b2b44c4b87b11fbd621d7f559ca2 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:46:55 +0800 Subject: [PATCH] fix(app): reconcile v2 session projections (#40376) --- .../timeline-stability/fixture.test.ts | 4 ++ .../session-timeline-history-root.spec.ts | 50 ++++++++++++------- .../app/src/context/server-session.test.ts | 28 +++++++++++ packages/app/src/context/server-session.ts | 11 +++- .../app/src/utils/session-message.test.ts | 25 ++++++++++ packages/app/src/utils/session-message.ts | 2 +- 6 files changed, 99 insertions(+), 21 deletions(-) diff --git a/packages/app/e2e/performance/timeline-stability/fixture.test.ts b/packages/app/e2e/performance/timeline-stability/fixture.test.ts index b003645e640..1f9c43d2c7b 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.test.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.test.ts @@ -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) { diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index e5ef7998ea7..d64a8cddf64 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -10,7 +10,6 @@ import { status, textPart, title, - userID, userMessage, } from "../performance/timeline-stability/fixture" import { mockOpenCodeServer } from "../utils/mock-server" @@ -19,18 +18,22 @@ import { expectSessionTitle } from "../utils/waits" const initialPageSize = 20 const historyPageSize = 200 -const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => - assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { - id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, - parentID: userID, - created: 1700000001000 + index * 1_000, - completed: index < initialPageSize, - }), -) -const messages = [userMessage(), ...assistants] +const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => { + const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user` + return [ + userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }), + assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { + id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, + parentID: id, + created: 1700000001000 + index * 2_000, + completed: index < initialPageSize, + }), + ] +}).flat() +const assistants = messages.filter((message) => message.info.role === "assistant") const lastAssistant = assistants.at(-1)! -const lastPartID = assistants.at(-1)!.parts[0]!.id -const userPartID = `prt_${userID}_text` +const lastPartID = `${assistants.at(-1)!.info.id}:text:0` +const userPartID = `${messages.at(-2)!.info.id}:text:0` const completed = { ...lastAssistant.info, time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, @@ -59,6 +62,7 @@ for (const scenario of scenarios) { retry: 20, }) await mockOpenCodeServer(page, { + protocol: "v2", directory, project: project(), provider: { @@ -154,15 +158,23 @@ for (const scenario of scenarios) { await expectSessionTitle(page, title) await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() - await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) + const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await viewport.hover() + const deadline = Date.now() + 10_000 + while (requests.filter((request) => request.phase === "start").length < 2) { + if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") + await page.mouse.wheel(0, -240) + await page.waitForTimeout(20) + } expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) - expect(sequence.slice(0, 4)).toEqual([ + expect(sequence.slice(0, 3)).toEqual([ "messages:start:latest", "messages:end:latest", - `message:${userID}`, `messages:start:${messages.at(-initialPageSize)!.info.id}`, ]) - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) + await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount( + initialPageSize / 2, + ) await page.evaluate(() => { ;( window as Window & { @@ -174,7 +186,9 @@ for (const scenario of scenarios) { expect(await visibleContentHidden(page)).toBe(false) const beforeHistory = await probeSamples(page) history.resolve() - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) + await expect + .poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count()) + .toBeGreaterThan(initialPageSize / 2) await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await waitForProbeSamples(page, beforeHistory) @@ -182,7 +196,7 @@ for (const scenario of scenarios) { { before: undefined, limit: initialPageSize }, { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, ]) - expect(roots).toEqual([{ sessionID, messageID: userID }]) + expect(roots).toEqual([]) const message = messageUpdated(scenario.info) const idle = status("idle") diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index ad0d572b5c1..dc1f0f33a34 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -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 () => { diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index e3f04f9bdb6..850bfdd9b72 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -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), diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts index a69c414e168..bbdb0a65a05 100644 --- a/packages/app/src/utils/session-message.test.ts +++ b/packages/app/src/utils/session-message.test.ts @@ -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 } }, diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts index 9cadf780e0b..79ed586bdf2 100644 --- a/packages/app/src/utils/session-message.ts +++ b/packages/app/src/utils/session-message.ts @@ -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}`,