From 10fa825adbac3b86720872e7769d8d0174a9e37b Mon Sep 17 00:00:00 2001 From: aidansunbury Date: Mon, 29 Jun 2026 15:54:52 -0700 Subject: [PATCH 1/3] docs: document fork update workflow --- OWNER_OPENCODE_FORK_UPDATE.md | 127 ++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 OWNER_OPENCODE_FORK_UPDATE.md diff --git a/OWNER_OPENCODE_FORK_UPDATE.md b/OWNER_OPENCODE_FORK_UPDATE.md new file mode 100644 index 000000000000..8ecb44b4f9a5 --- /dev/null +++ b/OWNER_OPENCODE_FORK_UPDATE.md @@ -0,0 +1,127 @@ +# Updating owner-opencode From Upstream + +This repository is a fork mirror of upstream OpenCode used by the `owner` repo as a submodule at: + +```text +apps/forge-ui/vendor/opencode +``` + +Do not use a GitHub pull request to update the fork from upstream. A PR from upstream into the fork can show a very large diff, may compare the wrong direction, and can fail with permission errors because GitHub treats it as a cross-repository PR. Update the fork with local git instead. + +Important: updating and pushing this fork is not enough for Forge UI to use the new code. The `owner` repo pins `apps/forge-ui/vendor/opencode` to a specific submodule commit. Changes only take effect in Forge UI after the `owner` repo updates that submodule pointer and commits the new gitlink. + +## Repositories + +- Fork repo: `https://github.com/owner/owner-opencode.git` +- Upstream repo: `https://github.com/anomalyco/opencode.git` +- Default branch: `dev` +- Parent repo that pins this fork as a submodule: `/Users/aidan/Desktop/work/code/owner` +- Fork checkout: `/Users/aidan/Desktop/work/code/owner-opencode` + +## Safe Fork Update Workflow + +Run these commands from `/Users/aidan/Desktop/work/code/owner-opencode`. + +```bash +git status --short --branch +git remote -v +``` + +The working tree should be clean before rebasing. Ensure `origin` points to `owner/owner-opencode` and `upstream` points to `anomalyco/opencode`. + +```bash +git remote add upstream https://github.com/anomalyco/opencode.git +``` + +If `upstream` already exists, skip that command. + +Fetch upstream and inspect counts without loading the full diff: + +```bash +git fetch upstream dev +git rev-list --left-right --count upstream/dev...dev +git log --oneline upstream/dev..dev +``` + +The first number is commits present only in upstream. The second number is commits present only in the fork. Review the fork-only commit list before rebasing. + +Rebase the fork onto upstream: + +```bash +git checkout dev +git rebase upstream/dev +``` + +If a fork-only bug-fix commit conflicts heavily with rewritten upstream code, inspect only that commit's patch and the conflicted regions: + +```bash +git show --stat --oneline +git show --format= --unified=30 -- +rg -n "^(<<<<<<<|=======|>>>>>>>)" +``` + +If upstream has already replaced the affected area and the local fix is obsolete, skip the commit: + +```bash +git rebase --skip +``` + +Only hand-port a local fix when the current upstream source still has the same bug. Do not preserve stale fork logic just to keep a fork commit. + +Verify the result: + +```bash +git status --short --branch +git rev-parse dev upstream/dev origin/dev +git rev-list --left-right --count upstream/dev...dev +``` + +When satisfied, update the fork remote: + +```bash +git push --force-with-lease origin dev +``` + +Use `--force-with-lease`, not plain `--force`, so the push fails if someone else updated `origin/dev` after the last fetch. + +## Updating The Parent Submodule Pin + +Pushing `owner-opencode` does not update the version used by `owner` or Forge UI. The parent repo records a specific submodule commit, so it remains pinned until the gitlink is changed and committed in `/Users/aidan/Desktop/work/code/owner`. + +Run these commands from `/Users/aidan/Desktop/work/code/owner`. + +```bash +git status --short --branch +git submodule status --recursive +git ls-tree HEAD apps/forge-ui/vendor/opencode +``` + +Then update the submodule checkout to the desired fork commit: + +```bash +cd apps/forge-ui/vendor/opencode +git fetch origin dev +git checkout origin/dev +git rev-parse HEAD +cd /Users/aidan/Desktop/work/code/owner +git status --short +``` + +The parent repo should now show `apps/forge-ui/vendor/opencode` as modified. That is the submodule gitlink update. + +Commit the parent repo pointer change: + +```bash +git add apps/forge-ui/vendor/opencode +git commit -m "chore(forge-ui): update opencode submodule" +``` + +Do not expect submodule updates to appear as normal file diffs in the parent repo. The parent commit stores only the new submodule commit SHA. + +## Notes For Agents + +- Avoid loading the full upstream-vs-fork diff when upstream is hundreds of commits ahead. +- Prefer commit counts, commit lists, `git show --stat`, and targeted file inspection. +- Do not push until local `dev` has been checked against `upstream/dev`. +- Do not update the parent repo submodule pin until the fork remote has the target commit. +- The submodule checkout is often detached; that is normal. From d87285d857b2b4227d744a458e645f188eec929f Mon Sep 17 00:00:00 2001 From: aidansunbury Date: Thu, 2 Jul 2026 13:04:19 -0700 Subject: [PATCH 2/3] fix(app): stop the viewed session's transcript from staying blank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message pane could stay permanently empty for a session even though its message API returned 200 with the full transcript — no error, green network tab. Two interacting issues in the session-sync layer: 1. `loadMessages` bails when `meta.loading[sessionID]` is set (guard at the top), but its `.finally` only clears `loading` when the generation is unchanged (`generations.get === active`). A generation change mid-load therefore left `loading` stuck true forever, so every later load — including a forced one — no-ops and the transcript never fills. 2. On a cold, deep-linked mount, the page-sync and the background prefetch race: the forced sync can be deduped into the prefetch's in-flight promise, and a load whose page is dropped on a generation change is never retried. Result: nothing commits to `data.message[sessionID]`, which is the only thing the timeline renders from. Fix: - Release the `loading` flag whenever the finishing load still owns the slot (`messageLoads.get === load`), regardless of generation, so a session can never get permanently stuck. - For the session being viewed, force the load and re-drive it until the store actually holds the session's messages (or the view moves on). Redundant refetch on the cold path is acceptable and intentional. Exposed reliably by the Forge embedding (opencode 1.17.11), which mounts straight onto /session/:id so the page-sync and prefetch start against a cold store simultaneously. --- packages/app/src/context/server-session.ts | 12 +++++++++--- packages/app/src/pages/session/timeline/model.ts | 15 +++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 9ba712ef2568..4b0f2115015b 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -596,7 +596,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: applied = true }) .finally(() => { - if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) { + const owns = messageLoads.get(sessionID) === load + if (!applied && generations.get(sessionID) === active && owns) { for (const messageID of load.orphanParents) { if (!orphanParts.get(sessionID)?.has(messageID)) continue setData(produce((draft) => deleteMessageParts(draft, messageID))) @@ -604,8 +605,13 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: } 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) + if (owns) messageLoads.delete(sessionID) + // Release the loading flag whenever this load still owns the slot, even if + // the generation changed mid-load. Gating this on the generation alone let + // `loading` stick true forever after a generation change, which made every + // later load (including a forced one) no-op at the `meta.loading` guard and + // left the session's transcript permanently blank. + if (owns || generations.get(sessionID) === active) setMeta("loading", sessionID, false) }) } diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts index 91bfc0eaf781..3760e469838a 100644 --- a/packages/app/src/pages/session/timeline/model.ts +++ b/packages/app/src/pages/session/timeline/model.ts @@ -18,7 +18,7 @@ export function createTimelineModel(input: { const [resource] = createResource( () => input.sessionID(), - (id) => { + async (id) => { clearRefresh() if (!id) return @@ -36,7 +36,18 @@ export function createTimelineModel(input: { }, 0) }) - return sync().session.sync(id) + if (cached) return sync().session.sync(id) + + // Cold load for the session being viewed. A concurrent background load can + // be deduped by the shared in-flight map or dropped on a generation change, + // which leaves the transcript permanently blank with no retry. Force a load + // and re-drive it until the session's messages are in the store (or the view + // moves on). + for (let attempt = 0; attempt < 5; attempt++) { + await sync().session.sync(id, { force: true }) + if (input.sessionID() !== id) return + if (untrack(() => sync().data.message[id] !== undefined)) return + } }, ) const messages = createMemo(() => { From 815d8167022495e2d688c63d95775f558222efc5 Mon Sep 17 00:00:00 2001 From: Max Schwenk Date: Thu, 9 Jul 2026 14:21:33 +0000 Subject: [PATCH 3/3] fix(app): hydrate timeline message parents (#35269) Cherry-pick of anomalyco/opencode@a12d50e15 with conflict resolution: keep upstream's parent-hydration in loadMessages, plus apply the pre-existing fork fix that releases meta.loading when the finishing load still owns the slot (upstream still gates this solely on the generation, which can wedge loading=true forever after a generation change). Also cherry-picks the sessionNotFoundError / isLocalSessionNotFoundError exports from server-errors.ts that upstream added in 4a42caef2, since server-session.ts depends on them. --- ...session-parent-hydration-benchmark.spec.ts | 146 +++++++++ .../timeline/session-tab-switch-metrics.ts | 5 +- .../timeline/session-tab-switch-probe.ts | 90 ++++- .../e2e/performance/unit/mock-server.test.ts | 46 +++ .../unit/session-tab-switch-metrics.test.ts | 32 ++ .../unit/session-tab-switch-probe.test.ts | 45 +++ .../session-timeline-history-root.spec.ts | 220 +++++++++++++ packages/app/e2e/utils/mock-server.ts | 15 +- .../app/src/context/server-session.test.ts | 308 ++++++++++++++++++ packages/app/src/context/server-session.ts | 153 ++++++--- .../src/pages/session/timeline/model.test.ts | 8 +- .../app/src/pages/session/timeline/model.ts | 21 +- packages/app/src/utils/server-errors.ts | 14 + 13 files changed, 1041 insertions(+), 62 deletions(-) create mode 100644 packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts create mode 100644 packages/app/e2e/performance/unit/mock-server.test.ts create mode 100644 packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts create mode 100644 packages/app/e2e/regression/session-timeline-history-root.spec.ts diff --git a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts new file mode 100644 index 000000000000..77c8491efda3 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts @@ -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>[] + 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() : 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 } +} diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts index e315c2ad43b9..f4046b7cf370 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts @@ -4,6 +4,8 @@ export type SessionSwitchSample = { source: string[] hasVisibleRows: boolean last: boolean + requiredPartVisible?: boolean + bottomAnchorRequired?: boolean bottomErrorPx?: number } @@ -31,7 +33,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) { sample.destination.length > 0 && sample.source.length === 0 && sample.last && - Math.abs(sample.bottomErrorPx ?? Infinity) <= 1 + sample.requiredPartVisible !== false && + (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) ) } diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts index 14f9d2d003e4..955da80367d7 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts @@ -8,19 +8,56 @@ type SessionSwitchProbe = { async function installSessionSwitchProbe( 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 source = new Set(sourceIDs) const samples: SessionSwitchSample[] = [] let started: number | undefined let running = true + const reviewLevels: Record = { + panel: "#review-panel", + tabs: '#review-panel [data-component="tabs"]', + body: '#review-panel [data-slot="session-review-v2-body"]', + review: '#review-panel [data-component="session-review-v2"]', + preview: '#review-panel [data-slot="session-review-v2-preview"]', + scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]', + file: '#review-panel [data-component="file"][data-mode="diff"]', + } + const initialReviewNodes: Record = {} const sample = () => { if (!running || started === undefined) return setTimeout(() => { if (!running || started === undefined) return const observedAtMs = performance.now() - started + const reviewPanel = document.querySelector("#review-panel") + const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]') + const initialReviewFile = initialReviewNodes.file + const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => { + const initial = initialReviewNodes[name] + if (!initial) return [] + const current = document.querySelector(selector) + return current && current !== initial ? [name] : [] + }) + const review = reviewPanel + ? { + fileHost: !!reviewFile, + fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile, + header: + reviewPanel + .querySelector('[data-slot="session-review-v2-file-header"]') + ?.textContent?.trim() ?? "", + replacedLevels, + } + : undefined const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => element.querySelector("[data-timeline-row]"), ) @@ -36,6 +73,13 @@ async function installSessionSwitchProbe( const rect = element.getBoundingClientRect() return rect.bottom > view.top && rect.top < view.bottom }) + const requiredPartVisible = requiredPartID + ? [...root.querySelectorAll("[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('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() samples.push({ observedAtMs, @@ -43,10 +87,22 @@ async function installSessionSwitchProbe( source: visible.filter((id) => source.has(id)), hasVisibleRows, last: visible.includes(lastID), + requiredPartVisible, + bottomAnchorRequired: requireBottomAnchor !== false, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined, + review, }) } else { - samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false }) + samples.push({ + observedAtMs, + destination: [], + source: [], + hasVisibleRows: false, + last: false, + requiredPartVisible: requiredPartID ? false : undefined, + bottomAnchorRequired: requireBottomAnchor !== false, + review, + }) } requestAnimationFrame(sample) }, 0) @@ -57,6 +113,9 @@ async function installSessionSwitchProbe( const link = event.target instanceof Element ? event.target.closest("a") : undefined if (link?.getAttribute("href") !== href) return started = performance.now() + for (const [name, selector] of Object.entries(reviewLevels)) { + initialReviewNodes[name] = document.querySelector(selector) + } requestAnimationFrame(sample) }, { capture: true, once: true }, @@ -83,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) { sample.destination.length > 0 && sample.source.length === 0 && sample.last && - Math.abs(sample.bottomErrorPx ?? Infinity) <= 1, + sample.requiredPartVisible !== false && + (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1), ) ) }) @@ -101,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) { export async function measureSessionSwitch( page: Page, - input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise }, + input: { + destinationIDs: string[] + sourceIDs: string[] + lastID: string + requiredPartID?: string + requireBottomAnchor?: boolean + href: string + switch: () => Promise + }, ) { const { switch: run, ...probe } = input await installSessionSwitchProbe(page, probe) - await run() - await waitForStableSessionSwitch(page) - return collectSessionSwitchResult(page) + try { + await run() + 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) { diff --git a/packages/app/e2e/performance/unit/mock-server.test.ts b/packages/app/e2e/performance/unit/mock-server.test.ts new file mode 100644 index 000000000000..83308c0a866b --- /dev/null +++ b/packages/app/e2e/performance/unit/mock-server.test.ts @@ -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() + let handler: ((route: Route) => Promise) | undefined + const page = { + route: (_url: string, callback: (route: Route) => Promise) => { + 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"]) +}) diff --git a/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts index dd771b7d57c9..4b824d9dfb0d 100644 --- a/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts +++ b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts @@ -52,3 +52,35 @@ test("reports missing correctness without throwing", () => { expect(result.firstCorrectObservedMs).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) +}) diff --git a/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts b/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts new file mode 100644 index 000000000000..3b71dc5c4631 --- /dev/null +++ b/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts @@ -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) { + 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) +}) diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts new file mode 100644 index 000000000000..15375cafed59 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -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() + const transport = await installSseTransport<{ directory: string; payload: Record }>(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("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + const visible = (partID: string) => { + const part = viewport?.querySelector(`[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, + ) +} diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 875c3b7a96c3..9ce7045131d0 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -11,7 +11,10 @@ export interface MockServerConfig { pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } vcsDiff?: unknown[] messageDelay?: number + beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise 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[] eventRetry?: number todos?: (sessionID: string) => unknown[] @@ -64,6 +67,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { 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$/) if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) @@ -74,7 +86,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const before = token ? cursors.get(token) : undefined if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) 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 pageData = config.pageMessages(messagesMatch[1], limit, before) config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 01f517478924..77871d347315 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -15,11 +15,13 @@ const session = (id: string, parentID?: string): Session => ({ }) type UserMessage = Extract +type AssistantMessage = Extract type TextPart = Extract type MessageResponse = { data: { info: Message; parts: Part[] }[] response: { headers: Headers } } +type SingleMessageResponse = { data: MessageResponse["data"][number] } const userMessage = (id: string, input: Partial = {}): UserMessage => ({ id, @@ -31,6 +33,22 @@ const userMessage = (id: string, input: Partial = {}): UserMessage ...input, }) +const assistantMessage = (id: string, parentID: string, input: Partial = {}): 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 => ({ id: "part", sessionID: "child", @@ -45,6 +63,8 @@ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageR response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) }, }) +const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } }) + const deferredResponse = () => Promise.withResolvers() function messageClient(...responses: Array>) { @@ -71,6 +91,40 @@ function messageClient(...responses: Array>, + roots: Array>, +) { + let pageIndex = 0 + let rootIndex = 0 + const requests: unknown[] = [] + const rootRequests: unknown[] = [] + const rootWaiting = new Map 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((resolve) => rootWaiting.set(count, resolve)) + }, + }) +} + const retryImmediately: typeof retry = async (task, options = {}) => { const attempts = options.attempts ?? 3 for (let attempt = 0; ; attempt++) { @@ -124,6 +178,240 @@ describe("server session", () => { 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 () => { const pending = deferredResponse() const user = userMessage("message-1") @@ -905,6 +1193,26 @@ describe("server session", () => { 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 () => { const older = userMessage("message-1") const latest = userMessage("message-2", { time: { created: 2 } }) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 4b0f2115015b..9e8de853a81f 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -14,6 +14,7 @@ import type { import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs" +import { sessionNotFoundError } from "@/utils/server-errors" import { rootSession } from "@/utils/session-route" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" @@ -53,6 +54,11 @@ type MessageLoadState = { clearedMessageParts: Set } +type MessageLoadBaseline = Pick< + MessageLoadState, + "touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts" +> + function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] } const session = [...page.session] @@ -235,7 +241,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: if (pending) return pending const active = generation(sessionID) const request = client.session.get({ sessionID }).then((result) => { - if (!result.data) throw new Error(`Session not found: ${sessionID}`) + if (!result.data) throw sessionNotFoundError(sessionID) if (generations.get(sessionID) !== active) return result.data return remember(result.data) }) @@ -346,7 +352,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: 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.retainedMessages.clear() load.touchedParts.clear() @@ -379,8 +385,27 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: parts.forEach((partID) => touched.add(partID)) 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() + 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[]) => { if (sessionIDs.length === 0) return const evicted = new Set(sessionIDs) @@ -459,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 messageIDs = new Set(messages.map((message) => message.id)) const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id)) @@ -577,42 +614,84 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: messageLoads.set(sessionID, load) setMeta("loading", sessionID, true) let applied = false - await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) - .then((page) => { - if (generations.get(sessionID) !== active) return - const first = page.session.reduce( - (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest), - undefined, - ) - const preserveUnfetched = - mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0))) - applyMessagePage( - sessionID, - page, - messageLoads.get(sessionID) === load ? load : undefined, - preserveUnfetched, - mode !== "prepend", - ) - applied = true - }) - .finally(() => { - const owns = messageLoads.get(sessionID) === load - if (!applied && generations.get(sessionID) === active && owns) { - 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) + try { + const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) + const first = page.session.reduce( + (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest), + undefined, + ) + if (generations.get(sessionID) !== active) return + + const parents = [] as Awaited>[] + if (mode !== "prepend") { + const users = new Set([ + ...page.session.filter((message) => message.role === "user").map((message) => message.id), + ...(data.message[sessionID] ?? []) + .filter((message) => { + if (message.role !== "user") return false + const item = optimistic.get(sessionID)?.get(message.id) + return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true) + }) + .map((message) => message.id), + ]) + const parentIDs = [ + ...new Set( + page.session.flatMap((message) => + message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [], + ), + ), + ] + 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 (owns) messageLoads.delete(sessionID) - // Release the loading flag whenever this load still owns the slot, even if - // the generation changed mid-load. Gating this on the generation alone let - // `loading` stick true forever after a generation change, which made every - // later load (including a forced one) no-op at the `meta.loading` guard and - // left the session's transcript permanently blank. - if (owns || 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 { + const owns = messageLoads.get(sessionID) === load + if (!applied && generations.get(sessionID) === active && owns) { + 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 (owns) messageLoads.delete(sessionID) + // Release the loading flag whenever this load still owns the slot, even if the + // generation changed mid-load. Gating solely on the generation left `loading` + // stuck true after a generation change, so every later load (including a forced + // one) no-oped at the `meta.loading` guard and the transcript stayed blank. + if (owns || generations.get(sessionID) === active) setMeta("loading", sessionID, false) + } } const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => { diff --git a/packages/app/src/pages/session/timeline/model.test.ts b/packages/app/src/pages/session/timeline/model.test.ts index 09f24ff5ef75..24612072c3d4 100644 --- a/packages/app/src/pages/session/timeline/model.test.ts +++ b/packages/app/src/pages/session/timeline/model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" 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 assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage @@ -15,6 +15,12 @@ describe("timeline model", () => { 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 () => { let calls = 0 const anchors: Array = [] diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts index 3760e469838a..7eebee608078 100644 --- a/packages/app/src/pages/session/timeline/model.ts +++ b/packages/app/src/pages/session/timeline/model.ts @@ -18,7 +18,7 @@ export function createTimelineModel(input: { const [resource] = createResource( () => input.sessionID(), - async (id) => { + (id) => { clearRefresh() if (!id) return @@ -36,18 +36,7 @@ export function createTimelineModel(input: { }, 0) }) - if (cached) return sync().session.sync(id) - - // Cold load for the session being viewed. A concurrent background load can - // be deduped by the shared in-flight map or dropped on a generation change, - // which leaves the transcript permanently blank with no retry. Force a load - // and re-drive it until the session's messages are in the store (or the view - // moves on). - for (let attempt = 0; attempt < 5; attempt++) { - await sync().session.sync(id, { force: true }) - if (input.sessionID() !== id) return - if (untrack(() => sync().data.message[id] !== undefined)) return - } + return sync().session.sync(id) }, ) const messages = createMemo(() => { @@ -56,7 +45,7 @@ export function createTimelineModel(input: { }) const ready = createMemo(() => { 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 visibleUserMessages = createMemo( @@ -109,6 +98,10 @@ export function selectUserMessages(messages: Message[]) { 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) { if (!revertMessageID) return messages return messages.filter((message) => message.id < revertMessageID) diff --git a/packages/app/src/utils/server-errors.ts b/packages/app/src/utils/server-errors.ts index 409b49f54e07..b34ae609ae2f 100644 --- a/packages/app/src/utils/server-errors.ts +++ b/packages/app/src/utils/server-errors.ts @@ -42,6 +42,20 @@ function unwrapNamedError(error: unknown): unknown { return error } +// Client-synthesized session not-found errors share one constructor and +// predicate so the message contract cannot drift between the sync store +// (server-session.ts), the route lineage (session-lineage.ts), and the +// not-found fallback matching (session.tsx). +const sessionNotFoundMessage = (sessionID: string) => `Session not found: ${sessionID}` + +export function sessionNotFoundError(sessionID: string) { + return new Error(sessionNotFoundMessage(sessionID)) +} + +export function isLocalSessionNotFoundError(error: unknown, sessionID: string) { + return error instanceof Error && error.message === sessionNotFoundMessage(sessionID) +} + export function isSessionNotFoundError(error: unknown, sessionID: string) { const unwrapped = unwrapNamedError(error) if (typeof unwrapped !== "object" || unwrapped === null) return false