From fa4f8a66c2489a61e667d8293632114dece405cc Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:00:32 +1000 Subject: [PATCH] fix(app): reuse hydrated composer history blobs (#46761) --- .../performance/composer-history/README.md | 37 ++++ .../composer-history.bench.ts | 48 +++++ .../performance/composer-history/fixture.tsx | 174 ++++++++++++++++++ .../performance/composer-history/index.html | 11 ++ .../composer-history/playwright.config.ts | 20 ++ .../composer-history/vite.config.ts | 10 + .../app/src/runtime/persistence/drafts.ts | 17 +- .../test-browser/draft-history-cache.test.ts | 130 +++++++++++++ 8 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 packages/app/e2e/performance/composer-history/README.md create mode 100644 packages/app/e2e/performance/composer-history/composer-history.bench.ts create mode 100644 packages/app/e2e/performance/composer-history/fixture.tsx create mode 100644 packages/app/e2e/performance/composer-history/index.html create mode 100644 packages/app/e2e/performance/composer-history/playwright.config.ts create mode 100644 packages/app/e2e/performance/composer-history/vite.config.ts create mode 100644 packages/app/test-browser/draft-history-cache.test.ts diff --git a/packages/app/e2e/performance/composer-history/README.md b/packages/app/e2e/performance/composer-history/README.md new file mode 100644 index 00000000000..e33e15b201b --- /dev/null +++ b/packages/app/e2e/performance/composer-history/README.md @@ -0,0 +1,37 @@ +# Composer History Hydration + +Manual benchmark for an empty destination composer. Runs the production +`ComposerEditor`, `createComposerEditor`, `createComposerHistory`, persistence +codec, and browser IndexedDB draft store. It does not run the surrounding app +shell or native desktop IPC. + +Workload: 100 normal prompts with realistic review instructions/code and 100 +shell commands. Separate cases have no images, 50 unique screenshots, or 50 +references to 5 screenshots. The fixture generates valid 1440 x 900 PNG code +screenshots before timing and reports their exact byte sizes. Each isolated +browser context measures a cold URL-cache mount followed by a warm remount. +The database was just seeded; this does not simulate a cold disk cache. + +`historyReadyMs` measures the mount action until both production history stores +are populated. This is history availability, not time to first editable input +(input can be usable before history finishes). The benchmark then verifies +ArrowUp recall and a decoded screenshot in the real editor. `recallObservedMs` +includes Playwright action/assertion overhead and is reported separately. +`mountRecallObservedMs` includes the mount, readiness checks, keyboard action, +and correct text/image completion; it also includes Playwright overhead. +IndexedDB reads and blob sizes are mechanism metrics, not desktop IPC bytes or +process memory. No timing threshold is enforced. + +From `packages/app`, set `OPENCODE_HISTORY_BUILD` and +`OPENCODE_HISTORY_OUTPUT` to artifact directories outside Git, then run: + +```sh +bun x vite build --config e2e/performance/composer-history/vite.config.ts +bun x playwright test --config e2e/performance/composer-history/playwright.config.ts --repeat-each=20 +``` + +The preview server owns port 4783 and is stopped by Playwright. Preserve each +build and its revision/hash for comparisons. `BENCHMARK` JSON lines contain all +raw samples. Optional Chrome traces use the existing +`OPENCODE_PERFORMANCE_TRACE_DIR` setting; keep trace runs separate from clean +timing. Screenshots are captured after timing on the first repeat only. diff --git a/packages/app/e2e/performance/composer-history/composer-history.bench.ts b/packages/app/e2e/performance/composer-history/composer-history.bench.ts new file mode 100644 index 00000000000..11f13e97203 --- /dev/null +++ b/packages/app/e2e/performance/composer-history/composer-history.bench.ts @@ -0,0 +1,48 @@ +import { benchmark, expect } from "../benchmark" + +benchmark.use({ traceScope: "page" }) +for (const shape of ["text", "unique", "repeated"]) { + benchmark(`composer global history: ${shape}, cold and warm mounts`, async ({ page, report }, testInfo) => { + const errors: string[] = [] + page.on("pageerror", (error) => errors.push(error.message)) + await page.goto(`/?shape=${shape}`) + const button = page.getByRole("button", { name: "Mount empty composer", exact: true }) + const input = page.getByRole("textbox", { name: "Prompt", exact: true }) + const samples = [] + for (const cache of ["cold", "warm"]) { + await expect(button).toBeEnabled() + const mountStarted = performance.now() + await button.click() + await expect(page.getByTestId("history-ready")).toHaveText("ready") + await expect(input).toBeEditable() + await expect(input).toBeEmpty() + const result = JSON.parse((await page.getByTestId("history-result").textContent())!) + expect(result.documents).toBe(2) + expect(result.historyReadyMs).toBeGreaterThan(0) + const start = performance.now() + await input.press("ArrowUp") + await expect(input).toContainText("Review the retry policy in src/network/request-0.ts.") + const images = page.getByRole("img", { name: "request-0.png", exact: true }) + await expect(images).toHaveCount(shape === "text" ? 0 : 1) + if (shape !== "text") + await expect + .poll(() => images.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth === 1440)) + .toBe(true) + samples.push({ + cache, + ...result, + recallObservedMs: performance.now() - start, + mountRecallObservedMs: performance.now() - mountStarted, + }) + } + expect(errors).toEqual([]) + report( + { samples }, + { + browser: page.context().browser()!.version(), + scope: "production composer editor/history, browser IndexedDB; no native IPC", + }, + ) + if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath(`${shape}.png`) }) + }) +} diff --git a/packages/app/e2e/performance/composer-history/fixture.tsx b/packages/app/e2e/performance/composer-history/fixture.tsx new file mode 100644 index 00000000000..5e24e2b44b7 --- /dev/null +++ b/packages/app/e2e/performance/composer-history/fixture.tsx @@ -0,0 +1,174 @@ +/// +import { createEffect, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { render } from "solid-js/web" +import { PlatformProvider, type Platform } from "@/runtime/platform/platform" +import { createBrowserDraftStore } from "@/runtime/persistence/drafts" +import { createComposerHistory } from "@/composer/history/store" +import { ComposerEditor } from "@/composer/editor/editor" +import { createComposerEditor } from "@/composer/editor/interaction" +import type { ComposerPersistedState } from "@/composer/types" +import "@/index.css" + +const shape = new URLSearchParams(location.search).get("shape") ?? "text" +const normal = Array.from({ length: 100 }, (_, index) => { + const content = + `Review the retry policy in src/network/request-${index}.ts. Preserve cancellation and the existing error messages.\n\n` + + `The request should stop after three attempts. Add coverage for a 429 response, a connection reset, and a successful retry. Verify that only idempotent requests are retried.\n\n` + + `Report ${index}:\n\`\`\`ts\nexport async function request(input: Request) {\n const response = await fetch(input)\n if (!response.ok) throw new Error(response.statusText)\n return response.json()\n}\n\`\`\`` + return { + prompt: [ + { type: "text", content, start: 0, end: content.length }, + ...(shape !== "text" && index % 2 === 0 + ? [ + { + type: "image", + id: `attachment-${index}`, + filename: `request-${index}.png`, + mime: "image/png", + blob: { id: `screenshot-${shape === "repeated" ? index % 10 : index}` }, + }, + ] + : []), + ], + comments: [], + } +}) +const shell = Array.from({ length: 100 }, (_, index) => { + const content = `bun test src/network/request-${index}.test.ts --timeout 30000` + return { prompt: [{ type: "text", content, start: 0, end: content.length }], comments: [] } +}) + +// Seed only this Playwright context, before opening the production draft store. +const request = indexedDB.open("opencode-drafts", 1) +request.onupgradeneeded = () => { + request.result.createObjectStore("documents") + request.result.createObjectStore("blobs") +} +const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) +}) +const ids = [...new Set(normal.flatMap((entry) => entry.prompt.flatMap((part) => (part.blob ? [part.blob.id] : []))))] +const screenshots: { id: string; blob: Blob }[] = [] +for (const id of ids) { + const canvas = document.createElement("canvas") + canvas.width = 1440 + canvas.height = 900 + const context = canvas.getContext("2d")! + context.fillStyle = "#15191f" + context.fillRect(0, 0, canvas.width, canvas.height) + context.font = "16px monospace" + context.fillStyle = "#b8c8d8" + context.fillText(`request.ts - ${id}`, 30, 35) + for (let line = 0; line < 38; line++) { + context.fillStyle = line % 3 ? "#a8c7ba" : "#d4a882" + context.fillText( + `${String(line + 1).padStart(3)} const response${line} = await fetch('/api/request/${id}/${line}', { signal, headers });`, + 30, + 70 + line * 20, + ) + } + const blob = await new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!), "image/png")) + screenshots.push({ id, blob }) +} +const transaction = db.transaction(["documents", "blobs"], "readwrite") +transaction.objectStore("documents").put(JSON.stringify({ entries: normal }), "opencode.global.dat:prompt-history") +transaction.objectStore("documents").put(JSON.stringify({ entries: shell }), "opencode.global.dat:prompt-history-shell") +screenshots.forEach(({ id, blob }) => transaction.objectStore("blobs").put(blob, id)) +await new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) +}) +db.close() + +const metrics = { reads: 0, blobBytes: 0, documents: 0 } +const originalGet = IDBObjectStore.prototype.get +IDBObjectStore.prototype.get = function (key) { + const request = originalGet.call(this, key) + if (this.name === "documents") metrics.documents++ + if (this.name === "blobs") { + metrics.reads++ + request.addEventListener("success", () => { + metrics.blobBytes += request.result?.size ?? 0 + }) + } + return request +} +const platform: Platform = { + platform: "web", + draftStore: createBrowserDraftStore(), + openExternal() {}, + restart: async () => {}, + notify: async () => {}, +} +const [state, setState] = createStore({ mount: 0, ready: false, result: "" }) +const workload = { + shape, + normalEntries: normal.length, + shellEntries: shell.length, + imageReferences: shape === "text" ? 0 : 50, + uniqueImages: ids.length, + storedImageBytes: screenshots.reduce((sum, item) => sum + item.blob.size, 0), + documentBytes: [normal, shell].reduce( + (sum, entries) => sum + new TextEncoder().encode(JSON.stringify({ entries })).length, + 0, + ), + screenshotDimensions: [1440, 900], +} +let started = 0 +function mount() { + metrics.reads = 0 + metrics.blobBytes = 0 + metrics.documents = 0 + setState({ ready: false, result: "" }) + started = performance.now() + setState("mount", state.mount + 1) +} +function Destination() { + // Same history creation and editor mapping as createComposerModel. Destination draft is empty. + const history = createComposerHistory() + const store = createStore({ + prompt: [{ type: "text", content: "", start: 0, end: 0 }], + cursor: 0, + context: { items: [] }, + }) + const controller = createComposerEditor({ + store, + commands: () => [], + context: () => [], + searchContextFiles: () => [], + history: { + entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })), + add: (prompt, mode) => history.add(prompt, mode, []), + }, + view: { + placeholder: () => "Empty destination composer", + submit: { stopping: () => false, onSubmit() {}, onStop() {} }, + }, + }) + createEffect(() => { + if (history.entries("normal").length !== 100 || history.entries("shell").length !== 100) return + setState({ + ready: true, + result: JSON.stringify({ historyReadyMs: performance.now() - started, ...metrics, ...workload }), + }) + }) + return +} +render( + () => ( + +
+

Composer global history: {shape}

+ + {state.ready ? "ready" : "idle"} +
{state.result}
+ + {(_mount) => } + +
+
+ ), + document.getElementById("root")!, +) diff --git a/packages/app/e2e/performance/composer-history/index.html b/packages/app/e2e/performance/composer-history/index.html new file mode 100644 index 00000000000..18da871376a --- /dev/null +++ b/packages/app/e2e/performance/composer-history/index.html @@ -0,0 +1,11 @@ + + + + + Composer history benchmark + + +
+ + + diff --git a/packages/app/e2e/performance/composer-history/playwright.config.ts b/packages/app/e2e/performance/composer-history/playwright.config.ts new file mode 100644 index 00000000000..45a0e5a4a1c --- /dev/null +++ b/packages/app/e2e/performance/composer-history/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test" +import { fileURLToPath } from "node:url" + +export default defineConfig({ + testDir: ".", + testMatch: "composer-history.bench.ts", + workers: 1, + retries: 0, + timeout: 60_000, + reporter: "line", + outputDir: process.env.OPENCODE_HISTORY_OUTPUT, + use: { baseURL: "http://127.0.0.1:4783", viewport: { width: 1440, height: 900 }, trace: "off", video: "off" }, + webServer: { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + command: + "bun x vite preview --config e2e/performance/composer-history/vite.config.ts --host 127.0.0.1 --port 4783 --strictPort", + url: "http://127.0.0.1:4783", + reuseExistingServer: false, + }, +}) diff --git a/packages/app/e2e/performance/composer-history/vite.config.ts b/packages/app/e2e/performance/composer-history/vite.config.ts new file mode 100644 index 00000000000..7d1d96f67d1 --- /dev/null +++ b/packages/app/e2e/performance/composer-history/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite" +import { fileURLToPath } from "node:url" +import app from "../../../vite" + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + publicDir: fileURLToPath(new URL("../../../public", import.meta.url)), + plugins: [app], + build: { target: "esnext", outDir: process.env.OPENCODE_HISTORY_BUILD, emptyOutDir: true }, +}) diff --git a/packages/app/src/runtime/persistence/drafts.ts b/packages/app/src/runtime/persistence/drafts.ts index c16694a7dac..544f21ddd3d 100644 --- a/packages/app/src/runtime/persistence/drafts.ts +++ b/packages/app/src/runtime/persistence/drafts.ts @@ -39,6 +39,19 @@ export async function createBlobReference(blob: Blob): Promise { export function createDraftStore(driver: Driver): DraftStore { const versions = new Map() + const loading = new Map>() + const loadBlobUrl = (id: string) => { + const existing = urls.get(id) + if (existing) return existing + const pending = loading.get(id) + if (pending) return pending + const next = driver + .getBlob(id) + .then((blob) => (blob ? blobUrl(id, blob) : undefined)) + .finally(() => loading.delete(id)) + loading.set(id, next) + return next + } const putBlob = async (blob: Blob) => { const id = await driver.putBlob(blob) return { id, url: blobUrl(id, blob) } @@ -71,8 +84,8 @@ export function createDraftStore(driver: Driver): DraftStore { if (item.blob && typeof item.blob === "object") { const ref = item.blob as Record if (typeof ref.id === "string") { - const blob = await driver.getBlob(ref.id) - if (blob) return { ...item, blob: { id: ref.id, url: blobUrl(ref.id, blob) } } + const url = await loadBlobUrl(ref.id) + if (url) return { ...item, blob: { id: ref.id, url } } } } return Object.fromEntries( diff --git a/packages/app/test-browser/draft-history-cache.test.ts b/packages/app/test-browser/draft-history-cache.test.ts new file mode 100644 index 00000000000..3a29415dce4 --- /dev/null +++ b/packages/app/test-browser/draft-history-cache.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from "bun:test" +import { resolveObjectURL } from "node:buffer" +import { createDraftStore } from "@/runtime/persistence/drafts" + +function fixture(id: string, getBlob: () => Promise) { + const documents = new Map([ + ["history", JSON.stringify({ entries: [{ prompt: [{ type: "image", blob: { id } }] }] })], + ["draft", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] })], + ]) + const store = createDraftStore({ + get: async (key) => documents.get(key) ?? null, + set: async (key, value) => void documents.set(key, value), + remove: async (key) => void documents.delete(key), + putBlob: async () => id, + getBlob, + }) + return { store, documents } +} + +test("deduplicates concurrent history and draft reads without invalidating either live reference", async () => { + const pending = Promise.withResolvers() + const started = Promise.withResolvers() + let reads = 0 + const { store } = fixture("history-cache-concurrent", () => { + reads++ + started.resolve() + return pending.promise + }) + const history = store.getItem("history") + const draft = store.getItem("draft") + await started.promise + pending.resolve(new Blob(["shared screenshot"])) + const [saved, active] = await Promise.all([history, draft]) + const reference = JSON.parse(saved!).entries[0].prompt[0].blob + expect(JSON.parse(active!).prompt[0].blob).toEqual(reference) + expect(reads).toBe(1) + await store.removeItem("history") + expect(await resolveObjectURL(reference.url)?.text()).toBe("shared screenshot") + expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference) + expect(reads).toBe(1) +}) + +test("hydrates repeated references once within one history document", async () => { + let reads = 0 + const { store, documents } = fixture("history-cache-repeated", async () => { + reads++ + return new Blob(["repeated screenshot"]) + }) + documents.set( + "history", + JSON.stringify({ + entries: Array.from({ length: 100 }, () => ({ + prompt: [{ type: "image", blob: { id: "history-cache-repeated" } }], + })), + }), + ) + const value = JSON.parse((await store.getItem("history"))!) + expect(value.entries).toHaveLength(100) + expect( + new Set(value.entries.map((entry: { prompt: { blob: { url: string } }[] }) => entry.prompt[0].blob.url)).size, + ).toBe(1) + expect(reads).toBe(1) +}) + +test("reuses a live URL on remount but reads the latest document", async () => { + let reads = 0 + const { store, documents } = fixture("history-cache-remount", async () => { + reads++ + return new Blob(["saved screenshot"]) + }) + const first = JSON.parse((await store.getItem("history"))!) + const changed = JSON.parse(documents.get("history")!) + changed.entries[0].prompt.unshift({ type: "text", content: "new admission" }) + documents.set("history", JSON.stringify(changed)) + const second = JSON.parse((await store.getItem("history"))!) + expect(second.entries[0].prompt[0].content).toBe("new admission") + expect(second.entries[0].prompt[1].blob).toEqual(first.entries[0].prompt[0].blob) + expect(reads).toBe(1) +}) + +test("reuses a just-stored attachment without a round trip", async () => { + let reads = 0 + const { store } = fixture("history-cache-put", async () => { + reads++ + return new Blob(["unexpected read"]) + }) + const reference = await store.putBlob(new Blob(["pending admission"])) + expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference) + expect(reads).toBe(0) + expect(await resolveObjectURL(reference.url)?.text()).toBe("pending admission") +}) + +test("does not retain a missing blob result", async () => { + let reads = 0 + const { store } = fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"]))) + expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toBeUndefined() + expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toStartWith("blob:") + expect(reads).toBe(2) +}) + +test("retries after a failed blob read", async () => { + let reads = 0 + const { store } = fixture("history-cache-failure", async () => { + if (++reads === 1) throw new Error("temporary storage failure") + return new Blob(["recovered"]) + }) + await expect(store.getItem("history")).rejects.toThrow("temporary storage failure") + expect(JSON.parse((await store.getItem("history"))!).entries[0].prompt[0].blob.url).toStartWith("blob:") + expect(reads).toBe(2) +}) + +test("keeps different blob IDs independent", async () => { + const reads: string[] = [] + const store = createDraftStore({ + get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))), + set: async () => {}, + remove: async () => {}, + putBlob: async () => "unused", + getBlob: async (id) => { + reads.push(id) + return new Blob([id]) + }, + }) + const value = JSON.parse((await store.getItem("history"))!) + expect(value[0].blob.url).not.toBe(value[1].blob.url) + expect( + await Promise.all(value.map((item: { blob: { url: string } }) => resolveObjectURL(item.blob.url)?.text())), + ).toEqual(reads) + expect(reads).toEqual(["history-cache-first", "history-cache-second"]) +})