diff --git a/packages/app/e2e/performance/README.md b/packages/app/e2e/performance/README.md index 05e862a2ff75..feadb25e0b16 100644 --- a/packages/app/e2e/performance/README.md +++ b/packages/app/e2e/performance/README.md @@ -148,6 +148,10 @@ bunx playwright test --config e2e/performance/playwright.config.ts \ `PROVIDER_MEMORY_MODELS` defaults to 1,200 and `PROVIDER_MEMORY_SWITCHES` defaults to 10. Each sample records Chromium's `Runtime.getHeapUsage` and `Memory.getDOMCounters` after an explicit garbage collection. This measures retained state, not allocation peaks or normal GC timing. It does not include worker heaps, the Electron main/GPU processes, or the OpenCode server, and must not be reported as total desktop RAM. Use identical model counts and navigation sequences for before/after comparisons. +`location-catalog-benchmark.spec.ts` restores one session tab per workspace for 5, 15, and 30 distinct directories. The mock serves every directory as its own location with the same 1,200-model catalog plus 8 agents with system prompts, 24 commands, and 12 skills with SKILL.md content. The scenario visits every tab, switches back to the first tab, closes every tab, pushes a `credential.switched` event, reopens one workspace from Home, and reconnects the event stream. Each phase records catalog requests by path and directory; the retained heap is sampled after an explicit GC between phases, so the visit and revisit timings are Playwright-observed diagnostics rather than clean latency samples. `LOCATION_CATALOG_DIRECTORIES` (default `5,15,30`) and `LOCATION_CATALOG_MODELS` (default 1,200) adjust the workload. + +The second case holds all first catalog responses until every tab has closed and the credential event has been handled. It then releases the responses while Home remains visible, waits for the catalog body readers to finish, samples retained heap, and reopens a workspace. This exposes late responses and event refreshes queued behind released reads. The browser probe retains only request counters, not payloads. `LOCATION_CATALOG_PENDING=0` selects the loaded case; `1` selects the pending case. Set `LOCATION_CATALOG_MEMORY=0` for separate timing runs with no forced GC. To compare frozen production bundles without rebuilding, set `CATALOGS_DIST` to the bundle directory and use `e2e/performance/playwright.catalogs.config.ts`. + ## Chrome traces Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically: diff --git a/packages/app/e2e/performance/playwright.catalogs.config.ts b/packages/app/e2e/performance/playwright.catalogs.config.ts new file mode 100644 index 000000000000..deee896c174a --- /dev/null +++ b/packages/app/e2e/performance/playwright.catalogs.config.ts @@ -0,0 +1,13 @@ +import config from "./playwright.config" + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 4795) +export default { + ...config, + testMatch: "timeline/location-catalog-benchmark.spec.ts", + webServer: { + command: `bun x vite preview --outDir "${process.env.CATALOGS_DIST}" --host 127.0.0.1 --port ${port} --strictPort`, + url: `http://127.0.0.1:${port}`, + reuseExistingServer: false, + timeout: 120_000, + }, +} diff --git a/packages/app/e2e/performance/timeline/location-catalog-benchmark.spec.ts b/packages/app/e2e/performance/timeline/location-catalog-benchmark.spec.ts new file mode 100644 index 000000000000..aba3a3504e7d --- /dev/null +++ b/packages/app/e2e/performance/timeline/location-catalog-benchmark.spec.ts @@ -0,0 +1,336 @@ +import { benchmark, expect } from "../benchmark" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" +import { mockOpenCodeServer } from "../../utils/mock-server" +import { installSseTransport } from "../../utils/sse-transport" +import { expectSessionTitle } from "../../utils/waits" +import { fixture } from "./session-timeline-stress.fixture" +import { createLocationCatalog } from "./location-catalog.fixture" +import { installTimelineSettings, stressSessionHref } from "./timeline-test-helpers" + +const counts = (process.env.LOCATION_CATALOG_DIRECTORIES ?? "5,15,30").split(",").map(Number) +const modelCount = Number(process.env.LOCATION_CATALOG_MODELS ?? 1200) +const memory = process.env.LOCATION_CATALOG_MEMORY !== "0" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const catalogPaths = [ + "/api/model", + "/api/provider", + "/api/agent", + "/api/command", + "/api/skill", + "/api/integration", + "/api/mcp", + "/api/mcp/resource", + "/api/reference", +] +// Four exchanges per workspace: the transcript is not the subject of this workload. +const transcript = fixture.messages[fixture.sourceID].slice(0, 8) +const homeSearch = '[data-component="home-session-search"]' +const composerModel = '[data-action="composer-model"]' + +type Request = { path: string; directory: string | null } +type CatalogWindow = Window & { __catalogReads?: { pending: number; completed: number } } + +benchmark.use({ viewport: { width: 1440, height: 900 }, video: "off", trace: "off", serviceWorkers: "block" }) + +for (const pending of [false, true].filter( + (value) => !process.env.LOCATION_CATALOG_PENDING || Number(process.env.LOCATION_CATALOG_PENDING) === Number(value), +)) { + for (const count of counts) { + benchmark( + `location catalogs: ${pending ? "close before first response" : "visit and close"} ${count} workspaces`, + async ({ page, report }, testInfo) => { + benchmark.setTimeout(300_000) + const catalog = createLocationCatalog(modelCount) + const directories = Array.from( + { length: count }, + (_, index) => `C:/OpenCode/Workspace-${String(index).padStart(2, "0")}`, + ) + const now = Date.now() + const sessions = directories.map((directory, index) => ({ + ...fixture.sessions[0], + id: `ses_catalog_${String(index).padStart(2, "0")}`, + directory, + title: `Workspace ${index} review`, + time: { created: now - index * 60_000, updated: now - index * 60_000 }, + })) + // Its session.created echo orders one deterministic read after event-driven catalog refreshes. + const marker = { ...fixture.sessions[0], id: "ses_catalog_marker", title: "Event marker" } + const pages = Object.fromEntries( + sessions.map((session) => [ + session.id, + transcript.map((message) => ({ ...message, id: `${message.id}_${session.id}` })), + ]), + ) + const requests: Request[] = [] + const inflight = new Set() + const errors: string[] = [] + const bytes: Record = {} + page.on("pageerror", (error) => errors.push(error.message)) + page.on("request", (request) => { + const url = new URL(request.url()) + if (!url.pathname.startsWith("/api/")) return + requests.push({ path: url.pathname, directory: url.searchParams.get("location[directory]") }) + if (url.pathname !== "/api/event") inflight.add(request.url()) + }) + page.on("requestfinished", (request) => inflight.delete(request.url())) + page.on("requestfailed", (request) => inflight.delete(request.url())) + page.on("response", (response) => { + const url = new URL(response.url()) + if (!url.pathname.startsWith("/api/")) return + if (!response.ok()) errors.push(`HTTP ${response.status()}: ${response.url()}`) + if (url.searchParams.get("location[directory]") !== directories[0] || !catalogPaths.includes(url.pathname)) + return + if (bytes[url.pathname] !== undefined) return + bytes[url.pathname] = 0 + void response + .body() + .then((body) => { + bytes[url.pathname] = body.byteLength + }) + .catch(() => undefined) + }) + + const gate = Promise.withResolvers() + if (!pending) gate.resolve() + await page.addInitScript( + ({ paths, server }) => { + const reads = { pending: 0, completed: 0 } + ;(window as CatalogWindow).__catalogReads = reads + const watched = (url: string) => + !!url && new URL(url).origin === server && paths.includes(new URL(url).pathname) + const fetch = window.fetch.bind(window) + Object.defineProperty(window, "fetch", { + configurable: true, + writable: true, + value: (input: RequestInfo | URL, init?: RequestInit) => { + if (watched(input instanceof Request ? input.url : String(input))) reads.pending++ + return fetch(input, init) + }, + }) + const text = Response.prototype.text + Response.prototype.text = async function () { + const body = await text.call(this) + if (watched(this.url)) { + reads.pending-- + reads.completed++ + } + return body + } + }, + { paths: catalogPaths, server }, + ) + const transport = await installSseTransport(page, { server }) + await mockOpenCodeServer(page, { + directory: fixture.directory, + directories, + project: fixture.project, + provider: catalog.provider, + agents: catalog.agents, + commands: catalog.commands, + skills: catalog.skills, + sessions: [...sessions, marker], + pageMessages: (id) => ({ items: pages[id] ?? [] }), + }) + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()) + if ( + pending && + catalogPaths.includes(url.pathname) && + directories.includes(url.searchParams.get("location[directory]") ?? "") + ) + await gate.promise + await route.fallback() + }) + page.on("close", () => gate.resolve()) + await installTimelineSettings(page) + await page.addInitScript( + ({ directories, sessionIDs, server }) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: directories.map((worktree) => ({ worktree, expanded: false })) }, + lastProject: {}, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, sessionId }))), + ) + }, + { directories, sessionIDs: sessions.map((session) => session.id), server }, + ) + const cdp = await page.context().newCDPSession(page) + // GC is an explicit retained-heap measurement between phases, not a readiness wait. + const retainedHeap = async () => { + if (!memory) return undefined + await cdp.send("HeapProfiler.collectGarbage") + return (await cdp.send("Runtime.getHeapUsage")).usedSize + } + const tab = (id: string) => page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(id)}"]`) + const catalogRequests = (from: number) => + requests.slice(from).filter((request) => catalogPaths.includes(request.path)) + const summarize = (list: Request[]) => ({ + requests: list.length, + directories: [...new Set(list.map((request) => request.directory || ""))].toSorted(), + byPath: Object.fromEntries( + catalogPaths.map((path) => [path, list.filter((request) => request.path === path).length]), + ), + }) + + await page.goto("/") + await expect(page.locator(homeSearch)).toBeVisible() + for (const session of sessions) await expect(tab(session.id)).toContainText(session.title) + const heapStart = await retainedHeap() + + // Visit every workspace once from its restored tab. + const visits = [] + for (const session of sessions) { + const from = requests.length + const started = performance.now() + await tab(session.id).click() + await expectSessionTitle(page, session.title) + if (pending) { + await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable() + await expect + .poll(() => + catalogRequests(from) + .filter((request) => request.directory === session.directory) + .map((request) => request.path) + .toSorted(), + ) + .toEqual(catalogPaths.toSorted()) + } else { + await expect(page.locator(composerModel)).toContainText("Claude Opus 4.6") + } + visits.push({ ms: performance.now() - started, catalogRequests: catalogRequests(from).length }) + } + + // Switching back to an open tab must not reload its catalogs in either build. + const warmFrom = requests.length + await tab(sessions[0]!.id).click() + await expectSessionTitle(page, sessions[0]!.title) + if (!pending) await expect(page.locator(composerModel)).toContainText("Claude Opus 4.6") + const warmSwitch = summarize(catalogRequests(warmFrom)) + const heapOpen = await retainedHeap() + + // Close every tab; the last close lands on Home. + for (const session of sessions) { + const slot = page + .locator("[data-titlebar-tab-slot]") + .filter({ has: page.locator(`a[href="${stressSessionHref(session.id)}"]`) }) + await slot.getByRole("button", { name: "Close tab", exact: true }).click() + await expect(slot).toHaveCount(0) + } + await expect(page).toHaveURL("/") + await expect(page.locator(homeSearch)).toBeVisible() + const heapClosed = await retainedHeap() + + // A credential switch refreshes the model and provider catalogs the client still retains. + const credentialFrom = requests.length + const markerRead = page.waitForRequest( + (request) => new URL(request.url()).pathname === `/api/session/${marker.id}`, + ) + await transport.burst([ + { + id: "evt_catalog_credential", + created: now, + type: "credential.switched", + data: { integrationID: "opencode", credentialID: "credential" }, + }, + { + id: "evt_catalog_marker", + created: now, + type: "session.created", + durable: { aggregateID: marker.id, seq: 1, version: 1 }, + data: { + sessionID: marker.id, + projectID: fixture.project.id, + location: { directory: fixture.directory }, + slug: "event-marker", + version: "dev", + }, + }, + ] satisfies OpenCodeEvent[]) + await markerRead + const readsAtRelease = catalogRequests(0).length + gate.resolve() + // The browser probe retains counters only, never response bodies or catalog objects. + // Waiting for decoded bodies and no pending catalog reads also covers event refreshes + // queued behind the gated first loads. Home remains the actual owning UI throughout. + await page.waitForFunction((minimum) => { + const reads = (window as CatalogWindow).__catalogReads + return reads && reads.completed >= minimum && reads.pending === 0 + }, readsAtRelease) + await expect(page.locator(homeSearch)).toBeVisible() + await expect(page.locator('[data-slot="titlebar-tabs"] a[data-titlebar-tab-link]')).toHaveCount(0) + const credentialRefresh = summarize(catalogRequests(credentialFrom)) + const heapRefreshed = await retainedHeap() + + // Reopen a closed workspace from Home. + const revisit = sessions[0]! + const revisitFrom = requests.length + const revisitStarted = performance.now() + await page + .locator(`[data-component="home-session-row-container"][data-session-id="${revisit.id}"]`) + .locator('[data-component="home-session-row"]') + .click() + await expectSessionTitle(page, revisit.title) + await expect(page.locator(composerModel)).toContainText("Claude Opus 4.6") + const revisitMs = performance.now() - revisitStarted + const revisitRequests = summarize(catalogRequests(revisitFrom)) + + // Reconnect refreshes the catalogs of the workspace on screen. + const connection = await transport.waitForConnection() + const reconnectFrom = requests.length + const reconnected = transport.waitForConnection({ after: connection.id }) + await transport.close() + await reconnected + await expect + .poll( + () => + catalogRequests(reconnectFrom).some( + (request) => request.path === "/api/model" && request.directory === revisit.directory, + ) && inflight.size === 0, + ) + .toBe(true) + const reconnectRequests = summarize(catalogRequests(reconnectFrom)) + await expect(page.locator(composerModel)).toContainText("Claude Opus 4.6") + + expect(errors).toEqual([]) + expect(visits).toHaveLength(count) + report( + { + heap: { start: heapStart, open: heapOpen, closed: heapClosed, refreshed: heapRefreshed }, + retainedAfterCloseBytes: + heapClosed === undefined || heapStart === undefined ? undefined : heapClosed - heapStart, + retainedAfterRefreshBytes: + heapRefreshed === undefined || heapStart === undefined ? undefined : heapRefreshed - heapStart, + visitCatalogRequests: visits.reduce((total, visit) => total + visit.catalogRequests, 0), + visitMs: visits.map((visit) => Math.round(visit.ms)), + warmSwitch, + credentialRefresh, + revisit: { ...revisitRequests, ms: Math.round(revisitMs) }, + reconnect: reconnectRequests, + lateCatalogRequests: + catalogRequests(0).length - readsAtRelease - revisitRequests.requests - reconnectRequests.requests, + }, + { + directories: count, + pendingFirstResponse: pending, + models: modelCount, + agents: catalog.agents.length, + commands: catalog.commands.length, + skills: catalog.skills.length, + responseBytes: bytes, + transport: "playwright-route", + gc: memory ? "explicit between phases; timing diagnostic only" : "natural; no forced GC", + scope: "production app renderer main isolate; not total desktop RAM", + browser: page.context().browser()!.version(), + }, + ) + if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath("revisit.png") }) + await cdp.detach() + }, + ) + } +} diff --git a/packages/app/e2e/performance/timeline/location-catalog.fixture.ts b/packages/app/e2e/performance/timeline/location-catalog.fixture.ts new file mode 100644 index 000000000000..717a08c4f9e7 --- /dev/null +++ b/packages/app/e2e/performance/timeline/location-catalog.fixture.ts @@ -0,0 +1,79 @@ +import { fixture } from "./session-timeline-stress.fixture" + +// A realistic large per-directory catalog: the provider-memory benchmark's 1,200-model +// catalog plus agents with system prompts, slash commands, and skills with SKILL.md content. +// Every directory serves the same payload, so distinct directories hold byte-identical catalogs. +export function createLocationCatalog(models: number) { + const provider = fixture.provider.all[0] + const selected = provider.models["claude-opus-4-6"] + return { + provider: { + ...fixture.provider, + all: [ + { + ...provider, + models: { + [selected.id]: selected, + ...Object.fromEntries( + Array.from({ length: models - 1 }, (_, index) => { + const id = `catalog-model-${index}` + return [ + id, + { + id, + name: `Catalog model ${index}`, + cost: { input: 1, output: 2 }, + limit: { context: 200_000, output: 8192 }, + variants: { high: { reasoningEffort: "high" } }, + }, + ] + }), + ), + }, + }, + ], + }, + agents: ["build", "plan", "explore", "review", "docs", "test", "refactor", "general"].map((id, index) => ({ + id, + name: id[0]!.toUpperCase() + id.slice(1), + description: `${id} agent for ${prose(index, 120)}`, + mode: index < 2 ? "primary" : "subagent", + hidden: false, + color: "blue", + steps: 50, + system: prose(index * 7, 2_500), + request: { settings: { temperature: 0.2 }, headers: {}, body: {} }, + permissions: [ + { action: "edit", resource: "*", effect: "allow" }, + { action: "bash", resource: "git *", effect: "ask" }, + { action: "webfetch", resource: "*", effect: index % 2 === 0 ? "allow" : "deny" }, + ], + })), + commands: Array.from({ length: 24 }, (_, index) => ({ + name: `command-${index}`, + description: prose(index * 3, 90), + })), + skills: Array.from({ length: 12 }, (_, index) => ({ + id: `skill-${index}`, + name: `skill-${index}`, + description: prose(index * 5, 140), + slash: true, + autoinvoke: false, + location: `.opencode/skills/skill-${index}/SKILL.md`, + content: `# Skill ${index}\n\n${prose(index * 11, 3_000)}`, + })), + } +} + +const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet"] + +function prose(seed: number, length: number) { + let out = "" + let index = seed + while (out.length < length) { + out += (out ? " " : "") + words[index % words.length] + if (index % 13 === 0) out += ".\n\n" + index += 3 + } + return out.slice(0, length) +} diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 45bef80164e3..74f7dfa924eb 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -10,6 +10,11 @@ export interface MockServerConfig { integrationMethods?: Record onConnectKey?: (input: { integrationID: string; body: unknown }) => void directory: string + // Directories served as their own locations. Requests for other directories resolve to `directory`. + directories?: string[] + agents?: unknown[] + commands?: unknown[] + skills?: unknown[] project: unknown sessions: ({ id: string } & Record)[] pageMessages: ( @@ -207,22 +212,11 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }), - reference: () => + reference: (ctx) => Effect.succeed({ location: location(config, ctx.request), data: [] }), + agent: (ctx) => Effect.succeed({ - location: { - directory: config.directory, - project: { - id: (config.project as { id?: string }).id, - directory: config.directory, - canonical: config.directory, - }, - }, - data: [], - }), - agent: () => - Effect.succeed({ - location: location(config), - data: [ + location: location(config, ctx.request), + data: config.agents ?? [ { id: "build", name: "Build", @@ -233,14 +227,19 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map Effect.succeed({ location: location(config), data: currentProviders(providerConfig(config)) }), - model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }), - modelDefault: () => - Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }), - integrationList: () => Effect.succeed({ location: location(config), data: [] }), + provider: (ctx) => + Effect.succeed({ location: location(config, ctx.request), data: currentProviders(providerConfig(config)) }), + model: (ctx) => + Effect.succeed({ location: location(config, ctx.request), data: currentModels(providerConfig(config)) }), + modelDefault: (ctx) => + Effect.succeed({ + location: location(config, ctx.request), + data: currentDefaultModel(providerConfig(config)), + }), + integrationList: (ctx) => Effect.succeed({ location: location(config, ctx.request), data: [] }), integrationGet: (ctx) => Effect.succeed({ - location: location(config), + location: location(config, ctx.request), data: { id: ctx.params.integrationID, name: ctx.params.integrationID, @@ -253,11 +252,12 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map noContent, - command: () => Effect.succeed({ location: location(config), data: [] }), - skill: () => Effect.succeed({ location: location(config), data: [] }), - plugin: () => Effect.succeed({ location: location(config), data: [] }), - mcp: () => Effect.succeed({ location: location(config), data: [] }), - mcpResource: () => Effect.succeed({ location: location(config), data: { resources: [], templates: [] } }), + command: (ctx) => Effect.succeed({ location: location(config, ctx.request), data: config.commands ?? [] }), + skill: (ctx) => Effect.succeed({ location: location(config, ctx.request), data: config.skills ?? [] }), + plugin: (ctx) => Effect.succeed({ location: location(config, ctx.request), data: [] }), + mcp: (ctx) => Effect.succeed({ location: location(config, ctx.request), data: [] }), + mcpResource: (ctx) => + Effect.succeed({ location: location(config, ctx.request), data: { resources: [], templates: [] } }), projectList: () => { const project = config.project as typeof config.project & { canonical?: string; worktree?: string } return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }]) @@ -286,27 +286,31 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map noContent, worktreeRefresh: () => noContent, - location: () => Effect.succeed(location(config)), - permissionRequests: () => + location: (ctx) => Effect.succeed(location(config, ctx.request)), + permissionRequests: (ctx) => Effect.succeed({ - location: location(config), + location: location(config, ctx.request), data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( currentPermission, ), }), - formRequests: () => + formRequests: (ctx) => Effect.succeed({ - location: location(config), + location: location(config, ctx.request), data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []), }), - vcs: () => - Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }), - vcsStatus: () => Effect.succeed({ location: location(config), data: [] }), - vcsBranches: () => Effect.succeed({ location: location(config), data: config.vcsBranches ?? ["main"] }), - vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }), + vcs: (ctx) => + Effect.succeed({ + location: location(config, ctx.request), + data: { branch: { current: "main", default: "main" } }, + }), + vcsStatus: (ctx) => Effect.succeed({ location: location(config, ctx.request), data: [] }), + vcsBranches: (ctx) => + Effect.succeed({ location: location(config, ctx.request), data: config.vcsBranches ?? ["main"] }), + vcsDiff: (ctx) => Effect.succeed({ location: location(config, ctx.request), data: config.vcsDiff ?? [] }), fsList: (ctx) => Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe( - Effect.map((data) => ({ location: location(config), data })), + Effect.map((data) => ({ location: location(config, ctx.request), data })), ), fsFind: (ctx) => Effect.promise(() => @@ -315,7 +319,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map ({ - location: location(config), + location: location(config, ctx.request), data: Array.isArray(entries) ? entries.map((entry) => typeof entry === "string" @@ -331,9 +335,9 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map Effect.succeed({ location: location(config), data: [] }), - ptyConnectToken: () => - Effect.succeed({ location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }), + shell: (ctx) => Effect.succeed({ location: location(config, ctx.request), data: [] }), + ptyConnectToken: (ctx) => + Effect.succeed({ location: location(config, ctx.request), data: { ticket: "e2e-ticket", expires_in: 60 } }), sessionList: (ctx) => { const sessions = config.sessions .filter((session) => { @@ -500,10 +504,12 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map) { const serverSDK = useServerSDK() const data = useData() + createEffect(() => { + const value = directory() + if (!value) return + onCleanup(data.location.retain({ directory: value })) + }) createEffect(() => { if (serverSDK.connection.status() !== "connected") return const value = directory() diff --git a/packages/app/src/providers/catalog/providers.ts b/packages/app/src/providers/catalog/providers.ts index 27273c4a8224..327f67f6a7a5 100644 --- a/packages/app/src/providers/catalog/providers.ts +++ b/packages/app/src/providers/catalog/providers.ts @@ -2,7 +2,7 @@ import { useData } from "@/runtime/server/current" import { useServerSDK } from "@/runtime/server/client" import { normalizeProviderList } from "@/runtime/server/global-sync/utils" import { Iterable, pipe } from "effect" -import { createEffect, createMemo, type Accessor } from "solid-js" +import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import type { ProviderListResponse } from "@/runtime/server/types" import { useIntegrations } from "./integrations" import { popularProviders } from "./order" @@ -19,6 +19,11 @@ export function useProviders(directory: Accessor) { return dir ? { directory: dir } : undefined } + createEffect(() => { + const ref = location() + if (!ref) return + onCleanup(data.location.retain(ref)) + }) createEffect(() => { if (sdk.connection.status() !== "connected") return const ref = location() diff --git a/packages/app/src/runtime/server/residency.ts b/packages/app/src/runtime/server/residency.ts new file mode 100644 index 000000000000..d6aaf378d863 --- /dev/null +++ b/packages/app/src/runtime/server/residency.ts @@ -0,0 +1,27 @@ +import { createEffect, mapArray, onCleanup } from "solid-js" +import type { Data } from "@opencode-ai/client/solid" +import type { ServerConnection } from "@/runtime/server/registry" +import type { Tab } from "@/shell/tabs/tabs" + +// Every open tab keeps its workspace catalogs resident, so switching between open tabs never +// reloads them. Closing the last tab for a directory releases its catalogs unless a mounted +// consumer such as the current route still holds them. +export function createLocationResidency(input: { + key: ServerConnection.Key + tabs: () => readonly Tab[] + data: Pick & { session: Pick } +}) { + createEffect( + mapArray( + () => input.tabs().filter((tab) => tab.server === input.key), + (tab) => { + createEffect(() => { + const location = + tab.type === "draft" ? { directory: tab.directory } : input.data.session.get(tab.sessionId)?.location + if (!location) return + onCleanup(input.data.location.retain(location)) + }) + }, + ), + ) +} diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index e6d1f6f662d1..2c62b9c66a3a 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -13,6 +13,8 @@ import { createServerNotificationState } from "@/shell/notifications/notificatio import { Persist, persisted } from "@/runtime/persistence/storage" import { createDesktopData } from "./data" import { ModelState } from "./persistence" +import { createLocationResidency } from "./residency" +import { useTabs } from "@/shell/tabs/tabs" export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ name: "Global", @@ -145,6 +147,8 @@ function createServerController( const sync = createServerSyncContext(sdk, data) createPermissionAutoApprover({ sdk, data }) const notification = createServerNotificationState({ sdk, data, key: connKey }) + const tabs = useTabs() + createLocationResidency({ key: connKey, tabs: () => tabs.store, data }) function enrich(project: { worktree: string; expanded: boolean }) { const [childStore] = sync.child(project.worktree, { bootstrap: false }) diff --git a/packages/app/src/workspaces/location.tsx b/packages/app/src/workspaces/location.tsx index 2bd83bc3e27c..3e631651a75f 100644 --- a/packages/app/src/workspaces/location.tsx +++ b/packages/app/src/workspaces/location.tsx @@ -28,6 +28,10 @@ const context = createSimpleContext({ ) const current = createMemo(() => data.location.info(ref())) + createEffect(() => { + onCleanup(data.location.retain(ref())) + }) + createEffect(() => { const location = ref() let stale = false diff --git a/packages/app/test-browser/location-residency.test.ts b/packages/app/test-browser/location-residency.test.ts new file mode 100644 index 000000000000..a9bb7753b1af --- /dev/null +++ b/packages/app/test-browser/location-residency.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test" +import { OpenCode } from "@opencode-ai/client/promise" +import { createData, type CreateDataInput } from "@opencode-ai/client/solid" +import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client/promise" +import { createRoot } from "solid-js" +import { createStore } from "solid-js/store" +import type { ServerConnection } from "@/runtime/server/registry" +import type { Tab } from "@/shell/tabs/tabs" +import { createLocationResidency } from "@/runtime/server/residency" + +const server = "local" as ServerConnection.Key +const other = "http://remote:4096" as ServerConnection.Key + +function session(id: string, directory: string): SessionInfo { + return { + id, + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + location: { directory }, + } +} + +function fixture() { + const requests: string[] = [] + const listeners = new Set[0]>() + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + const url = new URL(request.url) + const directory = url.searchParams.get("location[directory]") ?? "/default" + const workspaceID = url.searchParams.get("location[workspace]") ?? undefined + requests.push(`${url.pathname} ${directory}${workspaceID ? ` (${workspaceID})` : ""}`) + return Response.json({ + location: { directory, workspaceID, project: { id: "project", directory, canonical: directory } }, + data: [{ id: `model-${directory}`, providerID: "opencode" }], + }) + }, + { preconnect() {} }, + ), + }) + return createRoot((dispose) => { + const [tabs, setTabs] = createStore([]) + const data = createData({ + api: () => api, + directory: "/default", + event: { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + }, + }) + createLocationResidency({ key: server, tabs: () => tabs, data }) + // Releases drop catalogs after the current task. + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + return { + data, + tabs, + setTabs, + requests, + settle, + dispose, + emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })), + } + }) +} + +describe("location residency", () => { + test.each(["event", "remember"] as const)("moving a tab via %s releases the original identity only", async (mode) => { + const setup = fixture() + const a = { directory: "/repo/a", workspaceID: "workspace-a" } + const b = { directory: "/repo/b", workspaceID: "workspace-b" } + try { + setup.data.session.remember({ ...session("ses_move", a.directory), location: { ...a } }) + setup.setTabs([{ type: "session", server, sessionId: "ses_move" }]) + const original = setup.data.session.get("ses_move")!.location + await Promise.all( + [a, b].flatMap((ref) => [setup.data.location.model.sync(ref), setup.data.location.provider.sync(ref)]), + ) + if (mode === "event") { + setup.emit({ + id: "evt_move", + created: 1, + type: "session.moved", + durable: { aggregateID: "ses_move", seq: 1, version: 1 }, + data: { sessionID: "ses_move", location: b, projectID: "project" }, + }) + } else { + setup.data.session.remember({ ...session("ses_move", b.directory), location: { ...b } }) + } + await setup.settle() + const afterMove = { + model: setup.data.location.model.list(a) ?? null, + provider: setup.data.location.provider.list(a) ?? null, + } + setup.requests.length = 0 + await Promise.all([setup.data.location.model.sync(b), setup.data.location.provider.sync(b)]) + const bRequests = [...setup.requests] + setup.requests.length = 0 + await Promise.all([setup.data.location.model.sync(a), setup.data.location.provider.sync(a)]) + const evidence = { + mode, + sameProxy: original === setup.data.session.get("ses_move")!.location, + originalNow: { directory: original.directory, workspaceID: original.workspaceID }, + afterMove, + bRequests, + aRequests: [...setup.requests], + aModel: setup.data.location.model.list(a) ?? null, + aProvider: setup.data.location.provider.list(a) ?? null, + bModel: setup.data.location.model.list(b) ?? null, + bProvider: setup.data.location.provider.list(b) ?? null, + } + if (process.env.OPENCODE_LOCATION_EVIDENCE) console.log("LOCATION_MOVE", JSON.stringify(evidence)) + expect(evidence.afterMove).toEqual({ model: null, provider: null }) + expect(evidence.bRequests).toEqual([]) + expect(evidence.aRequests).toEqual(["/api/model /repo/a (workspace-a)", "/api/provider /repo/a (workspace-a)"]) + expect(evidence.aModel?.map((model) => model.id)).toEqual(["model-/repo/a"]) + expect(evidence.aProvider?.map((provider) => provider.id)).toEqual(["model-/repo/a"]) + expect(evidence.bModel?.map((model) => model.id)).toEqual(["model-/repo/b"]) + expect(evidence.bProvider?.map((provider) => provider.id)).toEqual(["model-/repo/b"]) + setup.requests.length = 0 + await Promise.all([setup.data.location.model.sync(a), setup.data.location.provider.sync(a)]) + expect(setup.requests).toEqual([]) + } finally { + setup.dispose() + } + }) + + test("keeps catalogs for open tabs and releases them when the last tab closes", async () => { + const setup = fixture() + try { + setup.data.session.remember(session("ses_a", "/repo/a")) + setup.data.session.remember(session("ses_b", "/repo/a")) + setup.setTabs([ + { type: "session", server, sessionId: "ses_a" }, + { type: "session", server, sessionId: "ses_b" }, + { type: "draft", server, draftID: "draft", directory: "/repo/draft" }, + { type: "session", server: other, sessionId: "ses_a" }, + ]) + await setup.data.location.model.sync({ directory: "/repo/a" }) + await setup.data.location.model.sync({ directory: "/repo/draft" }) + expect(setup.requests).toEqual(["/api/model /repo/a", "/api/model /repo/draft"]) + + setup.setTabs((tabs) => tabs.filter((tab) => tab.type !== "session" || tab.sessionId !== "ses_a")) + await setup.settle() + expect(setup.data.location.model.list({ directory: "/repo/a" })).toHaveLength(1) + + setup.setTabs((tabs) => tabs.filter((tab) => tab.type !== "session" || tab.server !== server)) + await setup.settle() + expect(setup.data.location.model.list({ directory: "/repo/a" })).toBeUndefined() + expect(setup.data.location.model.list({ directory: "/repo/draft" })).toHaveLength(1) + + setup.setTabs([]) + await setup.settle() + expect(setup.data.location.model.list({ directory: "/repo/draft" })).toBeUndefined() + + await setup.data.location.model.sync({ directory: "/repo/a" }) + expect(setup.requests).toHaveLength(3) + expect(setup.data.location.model.list({ directory: "/repo/a" })).toHaveLength(1) + } finally { + setup.dispose() + } + }) + + test("holds a session tab's directory once its session info arrives", async () => { + const setup = fixture() + try { + setup.setTabs([{ type: "session", server, sessionId: "ses_late" }]) + await setup.data.location.model.sync({ directory: "/repo/late" }) + setup.data.session.remember(session("ses_late", "/repo/late")) + setup.setTabs([]) + await setup.settle() + expect(setup.data.location.model.list({ directory: "/repo/late" })).toBeUndefined() + } finally { + setup.dispose() + } + }) + + test("promoting a draft to a session tab keeps the directory resident", async () => { + const setup = fixture() + try { + setup.data.session.remember(session("ses_new", "/repo/a")) + setup.setTabs([{ type: "draft", server, draftID: "draft", directory: "/repo/a" }]) + await setup.data.location.model.sync({ directory: "/repo/a" }) + setup.requests.length = 0 + setup.setTabs([{ type: "session", server, sessionId: "ses_new" }]) + await setup.settle() + expect(setup.data.location.model.list({ directory: "/repo/a" })).toHaveLength(1) + await setup.data.location.model.sync({ directory: "/repo/a" }) + expect(setup.requests).toEqual([]) + } finally { + setup.dispose() + } + }) +}) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index d8285c21f86b..88458e9327f6 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -138,14 +138,14 @@ function formRequestOptions(sessionID: string, ref?: LocationRef) { } function createSync() { - type Pending = { promise: Promise; invalidated: boolean } + type Pending = { promise: Promise; invalidated: boolean; current?: () => boolean } const state = new Map() - const start = (key: string, load: () => Promise, wait?: Promise) => { - const entry: Pending = { promise: Promise.resolve(), invalidated: false } + const start = (key: string, load: () => Promise, current?: () => boolean, wait?: Promise) => { + const entry: Pending = { promise: Promise.resolve(), invalidated: false, current } state.set(key, entry) entry.promise = (wait ? wait.catch(() => undefined).then(load) : load()) .then(() => { - if (state.get(key) === entry && !entry.invalidated) state.set(key, true) + if (state.get(key) === entry && !entry.invalidated && current?.() !== false) state.set(key, true) }) .finally(() => { if (state.get(key) === entry) state.delete(key) @@ -153,12 +153,12 @@ function createSync() { return entry.promise } return { - run(key: string, load: () => Promise) { + run(key: string, load: () => Promise, current?: () => boolean) { const active = state.get(key) if (active === true) return Promise.resolve() - if (!active) return start(key, load) + if (!active) return start(key, load, current) if (!active.invalidated) return active.promise - return start(key, load, active.promise) + return start(key, load, current, active.promise) }, complete(key: string) { if (state.has(key)) return @@ -171,17 +171,21 @@ function createSync() { const active = state.get(key) return active !== undefined && active !== true }, + // Reports whether the key held loaded or in-flight state, so event handlers can refresh + // only data a consumer has actually loaded. invalidate(key?: string) { if (key) { const active = state.get(key) if (active === true) state.delete(key) if (active !== undefined && active !== true) active.invalidated = true - return + // An obsolete pending catalog must not admit a fresh event-driven load after release. + return active !== undefined && (active === true || active.current?.() !== false) } state.forEach((active, current) => { if (active === true) state.delete(current) if (active !== true) active.invalidated = true }) + return true }, } } @@ -215,6 +219,8 @@ export function createData(config: CreateDataInput) { ) const messageIndex = new Map>() const sync = createSync() + const holds = new Map() + const locationGeneration = new Map() let activeUpdates: Map | undefined function setSessionActive(sessionID: string, status: DataSessionStatus) { @@ -1156,13 +1162,14 @@ export function createData(config: CreateDataInput) { return } + // Event-driven refreshes reload only catalogs a consumer has loaded or is loading. Released + // locations keep light metadata; their catalogs reload when a consumer next syncs them. if (event.type === "credential.updated" || event.type === "credential.switched") { Object.keys(store.location).forEach((key) => { const ref = JSON.parse(key) as [string, string | null] const location = { directory: ref[0], workspaceID: ref[1] ?? undefined } if (event.type === "credential.updated") { - result.location.integration.invalidate(location) - void result.location.integration.sync(location) + if (result.location.integration.invalidate(location)) void result.location.integration.sync(location) return } setStore("location", key, (data) => ({ @@ -1179,9 +1186,8 @@ export function createData(config: CreateDataInput) { } }), })) - result.location.model.invalidate(location) - result.location.provider.invalidate(location) - void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]) + if (result.location.model.invalidate(location)) void result.location.model.sync(location) + if (result.location.provider.invalidate(location)) void result.location.provider.sync(location) }) return } @@ -1190,21 +1196,17 @@ export function createData(config: CreateDataInput) { const location = event.location switch (event.type) { case "catalog.updated": - result.location.model.invalidate(location) - result.location.provider.invalidate(location) - void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]) + if (result.location.model.invalidate(location)) void result.location.model.sync(location) + if (result.location.provider.invalidate(location)) void result.location.provider.sync(location) break case "agent.updated": - result.location.agent.invalidate(location) - void result.location.agent.sync(location) + if (result.location.agent.invalidate(location)) void result.location.agent.sync(location) break case "command.updated": - result.location.command.invalidate(location) - void result.location.command.sync(location) + if (result.location.command.invalidate(location)) void result.location.command.sync(location) break case "skill.updated": - result.location.skill.invalidate(location) - void result.location.skill.sync(location) + if (result.location.skill.invalidate(location)) void result.location.skill.sync(location) break case "vcs.branch.updated": setStore("location", locationKey(location), (data) => ({ @@ -1245,14 +1247,9 @@ export function createData(config: CreateDataInput) { void result.location.reference.sync() break case "integration.updated": - result.location.integration.invalidate(location) - result.location.model.invalidate(location) - result.location.provider.invalidate(location) - void Promise.all([ - result.location.integration.sync(location), - result.location.model.sync(location), - result.location.provider.sync(location), - ]) + if (result.location.integration.invalidate(location)) void result.location.integration.sync(location) + if (result.location.model.invalidate(location)) void result.location.model.sync(location) + if (result.location.provider.invalidate(location)) void result.location.provider.sync(location) break case "config.updated": case "websearch.updated": @@ -1261,16 +1258,20 @@ export function createData(config: CreateDataInput) { // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, // so the mcp list syncs here rather than off integration.updated. case "mcp.status.changed": - result.location.mcp.server.invalidate(location) - void result.location.mcp.server.sync(location) + if (result.location.mcp.server.invalidate(location)) void result.location.mcp.server.sync(location) break case "mcp.resources.changed": - result.location.mcp.resource.invalidate(location) - void result.location.mcp.resource.sync(location) + if (result.location.mcp.resource.invalidate(location)) void result.location.mcp.resource.sync(location) break } } + function locationCurrent(ref: LocationRef) { + const key = locationKey(ref) + const generation = locationGeneration.get(key) + return () => locationGeneration.get(key) === generation + } + // A cached per-location catalog. `sync` loads once per invalidation, keyed by the // effective location, and publishes under the server's canonical location; `alias` // also publishes under the requested key when the two differ. @@ -1285,12 +1286,20 @@ export function createData(config: CreateDataInput) { sync: (ref?: LocationRef) => { const location = ref ?? defaultLocation() const id = locationKey(location) - return sync.run(`location.${field}:${id}`, async () => { - const response = await load(locationQuery(location)) - const key = locationKey(response.location) - publish(key, response.data) - if (options?.alias && key !== id) publish(id, response.data) - }) + // Capture before sync.run can queue the callback behind an earlier read. + const current = field === "vcs" || field === "shell" ? undefined : locationCurrent(location) + return sync.run( + `location.${field}:${id}`, + async () => { + if (current?.() === false) return + const response = await load(locationQuery(location)) + if (current?.() === false) return + const key = locationKey(response.location) + publish(key, response.data) + if (options?.alias && key !== id) publish(id, response.data) + }, + current, + ) }, invalidate: (ref?: LocationRef) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`), } @@ -1808,7 +1817,9 @@ export function createData(config: CreateDataInput) { }) }, async sync(ref?: LocationRef) { + const current = locationCurrent(ref ?? defaultLocation()) await result.location.syncInfo(ref) + if (!current()) return const location = ref ?? defaultLocation() await Promise.all([ result.location.vcs.sync(location), @@ -1841,6 +1852,53 @@ export function createData(config: CreateDataInput) { result.shell.invalidate(location) result.session.form.invalidate("global", location) }, + // Catalogs stay resident while a consumer holds the location. Releasing the last hold drops + // the loaded catalogs and their sync state so the next consumer reloads them. Light metadata + // (info, vcs, running shells) and the default location stay resident. + retain(location: LocationRef) { + // Session movement can mutate a caller's Solid store proxy in place. + const ref = { directory: location.directory, workspaceID: location.workspaceID } + const key = locationKey(ref) + holds.set(key, (holds.get(key) ?? 0) + 1) + let released = false + return () => { + if (released) return + released = true + const next = (holds.get(key) ?? 1) - 1 + if (next > 0) { + holds.set(key, next) + return + } + holds.delete(key) + // Route swaps and draft promotion release and re-acquire within one update, so the drop + // waits for the current task; a new hold by then keeps the catalogs. + queueMicrotask(() => { + if (holds.has(key) || key === locationKey(defaultLocation())) return + locationGeneration.set(key, (locationGeneration.get(key) ?? 0) + 1) + result.location.agent.invalidate(ref) + result.location.command.invalidate(ref) + result.location.integration.invalidate(ref) + result.location.mcp.server.invalidate(ref) + result.location.mcp.resource.invalidate(ref) + result.location.model.invalidate(ref) + result.location.provider.invalidate(ref) + result.location.reference.invalidate(ref) + result.location.skill.invalidate(ref) + if (!store.location[key]) return + setStore("location", key, { + agent: undefined, + command: undefined, + integration: undefined, + mcpServer: undefined, + mcpResource: undefined, + model: undefined, + provider: undefined, + reference: undefined, + skill: undefined, + }) + }) + } + }, vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate }, agent: locationResource("agent", (location) => api().agent.list({ location })), command: locationResource("command", (location) => api().command.list({ location })), diff --git a/packages/client/test/solid-location.test.ts b/packages/client/test/solid-location.test.ts new file mode 100644 index 000000000000..308c58d8cd2a --- /dev/null +++ b/packages/client/test/solid-location.test.ts @@ -0,0 +1,442 @@ +import { expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createData, type CreateDataInput, type Data } from "../src/solid" +import { OpenCode, type OpenCodeEvent } from "../src/promise" + +const held = { directory: "/held" } +const released = { directory: "/released" } +const categories = [ + "model", + "provider", + "agent", + "command", + "integration", + "skill", + "reference", + "mcp.server", + "mcp.resource", +] as const + +test("a release keeps the identity of a live session location across session.moved", async () => { + const setup = fixture() + const a = { directory: "/move-a" } + const b = { directory: "/move-b" } + try { + setup.data.session.remember({ + id: "ses_move", + projectID: "project", + location: { ...a }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + }) + const original = setup.data.session.get("ses_move")!.location + const release = setup.data.location.retain(original) + await Promise.all([a, b].flatMap((ref) => categories.map((category) => catalog(setup.data, category).sync(ref)))) + setup.emit({ + id: "evt_move", + created: 1, + type: "session.moved", + durable: { aggregateID: "ses_move", seq: 1, version: 1 }, + data: { sessionID: "ses_move", location: b, projectID: "project" }, + }) + release() + await setup.settle() + for (const category of categories) expect(catalog(setup.data, category).list(a)).toBeUndefined() + setup.requests.length = 0 + await Promise.all(categories.map((category) => catalog(setup.data, category).sync(b))) + const bRequests = [...setup.requests] + setup.requests.length = 0 + await Promise.all(categories.map((category) => catalog(setup.data, category).sync(a))) + if (process.env.OPENCODE_LOCATION_EVIDENCE) + console.log( + "LOCATION_MOVE_CLIENT", + JSON.stringify({ + sameProxy: original === setup.data.session.get("ses_move")!.location, + originalNow: original.directory, + bRequests, + aRequests: setup.requests, + a: categories.map((category) => [category, catalog(setup.data, category).list(a) ?? null]), + b: categories.map((category) => [category, catalog(setup.data, category).list(b) ?? null]), + }), + ) + expect(bRequests).toEqual([]) + expect(setup.requests).toHaveLength(categories.length) + for (const category of categories) { + expect(catalog(setup.data, category).list(a)).toHaveLength(1) + expect(catalog(setup.data, category).list(b)).toHaveLength(1) + } + setup.requests.length = 0 + await Promise.all(categories.map((category) => catalog(setup.data, category).sync(a))) + expect(setup.requests).toEqual([]) + } finally { + setup.dispose() + } +}) + +test.each(categories)("last release rejects a late first %s response", async (category) => { + const requested = Promise.withResolvers() + const gate = Promise.withResolvers() + const setup = fixture(async () => { + requested.resolve() + await gate.promise + }) + const resource = catalog(setup.data, category) + try { + const release = setup.data.location.retain(released) + const initial = resource.sync(released) + await requested.promise + release() + await Promise.resolve() + gate.resolve() + await initial + expect(resource.list(released)).toBeUndefined() + await resource.sync(released) + await resource.sync(released) + expect(setup.requests).toHaveLength(2) + expect(resource.list(released)).toHaveLength(1) + } finally { + gate.resolve() + setup.dispose() + } +}) + +test.each(categories)( + "a queued %s sync keeps its invocation generation across close/reopen/close", + async (category) => { + const requested = Promise.withResolvers() + const gate = Promise.withResolvers() + const setup = fixture(async () => { + requested.resolve() + await gate.promise + }) + const resource = catalog(setup.data, category) + try { + const release = setup.data.location.retain(released) + const initial = resource.sync(released) + await requested.promise + release() + await Promise.resolve() + const close = setup.data.location.retain(released) + const queued = resource.sync(released) + close() + await Promise.resolve() + gate.resolve() + await Promise.all([initial, queued]) + expect(resource.list(released)).toBeUndefined() + expect(setup.requests).toHaveLength(1) + const reopen = setup.data.location.retain(released) + await resource.sync(released) + await resource.sync(released) + expect(setup.requests).toHaveLength(2) + expect(resource.list(released)).toHaveLength(1) + reopen() + await Promise.resolve() + expect(resource.list(released)).toBeUndefined() + } finally { + gate.resolve() + setup.dispose() + } + }, +) + +test("released aggregate sync does not start catalogs after delayed location info", async () => { + const requested = Promise.withResolvers() + const gate = Promise.withResolvers() + const setup = fixture(async (url) => { + if (url.pathname !== "/api/location") return + requested.resolve() + await gate.promise + }) + try { + const release = setup.data.location.retain(released) + const initial = setup.data.location.sync(released) + await requested.promise + release() + await Promise.resolve() + gate.resolve() + await initial + for (const category of categories) expect(catalog(setup.data, category).list(released)).toBeUndefined() + expect(setup.requests).toEqual(["/api/location /released"]) + await setup.data.location.sync(released) + for (const category of categories) expect(catalog(setup.data, category).list(released)).toHaveLength(1) + } finally { + gate.resolve() + setup.dispose() + } +}) + +test.each([false, true])("release-window events do not revive pending catalogs (reacquire: %s)", async (reacquire) => { + const gate = Promise.withResolvers() + const setup = fixture((url) => (url.pathname === "/api/location" ? Promise.resolve() : gate.promise)) + try { + await setup.data.location.syncInfo(released) + const release = setup.data.location.retain(released) + const initial = categories.map((category) => catalog(setup.data, category).sync(released)) + release() + await Promise.resolve() + // Merely retaining again does not make an old pending request current. + const close = reacquire ? setup.data.location.retain(released) : () => {} + setup.emit({ id: "evt_credentials", created: 1, type: "credential.updated", data: {} }) + setup.emit({ + id: "evt_switch", + created: 1, + type: "credential.switched", + data: { integrationID: "integration", credentialID: null }, + }) + for (const type of [ + "catalog.updated", + "agent.updated", + "command.updated", + "skill.updated", + "integration.updated", + "mcp.status.changed", + "mcp.resources.changed", + ] as const) { + setup.emit({ id: `evt_${type}`, created: 1, type, location: released, data: { server: "fixture" } }) + } + gate.resolve() + await Promise.all(initial) + await setup.settle() + expect(setup.requests).toHaveLength(categories.length + 1) + for (const category of categories) expect(catalog(setup.data, category).list(released)).toBeUndefined() + // An explicit load after release is supported even without a hold (including TUI callers). + await Promise.all(categories.map((category) => catalog(setup.data, category).sync(released))) + const reads = setup.requests.length + setup.emit({ id: "evt_current", created: 2, type: "catalog.updated", location: released, data: {} }) + await setup.settle() + expect(setup.requests).toHaveLength(reads + 2) + for (const category of categories) expect(catalog(setup.data, category).list(released)).toHaveLength(1) + close() + } finally { + gate.resolve() + setup.dispose() + } +}) + +test("default-location late loads survive last release", async () => { + const gate = Promise.withResolvers() + const setup = fixture((url) => (url.pathname === "/api/location" ? Promise.resolve() : gate.promise)) + try { + await setup.data.location.syncInfo() + const release = setup.data.location.retain(setup.data.location.default()) + const initial = categories.map((category) => catalog(setup.data, category).sync()) + release() + await Promise.resolve() + gate.resolve() + await Promise.all(initial) + for (const category of categories) expect(catalog(setup.data, category).list()).toHaveLength(1) + const reads = setup.requests.length + await Promise.all(categories.map((category) => catalog(setup.data, category).sync())) + expect(setup.requests).toHaveLength(reads) + } finally { + gate.resolve() + setup.dispose() + } +}) + +test("a current explicit load queued after release can still be refreshed by events", async () => { + const gate = Promise.withResolvers() + const setup = fixture((url) => (url.pathname === "/api/location" ? Promise.resolve() : gate.promise)) + try { + await setup.data.location.syncInfo(released) + const release = setup.data.location.retain(released) + const initial = categories.map((category) => catalog(setup.data, category).sync(released)) + release() + await Promise.resolve() + const close = setup.data.location.retain(released) + const current = categories.map((category) => catalog(setup.data, category).sync(released)) + setup.emit({ id: "evt_reopened", created: 1, type: "catalog.updated", location: released, data: {} }) + gate.resolve() + await Promise.all([...initial, ...current]) + await setup.settle() + expect(setup.requests).toHaveLength(1 + categories.length * 2 + 2) + const reads = setup.requests.length + await Promise.all(categories.map((category) => catalog(setup.data, category).sync(released))) + expect(setup.requests).toHaveLength(reads) + for (const category of categories) expect(catalog(setup.data, category).list(released)).toHaveLength(1) + close() + } finally { + gate.resolve() + setup.dispose() + } +}) + +function catalog(data: Data, category: (typeof categories)[number]) { + if (category === "mcp.server") return data.location.mcp.server + if (category === "mcp.resource") return data.location.mcp.resource + return data.location[category] +} + +test("releasing the last hold drops catalogs, keeps light metadata, and reloads on the next sync", async () => { + const setup = fixture() + try { + const release = setup.data.location.retain(released) + const again = setup.data.location.retain(released) + await Promise.all([setup.data.location.sync(released), setup.data.location.sync(held)]) + setup.requests.length = 0 + + release() + await setup.settle() + expect(setup.data.location.model.list(released)).toHaveLength(1) + again() + again() + expect(setup.data.location.model.list(released)).toHaveLength(1) + await setup.settle() + expect(setup.data.location.model.list(released)).toBeUndefined() + expect(setup.data.location.provider.list(released)).toBeUndefined() + expect(setup.data.location.agent.list(released)).toBeUndefined() + expect(setup.data.location.command.list(released)).toBeUndefined() + expect(setup.data.location.skill.list(released)).toBeUndefined() + expect(setup.data.location.integration.list(released)).toBeUndefined() + expect(setup.data.location.mcp.server.list(released)).toBeUndefined() + expect(setup.data.location.mcp.resource.list(released)).toBeUndefined() + expect(setup.data.location.reference.list(released)).toBeUndefined() + expect(setup.data.location.info(released)?.directory).toBe("/released") + expect(setup.data.location.vcs.info(released)?.branch.current).toBe("main") + expect(setup.data.location.model.list(held)).toHaveLength(1) + expect(setup.requests).toEqual([]) + + await setup.data.location.model.sync(released) + expect(setup.requests).toEqual(["/api/model /released"]) + expect(setup.data.location.model.list(released)).toHaveLength(1) + } finally { + setup.dispose() + } +}) + +test("a hold re-acquired within the same task keeps the catalogs", async () => { + const setup = fixture() + try { + const release = setup.data.location.retain(released) + await setup.data.location.sync(released) + setup.requests.length = 0 + release() + const next = setup.data.location.retain(released) + await setup.settle() + expect(setup.data.location.model.list(released)).toHaveLength(1) + await setup.data.location.model.sync(released) + expect(setup.requests).toEqual([]) + next() + await setup.settle() + expect(setup.data.location.model.list(released)).toBeUndefined() + } finally { + setup.dispose() + } +}) + +test("the default location stays resident after its holds release", async () => { + const setup = fixture() + try { + await setup.data.location.sync() + const release = setup.data.location.retain(setup.data.location.default()) + release() + await setup.settle() + expect(setup.data.location.model.list()).toHaveLength(1) + expect(setup.data.location.model.list({ directory: "/project" })).toHaveLength(1) + } finally { + setup.dispose() + } +}) + +test("event-driven refreshes reload only catalogs that are loaded or loading", async () => { + const setup = fixture() + try { + const release = setup.data.location.retain(released) + await Promise.all([setup.data.location.sync(released), setup.data.location.sync(held)]) + release() + await setup.settle() + setup.requests.length = 0 + + setup.emit({ id: "evt_credential", created: 1, type: "credential.updated", data: {} }) + setup.emit({ + id: "evt_switched", + created: 2, + type: "credential.switched", + data: { integrationID: "integration", credentialID: "credential" }, + }) + await setup.settle() + expect(setup.requests.toSorted()).toEqual(["/api/integration /held", "/api/model /held", "/api/provider /held"]) + setup.requests.length = 0 + + for (const type of ["catalog.updated", "agent.updated", "command.updated", "skill.updated"] as const) { + setup.emit({ id: `evt_${type}_released`, created: 3, type, location: released, data: {} }) + setup.emit({ id: `evt_${type}_unknown`, created: 3, type, location: { directory: "/never" }, data: {} }) + setup.emit({ id: `evt_${type}_held`, created: 3, type, location: held, data: {} }) + } + await setup.settle() + expect(setup.requests.toSorted()).toEqual([ + "/api/agent /held", + "/api/command /held", + "/api/model /held", + "/api/provider /held", + "/api/skill /held", + ]) + expect(setup.data.location.model.list(released)).toBeUndefined() + } finally { + setup.dispose() + } +}) + +test("an event during the first load still refreshes after that load settles", async () => { + const gate = Promise.withResolvers() + const setup = fixture(async (url) => { + if (url.pathname === "/api/model" && url.searchParams.get("location[directory]") === "/held") await gate.promise + }) + try { + const initial = setup.data.location.model.sync(held) + setup.emit({ id: "evt_catalog", created: 1, type: "catalog.updated", location: held, data: {} }) + gate.resolve() + await initial + await setup.settle() + expect(setup.requests.filter((request) => request === "/api/model /held")).toHaveLength(2) + } finally { + gate.resolve() + setup.dispose() + } +}) + +function fixture(before?: (url: URL) => Promise) { + const listeners = new Set[0]>() + const requests: string[] = [] + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = new Request(input, init) + const url = new URL(request.url) + const directory = url.searchParams.get("location[directory]") || "/project" + requests.push(`${url.pathname} ${directory}`) + await before?.(url) + const location = { directory, project: { id: "project", directory, canonical: directory } } + if (url.pathname === "/api/location") return Response.json(location) + if (url.pathname === "/api/vcs") return Response.json({ location, data: { branch: { current: "main" } } }) + if (url.pathname === "/api/mcp/resource") + return Response.json({ location, data: { resources: [{ server: "mcp", uri: "file://x" }], templates: [] } }) + if (url.pathname === "/api/shell") return Response.json({ location, data: [] }) + if (url.pathname === "/api/form/request") return Response.json({ location, data: [] }) + return Response.json({ location, data: [{ id: `${url.pathname}:${directory}`, providerID: "opencode" }] }) + }, + }) + return createRoot((dispose) => { + const data = createData({ + api: () => api, + directory: "", + event: { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + }, + connection: { status: () => "connected" }, + }) + return { + data, + requests, + dispose, + emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })), + // Event handlers issue their reads synchronously; a macrotask lets those reads settle. + settle: () => new Promise((resolve) => setTimeout(resolve, 0)), + } + }) +} diff --git a/packages/tui/test/cli/tui/dialog-integration.test.tsx b/packages/tui/test/cli/tui/dialog-integration.test.tsx index ba52519e8725..01571c3076ff 100644 --- a/packages/tui/test/cli/tui/dialog-integration.test.tsx +++ b/packages/tui/test/cli/tui/dialog-integration.test.tsx @@ -67,8 +67,8 @@ test("switches the selected account with enter and keeps the reactive account ma const frame = fixture.app.captureCharFrame() expect(frame.indexOf("Personal")).toBeLessThan(frame.indexOf("Work")) expect(fixture.reads.integration).toBe(1) - expect(fixture.reads.model).toBeGreaterThan(0) - expect(fixture.reads.provider).toBeGreaterThan(0) + expect(fixture.reads.model).toBeGreaterThan(1) + expect(fixture.reads.provider).toBeGreaterThan(1) } finally { fixture.app.renderer.destroy() } @@ -82,8 +82,8 @@ test("does not refetch when selecting the already-active account", async () => { fixture.app.mockInput.pressEnter() expect(fixture.requests).toEqual([]) - expect(fixture.reads.model).toBe(0) - expect(fixture.reads.provider).toBe(0) + expect(fixture.reads.model).toBe(1) + expect(fixture.reads.provider).toBe(1) } finally { fixture.app.renderer.destroy() } @@ -130,8 +130,8 @@ test("requires delete confirmation and preserves the account manager when anothe expect(fixture.requests).toEqual([{ method: "DELETE", path: "/api/credential/cred_work" }]) expect(fixture.accounts).toEqual([{ type: "credential", id: "cred_personal", label: "Personal" }]) - expect(fixture.reads.model).toBe(0) - expect(fixture.reads.provider).toBe(0) + expect(fixture.reads.model).toBe(1) + expect(fixture.reads.provider).toBe(1) expect(fixture.app.captureCharFrame()).toContain("Add account") expect(fixture.app.captureCharFrame()).toContain("Personal") } finally { @@ -319,9 +319,13 @@ async function renderIntegration(activeLocation?: LocationRef) { const location = useLocation() onMount(() => { location.set(activeLocation) - void data.location.integration - .sync(activeLocation) - .then(() => dialog.replace(() => )) + // The TUI syncs the current location's catalogs before the account manager can open; + // credential events refresh only catalogs that are loaded. + void Promise.all([ + data.location.integration.sync(activeLocation), + data.location.model.sync(activeLocation), + data.location.provider.sync(activeLocation), + ]).then(() => dialog.replace(() => )) }) return null }