fix(app): hydrate timeline message parents (#35269)
This commit is contained in:
@@ -0,0 +1,146 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { expectSessionTitle } from "../../utils/waits"
|
||||||
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
|
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||||
|
import { fixture } from "./session-timeline-stress.fixture"
|
||||||
|
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
|
||||||
|
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||||
|
|
||||||
|
type ParentHydrationBenchmarkMode = "natural" | "candidate"
|
||||||
|
|
||||||
|
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
|
||||||
|
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
|
||||||
|
const userID = "msg_parent_hydration_user"
|
||||||
|
const user = {
|
||||||
|
...fixture.messages[fixture.targetID][0]!,
|
||||||
|
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
|
||||||
|
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
|
||||||
|
...part,
|
||||||
|
id: `prt_parent_hydration_user_${index}`,
|
||||||
|
messageID: userID,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
const assistantSeed = fixture.messages[fixture.targetID][3]!
|
||||||
|
const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||||
|
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
|
||||||
|
return {
|
||||||
|
...assistantSeed,
|
||||||
|
info: {
|
||||||
|
...assistantSeed.info,
|
||||||
|
id: messageID,
|
||||||
|
parentID: userID,
|
||||||
|
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
||||||
|
},
|
||||||
|
parts: assistantSeed.parts.map((part, partIndex) => ({
|
||||||
|
...part,
|
||||||
|
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
|
||||||
|
messageID,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const messages = [user, ...assistants]
|
||||||
|
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||||
|
const lastID = userID
|
||||||
|
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
|
||||||
|
|
||||||
|
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||||
|
benchmark.setTimeout(180_000)
|
||||||
|
const results = [] as Awaited<ReturnType<typeof trial>>[]
|
||||||
|
for (let run = 0; run < 5; run++) {
|
||||||
|
results.push(
|
||||||
|
await withBenchmarkPage(browser, `session-parent-hydration-${mode}-${run}`, (page) => trial(page, mode), testInfo),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
|
||||||
|
report(
|
||||||
|
{
|
||||||
|
results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
|
||||||
|
summary: {
|
||||||
|
firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
|
||||||
|
blankSamples: results.map((result) => result.metrics.blankSamples),
|
||||||
|
requestCounts: {
|
||||||
|
list: results.map((result) => result.requestCounts.list),
|
||||||
|
parent: results.map((result) => result.requestCounts.parent),
|
||||||
|
},
|
||||||
|
historyGateCount: results.map((result) => result.historyGateCount),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ mode },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||||
|
const requests: { type: "list" | "parent"; before?: string }[] = []
|
||||||
|
const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
|
||||||
|
let historyGates = 0
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
|
||||||
|
provider: fixture.provider,
|
||||||
|
directory: fixture.directory,
|
||||||
|
project: fixture.project,
|
||||||
|
messageDelay: 50,
|
||||||
|
onMessages: (request) => {
|
||||||
|
if (request.sessionID === fixture.targetID && request.phase === "start")
|
||||||
|
requests.push({ type: "list", before: request.before })
|
||||||
|
},
|
||||||
|
beforeMessagesResponse: (request) => {
|
||||||
|
if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
|
||||||
|
historyGates++
|
||||||
|
return history!.promise
|
||||||
|
},
|
||||||
|
onMessage: (request) => {
|
||||||
|
if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
|
||||||
|
},
|
||||||
|
message: (sessionID, messageID) => {
|
||||||
|
if (sessionID !== fixture.targetID || messageID !== userID) return
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
pageMessages: (sessionID, limit, before) => {
|
||||||
|
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
|
||||||
|
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
|
||||||
|
const start = Math.max(0, end - limit)
|
||||||
|
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await page.route(`**/session/${fixture.targetID}`, (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
|
||||||
|
)
|
||||||
|
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||||
|
await page.goto(stressSessionHref(fixture.sourceID))
|
||||||
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||||
|
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||||
|
|
||||||
|
const href = stressSessionHref(fixture.targetID)
|
||||||
|
await page.evaluate(
|
||||||
|
({ href, title }) => {
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.id = "parent-hydration-target"
|
||||||
|
link.href = href
|
||||||
|
link.textContent = title
|
||||||
|
document.body.append(link)
|
||||||
|
},
|
||||||
|
{ href, title: target.title },
|
||||||
|
)
|
||||||
|
const metrics = await measureSessionSwitch(page, {
|
||||||
|
destinationIDs: messages.map((message) => message.info.id),
|
||||||
|
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
||||||
|
lastID,
|
||||||
|
requiredPartID: lastPartID,
|
||||||
|
requireBottomAnchor: false,
|
||||||
|
href,
|
||||||
|
switch: async () => {
|
||||||
|
await page.locator("#parent-hydration-target").click()
|
||||||
|
await expectSessionTitle(page, target.title)
|
||||||
|
},
|
||||||
|
}).finally(() => history?.resolve())
|
||||||
|
expect(metrics.firstCorrectObservedMs).not.toBeNull()
|
||||||
|
const requestCounts = {
|
||||||
|
list: requests.filter((request) => request.type === "list").length,
|
||||||
|
parent: requests.filter((request) => request.type === "parent").length,
|
||||||
|
}
|
||||||
|
if (mode === "candidate") {
|
||||||
|
expect(requestCounts.parent).toBe(1)
|
||||||
|
expect(historyGates).toBe(1)
|
||||||
|
}
|
||||||
|
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ export type SessionSwitchSample = {
|
|||||||
source: string[]
|
source: string[]
|
||||||
hasVisibleRows: boolean
|
hasVisibleRows: boolean
|
||||||
last: boolean
|
last: boolean
|
||||||
|
requiredPartVisible?: boolean
|
||||||
|
bottomAnchorRequired?: boolean
|
||||||
bottomErrorPx?: number
|
bottomErrorPx?: number
|
||||||
review?: {
|
review?: {
|
||||||
fileHost: boolean
|
fileHost: boolean
|
||||||
@@ -41,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) {
|
|||||||
sample.destination.length > 0 &&
|
sample.destination.length > 0 &&
|
||||||
sample.source.length === 0 &&
|
sample.source.length === 0 &&
|
||||||
sample.last &&
|
sample.last &&
|
||||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
|
sample.requiredPartVisible !== false &&
|
||||||
|
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ type SessionSwitchProbe = {
|
|||||||
|
|
||||||
async function installSessionSwitchProbe(
|
async function installSessionSwitchProbe(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
|
input: {
|
||||||
|
destinationIDs: string[]
|
||||||
|
sourceIDs: string[]
|
||||||
|
lastID: string
|
||||||
|
requiredPartID?: string
|
||||||
|
requireBottomAnchor?: boolean
|
||||||
|
href: string
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
|
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
|
||||||
const destination = new Set(destinationIDs)
|
const destination = new Set(destinationIDs)
|
||||||
const source = new Set(sourceIDs)
|
const source = new Set(sourceIDs)
|
||||||
const samples: SessionSwitchSample[] = []
|
const samples: SessionSwitchSample[] = []
|
||||||
@@ -66,6 +73,13 @@ async function installSessionSwitchProbe(
|
|||||||
const rect = element.getBoundingClientRect()
|
const rect = element.getBoundingClientRect()
|
||||||
return rect.bottom > view.top && rect.top < view.bottom
|
return rect.bottom > view.top && rect.top < view.bottom
|
||||||
})
|
})
|
||||||
|
const requiredPartVisible = requiredPartID
|
||||||
|
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
|
||||||
|
if (element.dataset.timelinePartId !== requiredPartID) return false
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||||
samples.push({
|
samples.push({
|
||||||
observedAtMs,
|
observedAtMs,
|
||||||
@@ -73,11 +87,22 @@ async function installSessionSwitchProbe(
|
|||||||
source: visible.filter((id) => source.has(id)),
|
source: visible.filter((id) => source.has(id)),
|
||||||
hasVisibleRows,
|
hasVisibleRows,
|
||||||
last: visible.includes(lastID),
|
last: visible.includes(lastID),
|
||||||
|
requiredPartVisible,
|
||||||
|
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||||
review,
|
review,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review })
|
samples.push({
|
||||||
|
observedAtMs,
|
||||||
|
destination: [],
|
||||||
|
source: [],
|
||||||
|
hasVisibleRows: false,
|
||||||
|
last: false,
|
||||||
|
requiredPartVisible: requiredPartID ? false : undefined,
|
||||||
|
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||||
|
review,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
requestAnimationFrame(sample)
|
requestAnimationFrame(sample)
|
||||||
}, 0)
|
}, 0)
|
||||||
@@ -117,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) {
|
|||||||
sample.destination.length > 0 &&
|
sample.destination.length > 0 &&
|
||||||
sample.source.length === 0 &&
|
sample.source.length === 0 &&
|
||||||
sample.last &&
|
sample.last &&
|
||||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
|
sample.requiredPartVisible !== false &&
|
||||||
|
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -135,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) {
|
|||||||
|
|
||||||
export async function measureSessionSwitch(
|
export async function measureSessionSwitch(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
|
input: {
|
||||||
|
destinationIDs: string[]
|
||||||
|
sourceIDs: string[]
|
||||||
|
lastID: string
|
||||||
|
requiredPartID?: string
|
||||||
|
requireBottomAnchor?: boolean
|
||||||
|
href: string
|
||||||
|
switch: () => Promise<void>
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const { switch: run, ...probe } = input
|
const { switch: run, ...probe } = input
|
||||||
await installSessionSwitchProbe(page, probe)
|
await installSessionSwitchProbe(page, probe)
|
||||||
await run()
|
try {
|
||||||
await waitForStableSessionSwitch(page)
|
await run()
|
||||||
return collectSessionSwitchResult(page)
|
await waitForStableSessionSwitch(page)
|
||||||
|
return await collectSessionSwitchResult(page)
|
||||||
|
} finally {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForStableTimeline(page: Page, lastID: string) {
|
export async function waitForStableTimeline(page: Page, lastID: string) {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import type { Page, Route } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
|
|
||||||
|
test("applies message latency after a list response gate is released", async () => {
|
||||||
|
const events: string[] = []
|
||||||
|
const gate = Promise.withResolvers<void>()
|
||||||
|
let handler: ((route: Route) => Promise<void>) | undefined
|
||||||
|
const page = {
|
||||||
|
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
||||||
|
handler = callback
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
} as unknown as Page
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
provider: {},
|
||||||
|
directory: "C:/OpenCode",
|
||||||
|
project: {},
|
||||||
|
sessions: [{ id: "session" }],
|
||||||
|
messageDelay: 25,
|
||||||
|
beforeMessagesResponse: () => {
|
||||||
|
events.push("before")
|
||||||
|
return gate.promise
|
||||||
|
},
|
||||||
|
onMessages: (request) => events.push(request.phase),
|
||||||
|
pageMessages: () => {
|
||||||
|
events.push("page")
|
||||||
|
return { items: [] }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = handler!({
|
||||||
|
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
|
||||||
|
fulfill: () => {
|
||||||
|
events.push("fulfill")
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
} as unknown as Route)
|
||||||
|
expect(events).toEqual(["start", "before"])
|
||||||
|
|
||||||
|
const released = performance.now()
|
||||||
|
gate.resolve()
|
||||||
|
await response
|
||||||
|
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
|
||||||
|
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
|
||||||
|
})
|
||||||
@@ -52,3 +52,35 @@ test("reports missing correctness without throwing", () => {
|
|||||||
expect(result.firstCorrectObservedMs).toBeNull()
|
expect(result.firstCorrectObservedMs).toBeNull()
|
||||||
expect(result.stableObservedMs).toBeNull()
|
expect(result.stableObservedMs).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("requires an explicitly tracked part to be visible", () => {
|
||||||
|
const result = classifySessionSwitch([
|
||||||
|
{
|
||||||
|
observedAtMs: 16,
|
||||||
|
destination: ["destination"],
|
||||||
|
source: [],
|
||||||
|
hasVisibleRows: true,
|
||||||
|
last: true,
|
||||||
|
requiredPartVisible: false,
|
||||||
|
bottomErrorPx: 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result.firstCorrectObservedMs).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can measure content correctness without requiring a bottom anchor", () => {
|
||||||
|
const result = classifySessionSwitch([
|
||||||
|
{
|
||||||
|
observedAtMs: 16,
|
||||||
|
destination: ["destination"],
|
||||||
|
source: [],
|
||||||
|
hasVisibleRows: true,
|
||||||
|
last: true,
|
||||||
|
requiredPartVisible: true,
|
||||||
|
bottomAnchorRequired: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result.firstCorrectObservedMs).toBe(16)
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { measureSessionSwitch } from "../timeline/session-tab-switch-probe"
|
||||||
|
|
||||||
|
function testPage(waitFailure?: Error) {
|
||||||
|
const stops: unknown[] = []
|
||||||
|
const page = {
|
||||||
|
evaluate: async (_callback: unknown, input?: unknown) => {
|
||||||
|
if (input) return
|
||||||
|
stops.push(undefined)
|
||||||
|
},
|
||||||
|
waitForFunction: async () => {
|
||||||
|
if (waitFailure) throw waitFailure
|
||||||
|
},
|
||||||
|
} as unknown as Page
|
||||||
|
return { page, stops }
|
||||||
|
}
|
||||||
|
|
||||||
|
function input(run: () => Promise<void>) {
|
||||||
|
return {
|
||||||
|
destinationIDs: ["destination"],
|
||||||
|
sourceIDs: ["source"],
|
||||||
|
lastID: "destination",
|
||||||
|
href: "/session/destination",
|
||||||
|
switch: run,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("stops sampling when the session switch fails", async () => {
|
||||||
|
const failure = new Error("switch failed")
|
||||||
|
const context = testPage()
|
||||||
|
|
||||||
|
await expect(measureSessionSwitch(context.page, input(async () => Promise.reject(failure)))).rejects.toBe(failure)
|
||||||
|
|
||||||
|
expect(context.stops).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stops sampling when the stable wait fails", async () => {
|
||||||
|
const failure = new Error("stable wait failed")
|
||||||
|
const context = testPage(failure)
|
||||||
|
|
||||||
|
await expect(measureSessionSwitch(context.page, input(async () => {}))).rejects.toBe(failure)
|
||||||
|
|
||||||
|
expect(context.stops).toHaveLength(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import {
|
||||||
|
assistantMessage,
|
||||||
|
directory,
|
||||||
|
messageUpdated,
|
||||||
|
project,
|
||||||
|
session,
|
||||||
|
sessionID,
|
||||||
|
status,
|
||||||
|
textPart,
|
||||||
|
title,
|
||||||
|
userID,
|
||||||
|
userMessage,
|
||||||
|
} from "../performance/timeline-stability/fixture"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const assistants = Array.from({ length: 14 }, (_, 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 < 13,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const messages = [userMessage(), ...assistants]
|
||||||
|
const lastAssistant = assistants.at(-1)!
|
||||||
|
const lastPartID = assistants.at(-1)!.parts[0]!.id
|
||||||
|
const userPartID = `prt_${userID}_text`
|
||||||
|
const completed = {
|
||||||
|
...lastAssistant.info,
|
||||||
|
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
|
||||||
|
}
|
||||||
|
const scenarios = [
|
||||||
|
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
|
||||||
|
{
|
||||||
|
name: "interruption",
|
||||||
|
info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
|
||||||
|
idleFirst: true,
|
||||||
|
interrupted: true,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 646, height: 1385 } })
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
|
||||||
|
const requests: { before?: string; phase: "start" | "end" }[] = []
|
||||||
|
const pages: { before?: string; limit: number }[] = []
|
||||||
|
const roots: { sessionID: string; messageID: string }[] = []
|
||||||
|
const sequence: string[] = []
|
||||||
|
const history = Promise.withResolvers<void>()
|
||||||
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||||
|
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||||
|
retry: 20,
|
||||||
|
})
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: project(),
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"claude-opus-4-6": {
|
||||||
|
id: "claude-opus-4-6",
|
||||||
|
name: "Claude Opus 4.6",
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
},
|
||||||
|
sessions: [session()],
|
||||||
|
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||||
|
beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
|
||||||
|
onMessages: (request) => {
|
||||||
|
requests.push(request)
|
||||||
|
sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
|
||||||
|
},
|
||||||
|
onMessage: (request) => {
|
||||||
|
roots.push(request)
|
||||||
|
sequence.push(`message:${request.messageID}`)
|
||||||
|
},
|
||||||
|
message: (requestedSessionID, messageID) => {
|
||||||
|
if (requestedSessionID !== sessionID) return
|
||||||
|
return messages.find((item) => item.info.id === messageID)
|
||||||
|
},
|
||||||
|
pageMessages: (_, limit, before) => {
|
||||||
|
pages.push({ before, limit })
|
||||||
|
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
|
||||||
|
const start = Math.max(0, end - limit)
|
||||||
|
return {
|
||||||
|
items: messages.slice(start, end),
|
||||||
|
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await page.addInitScript(
|
||||||
|
({ userPartID, lastPartID }) => {
|
||||||
|
const state = { armed: false, hidden: false, samples: 0, stop: false }
|
||||||
|
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
||||||
|
const sample = () => {
|
||||||
|
if (state.armed) {
|
||||||
|
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||||
|
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||||
|
const view = viewport?.getBoundingClientRect()
|
||||||
|
const visible = (partID: string) => {
|
||||||
|
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
|
||||||
|
const rect = part?.getBoundingClientRect()
|
||||||
|
return (
|
||||||
|
!!rect &&
|
||||||
|
!!view &&
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0 &&
|
||||||
|
rect.bottom > view.top &&
|
||||||
|
rect.top < view.bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
|
||||||
|
state.samples++
|
||||||
|
}
|
||||||
|
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
|
},
|
||||||
|
{ userPartID, lastPartID },
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
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)
|
||||||
|
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
||||||
|
expect(sequence.slice(0, 4)).toEqual([
|
||||||
|
"messages:start:latest",
|
||||||
|
"messages:end:latest",
|
||||||
|
`message:${userID}`,
|
||||||
|
`messages:start:${messages.at(-2)!.info.id}`,
|
||||||
|
])
|
||||||
|
await page.evaluate(() => {
|
||||||
|
;(
|
||||||
|
window as Window & {
|
||||||
|
__historyRootProbe?: { armed: boolean }
|
||||||
|
}
|
||||||
|
).__historyRootProbe!.armed = true
|
||||||
|
})
|
||||||
|
await waitForProbeSamples(page, 0)
|
||||||
|
expect(await historyRootHidden(page)).toBe(false)
|
||||||
|
const beforeHistory = await probeSamples(page)
|
||||||
|
history.resolve()
|
||||||
|
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
|
||||||
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
|
await waitForProbeSamples(page, beforeHistory)
|
||||||
|
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
|
||||||
|
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
||||||
|
|
||||||
|
const message = messageUpdated(scenario.info)
|
||||||
|
const idle = status("idle")
|
||||||
|
for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
|
||||||
|
const beforeEvent = await probeSamples(page)
|
||||||
|
await transport.send(event)
|
||||||
|
if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
|
||||||
|
if (event === message && scenario.interrupted)
|
||||||
|
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||||
|
await waitForProbeSamples(page, beforeEvent)
|
||||||
|
const current = await timelineState(page)
|
||||||
|
expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
|
||||||
|
expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
|
||||||
|
expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
|
||||||
|
await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
|
||||||
|
await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
|
||||||
|
if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
|
||||||
|
.__historyRootProbe!
|
||||||
|
state.stop = true
|
||||||
|
return state.hidden
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineState(page: Page) {
|
||||||
|
return page.evaluate(() => ({
|
||||||
|
virtual: !!document.querySelector("[data-timeline-virtual-content]"),
|
||||||
|
rows: document.querySelectorAll("[data-timeline-key]").length,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeSamples(page: Page) {
|
||||||
|
return page.evaluate(
|
||||||
|
() => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForProbeSamples(page: Page, after: number) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(after) =>
|
||||||
|
(window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
|
||||||
|
after,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyRootHidden(page: Page) {
|
||||||
|
return page.evaluate(
|
||||||
|
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,7 +11,10 @@ export interface MockServerConfig {
|
|||||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||||
vcsDiff?: unknown[]
|
vcsDiff?: unknown[]
|
||||||
messageDelay?: number
|
messageDelay?: number
|
||||||
|
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
|
||||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||||
|
message?: (sessionID: string, messageID: string) => unknown
|
||||||
|
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||||
events?: () => unknown[]
|
events?: () => unknown[]
|
||||||
eventRetry?: number
|
eventRetry?: number
|
||||||
todos?: (sessionID: string) => unknown[]
|
todos?: (sessionID: string) => unknown[]
|
||||||
@@ -72,6 +75,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
return json(route, session ?? {})
|
return json(route, session ?? {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||||
|
if (messageMatch) {
|
||||||
|
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||||
|
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||||
|
const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
|
||||||
|
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||||
|
return json(route, message)
|
||||||
|
}
|
||||||
|
|
||||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||||
@@ -82,7 +94,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
const before = token ? cursors.get(token) : undefined
|
const before = token ? cursors.get(token) : undefined
|
||||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||||
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||||
|
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ const session = (id: string, parentID?: string): Session => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
type UserMessage = Extract<Message, { role: "user" }>
|
type UserMessage = Extract<Message, { role: "user" }>
|
||||||
|
type AssistantMessage = Extract<Message, { role: "assistant" }>
|
||||||
type TextPart = Extract<Part, { type: "text" }>
|
type TextPart = Extract<Part, { type: "text" }>
|
||||||
type MessageResponse = {
|
type MessageResponse = {
|
||||||
data: { info: Message; parts: Part[] }[]
|
data: { info: Message; parts: Part[] }[]
|
||||||
response: { headers: Headers }
|
response: { headers: Headers }
|
||||||
}
|
}
|
||||||
|
type SingleMessageResponse = { data: MessageResponse["data"][number] }
|
||||||
|
|
||||||
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
|
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
|
||||||
id,
|
id,
|
||||||
@@ -31,6 +33,22 @@ const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage
|
|||||||
...input,
|
...input,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const assistantMessage = (id: string, parentID: string, input: Partial<AssistantMessage> = {}): AssistantMessage => ({
|
||||||
|
id,
|
||||||
|
sessionID: "child",
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) },
|
||||||
|
parentID,
|
||||||
|
modelID: "model",
|
||||||
|
providerID: "provider",
|
||||||
|
mode: "build",
|
||||||
|
agent: "build",
|
||||||
|
path: { cwd: "/repo", root: "/repo" },
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
...input,
|
||||||
|
})
|
||||||
|
|
||||||
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
|
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
|
||||||
id: "part",
|
id: "part",
|
||||||
sessionID: "child",
|
sessionID: "child",
|
||||||
@@ -45,6 +63,8 @@ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageR
|
|||||||
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
|
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } })
|
||||||
|
|
||||||
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
||||||
|
|
||||||
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
||||||
@@ -71,6 +91,40 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rootMessageClient(
|
||||||
|
pages: Array<MessageResponse | Promise<MessageResponse>>,
|
||||||
|
roots: Array<SingleMessageResponse | Promise<SingleMessageResponse>>,
|
||||||
|
) {
|
||||||
|
let pageIndex = 0
|
||||||
|
let rootIndex = 0
|
||||||
|
const requests: unknown[] = []
|
||||||
|
const rootRequests: unknown[] = []
|
||||||
|
const rootWaiting = new Map<number, () => void>()
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
get: async () => ({ data: session("child", "root") }),
|
||||||
|
messages: (input: unknown) => {
|
||||||
|
requests.push(input)
|
||||||
|
return pages[pageIndex++]
|
||||||
|
},
|
||||||
|
message: (input: unknown) => {
|
||||||
|
rootRequests.push(input)
|
||||||
|
rootWaiting.get(rootRequests.length)?.()
|
||||||
|
rootWaiting.delete(rootRequests.length)
|
||||||
|
return roots[rootIndex++]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient
|
||||||
|
return Object.assign(client, {
|
||||||
|
requests,
|
||||||
|
rootRequests,
|
||||||
|
rootRequested(count: number) {
|
||||||
|
if (rootRequests.length >= count) return Promise.resolve()
|
||||||
|
return new Promise<void>((resolve) => rootWaiting.set(count, resolve))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const retryImmediately: typeof retry = async (task, options = {}) => {
|
const retryImmediately: typeof retry = async (task, options = {}) => {
|
||||||
const attempts = options.attempts ?? 3
|
const attempts = options.attempts ?? 3
|
||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
@@ -124,6 +178,240 @@ describe("server session", () => {
|
|||||||
expect(ctx.store.data.message.root).toEqual([])
|
expect(ctx.store.data.message.root).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("backfills an assistant-only initial page through its user root", async () => {
|
||||||
|
const user = userMessage("message-1")
|
||||||
|
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||||
|
const client = rootMessageClient(
|
||||||
|
[
|
||||||
|
response(
|
||||||
|
assistants.map((info) => ({ info, parts: [] })),
|
||||||
|
"older",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[singleResponse(user)],
|
||||||
|
)
|
||||||
|
const store = createServerSession(client)
|
||||||
|
|
||||||
|
await store.sync("child")
|
||||||
|
|
||||||
|
expect(client.requests).toEqual([
|
||||||
|
{ sessionID: "child", limit: 2, before: undefined },
|
||||||
|
])
|
||||||
|
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||||
|
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||||
|
expect(store.history.more("child")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not let an optimistic user suppress initial root backfill", async () => {
|
||||||
|
const user = userMessage("message-1")
|
||||||
|
const part = textPart(user.id)
|
||||||
|
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||||
|
const client = rootMessageClient(
|
||||||
|
[
|
||||||
|
response(
|
||||||
|
assistants.map((info) => ({ info, parts: [] })),
|
||||||
|
"older",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[singleResponse(user)],
|
||||||
|
)
|
||||||
|
const store = createServerSession(client)
|
||||||
|
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
|
||||||
|
|
||||||
|
await store.sync("child")
|
||||||
|
store.optimistic.remove({ sessionID: "child", messageID: user.id })
|
||||||
|
|
||||||
|
expect(client.requests).toHaveLength(1)
|
||||||
|
expect(client.rootRequests).toHaveLength(1)
|
||||||
|
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("backfills the parent of fetched assistants when another user is cached", async () => {
|
||||||
|
const unrelated = userMessage("message-0", { time: { created: 0 } })
|
||||||
|
const user = userMessage("message-1")
|
||||||
|
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||||
|
const client = rootMessageClient(
|
||||||
|
[
|
||||||
|
response([{ info: unrelated, parts: [] }]),
|
||||||
|
response(
|
||||||
|
assistants.map((info) => ({ info, parts: [] })),
|
||||||
|
"older",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[singleResponse(user)],
|
||||||
|
)
|
||||||
|
const store = createServerSession(client)
|
||||||
|
await store.sync("child")
|
||||||
|
|
||||||
|
await store.sync("child", { force: true })
|
||||||
|
|
||||||
|
expect(client.requests).toHaveLength(2)
|
||||||
|
expect(client.rootRequests).toHaveLength(1)
|
||||||
|
expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves cached history between an injected parent and the page boundary", async () => {
|
||||||
|
const user = userMessage("message-1")
|
||||||
|
const cached = userMessage("message-3", { time: { created: 3 } })
|
||||||
|
const assistant = assistantMessage("message-4", user.id)
|
||||||
|
const client = rootMessageClient(
|
||||||
|
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||||
|
[singleResponse(user)],
|
||||||
|
)
|
||||||
|
const store = createServerSession(client)
|
||||||
|
await store.sync("child")
|
||||||
|
|
||||||
|
await store.sync("child", { force: true })
|
||||||
|
|
||||||
|
expect(store.data.message.child).toEqual([user, cached, assistant])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
|
||||||
|
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||||
|
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||||
|
const stalePart = textPart(stale.id, { text: "stale" })
|
||||||
|
const freshPart = { ...stalePart, text: "fresh" }
|
||||||
|
const assistant = assistantMessage("message-2", stale.id)
|
||||||
|
const client = rootMessageClient(
|
||||||
|
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||||
|
[singleResponse(fresh, [freshPart])],
|
||||||
|
)
|
||||||
|
const store = createServerSession(client)
|
||||||
|
await store.sync("child")
|
||||||
|
|
||||||
|
await store.sync("child", { force: true })
|
||||||
|
|
||||||
|
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||||
|
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||||
|
expect(store.data.part[stale.id]).toEqual([freshPart])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
|
||||||
|
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||||
|
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||||
|
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
|
||||||
|
const refreshed = { ...confirmed, text: "fresh" }
|
||||||
|
const pending = textPart(stale.id, { id: "pending", text: "pending" })
|
||||||
|
const assistant = assistantMessage("message-2", stale.id)
|
||||||
|
const client = rootMessageClient(
|
||||||
|
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||||
|
[singleResponse(fresh, [refreshed])],
|
||||||
|
)
|
||||||
|
const store = createServerSession(client)
|
||||||
|
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
|
||||||
|
await store.sync("child")
|
||||||
|
|
||||||
|
await store.sync("child", { force: true })
|
||||||
|
|
||||||
|
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||||
|
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||||
|
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses a parent received by SSE during the replacement load", async () => {
|
||||||
|
const pending = deferredResponse()
|
||||||
|
const user = userMessage("message-1")
|
||||||
|
const assistant = assistantMessage("message-2", user.id)
|
||||||
|
const client = rootMessageClient([pending.promise], [])
|
||||||
|
const store = createServerSession(client)
|
||||||
|
const loading = store.sync("child")
|
||||||
|
|
||||||
|
store.apply({ type: "message.updated", properties: { info: user } })
|
||||||
|
pending.resolve(response([{ info: assistant, parts: [] }], "older"))
|
||||||
|
await loading
|
||||||
|
|
||||||
|
expect(client.rootRequests).toEqual([])
|
||||||
|
expect(store.data.message.child).toEqual([user, assistant])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses a successful retry over events received by a failed backfill attempt", async () => {
|
||||||
|
const failed = deferredResponse()
|
||||||
|
const user = userMessage("message-1")
|
||||||
|
const live = { ...user, agent: "stale" }
|
||||||
|
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||||
|
const client = rootMessageClient(
|
||||||
|
[
|
||||||
|
response(
|
||||||
|
assistants.map((info) => ({ info, parts: [] })),
|
||||||
|
"older",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||||
|
)
|
||||||
|
const store = createServerSession(client, { retry: retryImmediately })
|
||||||
|
const loading = store.sync("child")
|
||||||
|
await client.rootRequested(1)
|
||||||
|
|
||||||
|
store.apply({ type: "message.updated", properties: { info: live } })
|
||||||
|
failed.reject(new Error("retry"))
|
||||||
|
await loading
|
||||||
|
|
||||||
|
expect(client.requests).toHaveLength(1)
|
||||||
|
expect(client.rootRequests).toHaveLength(2)
|
||||||
|
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves newer-page events across a failed parent retry", async () => {
|
||||||
|
const failed = deferredResponse()
|
||||||
|
const user = userMessage("message-1")
|
||||||
|
const assistant = assistantMessage("message-2", user.id)
|
||||||
|
const live = { ...assistant, cost: 1 }
|
||||||
|
const client = rootMessageClient(
|
||||||
|
[response([{ info: assistant, parts: [] }], "older")],
|
||||||
|
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||||
|
)
|
||||||
|
const store = createServerSession(client, { retry: retryImmediately })
|
||||||
|
const loading = store.sync("child")
|
||||||
|
await client.rootRequested(1)
|
||||||
|
|
||||||
|
store.apply({ type: "message.updated", properties: { info: live } })
|
||||||
|
failed.reject(new Error("retry"))
|
||||||
|
await loading
|
||||||
|
|
||||||
|
expect(store.data.message.child).toEqual([user, live])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves unrelated message events across a failed parent retry", async () => {
|
||||||
|
const failed = deferredResponse()
|
||||||
|
const user = userMessage("message-1")
|
||||||
|
const assistant = assistantMessage("message-2", user.id)
|
||||||
|
const live = userMessage("message-4", { time: { created: 4 } })
|
||||||
|
const client = rootMessageClient(
|
||||||
|
[response([{ info: assistant, parts: [] }], "older")],
|
||||||
|
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||||
|
)
|
||||||
|
const store = createServerSession(client, { retry: retryImmediately })
|
||||||
|
const loading = store.sync("child")
|
||||||
|
await client.rootRequested(1)
|
||||||
|
|
||||||
|
store.apply({ type: "message.updated", properties: { info: live } })
|
||||||
|
failed.reject(new Error("retry"))
|
||||||
|
await loading
|
||||||
|
|
||||||
|
expect(store.data.message.child).toEqual([user, assistant, live])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves newer-page part events across a failed parent retry", async () => {
|
||||||
|
const failed = deferredResponse()
|
||||||
|
const user = userMessage("message-1")
|
||||||
|
const assistant = assistantMessage("message-2", user.id)
|
||||||
|
const stale = textPart(assistant.id, { text: "stale" })
|
||||||
|
const live = { ...stale, text: "live" }
|
||||||
|
const client = rootMessageClient(
|
||||||
|
[response([{ info: assistant, parts: [stale] }], "older")],
|
||||||
|
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||||
|
)
|
||||||
|
const store = createServerSession(client, { retry: retryImmediately })
|
||||||
|
const loading = store.sync("child")
|
||||||
|
await client.rootRequested(1)
|
||||||
|
|
||||||
|
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
|
||||||
|
failed.reject(new Error("retry"))
|
||||||
|
await loading
|
||||||
|
|
||||||
|
expect(store.data.part[assistant.id]).toEqual([live])
|
||||||
|
})
|
||||||
|
|
||||||
test("merges live events into the initial page", async () => {
|
test("merges live events into the initial page", async () => {
|
||||||
const pending = deferredResponse()
|
const pending = deferredResponse()
|
||||||
const user = userMessage("message-1")
|
const user = userMessage("message-1")
|
||||||
@@ -905,6 +1193,26 @@ describe("server session", () => {
|
|||||||
expect(store.data.message.child).toEqual([latest])
|
expect(store.data.message.child).toEqual([latest])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("does not scan cached messages for user roots during history prepend", async () => {
|
||||||
|
const guard = { active: false }
|
||||||
|
const latest = new Proxy(userMessage("message-2", { time: { created: 2 } }), {
|
||||||
|
get(target, property, receiver) {
|
||||||
|
if (guard.active && property === "role") throw new Error("cached role accessed")
|
||||||
|
return Reflect.get(target, property, receiver)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const older = userMessage("message-1")
|
||||||
|
const store = createServerSession(
|
||||||
|
messageClient(response([{ info: latest, parts: [] }], "older"), response([{ info: older, parts: [] }])),
|
||||||
|
)
|
||||||
|
await store.sync("child")
|
||||||
|
guard.active = true
|
||||||
|
|
||||||
|
await store.history.loadMore("child")
|
||||||
|
|
||||||
|
expect(store.data.message.child).toEqual([older, latest])
|
||||||
|
})
|
||||||
|
|
||||||
test("preserves loaded history during an incomplete refresh", async () => {
|
test("preserves loaded history during an incomplete refresh", async () => {
|
||||||
const older = userMessage("message-1")
|
const older = userMessage("message-1")
|
||||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ type MessageLoadState = {
|
|||||||
clearedMessageParts: Set<string>
|
clearedMessageParts: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MessageLoadBaseline = Pick<
|
||||||
|
MessageLoadState,
|
||||||
|
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
|
||||||
|
>
|
||||||
|
|
||||||
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||||
const session = [...page.session]
|
const session = [...page.session]
|
||||||
@@ -347,7 +352,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||||||
load.touchedParts.set(messageID, new Set([partID]))
|
load.touchedParts.set(messageID, new Set([partID]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
|
const resetMessageLoad = (sessionID: string, load: MessageLoadState, baseline?: MessageLoadBaseline) => {
|
||||||
load.touchedMessages.clear()
|
load.touchedMessages.clear()
|
||||||
load.retainedMessages.clear()
|
load.retainedMessages.clear()
|
||||||
load.touchedParts.clear()
|
load.touchedParts.clear()
|
||||||
@@ -380,8 +385,27 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||||||
parts.forEach((partID) => touched.add(partID))
|
parts.forEach((partID) => touched.add(partID))
|
||||||
load.touchedParts.set(messageID, touched)
|
load.touchedParts.set(messageID, touched)
|
||||||
}
|
}
|
||||||
|
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
|
||||||
|
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
|
||||||
|
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
|
||||||
|
baseline?.touchedParts.forEach((parts, messageID) => {
|
||||||
|
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||||
|
parts.forEach((partID) => touched.add(partID))
|
||||||
|
load.touchedParts.set(messageID, touched)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageLoadBaseline = (load: MessageLoadState, exclude: string): MessageLoadBaseline => ({
|
||||||
|
touchedMessages: new Set([...load.touchedMessages].filter((messageID) => messageID !== exclude)),
|
||||||
|
retainedMessages: new Set([...load.retainedMessages].filter((messageID) => messageID !== exclude)),
|
||||||
|
touchedParts: new Map(
|
||||||
|
[...load.touchedParts]
|
||||||
|
.filter(([messageID]) => messageID !== exclude)
|
||||||
|
.map(([messageID, parts]) => [messageID, new Set(parts)]),
|
||||||
|
),
|
||||||
|
clearedMessageParts: new Set([...load.clearedMessageParts].filter((messageID) => messageID !== exclude)),
|
||||||
|
})
|
||||||
|
|
||||||
const evict = (sessionIDs: string[]) => {
|
const evict = (sessionIDs: string[]) => {
|
||||||
if (sessionIDs.length === 0) return
|
if (sessionIDs.length === 0) return
|
||||||
const evicted = new Set(sessionIDs)
|
const evicted = new Set(sessionIDs)
|
||||||
@@ -460,6 +484,18 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
||||||
|
const response = await (options?.retry ?? retry)(() => {
|
||||||
|
onAttempt?.()
|
||||||
|
return client.session.message({ sessionID, messageID })
|
||||||
|
})
|
||||||
|
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
|
||||||
|
return {
|
||||||
|
message: cleanMessage(response.data.info),
|
||||||
|
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||||
const messageIDs = new Set(messages.map((message) => message.id))
|
const messageIDs = new Set(messages.map((message) => message.id))
|
||||||
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
||||||
@@ -578,36 +614,79 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||||||
messageLoads.set(sessionID, load)
|
messageLoads.set(sessionID, load)
|
||||||
setMeta("loading", sessionID, true)
|
setMeta("loading", sessionID, true)
|
||||||
let applied = false
|
let applied = false
|
||||||
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
try {
|
||||||
.then((page) => {
|
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||||
if (generations.get(sessionID) !== active) return
|
const first = page.session.reduce<Message | undefined>(
|
||||||
const first = page.session.reduce<Message | undefined>(
|
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||||
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
undefined,
|
||||||
undefined,
|
)
|
||||||
)
|
if (generations.get(sessionID) !== active) return
|
||||||
const preserveUnfetched =
|
|
||||||
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
const parents = [] as Awaited<ReturnType<typeof fetchMessage>>[]
|
||||||
applyMessagePage(
|
if (mode !== "prepend") {
|
||||||
sessionID,
|
const users = new Set([
|
||||||
page,
|
...page.session.filter((message) => message.role === "user").map((message) => message.id),
|
||||||
messageLoads.get(sessionID) === load ? load : undefined,
|
...(data.message[sessionID] ?? [])
|
||||||
preserveUnfetched,
|
.filter((message) => {
|
||||||
mode !== "prepend",
|
if (message.role !== "user") return false
|
||||||
)
|
const item = optimistic.get(sessionID)?.get(message.id)
|
||||||
applied = true
|
return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.map((message) => message.id),
|
||||||
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
])
|
||||||
for (const messageID of load.orphanParents) {
|
const parentIDs = [
|
||||||
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
...new Set(
|
||||||
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
page.session.flatMap((message) =>
|
||||||
orphanParts.get(sessionID)?.delete(messageID)
|
message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [],
|
||||||
}
|
),
|
||||||
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
),
|
||||||
|
]
|
||||||
|
for (const parentID of parentIDs) {
|
||||||
|
if (generations.get(sessionID) !== active) break
|
||||||
|
const parent = await fetchMessage(sessionID, parentID, () =>
|
||||||
|
resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
|
||||||
|
)
|
||||||
|
if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
|
||||||
|
parents.push(parent)
|
||||||
}
|
}
|
||||||
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
}
|
||||||
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
if (generations.get(sessionID) !== active) return
|
||||||
})
|
const result =
|
||||||
|
mode === "prepend"
|
||||||
|
? page
|
||||||
|
: {
|
||||||
|
...page,
|
||||||
|
session: merge(
|
||||||
|
page.session,
|
||||||
|
parents.map((parent) => parent.message),
|
||||||
|
),
|
||||||
|
part: merge(
|
||||||
|
page.part,
|
||||||
|
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
const preserveUnfetched =
|
||||||
|
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||||
|
applyMessagePage(
|
||||||
|
sessionID,
|
||||||
|
result,
|
||||||
|
messageLoads.get(sessionID) === load ? load : undefined,
|
||||||
|
preserveUnfetched,
|
||||||
|
mode !== "prepend",
|
||||||
|
)
|
||||||
|
applied = true
|
||||||
|
} finally {
|
||||||
|
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
||||||
|
for (const messageID of load.orphanParents) {
|
||||||
|
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
||||||
|
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||||
|
orphanParts.get(sessionID)?.delete(messageID)
|
||||||
|
}
|
||||||
|
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
||||||
|
}
|
||||||
|
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
||||||
|
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
|
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
|
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
|
||||||
|
|
||||||
const user = (id: string) => ({ id, role: "user" }) as UserMessage
|
const user = (id: string) => ({ id, role: "user" }) as UserMessage
|
||||||
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
|
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
|
||||||
@@ -15,6 +15,12 @@ describe("timeline model", () => {
|
|||||||
expect(selectVisibleUserMessages(users)).toBe(users)
|
expect(selectVisibleUserMessages(users)).toBe(users)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("waits for an assistant-only load to hydrate its user root", () => {
|
||||||
|
expect(isTimelineReady([assistant("msg_2")], true)).toBe(false)
|
||||||
|
expect(isTimelineReady([user("msg_1"), assistant("msg_2")], true)).toBe(true)
|
||||||
|
expect(isTimelineReady([], false)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("loads exactly one opaque cursor page", async () => {
|
test("loads exactly one opaque cursor page", async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const anchors: Array<string | boolean> = []
|
const anchors: Array<string | boolean> = []
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function createTimelineModel(input: {
|
|||||||
})
|
})
|
||||||
const ready = createMemo(() => {
|
const ready = createMemo(() => {
|
||||||
const id = input.sessionID()
|
const id = input.sessionID()
|
||||||
return !id || sync().data.message[id] !== undefined
|
return !id || isTimelineReady(sync().data.message[id], serverSync().session.history.loading(id))
|
||||||
})
|
})
|
||||||
const userMessages = createMemo(() => selectUserMessages(messages()), emptyUserMessages, { equals: same })
|
const userMessages = createMemo(() => selectUserMessages(messages()), emptyUserMessages, { equals: same })
|
||||||
const visibleUserMessages = createMemo(
|
const visibleUserMessages = createMemo(
|
||||||
@@ -98,6 +98,10 @@ export function selectUserMessages(messages: Message[]) {
|
|||||||
return messages.filter((message): message is UserMessage => message.role === "user")
|
return messages.filter((message): message is UserMessage => message.role === "user")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
|
||||||
|
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
|
||||||
|
}
|
||||||
|
|
||||||
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
|
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
|
||||||
if (!revertMessageID) return messages
|
if (!revertMessageID) return messages
|
||||||
return messages.filter((message) => message.id < revertMessageID)
|
return messages.filter((message) => message.id < revertMessageID)
|
||||||
|
|||||||
Reference in New Issue
Block a user