Compare commits

..

3 Commits

Author SHA1 Message Date
Brendan Allan 36c8789b70 test(app): update v2 history root fixture 2026-08-04 15:01:51 +08:00
Brendan Allan 5f3327ad57 Merge branch 'app-rendering-types' into app-v2-sessions 2026-08-04 14:58:57 +08:00
Brendan Allan 3c6e19d225 fix(app): reconcile v2 session projections 2026-08-04 13:46:53 +08:00
6 changed files with 99 additions and 21 deletions
@@ -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) {
@@ -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")
@@ -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 () => {
+9 -2
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),
@@ -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 } },
+1 -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}`,