From dd7671be20d180004e7e78bc4b0255eed5775272 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 6 Sep 2026 20:29:41 +0200 Subject: [PATCH] Remember model selections across workspace creation (#4401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(providers): remember model selections across workspace creation Keep saved model intent independent of catalogue readiness and use the caller-owned host and directory from the first render. Share discovery through provider-owned keys while preserving execution context. Give each target one committed snapshot, publish actual transitions atomically, and share catalogue content across client directory associations. Regression coverage exercises successful repeated creation and provider lifecycle boundaries. * fix(providers): preserve shared cache bodies during eviction Admit directory references with their bodies, retain reference recency during eviction and recovery, and remove the unused persisted index. Refresh known workspace catalogues concurrently. Make repeated-creation picker states explicit and seed mode tests from the installed provider catalogue, preserving assertions across Codex versions. * fix(providers): bound discovery independently per provider Settings refresh repairs all remembered targets, which otherwise starts an unbounded number of discoveries. Admit work after catalogue-key deduplication and keep each provider’s budget independent so a stalled provider cannot block another. Preserve active accounting across configuration changes and settle obsolete queued requests during replacement and shutdown. --- .github/ci-paths.yml | 12 + docs/plugins.md | 6 + docs/providers.md | 49 +- ...w-workspace-codex-mode-preferences.spec.ts | 76 +- .../new-workspace-model-memory.real.spec.ts | 69 + .../new-workspace-model-memory.spec.ts | 109 ++ .../new-workspace-model-recovery.spec.ts | 102 ++ .../app/e2e/support/helpers/model-memory.ts | 71 + .../src/components/workspace-setup-dialog.tsx | 8 - .../composer/draft/input-draft.live.test.tsx | 6 - .../app/src/composer/draft/input-draft.ts | 18 +- .../app/src/composer/draft/workspace-tab.tsx | 23 +- .../src/data/provider-snapshot-cache.test.ts | 343 +++- .../app/src/data/provider-snapshot-cache.ts | 361 +++- packages/app/src/data/providers-snapshot.ts | 114 ++ packages/app/src/data/push-router.test.ts | 29 +- packages/app/src/data/push-router.ts | 60 +- .../app/src/hooks/use-agent-form-state.ts | 225 +-- .../src/hooks/use-providers-snapshot.test.ts | 96 +- .../app/src/hooks/use-providers-snapshot.ts | 92 +- .../resolve-agent-form.test.ts | 225 ++- .../provider-selection/resolve-agent-form.ts | 165 +- packages/app/src/runtime/host-runtime.ts | 1 + .../app/src/schedules/schedule-form-model.ts | 14 - .../app/src/screens/new-workspace-screen.tsx | 12 +- .../src/compat/normalize-provider-models.ts | 6 +- packages/client/src/daemon-client.test.ts | 49 + packages/client/src/daemon-client.ts | 13 +- packages/plugin/src/provider.ts | 6 + packages/protocol/src/client-capabilities.ts | 1 + .../src/messages.providers-snapshot.test.ts | 17 + packages/protocol/src/messages.ts | 3 + .../executable-resolution.test.ts | 9 + .../executable-resolution.ts | 6 +- .../src/server/agent/agent-sdk-types.ts | 8 +- .../mutable-provider-config-owner.test.ts | 46 +- .../agent/mutable-provider-config-owner.ts | 28 +- .../src/server/agent/plugin-provider.ts | 3 + .../src/server/agent/provider-registry.ts | 3 +- .../agent/provider-snapshot-manager.test.ts | 1613 ++++++++++++++++- .../server/agent/provider-snapshot-manager.ts | 851 ++++----- .../server/agent/providers/claude/agent.ts | 5 + .../agent/providers/codex-app-server-agent.ts | 5 + .../src/server/plugins/index.posix.test.ts | 11 +- packages/server/src/server/plugins/index.ts | 8 + .../server/plugins/plugin-process-protocol.ts | 26 + .../src/server/plugins/plugin-process.ts | 24 +- packages/server/src/server/plugins/runtime.ts | 33 +- packages/server/src/server/session.test.ts | 139 +- packages/server/src/server/session.ts | 2 + .../provider/provider-catalog-session.test.ts | 403 +++- .../provider/provider-catalog-session.ts | 162 +- .../src/server/test-utils/session-stubs.ts | 148 +- scripts/ci-workflow.test.mjs | 24 + 54 files changed, 4326 insertions(+), 1612 deletions(-) create mode 100644 packages/app/e2e/browser/new-workspace-model-memory.real.spec.ts create mode 100644 packages/app/e2e/browser/new-workspace-model-memory.spec.ts create mode 100644 packages/app/e2e/browser/new-workspace-model-recovery.spec.ts create mode 100644 packages/app/e2e/support/helpers/model-memory.ts diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index c156ec0a16..7da0b185cb 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -55,6 +55,18 @@ sdk: - "packages/protocol/**" browser: + - "packages/server/src/server/agent/provider-snapshot-manager.ts" + - "packages/server/src/server/session/provider/provider-catalog-session.ts" + - "packages/client/src/compat/normalize-provider-models.ts" + - "packages/protocol/src/client-capabilities.ts" + - "packages/server/src/server/agent/provider-registry.ts" + - "packages/server/src/server/agent/agent-sdk-types.ts" + - "packages/server/src/server/agent/providers/codex-app-server-agent.ts" + - "packages/server/src/server/agent/providers/claude/agent.ts" + - "packages/server/src/server/agent/plugin-provider.ts" + - "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts" + - "packages/server/src/executable-resolution/**" + - "packages/plugin/src/provider.ts" - "packages/app/src/!(desktop)/**" - "packages/app/e2e/browser/**" - "packages/app/e2e/support/**" diff --git a/docs/plugins.md b/docs/plugins.md index 905f482bc7..3798d7c055 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -272,6 +272,12 @@ export default function contribute(server: PluginServerContext) { } ``` +Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue +probes. The callback runs in the plugin process before discovery and receives the actual global or +workspace target. Return a key covering effective configuration and execution environment, or +`undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers +need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract). + `send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state, configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`. Route messages, structured commands, steering, and command side effects through `session.prompt`. diff --git a/docs/providers.md b/docs/providers.md index df703b406e..f47d408d54 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -115,7 +115,18 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star ## Provider Snapshot Refresh Contract -The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory. +Provider snapshots are views of the catalogues needed for a target. Before looking up cached +results or discovery in flight, the manager calls optional `AgentClient.getCatalogCacheKey(options)`. +The provider owns equivalence: equal keys must mean the same availability, models and modes, +including the effective configuration and execution environment. `force` does not change identity. +Omitting the method or returning `undefined` keeps target-specific caching. Codex and Claude's +current host clients share across directories; configured provider identities remain isolated. + +The key chooses storage, never execution. Availability and catalogue discovery receive the actual +`{ scope: "global", force }` or `{ scope: "workspace", cwd, force }` request, including when another +target can share its result. Runtime-aware adapters must use that target for both probing and key +resolution. An explicit home-directory workspace is distinct from a semantic global request. +Plugin callbacks follow the same contract; see [plugin providers](plugins.md#contribute-a-provider). `ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the availability check and covers that check plus the complete catalog probe. Providers that make @@ -124,15 +135,41 @@ aborts the shared refresh signal at the deadline. Providers name active catalog finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations that were still active when the deadline expired. -Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own. +Catalogue results stay cached by identity until explicit refresh or a change to that provider's configuration. +Keys are resolved on each read so project configuration can select a different cached catalogue. +Selector opening may read a loading or stale query, but does not force provider probing. -Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal. +Saved provider/model choices are user intent. Catalogue failure must not erase them or substitute +another model. Creation reads the caller's host and directory directly; an earlier global snapshot +must not settle a project form's initial selection. -Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace. +The server's provider keys never cross the wire. Clients advertising `provider_snapshot_references` +receive directory-to-hash announcements; an unknown hash uses the existing snapshot request. +The manager detaches each fresh result once and publishes its content identity with the entry; +readers share these values read-only. Each target keeps its published snapshot until membership, +content or discovery freshness changes. All affected targets commit before subscribers run; +subscriber failures are logged without changing discovery or configuration outcomes. +The session compares both sides of a committed transition under the client's current visibility +policy and sends only visible changes. It combines result identities with the client's encoding +and icon policy, without traversing models. Reference hashes exclude freshness; +legacy embedded hashes include it. Only responses that send a body compact the catalogue. +Per-provider `fetchedAt` travels separately for reference clients and still means when discovery succeeded. `generatedAt` +remains the time the response or announcement was generated. Refresh keeps settled entries visible +until the next result, so unchanged discovery sends freshness without another model body. -Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path. +The app's snapshot cache owns compact expansion and stores one body per server/hash, with separate +directory/hash/freshness records under the same byte budget. Missing bodies share a React Query +request; directory queries cancel superseded pulls before accepting pushes. Default SDK clients +keep expanded entries and full updates. Only callers that own materialization opt into wire snapshots. +Keep the full encoding for older clients; compact-snapshot support alone does not imply reference support. -Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global. +Settings refresh invalidates requested providers across known targets and refreshes them in their +actual execution context, so every connected client receives the refreshed view. Discovery shares +work by provider key and admits at most four concurrent catalogue probes per configured provider, so a stalled provider does not block discovery for others. Configuration replacement invalidates +only changed providers; unchanged entries, clients, and discovery in flight survive. Preparation +leaves installed reads untouched until commit. Plugin replacement uses registration runtime +identity, so unchanged registrations and builtins keep their results. Await the refresh or warmup +promise for completion: equal results, including equal discovery timestamps, emit no transition. --- diff --git a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts index ddd415ed97..327666ecb8 100644 --- a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts @@ -1,4 +1,6 @@ import { expect, test, type Page } from "../support/fixtures"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { gotoAppShell } from "../support/helpers/app"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; @@ -49,30 +51,52 @@ function getSessionMessage(message: WebSocketMessage): Record | return maybeEnvelope.message as Record; } -async function seedCodexDefaultPermissionPreferences(page: Page): Promise { - await page.addInitScript( - ({ preferencesKey }) => { - localStorage.setItem( - preferencesKey, - JSON.stringify({ - provider: "codex", - providerPreferences: { - codex: { - model: "gpt-5.4-mini", - mode: "auto", - thinkingByModel: { - "gpt-5.4-mini": "low", +async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise { + const client = await connectDaemonClient({ + clientIdPrefix: "codex-mode-preferences", + }); + try { + await expect + .poll( + async () => + (await client.getProvidersSnapshot({ cwd })).entries.find( + (entry) => entry.provider === "codex", + )?.status, + ) + .toBe("ready"); + const snapshot = await client.getProvidersSnapshot({ cwd }); + const model = snapshot.entries + .find((entry) => entry.provider === "codex") + ?.models?.find((candidate) => candidate.thinkingOptions?.length); + if (!model?.thinkingOptions?.[0]) + throw new Error("Codex catalogue must expose a model with thinking options"); + await page.addInitScript( + ({ preferencesKey, modelId, thinkingOptionId }) => { + localStorage.setItem( + preferencesKey, + JSON.stringify({ + provider: "codex", + providerPreferences: { + codex: { + model: modelId, + mode: "auto", + thinkingByModel: { [modelId]: thinkingOptionId }, }, + mock: { model: "ten-second-stream" }, }, - mock: { - model: "ten-second-stream", - }, - }, - } satisfies FormPreferences), - ); - }, - { preferencesKey: CREATE_AGENT_PREFERENCES_KEY }, - ); + } satisfies FormPreferences), + ); + }, + { + preferencesKey: CREATE_AGENT_PREFERENCES_KEY, + modelId: model.id, + thinkingOptionId: model.thinkingOptions[0].id, + }, + ); + return model.id; + } finally { + await client.close(); + } } async function readCodexModePreference(page: Page): Promise { @@ -156,9 +180,8 @@ test.describe("New workspace Codex mode preferences", () => { }) => { const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" }); const createAgentRecorder = await recordAndBlockCreateAgentRequests(page); - await seedCodexDefaultPermissionPreferences(page); - try { + await seedCodexDefaultPermissionPreferences(page, seeded.repoPath); await gotoAppShell(page); await waitForSidebarHydration(page); await openGlobalNewWorkspaceComposer(page); @@ -193,16 +216,15 @@ test.describe("New workspace Codex mode preferences", () => { test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => { const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" }); - await seedCodexDefaultPermissionPreferences(page); - try { + const model = await seedCodexDefaultPermissionPreferences(page, seeded.repoPath); const agent = await seeded.client.createAgent({ provider: "codex", cwd: seeded.repoPath, workspaceId: seeded.workspaceId, title: "Codex live mode preference e2e", modeId: "auto", - model: "gpt-5.4-mini", + model, }); await openAgentRoute(page, { diff --git a/packages/app/e2e/browser/new-workspace-model-memory.real.spec.ts b/packages/app/e2e/browser/new-workspace-model-memory.real.spec.ts new file mode 100644 index 0000000000..39a0c9c495 --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-model-memory.real.spec.ts @@ -0,0 +1,69 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { test, expect } from "../support/fixtures"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { + chooseModel, + reselectModel, + startWithoutRememberedModel, + expectCreatedModelAgents, + expectRememberedModel, + expectSavedSelection, +} from "../support/helpers/model-memory"; +import { + openGlobalNewWorkspaceComposer, + submitNewWorkspacePrompt, +} from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +test("remembers real Codex GPT-6 Astra after repeated successful workspace creation", async ({ + page, +}) => { + test.setTimeout(180_000); + const workspace = await seedWorkspace({ repoPrefix: "codex-astra-memory-" }); + const client = await connectDaemonClient({ clientIdPrefix: "codex-astra-memory" }); + try { + await expect + .poll( + async () => + (await client.getProvidersSnapshot({ cwd: workspace.repoPath })).entries.find( + (entry) => entry.provider === "codex", + )?.status, + { timeout: 60_000 }, + ) + .toBe("ready"); + const snapshot = await client.getProvidersSnapshot({ cwd: workspace.repoPath }); + const codex = snapshot.entries.find((entry) => entry.provider === "codex"); + expect(codex?.status).toBe("ready"); + const astra = codex?.models?.find((model) => model.id === "gpt-6-astra"); + expect(astra?.id).toBe("gpt-6-astra"); + const label = astra!.label; + await startWithoutRememberedModel(page); + await gotoWorkspace(page, workspace.workspaceId); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + for (const [index, select] of [chooseModel, reselectModel].entries()) { + const count = index + 1; + await test.step(`create workspace ${count} and return`, async () => { + await select(page, "codex", label); + await expectSavedSelection(page, "codex", "gpt-6-astra"); + await submitNewWorkspacePrompt(page, "Reply with OK. Do not use tools or change files."); + await expect(page).toHaveURL(/\/workspace\//); + await expectCreatedModelAgents(page, client, "codex", "gpt-6-astra", count); + await expect( + page.getByTestId("assistant-message").filter({ hasText: "OK", visible: true }), + ).toBeVisible({ + timeout: 60_000, + }); + await openGlobalNewWorkspaceComposer(page); + await expectSavedSelection(page, "codex", "gpt-6-astra"); + await expectRememberedModel(page, label); + }); + } + await page.screenshot({ path: test.info().outputPath("astra-remembered.png") }); + } finally { + await client.close(); + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/new-workspace-model-memory.spec.ts b/packages/app/e2e/browser/new-workspace-model-memory.spec.ts new file mode 100644 index 0000000000..9e86b74ffb --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-model-memory.spec.ts @@ -0,0 +1,109 @@ +import { mkdtemp, rename, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { test, expect } from "../support/fixtures"; +import { seedModelProvider } from "../support/helpers/agent-profiles"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { + openGlobalNewWorkspaceComposer, + submitNewWorkspaceEmpty, + submitNewWorkspacePrompt, +} from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +import { + chooseModel, + reselectModel, + startWithoutRememberedModel, + expectSavedSelection, + expectCreatedModelAgents, + expectRememberedModel, +} from "../support/helpers/model-memory"; + +const PROVIDER = "model-memory-diagnostic"; +const MODEL = "pi-profile-model"; +const LABEL = "Pi profile model"; + +async function expectProviderStatus(client: DaemonClient, cwd: string | undefined, status: string) { + await expect + .poll( + async () => + (await client.getProvidersSnapshot(cwd ? { cwd } : undefined)).entries.find( + (entry) => entry.provider === PROVIDER, + )?.status, + { timeout: 30_000 }, + ) + .toBe(status); +} + +async function prepareCatalogState( + client: DaemonClient, + executable: string, + cwd: string, + hostStatus: "ready" | "unavailable", +) { + await expectProviderStatus(client, undefined, "ready"); + await expectProviderStatus(client, cwd, "ready"); + await rename(executable, `${executable}.parked`); + await client.refreshProvidersSnapshot({ providers: [PROVIDER] }); + await expectProviderStatus(client, undefined, "unavailable"); + await rename(`${executable}.parked`, executable); + if (hostStatus === "ready") await client.refreshProvidersSnapshot({ providers: [PROVIDER] }); + await client.refreshProvidersSnapshot({ cwd, providers: [PROVIDER] }); + await expectProviderStatus(client, cwd, "ready"); + await expectProviderStatus(client, undefined, hostStatus); +} + +for (const hostStatus of ["ready", "unavailable"] as const) { + test(`remembers the manually selected model after two creations with a ${hostStatus} host catalog and ready project catalog`, async ({ + page, + }) => { + test.setTimeout(150_000); + const workspace = await seedWorkspace({ repoPrefix: "model-memory-scope-" }); + const binDir = await mkdtemp(path.join(tmpdir(), "paseo-model-memory-bin-")); + const executable = path.join(binDir, "provider-node"); + await symlink(process.execPath, executable); + const provider = await seedModelProvider({ + id: PROVIDER, + label: "Model memory diagnostic", + extends: "pi", + command: [executable, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")], + models: [{ id: MODEL, label: LABEL, description: "Remembered selection diagnostic" }], + }); + const client = await connectDaemonClient({ clientIdPrefix: "model-memory" }); + try { + await prepareCatalogState(client, executable, workspace.repoPath, hostStatus); + + await startWithoutRememberedModel(page); + await gotoWorkspace(page, workspace.workspaceId); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + for (const [index, select] of [chooseModel, reselectModel].entries()) { + const count = index + 1; + await test.step(`create workspace ${count} and return`, async () => { + await select(page, PROVIDER, LABEL); + await expectSavedSelection(page, PROVIDER, MODEL); + await submitNewWorkspacePrompt(page, "Remember this model on the next workspace"); + await expect(page).toHaveURL(/\/workspace\//); + await expectCreatedModelAgents(page, client, PROVIDER, MODEL, count); + await openGlobalNewWorkspaceComposer(page); + await expectSavedSelection(page, PROVIDER, MODEL); + await expectRememberedModel(page, LABEL); + }); + } + await submitNewWorkspaceEmpty(page); + await expect(page).toHaveURL(/\/workspace\//); + await openGlobalNewWorkspaceComposer(page); + await expectSavedSelection(page, PROVIDER, MODEL); + await expectRememberedModel(page, LABEL); + } finally { + await client.close(); + await provider.restore(); + await workspace.cleanup(); + await rm(binDir, { recursive: true, force: true }); + } + }); +} diff --git a/packages/app/e2e/browser/new-workspace-model-recovery.spec.ts b/packages/app/e2e/browser/new-workspace-model-recovery.spec.ts new file mode 100644 index 0000000000..ad291a715d --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-model-recovery.spec.ts @@ -0,0 +1,102 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { test, expect, type Page } from "../support/fixtures"; +import { + closeModelPicker, + drillIntoProvider, + openModelPicker, + seedModelProvider, +} from "../support/helpers/agent-profiles"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { openGlobalNewWorkspaceComposer } from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { expectRememberedModel, expectSavedSelection } from "../support/helpers/model-memory"; + +const PROVIDER = "remembered-model-recovery"; +const MODEL = "remembered-model"; +const LABEL = "Remembered model"; + +async function rememberModel(page: Page) { + await page.addInitScript( + ({ provider, model }) => { + localStorage.setItem( + "@paseo:create-agent-preferences", + JSON.stringify({ + provider, + providerPreferences: { [provider]: { model } }, + }), + ); + }, + { provider: PROVIDER, model: MODEL }, + ); +} + +async function expectRecoveredModelInPicker(page: Page) { + await openModelPicker(page); + await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click(); + await drillIntoProvider(page, PROVIDER); + await expect( + page.getByTestId("combobox-desktop-container").getByText(LABEL, { exact: true }), + ).toBeVisible(); + await closeModelPicker(page); +} + +async function setProviderAvailability(client: DaemonClient, cwd: string, available: boolean) { + await client.patchDaemonConfig({ + providers: { + [PROVIDER]: { + command: [available ? process.execPath : "/missing-paseo-diagnostic-provider"], + }, + }, + }); + for (const scope of [undefined, cwd]) { + await expect + .poll( + async () => { + const snapshot = await client.getProvidersSnapshot(scope ? { cwd: scope } : undefined); + return snapshot.entries.find((entry) => entry.provider === PROVIDER)?.status; + }, + { timeout: 30_000 }, + ) + .toBe(available ? "ready" : "unavailable"); + } +} + +test("restores the remembered model when a provider recovers after New workspace opens", async ({ + page, +}) => { + test.setTimeout(120_000); + const workspace = await seedWorkspace({ repoPrefix: "model-recovery-" }); + const provider = await seedModelProvider({ + id: PROVIDER, + label: "Remembered provider", + models: [{ id: MODEL, label: LABEL, description: "Model restoration diagnostic" }], + command: [process.execPath], + }); + const client = await connectDaemonClient({ clientIdPrefix: "model-recovery" }); + try { + await rememberModel(page); + await gotoWorkspace(page, workspace.workspaceId); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + await expectRememberedModel(page, LABEL); + + await gotoWorkspace(page, workspace.workspaceId); + await waitForSidebarHydration(page); + await setProviderAvailability(client, workspace.repoPath, false); + await openGlobalNewWorkspaceComposer(page); + await expect( + page.getByTestId("combined-model-selector").filter({ visible: true }), + ).not.toContainText("Loading..."); + + await setProviderAvailability(client, workspace.repoPath, true); + await expectSavedSelection(page, PROVIDER, MODEL); + await expectRecoveredModelInPicker(page); + await expectRememberedModel(page, LABEL); + } finally { + await client.close(); + await provider.restore(); + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/support/helpers/model-memory.ts b/packages/app/e2e/support/helpers/model-memory.ts new file mode 100644 index 0000000000..7a41419db0 --- /dev/null +++ b/packages/app/e2e/support/helpers/model-memory.ts @@ -0,0 +1,71 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { expect, type Page } from "../fixtures"; +import { drillIntoProvider, openModelPicker } from "./agent-profiles"; + +export async function startWithoutRememberedModel(page: Page) { + await page.addInitScript(() => localStorage.removeItem("@paseo:create-agent-preferences")); +} + +export async function chooseModel(page: Page, provider: string, label: string) { + await openModelPicker(page); + await expect( + page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }), + ).toHaveCount(0); + await selectProviderModel(page, provider, label); +} + +export async function reselectModel(page: Page, provider: string, label: string) { + await expectRememberedModel(page, label); + await openModelPicker(page); + await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click(); + await selectProviderModel(page, provider, label); +} + +async function selectProviderModel(page: Page, provider: string, label: string) { + await drillIntoProvider(page, provider); + await page.getByTestId("combobox-desktop-container").getByText(label, { exact: true }).click(); + await expect( + page + .getByRole("button", { name: `Select model (${label})`, exact: true }) + .filter({ visible: true }), + ).toBeVisible(); +} + +export async function expectSavedSelection(page: Page, provider: string, model: string) { + await expect + .poll(() => + page.evaluate(() => + JSON.parse(localStorage.getItem("@paseo:create-agent-preferences") ?? "null"), + ), + ) + .toMatchObject({ provider, providerPreferences: { [provider]: { model } } }); +} + +export async function expectCreatedModelAgents( + page: Page, + client: DaemonClient, + provider: string, + model: string, + count: number, +) { + await expect + .poll( + async () => { + const result = await client.fetchAgents({ scope: "active" }); + return result.entries + .filter(({ agent }) => agent.provider === provider) + .map(({ agent }) => agent.model); + }, + { timeout: 60_000 }, + ) + .toEqual(Array(count).fill(model)); + await expect(page.getByTestId(/^workspace-tab-agent_/).filter({ visible: true })).toHaveCount(1); +} + +export async function expectRememberedModel(page: Page, label: string) { + await expect( + page + .getByRole("button", { name: `Select model (${label})`, exact: true }) + .filter({ visible: true }), + ).toBeVisible(); +} diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 29cd7ea0b7..968b721eb2 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -66,25 +66,18 @@ function resolveWorkspaceTitle({ function buildChatDraftComposerArgs({ serverId, - isConnected, workspaceDirectory, sourceDirectory, pendingWorkspaceSetup, }: { serverId: string; - isConnected: boolean; workspaceDirectory: string | undefined; sourceDirectory: string; pendingWorkspaceSetup: { creationMethod: string } | null; }) { return { initialServerId: serverId || null, - initialValues: - workspaceDirectory || sourceDirectory - ? { workingDir: workspaceDirectory || sourceDirectory } - : undefined, isVisible: pendingWorkspaceSetup !== null, - onlineServerIds: isConnected && serverId ? [serverId] : [], lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined, }; } @@ -195,7 +188,6 @@ export function WorkspaceSetupDialog() { draftKey: `workspace-setup:${serverId}:${sourceDirectory}`, composer: buildChatDraftComposerArgs({ serverId, - isConnected, workspaceDirectory: workspace?.workspaceDirectory, sourceDirectory, pendingWorkspaceSetup, diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 3442703d48..ef26aaf6fe 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -34,8 +34,6 @@ vi.mock("@/attachments/service", () => ({ vi.mock("@/hooks/use-agent-form-state", () => ({ useAgentFormState: () => ({ selectedServerId: "host-1", - setSelectedServerId: () => undefined, - setSelectedServerIdFromUser: () => undefined, selectedProvider: "codex", setProviderFromUser: () => undefined, selectedMode: "auto", @@ -45,8 +43,6 @@ vi.mock("@/hooks/use-agent-form-state", () => ({ selectedThinkingOptionId: "", setThinkingOptionFromUser: () => undefined, workingDir: "/repo", - setWorkingDir: () => undefined, - setWorkingDirFromUser: () => undefined, providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }], providerDefinitionMap: new Map(), agentDefinition: undefined, @@ -179,9 +175,7 @@ describe("useAgentInputDraft live contract", () => { draftKey, composer: { initialServerId: "host-1", - initialValues: { workingDir: "/repo" }, isVisible: true, - onlineServerIds: ["host-1"], lockedWorkingDir: "/repo", }, }); diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index c26e18696f..2bfaab0745 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -35,7 +35,6 @@ interface AgentInputDraftComposerOptions { initialValues?: CreateAgentInitialValues; initialFeatureValues?: Record; isVisible?: boolean; - onlineServerIds?: string[]; lockedWorkingDir?: string; } @@ -68,12 +67,13 @@ export interface AgentInputDraft { export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft { const composerOptions = input.composer ?? null; + const workingDir = composerOptions?.lockedWorkingDir?.trim() || ""; const formState = useAgentFormState({ - initialServerId: composerOptions?.initialServerId ?? null, + workingDir, + serverId: composerOptions?.initialServerId ?? null, initialValues: composerOptions?.initialValues, isVisible: composerOptions?.isVisible ?? false, isCreateFlow: true, - onlineServerIds: composerOptions?.onlineServerIds ?? [], }); const draftKey = useMemo( () => @@ -214,17 +214,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr }; }, [draftKey, publishTextReplacement]); - const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? ""; - useEffect(() => { - if (!composerOptions || !lockedWorkingDir) { - return; - } - if (formState.workingDir.trim() === lockedWorkingDir) { - return; - } - formState.setWorkingDir(lockedWorkingDir); - }, [composerOptions, formState, lockedWorkingDir]); - const providerSelection = useMemo( () => ({ provider: formState.selectedProvider, @@ -254,7 +243,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [effectiveModelId, providerSelection], ); - const workingDir = lockedWorkingDir || formState.workingDir; const { features: draftFeatures, featureValues: draftFeatureValues, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 9f1c2b172c..93c800cbf9 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -16,7 +16,7 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow"; import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -55,7 +55,6 @@ import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { useSettings } from "@/hooks/use-settings"; const EMPTY_PENDING_PERMISSIONS = new Map(); -const EMPTY_ONLINE_SERVER_IDS: string[] = []; const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: false, @@ -270,17 +269,10 @@ function buildDraftAgentSnapshot(input: { } function buildDraftInitialValues(input: { - workingDir: string | null; initialSetup: WorkspaceDraftTabSetup | null; }): CreateAgentInitialValues | undefined { - if (!input.workingDir) { - return undefined; - } - if (!input.initialSetup) { - return { workingDir: input.workingDir }; - } + if (!input.initialSetup) return undefined; return { - workingDir: input.workingDir, provider: input.initialSetup.provider, modeId: input.initialSetup.modeId, model: input.initialSetup.model, @@ -298,13 +290,6 @@ function resolveDraftWorkingDirectory(input: { return input.workspaceDirectory; } -function resolveOnlineServerIds(input: { isConnected: boolean; serverId: string }): string[] { - if (!input.isConnected) { - return EMPTY_ONLINE_SERVER_IDS; - } - return [input.serverId]; -} - interface WorkspaceDraftAgentTabProps { serverId: string; workspaceId: string; @@ -341,7 +326,6 @@ export function WorkspaceDraftAgentTab({ const { t } = useTranslation(); const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); - const isConnected = useHostRuntimeIsConnected(serverId); const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ workspaceDirectory: w.workspaceDirectory, id: w.id, @@ -353,10 +337,8 @@ export function WorkspaceDraftAgentTab({ initialSetup: draftSetup, }); const draftInitialValues = buildDraftInitialValues({ - workingDir: draftWorkingDirectory, initialSetup: draftSetup, }); - const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId }); const draftStoreKey = useMemo( () => buildDraftStoreKey({ @@ -373,7 +355,6 @@ export function WorkspaceDraftAgentTab({ initialValues: draftInitialValues, initialFeatureValues: draftSetup?.featureValues, isVisible: true, - onlineServerIds, lockedWorkingDir: draftWorkingDirectory ?? undefined, }, }); diff --git a/packages/app/src/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts index f92e7e9bf9..b69b0f5d52 100644 --- a/packages/app/src/data/provider-snapshot-cache.test.ts +++ b/packages/app/src/data/provider-snapshot-cache.test.ts @@ -2,16 +2,10 @@ import { describe, expect, it } from "vitest"; import { Buffer } from "buffer"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; -import { z } from "zod"; import { createProviderSnapshotCache, type ProviderSnapshotCache } from "./provider-snapshot-cache"; -const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v1:"; -const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; -const SnapshotIndexSchema = z.object({ - version: z.literal(1), - entries: z.array(z.object({ key: z.string(), bytes: z.number(), writtenAt: z.string() })), -}); - +const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v2:"; +const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v2"; function createStorage(maxSnapshotBytes = Number.POSITIVE_INFINITY) { const values = new Map(); const stats = { getAllKeysCalls: 0 }; @@ -109,21 +103,17 @@ function snapshotBytes(values: Map): number { ); } -function expectIndexMatchesSnapshots(values: Map): void { - const storedIndex = values.get(SNAPSHOT_INDEX_KEY); - if (!storedIndex) throw new Error("Expected provider snapshot index"); - const index = SnapshotIndexSchema.parse(JSON.parse(storedIndex)); - const storedSnapshots = [...values] - .filter(([key]) => key.startsWith(SNAPSHOT_KEY_PREFIX)) - .map(([key, value]) => ({ - key, - bytes: Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"), - })) - .toSorted((left, right) => left.key.localeCompare(right.key)); - const indexedSnapshots = index.entries - .map(({ key, bytes }) => ({ key, bytes })) - .toSorted((left, right) => left.key.localeCompare(right.key)); - expect(indexedSnapshots).toEqual(storedSnapshots); +function expectConsistentSnapshots(values: Map): void { + expect(values.has(SNAPSHOT_INDEX_KEY)).toBe(false); + for (const [key, value] of values) { + if (!key.startsWith(SNAPSHOT_KEY_PREFIX)) continue; + const [serverId, kind] = JSON.parse(key.slice(SNAPSHOT_KEY_PREFIX.length)); + if (kind !== "cwd") continue; + const { hash } = JSON.parse(value); + expect(values.has(`${SNAPSHOT_KEY_PREFIX}${JSON.stringify([serverId, "hash", hash])}`)).toBe( + true, + ); + } } function writeSnapshot( @@ -140,6 +130,94 @@ function writeSnapshot( } describe("provider snapshot cache", () => { + it("removes externally lost body references and retains independent orphan bodies on reconciliation", async () => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage); + await writeSnapshot(cache, { + cwd: "/lost", + label: "lost", + generatedAt: "2026-09-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/orphan", + label: "orphan", + generatedAt: "2026-09-02T00:00:00.000Z", + }); + storage.values.delete(`${SNAPSHOT_KEY_PREFIX}["server-1","hash","lost"]`); + storage.values.delete(`${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/orphan"]`); + + await expect(cache.read("server-1", "/lost")).resolves.toBeNull(); + expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/lost"]`)).toBe(false); + const restarted = createProviderSnapshotCache(storage); + await expect(restarted.readHash("server-1", "orphan")).resolves.not.toBeNull(); + expectConsistentSnapshots(storage.values); + }); + + it.each(["live", "restart", "late association"])( + "retains recently reused bodies under pressure (%s)", + async (variant) => { + const storage = createStorage(); + const seed = async (cache: ProviderSnapshotCache) => { + await writeSnapshot(cache, { + cwd: "/a", + label: "aaa", + generatedAt: "2026-09-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/b", + label: "bbb", + generatedAt: "2026-09-02T00:00:00.000Z", + }); + }; + await seed(createProviderSnapshotCache(storage)); + const maxBytes = snapshotBytes(storage.values); + let cache = createProviderSnapshotCache(storage, { maxBytes }); + await writeSnapshot(cache, { + cwd: "/c", + label: "aaa", + generatedAt: "2026-09-03T00:00:00.000Z", + }); + if (variant === "restart") cache = createProviderSnapshotCache(storage, { maxBytes }); + if (variant === "late association") { + await writeSnapshot(cache, { + cwd: "/a", + label: "aaa", + generatedAt: "2026-09-01T00:00:00.000Z", + }); + } + await writeSnapshot(cache, { + cwd: "/d", + label: "ccc", + generatedAt: "2026-09-04T00:00:00.000Z", + }); + + await expect(cache.read("server-1", "/c")).resolves.toMatchObject({ hash: "aaa" }); + await expect(cache.read("server-1", "/d")).resolves.toMatchObject({ hash: "ccc" }); + await expect(cache.readHash("server-1", "bbb")).resolves.toBeNull(); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectConsistentSnapshots(storage.values); + }, + ); + + it("keeps a reusable body when its directory association cannot fit beside it", async () => { + const input = { cwd: "/repo", label: "shared", generatedAt: "2026-09-06T12:00:00.000Z" }; + const stagingStorage = createStorage(); + await writeSnapshot(createProviderSnapshotCache(stagingStorage), input); + const maxBytes = snapshotBytes(stagingStorage.values) - 1; + const storage = createStorage(maxBytes); + const cache = createProviderSnapshotCache(storage, { maxBytes }); + + await writeSnapshot(cache, input); + + await expect(cache.readHash("server-1", "shared")).resolves.not.toBeNull(); + await expect(cache.read("server-1", "/repo")).resolves.toBeNull(); + expect([...storage.values.keys()].filter((key) => key.includes('"cwd"'))).toEqual([]); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectConsistentSnapshots(storage.values); + const restarted = createProviderSnapshotCache(storage, { maxBytes }); + await expect(restarted.readHash("server-1", "shared")).resolves.not.toBeNull(); + }); + it("round-trips compact snapshots with shared thinking option references", async () => { const thinkingOptions = [{ id: "high", label: "High", isDefault: true }]; const entries: ProviderSnapshotEntry[] = [ @@ -217,7 +295,7 @@ describe("provider snapshot cache", () => { await expect(cache.read("server-1", "/oldest")).resolves.toBeNull(); await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -236,7 +314,7 @@ describe("provider snapshot cache", () => { expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); it("clears unindexed snapshots on the first read", async () => { @@ -247,7 +325,7 @@ describe("provider snapshot cache", () => { await expect(cache.read("server-1", "/legacy")).resolves.toBeNull(); expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -266,7 +344,7 @@ describe("provider snapshot cache", () => { ]); await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -292,8 +370,8 @@ describe("provider snapshot cache", () => { expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); expect( [...storage.values.keys()].filter((key) => key.startsWith(SNAPSHOT_KEY_PREFIX)), - ).toHaveLength(1); - expectIndexMatchesSnapshots(storage.values); + ).toHaveLength(2); + expectConsistentSnapshots(storage.values); }); it("removes the previous value when a replacement exceeds the entire budget", async () => { @@ -314,10 +392,10 @@ describe("provider snapshot cache", () => { await expect(tinyCache.read("server-1", "/repo")).resolves.toBeNull(); expect(snapshotBytes(storage.values)).toBe(0); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); - it("resets corrupted index state before accepting another snapshot", async () => { + it("removes obsolete index data before accepting another snapshot", async () => { const storage = createStorage(); storage.values.set(SNAPSHOT_INDEX_KEY, "corrupt"); storage.values.set(`${SNAPSHOT_KEY_PREFIX}legacy`, "legacy"); @@ -331,7 +409,7 @@ describe("provider snapshot cache", () => { expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}legacy`)).toBe(false); await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -361,7 +439,7 @@ describe("provider snapshot cache", () => { }); await expect(cache.read("server-1", "/emoji")).resolves.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); it("keeps hundreds of concurrent writes bounded without rescanning snapshots", async () => { @@ -380,7 +458,7 @@ describe("provider snapshot cache", () => { expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); await expect(cache.read("server-1", "/repo-249")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -394,7 +472,7 @@ describe("provider snapshot cache", () => { label: "stable", generatedAt: "2026-08-01T00:00:00.000Z", }); - const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","/failed"]`; + const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/failed"]`; storage.failNext("setItem", timing, failedKey); await writeSnapshot(cache, { @@ -412,34 +490,7 @@ describe("provider snapshot cache", () => { await expect(restarted.read("server-1", "/stable")).resolves.not.toBeNull(); await expect(restarted.read("server-1", "/next")).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(2_000); - expectIndexMatchesSnapshots(storage.values); - }, - ); - - it.each(["before", "after"] as const)( - "reconciles snapshot records when the derived index write fails %s its side effect", - async (timing) => { - const storage = createStorage(); - const cache = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); - await writeSnapshot(cache, { - cwd: "/stable", - label: "stable", - generatedAt: "2026-08-01T00:00:00.000Z", - }); - storage.failNext("setItem", timing, SNAPSHOT_INDEX_KEY); - - await writeSnapshot(cache, { - cwd: "/orphan-after-index-failure", - label: "orphan", - generatedAt: "2026-08-02T00:00:00.000Z", - }); - - const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); - await expect( - restarted.read("server-1", "/orphan-after-index-failure"), - ).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); - expect(storage.stats.getAllKeysCalls).toBe(2); + expectConsistentSnapshots(storage.values); }, ); @@ -481,7 +532,7 @@ describe("provider snapshot cache", () => { expect(storage.values.get("@paseo/unrelated")).toBe("keep-me"); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); await expect(restarted.read("server-1", "/final")).resolves.not.toBeNull(); }); @@ -508,14 +559,14 @@ describe("provider snapshot cache", () => { await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); it.each(["before", "after"] as const)( "recovers when invalid-record cleanup fails %s deletion", async (timing) => { const storage = createStorage(); - const key = `${SNAPSHOT_KEY_PREFIX}["server-1","/invalid"]`; + const key = `${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/invalid"]`; storage.values.set(key, "invalid"); storage.failNext("multiRemove", timing); const cache = createProviderSnapshotCache(storage); @@ -529,7 +580,165 @@ describe("provider snapshot cache", () => { expect(storage.values.has(key)).toBe(false); await expect(cache.read("server-1", "/recovered")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }, ); }); + +it("stores and expands identical content once across directory associations", async () => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage); + for (const cwd of ["/a", "/b", "/c"]) { + await writeSnapshot(cache, { cwd, label: "shared", generatedAt: "2026-09-06T12:00:00.000Z" }); + } + const a = await cache.read("server-1", "/a"); + const b = await cache.read("server-1", "/b"); + expect(a?.entries[0]?.models).toBe(b?.entries[0]?.models); + expect( + [...storage.values.values()].filter((value) => value.includes('"compactSnapshot"')), + ).toHaveLength(1); +}); + +it("coalesces missing bodies and keeps a newer pushed association when an older pull completes", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + const { applyProvidersSnapshotUpdate } = await import("./push-router"); + const { fetchProvidersSnapshot, providersSnapshotQueryKey } = + await import("./providers-snapshot"); + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const serverId = "shared-host"; + const snapshot = (hash: string, fetchedAt = "2026-09-06T12:00:00.000Z") => ({ + entries: [], + snapshotHash: hash, + compactSnapshot: compactProviderSnapshot(snapshotEntries(hash)), + generatedAt: fetchedAt, + fetchedAt: { pi: fetchedAt }, + requestId: hash, + }); + let transfers = 0; + const client = { + async getProvidersSnapshot() { + transfers++; + return snapshot("shared"); + }, + }; + const announce = (cwd: string, hash: string, fetchedAt = "2026-09-06T12:00:00.000Z") => + applyProvidersSnapshotUpdate({ + serverId, + queryClient, + cache, + client, + message: { + type: "providers_snapshot_update", + payload: { + cwd, + entries: [], + snapshotHash: hash, + generatedAt: fetchedAt, + fetchedAt: { pi: fetchedAt }, + }, + }, + }); + try { + await Promise.all(["/a", "/b", "/c"].map((cwd) => announce(cwd, "shared"))); + expect(transfers).toBe(1); + const first = await cache.read(serverId, "/a"); + const second = await cache.read(serverId, "/b"); + expect(first!.entries[0]!.models).toBe(second!.entries[0]!.models); + expect( + queryClient.getQueryData<{ compactSnapshot: unknown }>( + providersSnapshotQueryKey(serverId, "/a"), + )!.compactSnapshot, + ).toBe(first!.compactSnapshot); + expect( + [...storage.values.values()].filter((value) => value.includes('"compactSnapshot"')), + ).toHaveLength(1); + await Promise.all( + ["/a", "/b", "/c"].map((cwd) => announce(cwd, "shared", "2026-09-06T13:00:00.000Z")), + ); + expect(transfers).toBe(1); + expect((await cache.read(serverId, "/a"))!.entries[0]!.fetchedAt).toBe( + "2026-09-06T13:00:00.000Z", + ); + + await queryClient.refetchQueries({ + queryKey: providersSnapshotQueryKey(serverId, "/b"), + exact: true, + }); + expect(transfers).toBe(2); + + let release!: (value: ReturnType) => void; + let began!: () => void; + const started = new Promise((resolve) => { + began = resolve; + }); + const oldClient = { + getProvidersSnapshot() { + began(); + return new Promise>((resolve) => { + release = resolve; + }); + }, + }; + const queryKey = providersSnapshotQueryKey(serverId, "/a"); + const old = queryClient.fetchQuery({ + queryKey, + staleTime: 0, + queryFn: ({ signal }) => + fetchProvidersSnapshot({ + serverId, + cwd: "/a", + queryClient, + cache, + client: oldClient, + signal, + }), + }); + const cancelled = old.catch(() => undefined); + await started; + await applyProvidersSnapshotUpdate({ + serverId, + queryClient, + cache, + client, + message: { type: "providers_snapshot_update", payload: { ...snapshot("new"), cwd: "/a" } }, + }); + release(snapshot("old")); + await cancelled; + // Drain the same public cache queue after the old transport finishes. + await cache.read(serverId, "/a"); + expect((await cache.read(serverId, "/a"))!.hash).toBe("new"); + expect(queryClient.getQueryData(queryKey)).toMatchObject({ + snapshotHash: "new", + entries: [{ models: [{ id: "new" }] }], + }); + expect((await cache.read(serverId, "/b"))!.hash).toBe("shared"); + + const restarted = createProviderSnapshotCache(storage); + expect((await restarted.read(serverId, "/a"))!.hash).toBe("new"); + const evicted = createProviderSnapshotCache(storage, { maxBytes: 1 }); + expect(await evicted.read(serverId, "/b")).toBeNull(); + await applyProvidersSnapshotUpdate({ + serverId, + queryClient, + cache: evicted, + client, + message: { + type: "providers_snapshot_update", + payload: { + cwd: "/b", + entries: [], + snapshotHash: "shared", + generatedAt: "2026-09-06T14:00:00.000Z", + }, + }, + }); + expect(transfers).toBe(3); + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/b"))).toMatchObject({ + entries: [{ models: [{ id: "shared" }] }], + }); + } finally { + queryClient.clear(); + } +}); diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index ed4823a39c..04a10303e3 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -7,11 +7,12 @@ import { } from "@getpaseo/protocol/provider-snapshot-codec"; import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages"; import { z } from "zod"; +import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages"; +type SnapshotPayload = GetProvidersSnapshotResponseMessage["payload"]; -const CACHE_VERSION = 1; -const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1"; -const CACHE_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; -const CACHE_INDEX_VERSION = 1; +const CACHE_VERSION = 2; +const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v2"; +const CACHE_INDEX_KEY = "@paseo/provider-snapshot-index/v2"; const DEFAULT_MAX_CACHE_BYTES = 4 * 1024 * 1024; interface ProviderSnapshotStorage { @@ -31,7 +32,8 @@ interface StoredProviderSnapshot { version: typeof CACHE_VERSION; hash: string; generatedAt: string; - compactSnapshot: CompactProviderSnapshot; + compactSnapshot?: CompactProviderSnapshot; + fetchedAt?: Record; } interface ProviderSnapshotIndexEntry { @@ -40,30 +42,31 @@ interface ProviderSnapshotIndexEntry { writtenAt: string; } -interface ProviderSnapshotIndex { - version: typeof CACHE_INDEX_VERSION; - entries: ProviderSnapshotIndexEntry[]; -} - const StoredProviderSnapshotSchema: z.ZodType = z.strictObject({ version: z.literal(CACHE_VERSION), hash: z.string(), generatedAt: z.string().datetime({ offset: true }), - compactSnapshot: CompactProviderSnapshotSchema, + compactSnapshot: CompactProviderSnapshotSchema.optional(), + fetchedAt: z.record(z.string(), z.string()).optional(), }); export interface CachedProviderSnapshot extends StoredProviderSnapshot { + compactSnapshot: CompactProviderSnapshot; entries: ProviderSnapshotEntry[]; } export interface ProviderSnapshotCache { read(serverId: string, cwd: string | null): Promise; + readHash(serverId: string, hash: string): Promise; + materialize(serverId: string, snapshot: SnapshotPayload): Promise; write(input: { serverId: string; cwd: string | null; hash: string; generatedAt: string; compactSnapshot: CompactProviderSnapshot; + fetchedAt?: Record; + signal?: AbortSignal; }): Promise; } @@ -75,22 +78,41 @@ export class ProviderSnapshotCacheMissError extends Error { } function cacheKey(serverId: string, cwd: string | null): string { - return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`; + return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, "cwd", cwd])}`; +} + +function bodyKey(serverId: string, hash: string): string { + return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, "hash", hash])}`; } function storedBytes(key: string, value: string): number { return Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"); } -function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIndexEntry): number { - const writtenAtOrder = left.writtenAt.localeCompare(right.writtenAt); - return writtenAtOrder !== 0 ? writtenAtOrder : left.key.localeCompare(right.key); +const CacheKeySchema = z.tuple([z.string(), z.enum(["cwd", "hash"]), z.string().nullable()]); + +function decodeCacheKey(key: string) { + return CacheKeySchema.parse(JSON.parse(key.slice(CACHE_KEY_PREFIX.length + 1))); } -function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null { +function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIndexEntry): number { + const writtenAtOrder = left.writtenAt.localeCompare(right.writtenAt); + if (writtenAtOrder !== 0) return writtenAtOrder; + // References must be evicted before equally recent bodies, regardless of key encoding. + const leftKind = decodeCacheKey(left.key)[1]; + const rightKind = decodeCacheKey(right.key)[1]; + if (leftKind !== rightKind) return leftKind === "cwd" ? -1 : 1; + return left.key.localeCompare(right.key); +} + +function parseStoredProviderSnapshot(key: string, value: string): StoredProviderSnapshot | null { const parsed: unknown = JSON.parse(value); const result = StoredProviderSnapshotSchema.safeParse(parsed); - return result.success ? result.data : null; + if (!result.success) return null; + const [, kind, identity] = decodeCacheKey(key); + const stored = result.data; + if (kind === "hash") return stored.compactSnapshot && identity === stored.hash ? stored : null; + return stored.compactSnapshot ? null : stored; } export function createProviderSnapshotCache( @@ -98,50 +120,76 @@ export function createProviderSnapshotCache( options: ProviderSnapshotCacheOptions = {}, ): ProviderSnapshotCache { const maxBytes = options.maxBytes ?? DEFAULT_MAX_CACHE_BYTES; - let index: ProviderSnapshotIndex | null = null; + let index: ProviderSnapshotIndexEntry[] | null = null; let operationQueue = Promise.resolve(); - - async function persistIndex(entries: ProviderSnapshotIndexEntry[]): Promise { - const nextIndex: ProviderSnapshotIndex = { version: CACHE_INDEX_VERSION, entries }; - await storage.setItem(CACHE_INDEX_KEY, JSON.stringify(nextIndex)); - index = nextIndex; - } + const bodies = new Map(); async function reconcileCache(): Promise { - const keys = (await storage.getAllKeys()).filter((key) => - key.startsWith(`${CACHE_KEY_PREFIX}:`), + const allKeys = await storage.getAllKeys(); + // COMPAT(providerSnapshotCache): added in v0.7.2, remove legacy cleanup after 2027-03-06. + const legacyKeys = allKeys.filter( + (key) => + key.startsWith("@paseo/provider-snapshot/v1:") || + key === "@paseo/provider-snapshot-index/v1" || + key === CACHE_INDEX_KEY, ); + if (legacyKeys.length) await storage.multiRemove(legacyKeys); + const keys = allKeys.filter((key) => key.startsWith(`${CACHE_KEY_PREFIX}:`)); const values = await storage.multiGet(keys); const keysToRemove: string[] = []; const entries: ProviderSnapshotIndexEntry[] = []; + const references: Array<{ key: string; body: string; writtenAt: string }> = []; for (const [key, value] of values) { if (value === null) continue; try { - const stored = parseStoredProviderSnapshot(value); + const stored = parseStoredProviderSnapshot(key, value); if (!stored) { keysToRemove.push(key); continue; } + const [serverId, kind] = decodeCacheKey(key); entries.push({ key, bytes: storedBytes(key, value), writtenAt: stored.generatedAt }); + if (kind === "cwd") + references.push({ + key, + body: bodyKey(serverId, stored.hash), + writtenAt: stored.generatedAt, + }); } catch (error) { - if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; + if ( + !(error instanceof SyntaxError) && + !(error instanceof RangeError) && + !(error instanceof z.ZodError) + ) + throw error; keysToRemove.push(key); } } - let totalBytes = entries.reduce((total, entry) => total + entry.bytes, 0); - const retainedEntries = [...entries].sort(oldestFirst); + // Reconstruct the dependency ordering before applying a possibly smaller budget. + for (const reference of references) { + const body = entries.find((entry) => entry.key === reference.body); + if (!body) keysToRemove.push(reference.key); + else if (body.writtenAt < reference.writtenAt) body.writtenAt = reference.writtenAt; + } + const retainedEntries = entries + .filter((entry) => !keysToRemove.includes(entry.key)) + .sort(oldestFirst); + let totalBytes = retainedEntries.reduce((total, entry) => total + entry.bytes, 0); while (totalBytes > maxBytes) { const evicted = retainedEntries.shift(); if (!evicted) break; totalBytes -= evicted.bytes; keysToRemove.push(evicted.key); } - if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove); - await persistIndex(retainedEntries); + if (keysToRemove.length > 0) { + for (const removedKey of keysToRemove) bodies.delete(removedKey); + await storage.multiRemove(keysToRemove); + } + index = retainedEntries; } - async function ensureCacheIndex(): Promise { + async function ensureCacheIndex(): Promise { if (index === null) await reconcileCache(); if (index === null) throw new Error("Provider snapshot cache reconciliation failed"); return index; @@ -158,83 +206,210 @@ export function createProviderSnapshotCache( return result; } - async function writeSnapshot(input: { - serverId: string; - cwd: string | null; - hash: string; - generatedAt: string; - compactSnapshot: CompactProviderSnapshot; - }): Promise { + async function writeRecord( + key: string, + input: StoredProviderSnapshot, + requiredBody?: { key: string; input: StoredProviderSnapshot }, + ): Promise { const currentIndex = await ensureCacheIndex(); - const key = cacheKey(input.serverId, input.cwd); - const stored = StoredProviderSnapshotSchema.safeParse({ - version: CACHE_VERSION, - hash: input.hash, - generatedAt: input.generatedAt, - compactSnapshot: input.compactSnapshot, - }); - if (!stored.success) { - await storage.removeItem(key); - await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key)); - return; + function prepare(recordKey: string, record: StoredProviderSnapshot) { + const value = JSON.stringify(record); + return { + key: recordKey, + bytes: storedBytes(recordKey, value), + writtenAt: record.generatedAt, + value, + }; } - const value = JSON.stringify(stored.data); - const incomingBytes = storedBytes(key, value); - const canStoreIncoming = incomingBytes <= maxBytes; - const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key); - let projectedBytes = retainedEntries.reduce((total, entry) => total + entry.bytes, 0); - if (canStoreIncoming) projectedBytes += incomingBytes; - - const keysToRemove: string[] = []; + const incoming = prepare(key, input); + // The index is committed last: membership proves a body was stored. Reusing it + // needs only its byte count, not another parse, expansion, or storage write. + const body = requiredBody + ? (currentIndex.find((entry) => entry.key === requiredBody.key) ?? + prepare(requiredBody.key, requiredBody.input)) + : undefined; + let admitted: Array = body + ? [body, incoming] + : [incoming]; + if (admitted.reduce((bytes, entry) => bytes + entry.bytes, 0) > maxBytes) { + admitted = body && body.bytes <= maxBytes ? [body] : []; + } else if (body) { + admitted = [ + { + ...body, + writtenAt: body.writtenAt > input.generatedAt ? body.writtenAt : input.generatedAt, + }, + incoming, + ]; + } + const replacedKeys = new Set([key, ...(requiredBody ? [requiredBody.key] : [])]); + const retainedEntries = currentIndex.filter((entry) => !replacedKeys.has(entry.key)); + let projectedBytes = [...retainedEntries, ...admitted].reduce( + (total, entry) => total + entry.bytes, + 0, + ); + const keysToRemove = [...replacedKeys].filter( + (replaced) => !admitted.some((entry) => entry.key === replaced), + ); for (const candidate of [...retainedEntries].sort(oldestFirst)) { if (projectedBytes <= maxBytes) break; projectedBytes -= candidate.bytes; keysToRemove.push(candidate.key); } const removedKeys = new Set(keysToRemove); - const nextEntries = retainedEntries.filter((entry) => !removedKeys.has(entry.key)); + if (keysToRemove.length > 0) { + for (const removedKey of keysToRemove) bodies.delete(removedKey); + await storage.multiRemove(keysToRemove); + } + // Body, then reference, then in-memory index. A failed write can leave a reusable + // orphan body; reconciliation never retains a reference without its body. + for (const entry of admitted) { + if (entry.value !== undefined) await storage.setItem(entry.key, entry.value); + } + index = [ + ...retainedEntries.filter((entry) => !removedKeys.has(entry.key)), + ...admitted.map(({ key: recordKey, bytes, writtenAt }) => ({ + key: recordKey, + bytes, + writtenAt, + })), + ]; + } - if (!canStoreIncoming && currentIndex.entries.some((entry) => entry.key === key)) { - keysToRemove.push(key); + async function readRecord(key: string): Promise { + const currentIndex = await ensureCacheIndex(); + const value = await storage.getItem(key); + if (value === null) return null; + try { + const stored = parseStoredProviderSnapshot(key, value); + if (!stored) throw new SyntaxError("Invalid provider snapshot"); + return stored; + } catch (error) { + if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; + bodies.delete(key); + await storage.removeItem(key); + index = currentIndex.filter((entry) => entry.key !== key); + return null; } - if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove); - if (canStoreIncoming) { - await storage.setItem(key, value); - nextEntries.push({ key, bytes: incomingBytes, writtenAt: input.generatedAt }); + } + + async function readBody(serverId: string, hash: string): Promise { + const key = bodyKey(serverId, hash); + const resident = bodies.get(key); + if (resident) return resident; + const stored = await readRecord(key); + if (!stored?.compactSnapshot) return null; + try { + const body = { + ...stored, + compactSnapshot: stored.compactSnapshot, + entries: expandProviderSnapshot(stored.compactSnapshot), + }; + bodies.set(key, body); + return body; + } catch (error) { + if (!(error instanceof RangeError)) throw error; + await storage.removeItem(key); + index = null; + return null; + } + } + + async function safely(operation: () => Promise, fallback: T): Promise { + try { + return await runSerialized(operation); + } catch (error) { + if (!(error instanceof Error)) throw error; + bodies.clear(); + return fallback; } - await persistIndex(nextEntries); } return { - async read(serverId, cwd) { - try { - return await runSerialized(async () => { - const currentIndex = await ensureCacheIndex(); - const key = cacheKey(serverId, cwd); - const value = await storage.getItem(key); - if (value === null) return null; - try { - const stored = parseStoredProviderSnapshot(value); - if (!stored) throw new SyntaxError("Invalid provider snapshot"); - return { ...stored, entries: expandProviderSnapshot(stored.compactSnapshot) }; - } catch (error) { - if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; - await storage.removeItem(key); - await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key)); - return null; - } + async materialize(serverId, snapshot) { + const { snapshotHash: hash, compactSnapshot } = snapshot; + if (!hash) return snapshot; + let body = await safely(async () => { + const cached = await readBody(serverId, hash); + if (cached) return cached; + if (!compactSnapshot) throw new ProviderSnapshotCacheMissError(); + await writeRecord(bodyKey(serverId, hash), { + version: CACHE_VERSION, + hash, + generatedAt: snapshot.generatedAt, + compactSnapshot, }); - } catch (error) { - if (error instanceof Error) return null; - throw error; + return await readBody(serverId, hash); + }, null); + if (!body) { + if (!compactSnapshot) throw new ProviderSnapshotCacheMissError(); + body = { + version: CACHE_VERSION, + hash, + generatedAt: snapshot.generatedAt, + compactSnapshot, + entries: expandProviderSnapshot(compactSnapshot), + }; } + return { + ...snapshot, + compactSnapshot: body.compactSnapshot, + entries: snapshot.fetchedAt + ? body.entries.map((entry) => ({ + ...entry, + fetchedAt: snapshot.fetchedAt![entry.provider], + })) + : body.entries, + }; }, - async write(input) { - try { - await runSerialized(() => writeSnapshot(input)); - } catch (error) { - if (!(error instanceof Error)) throw error; - } + readHash(serverId, hash) { + return safely(() => readBody(serverId, hash), null); + }, + read(serverId, cwd) { + return safely(async () => { + const association = await readRecord(cacheKey(serverId, cwd)); + if (!association) return null; + const body = await readBody(serverId, association.hash); + if (!body) { + // Storage may be removed or corrupted outside this owner's write queue. + await reconcileCache(); + return null; + } + return { + ...body, + ...association, + compactSnapshot: body.compactSnapshot, + entries: association.fetchedAt + ? body.entries.map((entry) => ({ + ...entry, + fetchedAt: association.fetchedAt![entry.provider], + })) + : body.entries, + }; + }, null); + }, + write(input) { + return safely(async () => { + if (input.signal?.aborted) return; + await writeRecord( + cacheKey(input.serverId, input.cwd), + { + version: CACHE_VERSION, + hash: input.hash, + generatedAt: input.generatedAt, + fetchedAt: input.fetchedAt, + }, + { + key: bodyKey(input.serverId, input.hash), + input: { + version: CACHE_VERSION, + hash: input.hash, + generatedAt: input.generatedAt, + compactSnapshot: input.compactSnapshot, + }, + }, + ); + }, undefined); }, }; } diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 5a59be037c..868756ee46 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -1,3 +1,14 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages"; +import { + providerSnapshotCache, + ProviderSnapshotCacheMissError, + type ProviderSnapshotCache, +} from "./provider-snapshot-cache"; +import { queryClient as singletonQueryClient } from "./query-client"; +import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name"; +import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; @@ -34,3 +45,106 @@ export function providersSnapshotRequestOptions(input: { export function isProvidersSnapshotHomeScope(cwd?: string | null): boolean { return normalizeProvidersSnapshotCwd(cwd) === null; } + +type Snapshot = GetProvidersSnapshotResponseMessage["payload"]; +type SnapshotClient = Pick; + +export async function fetchProvidersSnapshot(input: { + client: SnapshotClient; + serverId: string; + cwd: string | null; + queryClient?: QueryClient; + cache?: ProviderSnapshotCache; + signal?: AbortSignal; + snapshot?: Snapshot; +}): Promise { + const cache = input.cache ?? providerSnapshotCache; + const queryClient = input.queryClient ?? singletonQueryClient; + const cached = input.snapshot ? null : await cache.read(input.serverId, input.cwd); + let snapshot = + input.snapshot ?? + (await input.client.getProvidersSnapshot( + providersSnapshotRequestOptions({ cwd: input.cwd, ifNoneMatch: cached?.hash }), + )); + if (snapshot.snapshotHash && !snapshot.compactSnapshot) { + const known = + cached?.hash === snapshot.snapshotHash + ? cached + : await cache.readHash(input.serverId, snapshot.snapshotHash); + if (known) snapshot = { ...snapshot, compactSnapshot: known.compactSnapshot }; + else if (snapshot.notModified) throw new ProviderSnapshotCacheMissError(); + else { + const hash = snapshot.snapshotHash; + const body = await queryClient.fetchQuery({ + queryKey: ["providerSnapshotContent", input.serverId, hash], + gcTime: 0, + staleTime: 0, + retry: false, + queryFn: async () => { + const response = await input.client.getProvidersSnapshot( + providersSnapshotRequestOptions({ cwd: input.cwd }), + ); + // Materialize before settling so simultaneous directory announcements share + // both the transfer and decoded body through the existing query deduplication. + return cache.materialize(input.serverId, response); + }, + }); + if (body.snapshotHash === hash) { + snapshot = { ...snapshot, compactSnapshot: body.compactSnapshot }; + } else if (normalizeProvidersSnapshotCwd(body.cwd) === input.cwd) { + snapshot = body; + } else { + snapshot = await input.client.getProvidersSnapshot( + providersSnapshotRequestOptions({ cwd: input.cwd }), + ); + } + } + } + input.signal?.throwIfAborted(); + snapshot = await cache.materialize(input.serverId, snapshot); + if (snapshot.compactSnapshot && snapshot.snapshotHash) { + await cache.write({ + serverId: input.serverId, + cwd: input.cwd, + hash: snapshot.snapshotHash, + generatedAt: snapshot.generatedAt, + fetchedAt: snapshot.fetchedAt, + compactSnapshot: snapshot.compactSnapshot, + signal: input.signal, + }); + } + input.signal?.throwIfAborted(); + replaceProviderSnapshotIcons(input.serverId, snapshot.entries); + return snapshot; +} + +export async function refreshAndApplyProvidersSnapshot(input: { + client: SnapshotClient & Pick; + queryClient: QueryClient; + serverId: string; + cwd: string | null; + providers?: AgentProvider[]; + cache?: ProviderSnapshotCache; +}) { + const result = await input.client.refreshProvidersSnapshot( + providersSnapshotRequestOptions(input), + ); + const queryKey = providersSnapshotQueryKey(input.serverId, input.cwd); + await input.queryClient.cancelQueries({ queryKey, exact: true }); + await input.queryClient.fetchQuery({ + queryKey, + staleTime: 0, + queryFn: ({ signal }) => fetchProvidersSnapshot({ ...input, signal }), + }); + void input.queryClient.invalidateQueries({ + queryKey: agentCommandsQueryRoot(input.serverId), + exact: false, + }); + if (isProvidersSnapshotHomeScope(input.cwd)) { + void input.queryClient.invalidateQueries({ + queryKey: providersSnapshotQueryRoot(input.serverId), + exact: false, + }); + } + return result; +} diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index f468a3f928..7bc9ea2b51 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -96,6 +96,9 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} return { client: { + async getProvidersSnapshot() { + throw new Error("Unexpected snapshot pull"); + }, on, async subscribeCheckoutDiff(cwd, compare, requestOptions) { subscribeCheckoutDiffCalls.push({ @@ -143,7 +146,7 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage { } describe("server data push router", () => { - it("routes provider snapshot and daemon config payloads until detached", () => { + it("routes provider snapshot and daemon config payloads until detached", async () => { const queryClient = new QueryClient(); const fake = createFakeClient(); const serverId = "server-1"; @@ -157,22 +160,26 @@ describe("server data push router", () => { payload: { status: "daemon_config_changed", config: daemonConfig }, }); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({ - entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], - generatedAt: "2026-01-01T00:00:00.000Z", - requestId: "providers_snapshot_update", - }); + await expect + .poll(() => queryClient.getQueryData(providersSnapshotQueryKey(serverId))) + .toEqual({ + entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], + generatedAt: "2026-01-01T00:00:00.000Z", + requestId: "providers_snapshot_update", + }); expect(queryClient.getQueryData(daemonConfigQueryKey(serverId))).toEqual(daemonConfig); expect(queryClient.getQueryState(pairingOfferKey)?.isInvalidated).toBe(true); unmount(); fake.emit(providerUpdate("2026-01-01T00:00:01.000Z")); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({ - entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], - generatedAt: "2026-01-01T00:00:00.000Z", - requestId: "providers_snapshot_update", - }); + await expect + .poll(() => queryClient.getQueryData(providersSnapshotQueryKey(serverId))) + .toEqual({ + entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], + generatedAt: "2026-01-01T00:00:00.000Z", + requestId: "providers_snapshot_update", + }); }); it("subscribes active checkout diff queries and writes matching diff events", () => { diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 74002bac2e..1cdf555874 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -5,13 +5,13 @@ import type { SessionOutboundMessage, } from "@getpaseo/protocol/messages"; import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; -import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name"; import { orderCheckoutDiffFiles } from "@/git/diff-order"; import { daemonConfigQueryKey } from "@/data/daemon-config"; import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing"; -import { providerSnapshotCache, type ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; +import { type ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; import { normalizeProvidersSnapshotCwd, + fetchProvidersSnapshot, providersSnapshotQueryKey, providersSnapshotQueryRoot, } from "@/data/providers-snapshot"; @@ -69,6 +69,7 @@ export interface ServerDataQueryMeta extends Record { export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage; interface ServerDataPushClient { + getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"]; on( type: TType, handler: (message: Extract) => void, @@ -186,36 +187,40 @@ export function invalidateServerDataQueriesAfterReconnect(input: { } } -export function applyProvidersSnapshotUpdate(input: { +export async function applyProvidersSnapshotUpdate(input: { serverId: string; queryClient: QueryClient; message: ProvidersSnapshotUpdate; + client: Pick; cache?: ProviderSnapshotCache; -}): void { - if (input.message.type !== "providers_snapshot_update") { - return; - } - replaceProviderSnapshotIcons(input.serverId, input.message.payload.entries); - const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd); - input.queryClient.setQueryData(queryKey, { - entries: input.message.payload.entries, - generatedAt: input.message.payload.generatedAt, - requestId: "providers_snapshot_update", +}): Promise { + const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" }; + const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd); + const queryKey = providersSnapshotQueryKey(input.serverId, cwd); + const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey); + let announcement: typeof snapshot | undefined = snapshot; + void input.queryClient.cancelQueries({ queryKey, exact: true }); + await input.queryClient.fetchQuery({ + queryKey, + staleTime: 0, + retry: false, + queryFn: ({ signal }) => { + const incoming = announcement; + announcement = undefined; + return fetchProvidersSnapshot({ + ...input, + cwd, + snapshot: incoming, + signal, + }); + }, }); - const { compactSnapshot, snapshotHash } = input.message.payload; - if (compactSnapshot && snapshotHash) { - void (input.cache ?? providerSnapshotCache).write({ - serverId: input.serverId, - cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd), - hash: snapshotHash, - generatedAt: input.message.payload.generatedAt, - compactSnapshot, + if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) { + void input.queryClient.invalidateQueries({ + queryKey: agentCommandsQueryRoot(input.serverId), + exact: false, }); } - void input.queryClient.invalidateQueries({ - queryKey: agentCommandsQueryRoot(input.serverId), - exact: false, - }); } export function mountServerDataPushRouter(input: PushRouterInput): () => void { @@ -285,10 +290,13 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void { reconcileSubscriptions(); }); const unsubscribeProviders = input.client.on("providers_snapshot_update", (message) => { - applyProvidersSnapshotUpdate({ + void applyProvidersSnapshotUpdate({ + client: input.client, queryClient: input.queryClient, serverId: input.serverId, message, + }).catch(() => { + /* Query state owns fetch failures; reconnect/refetch repairs them. */ }); }); const unsubscribeDaemonConfig = input.client.on("status", (message) => { diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 456ce249c2..26908bd6a0 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -6,7 +6,6 @@ import type { AgentProvider, ProviderSnapshotEntry, } from "@getpaseo/protocol/agent-types"; -import { useHosts } from "@/runtime/host-runtime"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { buildSelectableProviderSelectorProviders, @@ -27,7 +26,6 @@ import { normalizeSelectedModelId, resolveDefaultModelId, mergeSelectedComposerPreferences, - combineInitialValues, buildProviderDefinitionMap, buildProviderDefinitionMapForStatuses, INITIAL_AGENT_FORM_RESOLUTION, @@ -43,18 +41,15 @@ import type { MaterializedAgentProfile } from "@/agent-profiles"; export type { FormInitialValues } from "@/provider-selection/resolve-agent-form"; export interface UseAgentFormStateOptions { - initialServerId?: string | null; + serverId: string | null; + workingDir: string; initialValues?: FormInitialValues; isVisible?: boolean; isCreateFlow?: boolean; - isTargetDaemonReady?: boolean; - onlineServerIds?: string[]; } export interface UseAgentFormStateResult { selectedServerId: string | null; - setSelectedServerId: (value: string | null) => void; - setSelectedServerIdFromUser: (value: string | null) => void; selectedProvider: AgentProvider | null; selectedMode: string; setModeFromUser: (modeId: string) => void; @@ -63,8 +58,6 @@ export interface UseAgentFormStateResult { selectedThinkingOptionId: string; setThinkingOptionFromUser: (thinkingOptionId: string) => void; workingDir: string; - setWorkingDir: (value: string) => void; - setWorkingDirFromUser: (value: string) => void; providerDefinitions: AgentProviderDefinition[]; providerDefinitionMap: Map; agentDefinition?: AgentProviderDefinition; @@ -87,59 +80,6 @@ export interface UseAgentFormStateResult { persistFormPreferences: () => Promise; } -function shouldAutoSelectServerId(input: { - isVisible: boolean; - isCreateFlow: boolean; - isPreferencesLoading: boolean; - userModifiedServerId: boolean; - initialServerId: string | null | undefined; - currentServerId: string | null; -}): boolean { - const { - isVisible, - isCreateFlow, - isPreferencesLoading, - userModifiedServerId, - initialServerId, - currentServerId, - } = input; - if (!isVisible || !isCreateFlow) return false; - if (isPreferencesLoading) return false; - if (userModifiedServerId) return false; - if (initialServerId !== undefined) return false; - if (currentServerId) return false; - return true; -} - -function resolutionIntentKeyPart(value: string | null | undefined): string { - if (value === undefined) return "undefined"; - if (value === null) return "null"; - return value; -} - -function buildResolutionIntentKey(initialValues: FormInitialValues | undefined): string { - if (!initialValues) return "none"; - // workingDir seeds the open request, but locked cwd updates must not re-run - // provider/model/mode resolution after the form has settled. - return [ - resolutionIntentKeyPart(initialValues.serverId), - resolutionIntentKeyPart(initialValues.provider), - resolutionIntentKeyPart(initialValues.modeId), - resolutionIntentKeyPart(initialValues.model), - resolutionIntentKeyPart(initialValues.thinkingOptionId), - ].join("\n"); -} - -function hasSnapshotDataForResolution(input: { - serverId: string | null; - snapshotEntries: ProviderSnapshotEntry[] | undefined; -}): boolean { - if (!input.serverId) { - return false; - } - return input.snapshotEntries !== undefined; -} - function resolveSelectedProviderModes(input: { selectedEntry: ProviderSnapshotEntry | null; provider: AgentProvider | null; @@ -170,7 +110,10 @@ function buildProviderModelsByProvider( ): ProviderModelsByProvider { const map: ProviderModelsByProvider = new Map(); for (const entry of snapshotEntries ?? []) { - map.set(entry.provider, filterSelectableModels(entry.models ?? null)); + map.set( + entry.provider, + entry.status === "ready" ? filterSelectableModels(entry.models ?? null) : null, + ); } return map; } @@ -201,15 +144,8 @@ async function persistProviderPreferences(input: { ); } -export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult { - const { - initialServerId = null, - initialValues, - isVisible = true, - isCreateFlow = true, - isTargetDaemonReady: _isTargetDaemonReady = true, - onlineServerIds = [], - } = options; +export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFormStateResult { + const { serverId, initialValues, workingDir, isVisible = true, isCreateFlow = true } = options; const { preferences, isLoading: isPreferencesLoading, updatePreferences } = useFormPreferences(); const preferenceOverlayRef = useRef(new OptimisticFormPreferences(preferences)); @@ -235,32 +171,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg [updatePreferences], ); - const daemons = useHosts(); - - const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]); - - const [{ form: formState, userModified, resolution }, dispatch] = useReducer( - resolveAgentForm, - initialServerId, - (serverId) => ({ - form: { - serverId, - provider: null, - modeId: "", - model: "", - thinkingOptionId: "", - workingDir: "", - }, - userModified: INITIAL_USER_MODIFIED, - resolution: INITIAL_AGENT_FORM_RESOLUTION, - }), - ); - - useEffect(() => { - if (!isVisible) { - dispatch({ type: "RESET" }); - } - }, [isVisible]); + const [{ form: formState, userModified, resolution }, dispatch] = useReducer(resolveAgentForm, { + form: { provider: null, modeId: "", model: "", thinkingOptionId: "" }, + userModified: INITIAL_USER_MODIFIED, + resolution: INITIAL_AGENT_FORM_RESOLUTION, + }); const { entries: snapshotEntries, @@ -269,7 +184,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg error: snapshotError, refresh: refreshSnapshot, refetchIfStale: refetchSnapshotIfStale, - } = useProvidersSnapshot(formState.serverId, { cwd: formState.workingDir }); + } = useProvidersSnapshot(serverId, { cwd: workingDir }); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const snapshotProviderDefinitions = useMemo( @@ -335,91 +250,31 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading; const isAllModelsLoading = isModelSelectionLoading; - const combinedInitialValues = useMemo( - () => combineInitialValues(initialValues, initialServerId), - [initialValues, initialServerId], - ); - const resolutionIntentKey = useMemo( - () => buildResolutionIntentKey(combinedInitialValues), - [combinedInitialValues], - ); - useEffect(() => { - if (!isVisible || !isCreateFlow) { - return; - } - - dispatch({ type: "REQUEST_RESOLUTION" }); - }, [isVisible, isCreateFlow, resolutionIntentKey]); - - useEffect(() => { - if (!isVisible || !isCreateFlow || resolution.status !== "pending") { - return; - } - if (isPreferencesLoading) { - return; - } - if ( - !hasSnapshotDataForResolution({ - serverId: formState.serverId, - snapshotEntries, - }) - ) { - return; - } - dispatch({ - type: "COMPLETE_RESOLUTION", - initialValues: combinedInitialValues, + type: "INPUTS_CHANGED", + serverId, + isVisible, + isCreateFlow, + isPreferencesLoading, + hasSnapshot: snapshotEntries !== undefined, + initialValues, preferences, providerModelsByProvider: snapshotProviderModelsByProvider, allowedProviderMap: snapshotResolvableProviderDefinitionMap, }); }, [ - combinedInitialValues, - formState.serverId, + serverId, + isVisible, isCreateFlow, isPreferencesLoading, - isVisible, - preferences, - resolution.status, snapshotEntries, + initialValues, + preferences, snapshotProviderModelsByProvider, snapshotResolvableProviderDefinitionMap, ]); - const onlineServerIdsKey = onlineServerIds.join("|"); - useEffect(() => { - const canAutoSelectServerId = shouldAutoSelectServerId({ - isVisible, - isCreateFlow, - isPreferencesLoading, - userModifiedServerId: userModified.serverId, - initialServerId: combinedInitialValues?.serverId, - currentServerId: formState.serverId, - }); - if (!canAutoSelectServerId) return; - - const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null; - if (!candidate) return; - - dispatch({ type: "AUTO_SELECT_SERVER", candidateServerId: candidate }); - }, [ - combinedInitialValues?.serverId, - isCreateFlow, - isPreferencesLoading, - isVisible, - onlineServerIds, - onlineServerIdsKey, - formState.serverId, - userModified.serverId, - validServerIds, - ]); - - const setSelectedServerIdFromUser = useCallback((value: string | null) => { - dispatch({ type: "SET_SERVER_ID_FROM_USER", value }); - }, []); - const setProviderAndModelFromUser = useCallback( (provider: AgentProvider, modelId: string) => { if (!selectableProviderDefinitionMap.has(provider)) { @@ -579,18 +434,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg [formState, updateCurrentPreferences], ); - const setWorkingDir = useCallback((value: string) => { - dispatch({ type: "SET_WORKING_DIR", value }); - }, []); - - const setWorkingDirFromUser = useCallback((value: string) => { - dispatch({ type: "SET_WORKING_DIR_FROM_USER", value }); - }, []); - - const setSelectedServerId = useCallback((value: string | null) => { - dispatch({ type: "SET_SERVER_ID", value }); - }, []); - const refreshProviderModels = useCallback( (provider?: AgentProvider) => { void refreshSnapshot(provider ? [provider] : undefined); @@ -626,13 +469,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const isModelLoading = isModelSelectionLoading; const modelError = snapshotError; - const workingDirIsEmpty = !formState.workingDir.trim(); + const workingDirIsEmpty = !workingDir.trim(); return useMemo( () => ({ - selectedServerId: formState.serverId, - setSelectedServerId, - setSelectedServerIdFromUser, + selectedServerId: serverId, selectedProvider: formState.provider, selectedMode: formState.modeId, setModeFromUser, @@ -640,9 +481,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg setModelFromUser, selectedThinkingOptionId: formState.thinkingOptionId, setThinkingOptionFromUser, - workingDir: formState.workingDir, - setWorkingDir, - setWorkingDirFromUser, + workingDir, providerDefinitions, providerDefinitionMap, agentDefinition, @@ -665,19 +504,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg persistFormPreferences, }), [ - formState.serverId, + serverId, formState.provider, formState.modeId, formState.model, formState.thinkingOptionId, - formState.workingDir, - setSelectedServerId, - setSelectedServerIdFromUser, + workingDir, setModeFromUser, setModelFromUser, setThinkingOptionFromUser, - setWorkingDir, - setWorkingDirFromUser, providerDefinitions, providerDefinitionMap, agentDefinition, diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 08eaa5130f..83f62b4812 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; -import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; +import { + compactProviderSnapshot, + expandProviderSnapshot, +} from "@getpaseo/protocol/provider-snapshot-codec"; import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; import { resolveProviderIconName } from "@/components/provider-icon-name"; @@ -82,12 +85,28 @@ function codexEntry( const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const; const serverId = "server-1"; -function createCache(initial: CachedProviderSnapshot | null = null): ProviderSnapshotCache & { +function createCache( + initial: CachedProviderSnapshot | null = null, + bodyResident = true, +): ProviderSnapshotCache & { writes: Parameters[0][]; } { const writes: Parameters[0][] = []; return { writes, + async readHash(_serverId, hash) { + return bodyResident && initial?.hash === hash ? initial : null; + }, + async materialize(_serverId, snapshot) { + if (initial && initial.hash === snapshot.snapshotHash) + return { ...snapshot, entries: initial.entries }; + return { + ...snapshot, + entries: snapshot.compactSnapshot + ? expandProviderSnapshot(snapshot.compactSnapshot) + : snapshot.entries, + }; + }, async read() { return initial; }, @@ -98,7 +117,7 @@ function createCache(initial: CachedProviderSnapshot | null = null): ProviderSna } describe("providersSnapshotQueryKey", () => { - it("uses separate keys for home and workspace scopes", () => { + it("uses separate keys for home and workspace scopes", async () => { expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, "home"]); expect(providersSnapshotQueryKey(serverId, "/repo-a")).toEqual([ "providersSnapshot", @@ -149,16 +168,19 @@ describe("fetchProvidersSnapshot", () => { expect(client.getCalls).toEqual([{ cwd: "/repo-a" }]); }); - it("reuses a cached snapshot when the daemon reports its hash unchanged", async () => { + it("reuses a not-modified response even if its body was evicted during the request", async () => { const entries = [codexEntry("ready", [readyCodexModel])]; const compactSnapshot = compactProviderSnapshot(entries); - const cache = createCache({ - version: 1, - hash: "snapshot-hash", - generatedAt: "2026-01-01T00:00:00.000Z", - compactSnapshot, - entries, - }); + const cache = createCache( + { + version: 2, + hash: "snapshot-hash", + generatedAt: "2026-01-01T00:00:00.000Z", + compactSnapshot, + entries, + }, + false, + ); const client = createClient({ snapshots: [ { @@ -180,7 +202,7 @@ describe("fetchProvidersSnapshot", () => { expect(client.getCalls).toEqual([{ cwd: "/repo-a", ifNoneMatch: "snapshot-hash" }]); expect(snapshot.entries).toBe(entries); - expect(cache.writes).toEqual([]); + expect(cache.writes).toHaveLength(1); }); it("persists a changed compact snapshot for the next launch", async () => { @@ -201,7 +223,7 @@ describe("fetchProvidersSnapshot", () => { await fetchProvidersSnapshot({ client, serverId, cwd: "/repo-a", cache }); - expect(cache.writes).toEqual([ + expect(cache.writes.map(({ signal: _signal, ...write }) => write)).toEqual([ { serverId, cwd: "/repo-a", @@ -342,8 +364,9 @@ describe("applyProvidersSnapshotUpdate", () => { }; } - it("routes updates to the home query cache when the message carries no cwd", () => { - applyProvidersSnapshotUpdate({ + it("routes updates to the home query cache when the message carries no cwd", async () => { + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])]), @@ -356,16 +379,17 @@ describe("applyProvidersSnapshotUpdate", () => { }); }); - it("routes workspace updates to the matching scope without touching siblings", () => { + it("routes workspace updates to the matching scope without touching siblings", async () => { queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([])); - applyProvidersSnapshotUpdate({ + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), }); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toEqual({ + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toMatchObject({ entries: [codexEntry("ready", [readyCodexModel])], generatedAt: "2026-01-01T00:00:01.000Z", requestId: "providers_snapshot_update", @@ -375,7 +399,7 @@ describe("applyProvidersSnapshotUpdate", () => { ); }); - it("persists compact push updates", () => { + it("persists compact push updates", async () => { const entries = [codexEntry("ready", [readyCodexModel])]; const compactSnapshot = compactProviderSnapshot(entries); const cache = createCache(); @@ -383,9 +407,15 @@ describe("applyProvidersSnapshotUpdate", () => { message.payload.compactSnapshot = compactSnapshot; message.payload.snapshotHash = "push-hash"; - applyProvidersSnapshotUpdate({ serverId, queryClient, message, cache }); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message, + cache, + }); - expect(cache.writes).toEqual([ + expect(cache.writes.map(({ signal: _signal, ...write }) => write)).toEqual([ { serverId, cwd: "/repo-a", @@ -396,7 +426,7 @@ describe("applyProvidersSnapshotUpdate", () => { ]); }); - it("applies Windows daemon updates to app-normalized workspace paths", () => { + it("applies Windows daemon updates to app-normalized workspace paths", async () => { const workspaceCwd = "C:/Users/Ezekiel Bulver/project"; const daemonCwd = "C:\\Users\\Ezekiel Bulver\\project"; queryClient.setQueryData( @@ -404,27 +434,31 @@ describe("applyProvidersSnapshotUpdate", () => { providersSnapshot([codexEntry("loading")]), ); - applyProvidersSnapshotUpdate({ + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])], daemonCwd), }); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, workspaceCwd))).toEqual({ + expect( + queryClient.getQueryData(providersSnapshotQueryKey(serverId, workspaceCwd)), + ).toMatchObject({ entries: [codexEntry("ready", [readyCodexModel])], generatedAt: "2026-01-01T00:00:01.000Z", requestId: "providers_snapshot_update", }); }); - it("invalidates cached agent commands when a provider snapshot update arrives", () => { + it("invalidates cached agent commands when a provider snapshot update arrives", async () => { const commandsKey = draftAgentCommandsQueryKey({ serverId, draftConfig: { provider: "codex", cwd: "/repo-a" }, }); queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]); - applyProvidersSnapshotUpdate({ + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), @@ -435,7 +469,7 @@ describe("applyProvidersSnapshotUpdate", () => { }); describe("selectorOpenRefetchDecision", () => { - it("refetches stale entries when no provider is selected", () => { + it("refetches stale entries when no provider is selected", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("ready", [readyCodexModel])], @@ -444,13 +478,13 @@ describe("selectorOpenRefetchDecision", () => { ).toBe("refetch-stale"); }); - it("forces a refetch when the selected provider has no entry", () => { + it("forces a refetch when the selected provider has no entry", async () => { expect(selectorOpenRefetchDecision({ entries: [], selectedProvider: "codex" })).toBe( "refetch-always", ); }); - it("forces a refetch when the selected provider is still loading", () => { + it("forces a refetch when the selected provider is still loading", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("loading")], @@ -459,7 +493,7 @@ describe("selectorOpenRefetchDecision", () => { ).toBe("refetch-always"); }); - it("keeps a stale-only refetch when the selected provider is ready with no models", () => { + it("keeps a stale-only refetch when the selected provider is ready with no models", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("ready", [])], @@ -468,7 +502,7 @@ describe("selectorOpenRefetchDecision", () => { ).toBe("refetch-stale"); }); - it("keeps a stale-only refetch when the selected provider is ready with models", () => { + it("keeps a stale-only refetch when the selected provider is ready with models", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("ready", [readyCodexModel])], diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 52fff6e36a..f79bcbc065 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from "react"; -import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -9,92 +9,25 @@ import { useSessionStore } from "@/stores/session-store"; import { useReplicaQuery } from "@/data/query"; import { queryClient as singletonQueryClient } from "@/data/query-client"; import { - providerSnapshotCache, - ProviderSnapshotCacheMissError, - type ProviderSnapshotCache, -} from "@/data/provider-snapshot-cache"; -import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; -import { - isProvidersSnapshotHomeScope, normalizeProvidersSnapshotCwd, providersSnapshotQueryKey, providersSnapshotQueryRoot, - providersSnapshotRequestOptions, + fetchProvidersSnapshot, + refreshAndApplyProvidersSnapshot, } from "@/data/providers-snapshot"; -import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name"; -type GetProvidersSnapshotResult = Awaited>; -type RefreshProvidersSnapshotResult = Awaited>; - -export { providersSnapshotQueryKey, providersSnapshotQueryRoot }; +export { + providersSnapshotQueryKey, + providersSnapshotQueryRoot, + fetchProvidersSnapshot, + refreshAndApplyProvidersSnapshot, +}; export type ProvidersSnapshotClient = Pick< DaemonClient, "getProvidersSnapshot" | "refreshProvidersSnapshot" >; -export async function fetchProvidersSnapshot(input: { - client: ProvidersSnapshotClient; - serverId: string; - cwd: string | null; - cache?: ProviderSnapshotCache; -}): Promise { - const cache = input.cache ?? providerSnapshotCache; - const cached = await cache.read(input.serverId, input.cwd); - const snapshot = await input.client.getProvidersSnapshot( - providersSnapshotRequestOptions({ cwd: input.cwd, ifNoneMatch: cached?.hash }), - ); - if (snapshot.notModified) { - if (!cached) { - throw new ProviderSnapshotCacheMissError(); - } - replaceProviderSnapshotIcons(input.serverId, cached.entries); - return { ...snapshot, entries: cached.entries }; - } - replaceProviderSnapshotIcons(input.serverId, snapshot.entries); - if (snapshot.compactSnapshot && snapshot.snapshotHash) { - await cache.write({ - serverId: input.serverId, - cwd: input.cwd, - hash: snapshot.snapshotHash, - generatedAt: snapshot.generatedAt, - compactSnapshot: snapshot.compactSnapshot, - }); - } - return snapshot; -} - -export async function refreshAndApplyProvidersSnapshot(input: { - client: ProvidersSnapshotClient; - queryClient: QueryClient; - serverId: string; - cwd: string | null; - providers?: AgentProvider[]; - cache?: ProviderSnapshotCache; -}): Promise { - const refreshResult = await input.client.refreshProvidersSnapshot( - providersSnapshotRequestOptions({ cwd: input.cwd, providers: input.providers }), - ); - const snapshot = await fetchProvidersSnapshot({ - client: input.client, - serverId: input.serverId, - cwd: input.cwd, - cache: input.cache, - }); - input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot); - void input.queryClient.invalidateQueries({ - queryKey: agentCommandsQueryRoot(input.serverId), - exact: false, - }); - if (isProvidersSnapshotHomeScope(input.cwd)) { - void input.queryClient.invalidateQueries({ - queryKey: providersSnapshotQueryRoot(input.serverId), - exact: false, - }); - } - return refreshResult; -} - export type SelectorOpenRefetchDecision = "refetch-stale" | "refetch-always"; export function selectorOpenRefetchDecision(input: { @@ -148,11 +81,11 @@ export function useProvidersSnapshot( queryKey, enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected), pushEvent: "providers_snapshot_update", - queryFn: async () => { + queryFn: async ({ signal }) => { if (!client || !serverId) { throw new Error(t("workspace.terminal.hostDisconnected")); } - return fetchProvidersSnapshot({ client, serverId, cwd }); + return fetchProvidersSnapshot({ client, serverId, cwd, queryClient, signal }); }, }); @@ -216,6 +149,7 @@ export function prefetchProvidersSnapshot( void singletonQueryClient.prefetchQuery({ queryKey, staleTime: Infinity, - queryFn: () => fetchProvidersSnapshot({ client, serverId, cwd }), + queryFn: ({ signal }) => + fetchProvidersSnapshot({ client, serverId, cwd, queryClient: singletonQueryClient, signal }), }); } diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index 5b6cc2a509..3912d87e13 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"; import { resolveAgentForm, resolveFormState, + resolveEffectiveModel, resolveThinkingOptionId, mergeSelectedComposerPreferences, - combineInitialValues, buildProviderDefinitionMap, buildProviderDefinitionMapForStatuses, resolveDefaultModel, @@ -90,12 +90,11 @@ function makeState( ): AgentFormReducerState { return { form: { - serverId: null, provider: null, modeId: "", model: "", thinkingOptionId: "", - workingDir: "", + ...overrides, }, userModified: { ...INITIAL_USER_MODIFIED, ...modified }, @@ -262,35 +261,6 @@ describe("resolveThinkingOptionId", () => { }); }); -describe("combineInitialValues", () => { - it("returns undefined when no initial values and no initial server id", () => { - expect(combineInitialValues(undefined, null)).toBeUndefined(); - }); - - it("does not inject a null serverId override when initialValues are present but serverId is absent", () => { - const combined = combineInitialValues({}, null); - expect(combined).toEqual({}); - expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false); - }); - - it("injects serverId from options when provided", () => { - expect(combineInitialValues({}, "daemon-1")).toEqual({ serverId: "daemon-1" }); - }); - - it("keeps other initial values without forcing serverId", () => { - const combined = combineInitialValues({ workingDir: "/repo" }, null); - expect(combined).toEqual({ workingDir: "/repo" }); - expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false); - }); - - it("respects an explicit serverId override (including null) over initialServerId", () => { - expect(combineInitialValues({ serverId: null }, "daemon-1")).toEqual({ serverId: null }); - expect(combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({ - serverId: "daemon-2", - }); - }); -}); - describe("mergeSelectedComposerPreferences", () => { it("stores the selected model for the selected provider", () => { expect( @@ -534,7 +504,7 @@ describe("resolveFormState", () => { expect(resolved.thinkingOptionId).toBe("low"); }); - it("clears an invalid provider instead of falling back to the first allowed provider", () => { + it("preserves the remembered provider when it is absent from the available catalogue", () => { const resolved = resolveFormState( undefined, { provider: "codex" }, @@ -545,7 +515,7 @@ describe("resolveFormState", () => { claudeProviderMap, ); - expect(resolved.provider).toBeNull(); + expect(resolved.provider).toBe("codex"); }); it("preserves a user-selected provider and model while that provider is loading during refresh", () => { @@ -582,12 +552,10 @@ describe("resolveFormState", () => { {}, null, { - serverId: false, provider: true, modeId: true, model: true, thinkingOptionId: true, - workingDir: false, }, makeState({ provider: "codex", @@ -677,7 +645,7 @@ describe("resolveFormState", () => { expect(resolved.modeId).toBe("auto"); }); - it("ignores disabled ready providers when resolving selectable defaults", () => { + it("preserves saved intent even when a provider is disabled", () => { const entries: ProviderSnapshotEntry[] = [ { provider: "codex", @@ -715,8 +683,8 @@ describe("resolveFormState", () => { selectableProviderMap, ); - expect(resolved.provider).toBe("codex"); - expect(resolved.modeId).toBe("auto"); + expect(resolved.provider).toBe("claude"); + expect(resolved.modeId).toBe(""); }); it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => { @@ -752,7 +720,7 @@ describe("resolveFormState", () => { expect(providerDefinitions.map((d) => d.id)).toEqual(["codex", "claude"]); }); - it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => { + it("preserves a user-selected provider when the refreshed snapshot marks it unavailable", () => { const unavailableEntries: ProviderSnapshotEntry[] = [ { provider: "codex", @@ -785,7 +753,13 @@ describe("resolveFormState", () => { undefined, {}, null, - { ...INITIAL_USER_MODIFIED, provider: true }, + { + ...INITIAL_USER_MODIFIED, + provider: true, + modeId: true, + model: true, + thinkingOptionId: true, + }, makeState({ provider: "codex", modeId: "full-access", @@ -796,10 +770,10 @@ describe("resolveFormState", () => { resolvableProviderMap, ); - expect(resolved.provider).toBeNull(); - expect(resolved.modeId).toBe(""); - expect(resolved.model).toBe(""); - expect(resolved.thinkingOptionId).toBe(""); + expect(resolved.provider).toBe("codex"); + expect(resolved.modeId).toBe("full-access"); + expect(resolved.model).toBe("gpt-5.3-codex"); + expect(resolved.thinkingOptionId).toBe("xhigh"); }); it("does not force fallback provider when allowed provider map is empty", () => { @@ -817,8 +791,63 @@ describe("resolveFormState", () => { }); }); +it("keeps the explicit model when a refreshed catalogue no longer lists it", () => { + const resolved = resolveFormState( + undefined, + { provider: "codex", providerPreferences: { codex: { model: "gpt-6-astra" } } }, + CODEX_MODELS, + INITIAL_USER_MODIFIED, + makeState().form, + codexProviderMap, + ); + expect(resolved.model).toBe("gpt-6-astra"); + // Label/persistence lookup must not reinterpret the submitted ID as another model. + expect(resolveEffectiveModel(CODEX_MODELS, resolved.model)).toBeNull(); +}); + describe("resolveAgentForm", () => { describe("resolution state", () => { + it.each(["error", "unavailable"] as const)( + "restores a remembered model after opening against a %s provider snapshot", + (status) => { + const preferences = { + provider: "codex", + providerPreferences: { codex: { model: "gpt-5.3-codex" } }, + }; + const snapshotEntries: ProviderSnapshotEntry[] = [ + { + provider: "codex", + enabled: true, + status, + models: [], + }, + ]; + const opened = resolveAgentForm(makeState(), { + type: "COMPLETE_RESOLUTION", + initialValues: undefined, + preferences, + providerModelsByProvider: makeProviderModelsByProvider([["codex", []]]), + allowedProviderMap: buildProviderDefinitionMapForStatuses({ + snapshotEntries, + providerDefinitions: buildProviderDefinitions(snapshotEntries), + statuses: new Set(["ready", "loading"]), + }), + }); + const recovered = resolveAgentForm(opened, { + type: "COMPLETE_RESOLUTION", + initialValues: undefined, + preferences, + providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]), + allowedProviderMap: codexProviderMap, + }); + + expect(recovered.form).toMatchObject({ + provider: "codex", + model: "gpt-5.3-codex", + }); + }, + ); + it("requests resolution without changing the current form values", () => { const state = makeState( { provider: "codex", modeId: "auto", model: "gpt-5.3-codex" }, @@ -833,7 +862,7 @@ describe("resolveAgentForm", () => { }); it("completes a pending open resolution when snapshot models arrive late", () => { - const state = resolveAgentForm(makeState({ serverId: "host-1" }), { + const state = resolveAgentForm(makeState(), { type: "REQUEST_RESOLUTION", }); const next = resolveAgentForm(state, { @@ -855,7 +884,7 @@ describe("resolveAgentForm", () => { }); it("does not change settled selection when a background snapshot has different defaults", () => { - const settled = resolveAgentForm(makeState({ serverId: "host-1" }), { + const settled = resolveAgentForm(makeState(), { type: "COMPLETE_RESOLUTION", initialValues: undefined, preferences: { @@ -885,7 +914,7 @@ describe("resolveAgentForm", () => { }); it("prefills edit hydration from initial values", () => { - const state = makeState({ serverId: "host-1" }); + const state = makeState(); const next = resolveAgentForm(state, { type: "COMPLETE_RESOLUTION", initialValues: { @@ -893,7 +922,6 @@ describe("resolveAgentForm", () => { modeId: "full-access", model: "gpt-5.3-codex", thinkingOptionId: "low", - workingDir: "/repo", }, preferences: { provider: "claude" }, providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]), @@ -904,7 +932,6 @@ describe("resolveAgentForm", () => { expect(next.form.modeId).toBe("full-access"); expect(next.form.model).toBe("gpt-5.3-codex"); expect(next.form.thinkingOptionId).toBe("low"); - expect(next.form.workingDir).toBe("/repo"); }); it("keeps a user model change after resolution has completed", () => { @@ -912,7 +939,7 @@ describe("resolveAgentForm", () => { ...CODEX_MODELS, { provider: "codex", id: "gpt-5.4-codex", label: "gpt-5.4-codex" }, ]; - const settled = resolveAgentForm(makeState({ serverId: "host-1" }), { + const settled = resolveAgentForm(makeState(), { type: "COMPLETE_RESOLUTION", initialValues: undefined, preferences: { @@ -958,26 +985,6 @@ describe("resolveAgentForm", () => { }); }); - describe("SET_SERVER_ID", () => { - it("updates serverId without marking it user-modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { type: "SET_SERVER_ID", value: "host-1" }); - - expect(next.form.serverId).toBe("host-1"); - expect(next.userModified.serverId).toBe(false); - }); - }); - - describe("SET_SERVER_ID_FROM_USER", () => { - it("updates serverId and marks it user-modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { type: "SET_SERVER_ID_FROM_USER", value: "host-2" }); - - expect(next.form.serverId).toBe("host-2"); - expect(next.userModified.serverId).toBe(true); - }); - }); - describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => { it("sets provider, model, and default mode; marks both modified", () => { const state = makeState(); @@ -1167,51 +1174,6 @@ describe("resolveAgentForm", () => { }); }); - describe("SET_WORKING_DIR", () => { - it("updates workingDir without marking it modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { type: "SET_WORKING_DIR", value: "/home/user/proj" }); - - expect(next.form.workingDir).toBe("/home/user/proj"); - expect(next.userModified.workingDir).toBe(false); - }); - }); - - describe("SET_WORKING_DIR_FROM_USER", () => { - it("updates workingDir and marks it modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { - type: "SET_WORKING_DIR_FROM_USER", - value: "/home/user/proj", - }); - - expect(next.form.workingDir).toBe("/home/user/proj"); - expect(next.userModified.workingDir).toBe(true); - }); - }); - - describe("AUTO_SELECT_SERVER", () => { - it("sets serverId when currently null", () => { - const state = makeState({ serverId: null }); - const next = resolveAgentForm(state, { - type: "AUTO_SELECT_SERVER", - candidateServerId: "host-1", - }); - - expect(next.form.serverId).toBe("host-1"); - }); - - it("does not override an already-set serverId", () => { - const state = makeState({ serverId: "existing" }); - const next = resolveAgentForm(state, { - type: "AUTO_SELECT_SERVER", - candidateServerId: "host-1", - }); - - expect(next).toBe(state); - }); - }); - describe("RESET", () => { it("keeps form values but marks them unresolved for the next open", () => { const state = makeState( @@ -1276,3 +1238,34 @@ describe("resolveAgentForm", () => { }); }); }); + +it("owns input readiness, reopening and user edits in the reducer", () => { + const inputs = { + type: "INPUTS_CHANGED" as const, + serverId: "host", + isVisible: true, + isCreateFlow: true, + isPreferencesLoading: true, + hasSnapshot: false, + initialValues: undefined, + preferences: { provider: "codex", providerPreferences: { codex: { model: "astra" } } }, + allowedProviderMap: new Map(), + providerModelsByProvider: new Map(), + }; + let state = resolveAgentForm(makeState(), inputs); + expect(state.resolution.status).toBe("pending"); + state = resolveAgentForm(state, { ...inputs, isPreferencesLoading: false, hasSnapshot: true }); + expect(state.form).toMatchObject({ provider: "codex", model: "astra" }); + state = resolveAgentForm(state, { + type: "SET_MODEL_FROM_USER", + modelId: "manual", + availableModels: null, + providerPrefs: undefined, + }); + state = resolveAgentForm(state, { ...inputs, isPreferencesLoading: false, hasSnapshot: true }); + expect(state.form.model).toBe("manual"); + state = resolveAgentForm(state, { ...inputs, isVisible: false }); + expect(state.resolution.status).toBe("pending"); + state = resolveAgentForm(state, { ...inputs, isPreferencesLoading: false, hasSnapshot: true }); + expect(state.form).toMatchObject({ provider: "codex", model: "astra" }); +}); diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index 9fa187200e..08e88fd7a3 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -12,30 +12,24 @@ import { import { findModelByReference } from "./model-catalog"; export interface FormInitialValues { - serverId?: string | null; provider?: AgentProvider; modeId?: string | null; model?: string | null; thinkingOptionId?: string | null; - workingDir?: string; } export interface FormState { - serverId: string | null; provider: AgentProvider | null; modeId: string; model: string; thinkingOptionId: string; - workingDir: string; } export interface UserModifiedFields { - serverId: boolean; provider: boolean; modeId: boolean; model: boolean; thinkingOptionId: boolean; - workingDir: boolean; } export type ProviderModelsByProvider = Map; @@ -46,15 +40,18 @@ export interface AgentFormReducerState { form: FormState; userModified: UserModifiedFields; resolution: AgentFormResolutionState; + inputs?: { + serverId: string | null; + initialValues: FormInitialValues | undefined; + active: boolean; + }; } export const INITIAL_USER_MODIFIED: UserModifiedFields = { - serverId: false, provider: false, modeId: false, model: false, thinkingOptionId: false, - workingDir: false, }; export const PENDING_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "pending" }; @@ -68,7 +65,21 @@ export const RESOLVABLE_PROVIDER_STATUSES = new Set(["ready"]); +interface AgentFormInputs { + type: "INPUTS_CHANGED"; + serverId: string | null; + isVisible: boolean; + isCreateFlow: boolean; + isPreferencesLoading: boolean; + hasSnapshot: boolean; + initialValues: FormInitialValues | undefined; + preferences: FormPreferences | null; + providerModelsByProvider: ProviderModelsByProvider; + allowedProviderMap: Map; +} + export type AgentFormAction = + | AgentFormInputs | { type: "REQUEST_RESOLUTION" } | { type: "COMPLETE_RESOLUTION"; @@ -77,8 +88,6 @@ export type AgentFormAction = providerModelsByProvider: ProviderModelsByProvider; allowedProviderMap: Map; } - | { type: "SET_SERVER_ID"; value: string | null } - | { type: "SET_SERVER_ID_FROM_USER"; value: string | null } | { type: "SET_PROVIDER_AND_MODEL_FROM_USER"; provider: AgentProvider; @@ -106,9 +115,6 @@ export type AgentFormAction = } | { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" } | { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string } - | { type: "SET_WORKING_DIR"; value: string } - | { type: "SET_WORKING_DIR_FROM_USER"; value: string } - | { type: "AUTO_SELECT_SERVER"; candidateServerId: string } | { type: "RESET" }; type CompleteResolutionAction = Extract; @@ -144,7 +150,7 @@ export function resolveEffectiveModel( ): AgentModelDefinition | null { if (!availableModels || availableModels.length === 0) return null; if (!normalizeSelectedModelId(modelId)) return null; - return findModelByReference(availableModels, modelId) ?? resolveDefaultModel(availableModels); + return findModelByReference(availableModels, modelId) ?? null; } function resolvePreferredThinkingOptionId(input: { @@ -216,36 +222,12 @@ export function mergeSelectedComposerPreferences(args: { }); } -export function combineInitialValues( - initialValues: FormInitialValues | undefined, - initialServerId: string | null, -): FormInitialValues | undefined { - const hasExplicitServerId = initialValues?.serverId !== undefined; - const serverIdFromOptions = initialServerId === null ? undefined : initialServerId; - - if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) { - return undefined; - } - - if (hasExplicitServerId) { - return { ...initialValues, serverId: initialValues?.serverId }; - } - - if (serverIdFromOptions !== undefined) { - return { ...initialValues, serverId: serverIdFromOptions }; - } - - return initialValues; -} - export function hasFormStateChanged(prev: FormState, next: FormState): boolean { return ( - prev.serverId !== next.serverId || prev.provider !== next.provider || prev.modeId !== next.modeId || prev.model !== next.model || - prev.thinkingOptionId !== next.thinkingOptionId || - prev.workingDir !== next.workingDir + prev.thinkingOptionId !== next.thinkingOptionId ); } @@ -282,29 +264,11 @@ function resolveProvider(input: { userModified: boolean; initialValues: FormInitialValues | undefined; preferences: FormPreferences | null; - allowedProviderMap: Map; }): AgentProvider | null { - const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input; - if (userModified) { - if ( - currentProvider && - allowedProviderMap.size > 0 && - !allowedProviderMap.has(currentProvider) - ) { - return null; - } - return currentProvider; - } - if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) { - return initialValues.provider; - } - if (preferences?.provider && allowedProviderMap.has(preferences.provider)) { - return preferences.provider; - } - if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) { - return null; - } - return currentProvider; + const { currentProvider, userModified, initialValues, preferences } = input; + // Discovery readiness does not change the user's saved or explicit choice. + if (userModified) return currentProvider; + return initialValues?.provider ?? preferences?.provider ?? currentProvider; } function resolveModeId(input: { @@ -340,16 +304,22 @@ function resolveModelField(input: { if (!provider) return ""; const initialModel = normalizeSelectedModelId(initialValues?.model); const preferredModel = normalizeSelectedModelId(providerPrefs?.model); - const defaultModelId = resolveDefaultModelId(availableModels); + // COMPAT(default-model-id): added in v0.7.2, remove after 2026-12-06. + // Older drafts used "default" before providers exposed concrete model IDs. + if ((initialModel || preferredModel) === "default" && availableModels?.length) { + return ( + findModelByReference(availableModels, "default")?.id || resolveDefaultModelId(availableModels) + ); + } if (initialModel) { return !availableModels ? initialModel - : resolveCanonicalModelId(availableModels, initialModel) || defaultModelId; + : resolveCanonicalModelId(availableModels, initialModel) || initialModel; } if (preferredModel) { return !availableModels ? preferredModel - : resolveCanonicalModelId(availableModels, preferredModel) || defaultModelId; + : resolveCanonicalModelId(availableModels, preferredModel) || preferredModel; } return ""; } @@ -403,7 +373,6 @@ export function resolveFormState( userModified: userModified.provider, initialValues, preferences, - allowedProviderMap, }); const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined; @@ -447,14 +416,6 @@ export function resolveFormState( }); } - if (!userModified.serverId && initialValues?.serverId !== undefined) { - result.serverId = initialValues.serverId; - } - - if (!userModified.workingDir && initialValues?.workingDir !== undefined) { - result.workingDir = initialValues.workingDir; - } - return result; } @@ -615,11 +576,45 @@ function applyProfile(state: AgentFormReducerState, action: ApplyProfileAction) }; } +function sameInitialValues(left: FormInitialValues = {}, right: FormInitialValues = {}): boolean { + return ( + left.provider === right.provider && + left.model === right.model && + left.modeId === right.modeId && + left.thinkingOptionId === right.thinkingOptionId + ); +} + +function receiveInputs( + state: AgentFormReducerState, + action: AgentFormInputs, +): AgentFormReducerState { + const active = action.isVisible && action.isCreateFlow; + const previous = state.inputs; + const initial = action.initialValues; + const changed = + previous?.active !== active || + previous.serverId !== action.serverId || + !sameInitialValues(previous.initialValues, initial); + let next = state; + if (changed) { + next = { + ...resolveAgentForm(state, { type: action.isVisible ? "REQUEST_RESOLUTION" : "RESET" }), + inputs: { serverId: action.serverId, initialValues: initial, active }, + }; + } + if (!active || action.isPreferencesLoading || !action.serverId || !action.hasSnapshot) + return next; + return completeResolution(next, { ...action, type: "COMPLETE_RESOLUTION" }); +} + export function resolveAgentForm( state: AgentFormReducerState, action: AgentFormAction, ): AgentFormReducerState { switch (action.type) { + case "INPUTS_CHANGED": + return receiveInputs(state, action); case "REQUEST_RESOLUTION": return { ...state, @@ -630,16 +625,6 @@ export function resolveAgentForm( case "COMPLETE_RESOLUTION": return completeResolution(state, action); - case "SET_SERVER_ID": - return { ...state, form: { ...state.form, serverId: action.value } }; - - case "SET_SERVER_ID_FROM_USER": - return { - ...state, - form: { ...state.form, serverId: action.value }, - userModified: { ...state.userModified, serverId: true }, - }; - case "SET_PROVIDER_AND_MODEL_FROM_USER": { const normalizedModelId = resolveCanonicalModelId(action.providerModels, action.modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels); @@ -730,20 +715,6 @@ export function resolveAgentForm( userModified: { ...state.userModified, thinkingOptionId: true }, }; - case "SET_WORKING_DIR": - return { ...state, form: { ...state.form, workingDir: action.value } }; - - case "SET_WORKING_DIR_FROM_USER": - return { - ...state, - form: { ...state.form, workingDir: action.value }, - userModified: { ...state.userModified, workingDir: true }, - }; - - case "AUTO_SELECT_SERVER": - if (state.form.serverId) return state; - return { ...state, form: { ...state.form, serverId: action.candidateServerId } }; - case "RESET": return { ...state, diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 20fb842e06..ad33968740 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -518,6 +518,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { runtimeGeneration, capabilities: appCapabilities, trace: nativePerformanceTrace, + providerSnapshots: "wire", } satisfies Omit; if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ diff --git a/packages/app/src/schedules/schedule-form-model.ts b/packages/app/src/schedules/schedule-form-model.ts index 4a889f9ef1..d7d2a115d0 100644 --- a/packages/app/src/schedules/schedule-form-model.ts +++ b/packages/app/src/schedules/schedule-form-model.ts @@ -338,19 +338,16 @@ function isSelectedModelValidForProviders(input: { function normalizeInitialValues(input: { snapshot: ScheduleFormSnapshot; - selectedServerId: string | null; }): FormInitialValues | undefined { const config = newAgentConfig(input.snapshot.schedule); if (!config) { return undefined; } return { - serverId: input.selectedServerId, provider: config.provider, model: config.model ?? null, modeId: config.modeId ?? null, thinkingOptionId: config.thinkingOptionId ?? null, - workingDir: config.cwd, }; } @@ -711,24 +708,20 @@ function buildInitialState(snapshot: ScheduleFormSnapshot): ScheduleFormState { function toFormState(state: ScheduleFormState): FormState { return { - serverId: state.selectedServerId, provider: state.selectedProvider, modeId: state.selectedMode, model: state.selectedModel, thinkingOptionId: state.selectedThinkingOptionId, - workingDir: state.workingDir, }; } function applyResolvedFormState(state: ScheduleFormState, form: FormState): ScheduleFormState { return { ...state, - selectedServerId: form.serverId, selectedProvider: form.provider, selectedMode: form.modeId, selectedModel: form.model, selectedThinkingOptionId: form.thinkingOptionId, - workingDir: form.workingDir, }; } @@ -846,7 +839,6 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo const listeners = new Set<() => void>(); const initialValues = normalizeInitialValues({ snapshot, - selectedServerId: resolveInitialServerId(snapshot), }); let closed = false; let hosts = snapshot.hosts; @@ -1017,11 +1009,6 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo if (closed || state.selectedServerId === serverId) { return; } - userModified = { - ...userModified, - serverId: true, - workingDir: true, - }; publish( clearProviderSelection({ ...state, @@ -1046,7 +1033,6 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo if (!providerScopeChanged && state.selectedProjectOptionId === target.optionId) { return; } - userModified = { ...userModified, serverId: true, workingDir: true }; const nextState = { ...state, selectedServerId: target.serverId, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 9096ae9e94..ca189cf7b4 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -905,21 +905,16 @@ function buildWorkspaceDraftSetupForCreatedWorkspace(input: { } function buildComposerInitialValues(input: { - workingDir: string | undefined; initialSetup?: WorkspaceDraftTabSetup | null; }): CreateAgentInitialValues | undefined { if (input.initialSetup) { return { - workingDir: input.workingDir ?? input.initialSetup.cwd, provider: input.initialSetup.provider, modeId: input.initialSetup.modeId, model: input.initialSetup.model, thinkingOptionId: input.initialSetup.thinkingOptionId, }; } - if (input.workingDir) { - return { workingDir: input.workingDir }; - } return undefined; } @@ -969,19 +964,17 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { function buildComposerConfig(input: { serverId: string; - isConnected: boolean; workspaceDirectory: string | null; sourceDirectory: string | null; initialSetup?: WorkspaceDraftTabSetup | null; }): Parameters[0]["composer"] { - const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input; + const { serverId, workspaceDirectory, sourceDirectory, initialSetup } = input; const workingDir = workspaceDirectory || sourceDirectory || undefined; return { initialServerId: serverId || null, - initialValues: buildComposerInitialValues({ workingDir, initialSetup }), + initialValues: buildComposerInitialValues({ initialSetup }), initialFeatureValues: initialSetup?.featureValues, isVisible: true, - onlineServerIds: isConnected && serverId ? [serverId] : [], lockedWorkingDir: workingDir, }; } @@ -1679,7 +1672,6 @@ export function NewWorkspaceScreen({ draftKey, composer: buildComposerConfig({ serverId: selectedServerId, - isConnected, workspaceDirectory: workspace?.workspaceDirectory ?? null, sourceDirectory: selectedSourceDirectory, initialSetup: forkDraftSetup?.setup, diff --git a/packages/client/src/compat/normalize-provider-models.ts b/packages/client/src/compat/normalize-provider-models.ts index 65d4addcbb..023c8681e3 100644 --- a/packages/client/src/compat/normalize-provider-models.ts +++ b/packages/client/src/compat/normalize-provider-models.ts @@ -59,7 +59,8 @@ export function normalizeListProviderModelsPayload( export function normalizeProvidersSnapshotPayload< T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload, ->(payload: T): T { +>(payload: T, expand = true): T { + if (payload.compactSnapshot && !expand) return payload; const entries = payload.compactSnapshot ? expandProviderSnapshot(payload.compactSnapshot) : normalizeProviderSnapshotEntries(payload.entries); @@ -68,10 +69,11 @@ export function normalizeProvidersSnapshotPayload< export function normalizeProviderSnapshotUpdateMessage( msg: SessionOutboundMessage, + expand = true, ): SessionOutboundMessage { if (msg.type !== "providers_snapshot_update") { return msg; } - return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload) }; + return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload, expand) }; } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index f599226744..e4697dfaf8 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -6169,3 +6169,52 @@ test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline", vi.useRealTimers(); } }); + +test("wire snapshot callers own expansion and receive hash references unchanged", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "wire-catalog", + providerSnapshots: "wire", + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ preserveSent: true }); + await connected; + expect(JSON.parse(assertStr(transport.sent[0])).capabilities.provider_snapshot_references).toBe( + true, + ); + const received: unknown[] = []; + client.on("providers_snapshot_update", (message) => received.push(message.payload)); + const payload = { + entries: [], + snapshotHash: "content", + fetchedAt: { codex: "2026-09-06T12:00:00.000Z" }, + generatedAt: "2026-09-06T12:00:00.000Z", + }; + transport.triggerMessage(wrapSessionMessage({ type: "providers_snapshot_update", payload })); + expect(received).toEqual([payload]); + const request = client.getProvidersSnapshot(); + const sent = parseSentFrame(transport.sent.at(-1)!); + const body = { + ...payload, + compactSnapshot: { + entries: [ + { + provider: "codex", + enabled: true, + status: "ready", + models: [{ id: "astra", label: "Astra" }], + }, + ], + thinkingSets: [], + }, + requestId: sent.requestId, + }; + transport.triggerMessage( + wrapSessionMessage({ type: "get_providers_snapshot_response", payload: body }), + ); + expect(await request).toEqual(body); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index ea4ec2d121..eb0fcbf1a4 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -308,6 +308,9 @@ export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteReq export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse; export interface DaemonClientConfig { + /** Deliver compact bodies/hash references to a caller-owned snapshot cache. + * The default keeps public SDK snapshot entries expanded. */ + providerSnapshots?: "wire"; url: string; clientId: string; clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub"; @@ -4689,7 +4692,7 @@ export class DaemonClient { }, responseType: "get_providers_snapshot_response", }); - return normalizeProvidersSnapshotPayload(payload); + return normalizeProvidersSnapshotPayload(payload, this.config.providerSnapshots !== "wire"); } async getDaemonConfig( @@ -5674,6 +5677,9 @@ export class DaemonClient { [CLIENT_CAPS.providerSubagents]: true, [CLIENT_CAPS.projectUpdates]: true, [CLIENT_CAPS.compactProviderSnapshots]: true, + ...(this.config.providerSnapshots === "wire" + ? { [CLIENT_CAPS.providerSnapshotReferences]: true } + : {}), [CLIENT_CAPS.timelineNotifications]: true, ...this.config.capabilities, }, @@ -6100,7 +6106,10 @@ export class DaemonClient { } private handleSessionMessage(msg: SessionOutboundMessage): void { - const consumerMessage = normalizeProviderSnapshotUpdateMessage(msg); + const consumerMessage = normalizeProviderSnapshotUpdateMessage( + msg, + this.config.providerSnapshots !== "wire", + ); if (consumerMessage.type === "status") { const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload); diff --git a/packages/plugin/src/provider.ts b/packages/plugin/src/provider.ts index 3051a89bb0..1ac5bcbc3c 100644 --- a/packages/plugin/src/provider.ts +++ b/packages/plugin/src/provider.ts @@ -26,6 +26,8 @@ export const PROVIDER_CAPABILITIES = [ export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number]; export interface ProviderRegistration { + /** Equal keys share discovery within this provider. Include effective configuration and execution environment. */ + getCatalogCacheKey?(options: ProviderCatalogOptions): Promise; id: string; label: string; description?: string; @@ -34,6 +36,10 @@ export interface ProviderRegistration { connect(request: ProviderConnectRequest): Promise; } +export type ProviderCatalogOptions = + | { scope: "global"; force?: boolean } + | { scope: "workspace"; cwd: string; force?: boolean }; + export interface ProviderConnectRequest { versions: readonly number[]; capabilities: readonly string[]; diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index b67dd347d3..d07ffdf242 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -26,6 +26,7 @@ export const CLIENT_CAPS = { // provider catalogs with shared thinking sets and may revalidate by content hash. // Remove the legacy snapshot encoding after 2027-02-04. compactProviderSnapshots: "compact_provider_snapshots", + providerSnapshotReferences: "provider_snapshot_references", // COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove legacy // reconstructed timeline replay after 2027-02-21 once the client floor supports invalidation. timelineReplacementInvalidation: "timeline_replacement_invalidation", diff --git a/packages/protocol/src/messages.providers-snapshot.test.ts b/packages/protocol/src/messages.providers-snapshot.test.ts index fc87b13b3c..91b08a3cdc 100644 --- a/packages/protocol/src/messages.providers-snapshot.test.ts +++ b/packages/protocol/src/messages.providers-snapshot.test.ts @@ -94,3 +94,20 @@ describe("provider snapshot message schemas", () => { expect(parsed.payload.entries[0]?.enabled).toBe(true); }); }); + +test("accepts a bodyless announcement with separate discovery freshness", async () => { + const { validateWSOutboundMessage } = await import("./validation/ws-outbound.js"); + const message = { + type: "providers_snapshot_update", + payload: { + cwd: "/project", + entries: [], + snapshotHash: "content-hash", + fetchedAt: { codex: "2026-09-06T12:00:00.000Z" }, + generatedAt: "2026-09-06T13:00:00.000Z", + }, + }; + expect(ProvidersSnapshotUpdateMessageSchema.parse(message)).toEqual(message); + const result = validateWSOutboundMessage({ type: "session", message }); + expect(result.success).toBe(true); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 3ad434ce14..4f6415b8c9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -5874,6 +5874,7 @@ export const GetProvidersSnapshotResponseMessageSchema = z.object({ entries: z.array(ProviderSnapshotEntrySchema), compactSnapshot: CompactProviderSnapshotSchema.optional(), snapshotHash: z.string().optional(), + fetchedAt: z.record(z.string(), z.string()).optional(), notModified: z.boolean().optional(), generatedAt: z.string(), requestId: z.string(), @@ -5888,6 +5889,7 @@ export const ProvidersSnapshotUpdateMessageSchema = z.object({ entries: z.array(ProviderSnapshotEntrySchema), compactSnapshot: CompactProviderSnapshotSchema.optional(), snapshotHash: z.string().optional(), + fetchedAt: z.record(z.string(), z.string()).optional(), generatedAt: z.string(), }), }); @@ -7100,6 +7102,7 @@ export const WSHelloMessageSchema = z.object({ [CLIENT_CAPS.providerSubagents]: z.boolean().optional(), [CLIENT_CAPS.projectUpdates]: z.boolean().optional(), [CLIENT_CAPS.compactProviderSnapshots]: z.boolean().optional(), + [CLIENT_CAPS.providerSnapshotReferences]: z.boolean().optional(), [CLIENT_CAPS.timelineReplacementInvalidation]: z.boolean().optional(), [CLIENT_CAPS.timelineNotifications]: z.boolean().optional(), [CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(), diff --git a/packages/server/src/executable-resolution/executable-resolution.test.ts b/packages/server/src/executable-resolution/executable-resolution.test.ts index c202b4d15d..4434089e98 100644 --- a/packages/server/src/executable-resolution/executable-resolution.test.ts +++ b/packages/server/src/executable-resolution/executable-resolution.test.ts @@ -58,6 +58,15 @@ afterEach(() => { }); describe("findExecutable", () => { + test.runIf(isPlatform("linux"))( + "propagates a failed lookup process instead of reporting a missing executable", + async () => { + await expect(findExecutable("x".repeat(1024 * 1024))).rejects.toMatchObject({ + code: "E2BIG", + }); + }, + ); + describe.skipIf(isPlatform("win32"))("POSIX", () => { test("finds an extensionless executable and skips an earlier non-executable candidate", async () => { const executableDir = makeTempDir(); diff --git a/packages/server/src/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index a6edcc6e7b..d7af13a3d0 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -30,8 +30,10 @@ async function enumerateCandidatesViaSystemWhich(name: string): Promise; + /** Equal keys share availability and catalogue discovery within this configured client. + * Include the execution environment and effective configuration; omit to use target identity. + * force must not affect identity. Resolve before every cache lookup. */ + getCatalogCacheKey?(options: FetchCatalogOptions): Promise; /** * Discover models and modes together. Implementations may use one upstream * process, separate upstream calls, static modes, or private helpers; callers @@ -775,10 +779,10 @@ export interface AgentClient { context: ImportProviderSessionContext, ): Promise; /** - * Check if this provider is available (CLI binary is installed). + * Check availability in the catalogue target when supplied (CLI binary is installed). * Returns true if available, false otherwise. */ - isAvailable(signal?: AbortSignal): Promise; + isAvailable(signal?: AbortSignal, options?: FetchCatalogOptions): Promise; getDiagnostic?(): Promise<{ diagnostic: string }>; /** * Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close(). diff --git a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts index 4a8c30b1bb..53270c1a78 100644 --- a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts +++ b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts @@ -12,10 +12,12 @@ import type { AgentMode, AgentModelDefinition, FetchCatalogOptions, - ProviderSnapshotEntry, } from "./agent-sdk-types.js"; import { attachMutableProviderConfigOwner } from "./mutable-provider-config-owner.js"; -import { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; +import { + type ProviderSnapshotTransition, + ProviderSnapshotManager, +} from "./provider-snapshot-manager.js"; const tempDirs: string[] = []; const CONTROLLED_PROVIDERS = { @@ -61,15 +63,15 @@ function catalog(modelId: string): Catalog { }; } -function recordCodexEvent(events: string[]): (entries: ProviderSnapshotEntry[]) => void { - return (entries) => { - const codex = entries.find((entry) => entry.provider === "codex"); +function recordCodexEvent(events: string[]): (transition: ProviderSnapshotTransition) => void { + return ({ current }) => { + const codex = current.records.find(({ entry }) => entry.provider === "codex")?.entry; events.push(`${codex?.status}:${codex?.models?.[0]?.id ?? "none"}`); }; } function getCodexSnapshot(manager: ProviderSnapshotManager, cwd: string) { - return manager.getSnapshot(cwd).find((entry) => entry.provider === "codex"); + return manager.getSnapshot(cwd).records.find(({ entry }) => entry.provider === "codex")?.entry; } function mutableConfig(persisted: PersistedConfig): MutableDaemonConfig { @@ -77,7 +79,7 @@ function mutableConfig(persisted: PersistedConfig): MutableDaemonConfig { relay: { enabled: false }, mcp: { enabled: true, injectIntoAgents: false }, browserTools: { enabled: false }, - providers: persisted.agents?.providers ?? CONTROLLED_PROVIDERS, + providers: CONTROLLED_PROVIDERS, metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, enableTerminalAgentHooks: false, @@ -108,7 +110,7 @@ describe("mutable provider config owner", () => { const cwd = path.resolve("/tmp/provider-config-owner"); manager.getSnapshot(cwd); const events: string[] = []; - manager.on("change", (_entries, changedCwd) => events.push(changedCwd)); + manager.on("change", ({ current }) => events.push(current.cwd)); let agentManagerState = manager.getAgentManagerProviderState(); const unsubscribe = attachMutableProviderConfigOwner({ store, @@ -158,7 +160,6 @@ describe("mutable provider config owner", () => { git: { maxProcessesPerSecond: 64, maxProcessConcurrency: 8 }, }, app: { baseUrl: "https://before.example.test" }, - agents: { providers: { codex: { enabled: true } } }, }; const configPath = path.join(paseoHome, "config.json"); writeFileSync(configPath, `${JSON.stringify(initial, null, 2)}\n`, "utf-8"); @@ -177,7 +178,7 @@ describe("mutable provider config owner", () => { }); manager.getSnapshot("/tmp/provider-config-owner"); const events: string[] = []; - manager.on("change", (_entries, cwd) => events.push(cwd)); + manager.on("change", ({ current }) => events.push(current.cwd)); let registryUpdates = 0; const unsubscribe = attachMutableProviderConfigOwner({ store, @@ -242,6 +243,7 @@ describe("mutable provider config owner", () => { try { const originalRead = manager.getProvider({ cwd, provider: "codex", wait: true }); await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1)); + expect(events).toEqual([]); store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } }); expect(events).toEqual(["loading:none"]); @@ -282,23 +284,42 @@ describe("mutable provider config owner", () => { const cwd = path.resolve("/tmp/provider-config-rollback"); const events: string[] = []; manager.on("change", recordCodexEvent(events)); + const previousState = manager.getAgentManagerProviderState(); + let agentManagerState = previousState; const unsubscribe = attachMutableProviderConfigOwner({ store, providerSnapshotManager: manager, - updateProviderRegistry: () => undefined, + updateProviderRegistry: (state) => { + agentManagerState = state; + }, }); + const observedDuringApply: Array<{ + label: string; + snapshot: ReturnType; + }> = []; const unsubscribeFailure = store.onApply(() => { + observedDuringApply.push({ + label: manager.getProviderLabel("codex"), + snapshot: manager.getSnapshot(cwd), + }); throw new Error("later owner failed"); }); try { const originalRead = manager.getProvider({ cwd, provider: "codex", wait: true }); await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1)); + expect(events).toEqual([]); + const before = manager.getSnapshot(cwd); expect(() => store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }), ).toThrow("later owner failed"); expect(events).toEqual([]); + expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]); + expect(observedDuringApply[0]!.snapshot).toBe(before); + expect(manager.getSnapshot(cwd)).toBe(before); + expect(agentManagerState).toEqual(previousState); + expect(agentManagerState.clients.codex).toBe(previousState.clients.codex); catalogResolvers[0]?.(catalog("original-model")); await expect(originalRead).resolves.toMatchObject({ @@ -309,8 +330,7 @@ describe("mutable provider config owner", () => { status: "ready", models: [{ id: "original-model" }], }); - expect(events.length).toBeGreaterThan(0); - expect(new Set(events)).toEqual(new Set(["ready:original-model"])); + expect(events).toEqual(["ready:original-model"]); expect(catalogResolvers).toHaveLength(1); } finally { unsubscribeFailure(); diff --git a/packages/server/src/server/agent/mutable-provider-config-owner.ts b/packages/server/src/server/agent/mutable-provider-config-owner.ts index 950a70e3c7..8ae589591c 100644 --- a/packages/server/src/server/agent/mutable-provider-config-owner.ts +++ b/packages/server/src/server/agent/mutable-provider-config-owner.ts @@ -11,35 +11,37 @@ export function attachMutableProviderConfigOwner(options: { providerSnapshotManager: ProviderSnapshotManager; updateProviderRegistry: (state: AgentManagerProviderState) => void; }): () => void { - let publishPendingProviderChange: (() => void) | null = null; + let commitPendingProviderChange: (() => void) | null = null; const unsubscribeApply = options.store.onApply((config, previous, details) => { if (equal(config.providers, previous.providers)) return () => undefined; const previousAgentManagerState = options.providerSnapshotManager.getAgentManagerProviderState(); - const staged = options.providerSnapshotManager.stageMutableProviderConfig(config.providers, { - removeProviders: details.removedProviders, - replace: true, - }); + const prepared = options.providerSnapshotManager.prepareMutableProviderConfig( + config.providers, + { + removeProviders: details.removedProviders, + replace: true, + }, + ); try { - options.updateProviderRegistry(staged.agentManagerState); + options.updateProviderRegistry(prepared.agentManagerState); } catch (error) { - staged.rollback(); + options.updateProviderRegistry(previousAgentManagerState); throw error; } - publishPendingProviderChange = staged.publish; + commitPendingProviderChange = prepared.commit; return () => { - publishPendingProviderChange = null; - staged.rollback(); + commitPendingProviderChange = null; options.updateProviderRegistry(previousAgentManagerState); }; }); const unsubscribeChange = options.store.onChange(() => { - const publish = publishPendingProviderChange; - publishPendingProviderChange = null; - publish?.(); + const commit = commitPendingProviderChange; + commitPendingProviderChange = null; + commit?.(); }); return () => { diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index a9dd271220..7b7df64a22 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -837,7 +837,10 @@ class PluginAgentClient implements AgentClient { private readonly rootsBySession = new Map(); private readonly pendingChildren: PendingChild[] = []; + readonly getCatalogCacheKey?: AgentClient["getCatalogCacheKey"]; + constructor(registration: ProviderRegistration) { + this.getCatalogCacheKey = registration.getCatalogCacheKey?.bind(registration); this.provider = registration.id; this.runtime = new ProviderRuntime(registration); this.runtime.onSessionOpened((session, opened) => this.acceptChild(session, opened)); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index ac70c936cb..3ac480a872 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -575,7 +575,8 @@ function wrapClientProvider( }; } : undefined, - isAvailable: (signal) => inner.isAvailable(signal), + getCatalogCacheKey: inner.getCatalogCacheKey?.bind(inner), + isAvailable: (signal, options) => inner.isAvailable(signal, options), getDiagnostic: inner.getDiagnostic?.bind(inner), }; } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index f30cd6e92c..b1e50082da 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1,3 +1,4 @@ +import pino from "pino"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; @@ -17,6 +18,7 @@ import type { ManagedAgent } from "./agent-manager.js"; import { GLOBAL_PROVIDER_SNAPSHOT_KEY, ProviderSnapshotManager, + type ProviderSnapshot, resolveSnapshotCwd, } from "./provider-snapshot-manager.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; @@ -29,6 +31,34 @@ const TEST_CAPABILITIES = { supportsReasoningStream: false, supportsToolInvocations: false, } as const; +const CLOSED_PARENT: ManagedAgent = { + id: "parent-agent", + provider: "codex", + cwd: "/tmp/project", + capabilities: TEST_CAPABILITIES, + config: { provider: "codex", cwd: "/tmp/project" }, + createdAt: new Date(0), + updatedAt: new Date(0), + availableModes: [], + currentModeId: null, + pendingPermissions: new Map(), + bufferedPermissionResolutions: new Map(), + inFlightPermissionResponses: new Set(), + pendingReplacement: false, + persistence: null, + historyPrimed: false, + lastUserMessageAt: null, + activeTurnId: null, + activeTurnStartedAt: null, + attention: { requiresAttention: false }, + foregroundTurnWaiters: new Set(), + finalizedForegroundTurnIds: new Set(), + unsubscribeSession: null, + labels: {}, + lifecycle: "closed", + session: null, + activeForegroundTurnId: null, +}; const TEST_REFRESH_TIMEOUT_MS = 120_000; // Builds an AgentClient that can be injected via the public extraClients option. @@ -40,6 +70,8 @@ function createExtraClient( return { provider, capabilities: TEST_CAPABILITIES, + getCatalogCacheKey: + provider === "codex" || provider === "claude" ? async () => "host" : undefined, async createSession() { throw new Error("not implemented"); }, @@ -121,7 +153,7 @@ describe("ProviderSnapshotManager public surface", () => { try { manager.replacePluginProviders([registration]); - expect(manager.getSnapshot("/tmp/project")).toContainEqual( + expect(manager.getSnapshot("/tmp/project").records.map(({ entry }) => entry)).toContainEqual( expect.objectContaining({ provider: "icon-provider", iconSvg }), ); } finally { @@ -253,7 +285,7 @@ describe("ProviderSnapshotManager public surface", () => { test("getSnapshot returns loading entries for built-in providers before warmup", () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); try { - const snapshot = manager.getSnapshot("/tmp/project"); + const snapshot = manager.getSnapshot("/tmp/project").records.map(({ entry }) => entry); const claude = snapshot.find((entry) => entry.provider === "claude"); const codex = snapshot.find((entry) => entry.provider === "codex"); expect(claude?.status).toBe("loading"); @@ -496,6 +528,7 @@ describe("ProviderSnapshotManager public surface", () => { test("defaults provider refreshes to a two-minute deadline", async () => { vi.useFakeTimers(); + let recovered = false; const manager = new ProviderSnapshotManager({ logger: createTestLogger(), providerOverrides: { @@ -505,7 +538,9 @@ describe("ProviderSnapshotManager public surface", () => { pi: { enabled: false }, }, extraClients: { - codex: createExtraClient("codex", { isAvailable: waitUntilAborted }), + codex: createExtraClient("codex", { + isAvailable: (signal) => (recovered ? Promise.resolve(true) : waitUntilAborted(signal)), + }), }, }); @@ -522,6 +557,14 @@ describe("ProviderSnapshotManager public surface", () => { status: "error", error: "Timed out refreshing Codex after 120000ms; pending: availability", }); + recovered = true; + await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] }); + const ready = manager.getSnapshot("/tmp/project"); + const transitions: unknown[] = []; + manager.on("change", (transition) => transitions.push(transition)); + await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] }); + expect(manager.getSnapshot("/tmp/project")).toBe(ready); + expect(transitions).toEqual([]); } finally { manager.destroy(); vi.useRealTimers(); @@ -611,7 +654,8 @@ describe("ProviderSnapshotManager public surface", () => { await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] }); expect(fetchCatalog).toHaveBeenCalledTimes(2); expect( - manager.getSnapshot("/tmp/project").find((entry) => entry.provider === "codex"), + manager.getSnapshot("/tmp/project").records.find(({ entry }) => entry.provider === "codex") + ?.entry, ).toMatchObject({ provider: "codex", status: "ready" }); } finally { manager.destroy(); @@ -889,7 +933,7 @@ describe("ProviderSnapshotManager public surface", () => { { provider: "codex", id: "gpt-5.4-mini", label: "GPT 5.4 Mini" }, ]; const catalogModes: AgentMode[] = [{ id: "agent", label: "Agent" }]; - const fetchCatalog = vi.fn(async () => ({ + const fetchCatalog = vi.fn(async (_options: FetchCatalogOptions) => ({ models: catalogModels, modes: catalogModes, })); @@ -1174,7 +1218,7 @@ describe("ProviderSnapshotManager public surface", () => { async fetchCatalog() { return { models: [] as AgentModelDefinition[], modes: childModes }; }, - async resolveCreateConfig(input) { + resolveCreateConfig(input) { resolverInputs.push(input); return { modeId: input.parent?.isUnattended ? "child-unattended" : undefined, @@ -1196,13 +1240,14 @@ describe("ProviderSnapshotManager public surface", () => { }, }); try { - const parent = { + const parent: ManagedAgent = { + ...CLOSED_PARENT, id: "parent-agent", provider: "claude", currentModeId: "parent-unattended", availableModes: parentModes, config: { provider: "claude", cwd: "/tmp/project" }, - } as ManagedAgent; + }; const resolved = await manager.resolveCreateConfig({ cwd: "/tmp/project", @@ -1252,7 +1297,7 @@ describe("ProviderSnapshotManager public surface", () => { async fetchCatalog() { return { models: [] as AgentModelDefinition[], modes }; }, - async resolveCreateConfig(input) { + resolveCreateConfig(input) { resolverInputs.push(input); return { modeId: input.unattended ? "worker" : undefined, @@ -1317,7 +1362,8 @@ describe("ProviderSnapshotManager public surface", () => { }, }); try { - const parent = { + const parent: ManagedAgent = { + ...CLOSED_PARENT, id: "parent-agent", provider: "opencode", currentModeId: "orchestrator", @@ -1327,7 +1373,7 @@ describe("ProviderSnapshotManager public surface", () => { cwd: "/tmp/project", featureValues: { auto_accept: true }, }, - } as ManagedAgent; + }; const resolved = await manager.resolveCreateConfig({ cwd: "/tmp/project", @@ -1367,9 +1413,12 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { expect(manager.hasProvider("zai-claude")).toBe(true); expect(state.providerDefinitions["zai-claude"]).toMatchObject({ enabled: true }); expect(manager.listRegisteredProviderIds()).toContain("zai-claude"); - expect(manager.getSnapshot().find((entry) => entry.provider === "zai-claude")?.source).toBe( - "custom", - ); + expect( + manager + .getSnapshot() + .records.map(({ entry }) => entry) + .find((entry) => entry.provider === "zai-claude")?.source, + ).toBe("custom"); } finally { manager.destroy(); } @@ -1389,7 +1438,12 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { expect(manager.hasProvider("zai-claude")).toBe(false); expect(state.providerDefinitions["zai-claude"]).toBeUndefined(); - expect(manager.getSnapshot().some((entry) => entry.provider === "zai-claude")).toBe(false); + expect( + manager + .getSnapshot() + .records.map(({ entry }) => entry) + .some((entry) => entry.provider === "zai-claude"), + ).toBe(false); manager.applyMutableProviderConfig({ codex: { enabled: false } }); expect(manager.hasProvider("zai-claude")).toBe(false); @@ -1451,7 +1505,7 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { "zai-claude": { extends: "claude", label: "ZAI", enabled: true }, }); - const cwds = listener.mock.calls.map((call) => call[1]).sort(); + const cwds = [...new Set(listener.mock.calls.map((call) => call[0].current.cwd))].sort(); expect(cwds).toEqual([cwdA, cwdB].sort()); } finally { manager.destroy(); @@ -1494,7 +1548,7 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { try { for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); const getCodexEntry = (cwd: string) => - manager.getSnapshot(cwd).find((entry) => entry.provider === "codex"); + manager.getSnapshot(cwd).records.find(({ entry }) => entry.provider === "codex")?.entry; const before = cwds.map(getCodexEntry); const definition = manager.getAgentManagerProviderState().providerDefinitions.codex; manager.applyMutableProviderConfig( @@ -1504,7 +1558,7 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { expect(cwds.map(getCodexEntry)).toEqual(before); expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toEqual(definition); for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); - expect(calls).toEqual({ claude: 4, codex: 2 }); + expect(calls).toEqual({ claude: 2, codex: 1 }); const listener = vi.fn(); manager.on("change", listener); manager.applyMutableProviderConfig( @@ -1512,14 +1566,14 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { { replace: true }, ); for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); - expect(calls).toEqual({ claude: 4, codex: 2 }); + expect(calls).toEqual({ claude: 2, codex: 1 }); expect(listener).not.toHaveBeenCalled(); } finally { manager.destroy(); } }); - test("reload preserves an unchanged lookup in flight through commit and rollback", async () => { + test("reload preserves an unchanged lookup in flight through commit and discarded preparation", async () => { let finish!: () => void; const gate = new Promise((complete) => { finish = complete; @@ -1557,14 +1611,19 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { { ...config, claude: { enabled: false, label: "Changed" } }, { replace: true }, ); - const staged = manager.stageMutableProviderConfig( + const staged = manager.prepareMutableProviderConfig( { ...config, claude: { enabled: false, label: "Rollback" } }, { replace: true }, ); - staged.rollback(); + expect(staged.agentManagerState.providerDefinitions.claude?.enabled).toBe(false); finish(); await pending; - expect(manager.getSnapshot(cwd).find((e) => e.provider === "codex")?.status).toBe("ready"); + expect( + manager + .getSnapshot(cwd) + .records.map(({ entry }) => entry) + .find((e) => e.provider === "codex")?.status, + ).toBe("ready"); expect(calls).toBe(1); } finally { finish(); @@ -1572,7 +1631,7 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { } }); - test("reload replaces derived clients only when their provider configuration changes", () => { + test("reload replaces derived clients only when their provider configuration changes", async () => { const config = { claude: { enabled: true }, codex: { enabled: true }, @@ -1588,6 +1647,18 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { }); try { const before = manager.getAgentManagerProviderState().clients; + for (const provider of ["codex", "claude", "codex-2"]) { + expect( + await before[provider]!.getCatalogCacheKey?.({ scope: "global", force: false }), + ).toBe("host"); + expect( + await before[provider]!.getCatalogCacheKey?.({ + scope: "workspace", + cwd: resolveSnapshotCwd("/project"), + force: true, + }), + ).toBe("host"); + } const unchanged = manager.applyMutableProviderConfig(config, { replace: true }).clients; expect(unchanged.codex).toBe(before.codex); expect(unchanged["codex-2"]).toBe(before["codex-2"]); @@ -1607,72 +1678,95 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { } }); - test("stages provider state without events, then publishes or rolls back", () => { + test("preparation leaves installed reads untouched and commit installs all target memberships before notification", () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - providerOverrides: { - claude: { enabled: false }, - codex: { enabled: true }, - copilot: { enabled: false }, - opencode: { enabled: false }, - pi: { enabled: false }, - }, + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { codex: createExtraClient("codex") }, }); try { - const cwd = resolve("/tmp/project"); - manager.getSnapshot(cwd); - const events: string[] = []; - manager.on("change", (_entries, changedCwd) => events.push(changedCwd)); - - const rolledBack = manager.stageMutableProviderConfig( - { codex: { enabled: false } }, - { replace: true }, + const cwds = ["/tmp/install-a", "/tmp/install-b"]; + const previous = cwds.map((cwd) => manager.getSnapshot(cwd)); + const observations: Array<{ current: ProviderSnapshot; reads: ProviderSnapshot[] }> = []; + manager.on("change", ({ current }) => + observations.push({ + current, + reads: [manager.getSnapshot(cwds[0]), manager.getSnapshot(cwds[1])], + }), ); - expect(events).toEqual([]); - expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({ - enabled: false, - }); - rolledBack.rollback(); - expect(events).toEqual([]); - expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({ - enabled: true, - }); - - const committed = manager.stageMutableProviderConfig( - { codex: { enabled: false } }, - { replace: true }, - ); - expect(events).toEqual([]); - committed.publish(); - expect(events).toEqual([cwd]); + const config = { + ...PUBLICATION_PROVIDERS, + codex: { enabled: false }, + claude: { enabled: false, label: "Changed" }, + custom: { extends: "codex", label: "Custom", enabled: false }, + }; + const prepared = manager.prepareMutableProviderConfig(config, { replace: true }); + expect(prepared.agentManagerState.providerDefinitions.custom?.enabled).toBe(false); + expect(manager.hasProvider("custom")).toBe(false); + expect(manager.listRegisteredProviderIds()).not.toContain("custom"); + expect(manager.getAgentManagerProviderState().providerDefinitions.codex?.enabled).toBe(true); + expect(cwds.map((cwd) => manager.getSnapshot(cwd))).toEqual(previous); + expect(manager.getSnapshot(cwds[0])).toBe(previous[0]); + expect(observations).toEqual([]); + expect(() => + manager.prepareMutableProviderConfig( + { custom: { extends: "missing-provider" } }, + { replace: true }, + ), + ).toThrow(); + expect(manager.getSnapshot(cwds[0])).toBe(previous[0]); + prepared.commit(); + const committed = cwds.map((cwd) => manager.getSnapshot(cwd)); + expect(observations).toHaveLength(2); + for (const [index, observation] of observations.entries()) { + expect(observation.current).toBe(committed[index]); + expect(observation.reads[0]).toBe(committed[0]); + expect(observation.reads[1]).toBe(committed[1]); + } + expect(manager.hasProvider("custom")).toBe(true); + expect( + committed[0]!.records.map(({ entry }) => [entry.provider, entry.status, entry.label]), + ).toContainEqual(["codex", "unavailable", "Codex"]); + expect(manager.getProviderLabel("claude")).toBe("Changed"); } finally { manager.destroy(); } }); - test("restores provider state when applying a live config fails", () => { + test("subscriber failures cannot change discovery results, roll back commits or starve another listener", async () => { + const logs: string[] = []; + const logger = pino({ level: "warn" }, { write: (line) => logs.push(line) }); const manager = new ProviderSnapshotManager({ - logger: createTestLogger(), - providerOverrides: { - claude: { enabled: false }, - codex: { enabled: true }, - copilot: { enabled: false }, - opencode: { enabled: false }, - pi: { enabled: false }, - }, + logger, + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true }) }, }); - manager.getSnapshot("/tmp/project"); + const transitions: ProviderSnapshot[] = []; manager.on("change", () => { - throw new Error("snapshot consumer failed"); + throw new Error("subscriber failed"); }); - + manager.on("change", ({ current }) => transitions.push(current)); try { - expect(() => - manager.applyMutableProviderConfig({ codex: { enabled: false } }, { replace: true }), - ).toThrow("snapshot consumer failed"); - expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({ - enabled: true, - }); + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/subscribers" }); + expect( + manager + .getSnapshot("/tmp/subscribers") + .records.find(({ entry }) => entry.provider === "codex")!.entry.status, + ).toBe("ready"); + manager.applyMutableProviderConfig( + { ...PUBLICATION_PROVIDERS, codex: { enabled: false } }, + { replace: true }, + ); + expect(manager.getAgentManagerProviderState().providerDefinitions.codex?.enabled).toBe(false); + const statuses = []; + for (const { records } of transitions) { + statuses.push(records.find(({ entry }) => entry.provider === "codex")!.entry.status); + } + expect(statuses).toEqual(["ready", "unavailable"]); + expect(logs.map((line) => JSON.parse(line))).toMatchObject([ + { msg: "Provider snapshot subscriber failed", err: { message: "subscriber failed" } }, + { msg: "Provider snapshot subscriber failed", err: { message: "subscriber failed" } }, + ]); } finally { manager.destroy(); } @@ -1703,23 +1797,24 @@ describe("ProviderSnapshotManager lifecycle", () => { }; const initialClient = trackShutdown(manager.getAgentManagerProviderState().clients.codex); - const published = manager.stageMutableProviderConfig(providerConfig("Published"), { + const published = manager.prepareMutableProviderConfig(providerConfig("Published"), { replace: true, }); const publishedClient = trackShutdown(published.agentManagerState.clients.codex); - published.publish(); + published.commit(); - const rolledBack = manager.stageMutableProviderConfig(providerConfig("Rolled back"), { + const rolledBack = manager.prepareMutableProviderConfig(providerConfig("Rolled back"), { replace: true, }); trackShutdown(rolledBack.agentManagerState.clients.codex); - rolledBack.rollback(); expect(manager.getAgentManagerProviderState().clients.codex).toBe(publishedClient); - const newest = manager.stageMutableProviderConfig(providerConfig("Newest"), { replace: true }); + const newest = manager.prepareMutableProviderConfig(providerConfig("Newest"), { + replace: true, + }); const newestClient = trackShutdown(newest.agentManagerState.clients.codex); - newest.publish(); + newest.commit(); expect(initialClient).not.toBe(publishedClient); expect(manager.getAgentManagerProviderState().clients.codex).toBe(newestClient); @@ -1781,8 +1876,130 @@ describe("ProviderSnapshotManager lifecycle", () => { }); describe("ProviderSnapshotManager cwd routing", () => { + test("deduplicates cold global catalogue discovery across concurrent workspace reads", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((resolvePending) => { + release = resolvePending; + }); + const discoveryStarted = new Promise((resolveStarted) => { + started = resolveStarted; + }); + const requests: FetchCatalogOptions[] = []; + let availabilityProbes = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + availabilityProbes++; + return true; + }, + async fetchCatalog(options) { + requests.push(options); + started(); + await pending; + return { + models: [{ provider: "codex", id: "gpt-6-astra", label: "GPT-6 Astra" }], + modes: [], + }; + }, + }), + }, + }); + try { + const reads = Promise.all( + [ + undefined, + resolveSnapshotCwd("/tmp/concurrent-a"), + resolveSnapshotCwd("/tmp/concurrent-b"), + ].map((cwd) => manager.getProvider({ provider: "codex", cwd, wait: true })), + ); + await discoveryStarted; + expect(requests).toEqual([{ scope: "global", force: false }]); + release(); + const entries = await reads; + expect(entries.map((entry) => entry.models?.[0]?.id)).toEqual([ + "gpt-6-astra", + "gpt-6-astra", + "gpt-6-astra", + ]); + expect(requests).toHaveLength(1); + expect(availabilityProbes).toBe(1); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } + }); + + test.each(["codex", "custom-codex"])( + "shares %s discovery and refresh recovery across snapshot directories", + async (provider) => { + const requests: FetchCatalogOptions[] = []; + let available = false; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + "custom-codex": { extends: "codex", label: "Custom Codex", enabled: true }, + }, + extraClients: { + [provider]: createExtraClient(provider, { + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + return available; + }, + async fetchCatalog(options) { + requests.push(options); + return { + models: [{ provider, id: "gpt-6-astra", label: "GPT-6 Astra" }], + modes: [], + }; + }, + }), + }, + }); + try { + expect(await manager.getProvider({ provider, wait: true })).toMatchObject({ + status: "unavailable", + }); + available = true; + await manager.refreshSnapshotForCwd({ + cwd: resolveSnapshotCwd("/tmp/model-memory-a"), + providers: [provider], + }); + expect(await manager.getProvider({ provider, wait: true })).toMatchObject({ + status: "ready", + }); + await Promise.all([ + manager.getProvider({ + provider, + cwd: resolveSnapshotCwd("/tmp/model-memory-a"), + wait: true, + }), + manager.getProvider({ + provider, + cwd: resolveSnapshotCwd("/tmp/model-memory-b"), + wait: true, + }), + ]); + expect(requests).toEqual([ + { scope: "workspace", cwd: resolveSnapshotCwd("/tmp/model-memory-a"), force: true }, + ]); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }, + ); + test("settings refresh passes the semantic global scope to providers", async () => { - const fetchCatalog = vi.fn(async () => ({ + const fetchCatalog = vi.fn(async (_options: FetchCatalogOptions) => ({ models: [] as AgentModelDefinition[], modes: [] as AgentMode[], })); @@ -1805,22 +2022,22 @@ describe("ProviderSnapshotManager cwd routing", () => { }); test("global snapshot does not satisfy an explicit home workspace read", async () => { - const fetchCatalog = vi.fn(async () => ({ + const fetchCatalog = vi.fn(async (_options: FetchCatalogOptions) => ({ models: [] as AgentModelDefinition[], modes: [] as AgentMode[], })); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), extraClients: { - codex: createExtraClient("codex", { + pi: createExtraClient("pi", { isAvailable: vi.fn(async () => true), fetchCatalog, }), }, }); try { - await manager.refreshSettingsSnapshot({ providers: ["codex"] }); - await manager.listProviders({ cwd: homedir(), providers: ["codex"], wait: true }); + await manager.refreshSettingsSnapshot({ providers: ["pi"] }); + await manager.listProviders({ cwd: homedir(), providers: ["pi"], wait: true }); expect(fetchCatalog.mock.calls.map((call) => call[0])).toEqual([ expect.objectContaining({ scope: "global", force: true }), @@ -1847,8 +2064,12 @@ describe("ProviderSnapshotManager cwd routing", () => { }, }); try { - const a = manager.getSnapshot("/tmp/project-a"); - const b = manager.getSnapshot("/tmp/project-b"); + const a = manager + .getSnapshot(resolveSnapshotCwd("/tmp/project-a")) + .records.map(({ entry }) => entry); + const b = manager + .getSnapshot(resolveSnapshotCwd("/tmp/project-b")) + .records.map(({ entry }) => entry); expect(a).not.toBe(b); expect(a.map((entry) => entry.provider).sort()).toEqual( b.map((entry) => entry.provider).sort(), @@ -1874,13 +2095,149 @@ describe("ProviderSnapshotManager cwd routing", () => { manager.on("change", listener); manager.getSnapshot(); manager.applyMutableProviderConfig({ claude: { enabled: false, label: "Changed" } }); - const cwds = listener.mock.calls.map((call) => call[1]); + const cwds = listener.mock.calls.map((call) => call[0].current.cwd); expect(cwds).toContain(GLOBAL_PROVIDER_SNAPSHOT_KEY); } finally { manager.destroy(); } }); + test.each([ + { sharing: "shared", expectedCwds: [resolveSnapshotCwd("/tmp/plugin-a")] }, + { + sharing: "distinct", + expectedCwds: [ + undefined, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ], + }, + { + sharing: "absent", + expectedCwds: [ + undefined, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ], + }, + ])( + "uses the plugin's provider-owned catalogue key: $sharing", + async ({ sharing, expectedCwds }) => { + let listener: ((event: ProviderEvent) => void) | null = null; + const cwds: Array = []; + const registration: ProviderRegistration = { + id: "scoped-plugin", + label: "Scoped plugin", + getCatalogCacheKey: + sharing === "absent" + ? undefined + : async (options) => + sharing === "shared" + ? "host" + : JSON.stringify(options.scope === "workspace" ? options.cwd : null), + async connect() { + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type !== "catalog") return; + cwds.push(input.cwd); + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [{ id: input.cwd ?? "global-model", label: "Model" }], + modes: [], + }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = null; + }; + }, + async close() {}, + }; + }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + codex: { enabled: false }, + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + }); + try { + // Register after workspace views exist, as happens when reloading a plugin. + for (const cwd of [ + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + undefined, + ]) { + await manager.listProviders({ cwd, wait: true }); + } + const published: ProviderSnapshot[] = []; + manager.on("change", ({ current }) => published.push(current)); + manager.replacePluginProviders([registration]); + for (const cwd of [ + undefined, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ]) { + const entry = await manager.getProvider({ provider: registration.id, cwd, wait: true }); + expect(entry.status).toBe("ready"); + expect(entry.models?.[0]?.id).toBe( + sharing === "shared" ? resolveSnapshotCwd("/tmp/plugin-a") : (cwd ?? "global-model"), + ); + } + expect(cwds).toEqual(expect.arrayContaining(expectedCwds)); + expect(cwds).toHaveLength(expectedCwds.length); + for (const snapshot of published) { + expect(snapshot.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entry: expect.objectContaining({ provider: registration.id }), + }), + ]), + ); + } + expect(new Set(published.map((snapshot) => snapshot.cwd))).toEqual( + new Set([ + GLOBAL_PROVIDER_SNAPSHOT_KEY, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ]), + ); + const lastFetchedAt = manager + .getSnapshot(resolveSnapshotCwd("/tmp/plugin-a")) + .records.find(({ entry }) => entry.provider === registration.id)!.entry.fetchedAt!; + await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(Date.parse(lastFetchedAt))); + published.length = 0; + await manager.refreshSnapshotForCwd({ + cwd: resolveSnapshotCwd("/tmp/plugin-a"), + providers: [registration.id], + }); + const affectedCwds = + sharing === "shared" + ? [ + GLOBAL_PROVIDER_SNAPSHOT_KEY, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ] + : [resolveSnapshotCwd("/tmp/plugin-a")]; + expect(new Set(published.map((snapshot) => snapshot.cwd))).toEqual(new Set(affectedCwds)); + expect(published).toHaveLength(affectedCwds.length); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }, + ); + test("registers and unregisters plugin providers without rebuilding built-in clients", async () => { let listener: ((event: ProviderEvent) => void) | null = null; const registration: ProviderRegistration = { @@ -1939,3 +2296,1081 @@ describe("ProviderSnapshotManager cwd routing", () => { } }); }); + +describe("provider-owned catalogue identity", () => { + const disabledProviders = { + codex: { enabled: false }, + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }; + + test("shares effective runtime/configuration keys, preserves targets, and isolates provider identities", async () => { + const identities = new Map([ + [resolveSnapshotCwd("/a"), "runtime-1/config-1"], + [resolveSnapshotCwd("/b"), "runtime-1/config-1"], + [resolveSnapshotCwd("/c"), "runtime-2/config-1"], + ]); + const probes: Array<{ provider: string; options: FetchCatalogOptions | undefined }> = []; + const discoveries: Array<{ provider: string; options: FetchCatalogOptions }> = []; + const client = (provider: string) => + createExtraClient(provider, { + async getCatalogCacheKey(options) { + return options.scope === "workspace" ? identities.get(options.cwd) : undefined; + }, + async isAvailable(_signal, options) { + probes.push({ provider, options }); + return true; + }, + async fetchCatalog(options) { + discoveries.push({ provider, options }); + return { + models: [{ provider, id: `${provider}-${discoveries.length}`, label: "Model" }], + modes: [], + }; + }, + }); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + ...disabledProviders, + first: { extends: "codex", label: "First", enabled: true }, + second: { extends: "codex", label: "Second", enabled: true }, + }, + extraClients: { first: client("first"), second: client("second") }, + }); + try { + const read = (cwd: string, provider = "first") => + manager.getProvider({ provider, cwd, wait: true }); + const [a, b, c] = await Promise.all([ + read(resolveSnapshotCwd("/a")), + read(resolveSnapshotCwd("/b")), + read(resolveSnapshotCwd("/c")), + ]); + expect(a.models).toEqual(b.models); + expect(c.models).not.toEqual(a.models); + expect(discoveries).toHaveLength(2); + expect(probes).toEqual(discoveries); + expect(discoveries.map(({ options }) => options)).toEqual([ + { scope: "workspace", cwd: resolveSnapshotCwd("/a"), force: false }, + { scope: "workspace", cwd: resolveSnapshotCwd("/c"), force: false }, + ]); + await read(resolveSnapshotCwd("/a"), "second"); + expect(discoveries).toHaveLength(3); + identities.set(resolveSnapshotCwd("/b"), "runtime-2/config-1"); + expect((await read(resolveSnapshotCwd("/b"))).models).toEqual(c.models); + expect(discoveries).toHaveLength(3); + identities.set(resolveSnapshotCwd("/a"), "runtime-1/config-2"); + expect((await read(resolveSnapshotCwd("/a"))).models).not.toEqual(a.models); + expect(discoveries).toHaveLength(4); + await manager.refreshSnapshotForCwd({ cwd: resolveSnapshotCwd("/b"), providers: ["first"] }); + expect(discoveries).toHaveLength(5); + expect(discoveries[4]?.options).toEqual({ + scope: "workspace", + cwd: resolveSnapshotCwd("/b"), + force: true, + }); + expect((await read(resolveSnapshotCwd("/c"))).models).toEqual( + (await read(resolveSnapshotCwd("/b"))).models, + ); + expect(discoveries).toHaveLength(5); + expect(probes).toEqual(discoveries); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("a refresh supersedes an older in-flight result for the same provider key", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { ...disabledProviders, codex: { enabled: true } }, + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + return "runtime/config"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + const call = ++calls; + if (call === 1) { + started(); + await pending; + } + return { + models: [{ provider: "codex", id: `model-${call}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + try { + const first = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/a"), + wait: true, + }); + await began; + await manager.refreshSnapshotForCwd({ cwd: resolveSnapshotCwd("/b"), providers: ["codex"] }); + release(); + await first; + for (const cwd of [resolveSnapshotCwd("/a"), resolveSnapshotCwd("/b")]) { + expect( + (await manager.getProvider({ provider: "codex", cwd, wait: true })).models?.[0]?.id, + ).toBe("model-2"); + } + expect(calls).toBe(2); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } + }); + + test("key lookup failure detaches a view from its previous shared catalogue", async () => { + let broken = false; + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { ...disabledProviders, codex: { enabled: true } }, + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey(options) { + if (broken && options.scope === "workspace" && options.cwd === resolveSnapshotCwd("/a")) + throw new Error("configuration unreadable"); + return "runtime/config"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + calls++; + return { + models: [{ provider: "codex", id: `model-${calls}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + try { + for (const cwd of [resolveSnapshotCwd("/a"), resolveSnapshotCwd("/b")]) + await manager.getProvider({ provider: "codex", cwd, wait: true }); + broken = true; + expect( + await manager.getProvider({ provider: "codex", cwd: resolveSnapshotCwd("/a"), wait: true }), + ).toMatchObject({ status: "error", error: "configuration unreadable" }); + const published: string[] = []; + manager.on("change", ({ current }) => published.push(current.cwd)); + await manager.refreshSnapshotForCwd({ cwd: resolveSnapshotCwd("/b"), providers: ["codex"] }); + expect(published).toEqual([resolveSnapshotCwd("/b")]); + expect(calls).toBe(2); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); +}); + +test("late key resolution cannot restore an old identity and concurrent readers wait for discovery", async () => { + let releaseKey!: () => void; + let releaseCatalog!: () => void; + let catalogStarted!: () => void; + const keyPending = new Promise((finish) => { + releaseKey = finish; + }); + const catalogPending = new Promise((finish) => { + releaseCatalog = finish; + }); + const began = new Promise((finish) => { + catalogStarted = finish; + }); + let keyCalls = 0; + let probes = 0; + const targets: FetchCatalogOptions[] = []; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + if (++keyCalls === 1) { + await keyPending; + return "old-runtime"; + } + return "current-runtime"; + }, + async isAvailable() { + probes++; + return true; + }, + async fetchCatalog(options) { + targets.push(options); + catalogStarted(); + await catalogPending; + return { models: [{ provider: "codex", id: "current", label: "Current" }], modes: [] }; + }, + }), + }, + }); + try { + const first = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + const second = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + await began; + const third = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + releaseKey(); + releaseCatalog(); + const results = await Promise.all([first, second, third]); + expect(results.map((result) => result.models?.[0]?.id)).toEqual([ + "current", + "current", + "current", + ]); + expect(targets).toEqual([ + { scope: "workspace", cwd: resolveSnapshotCwd("/project"), force: false }, + ]); + expect(probes).toBe(1); + } finally { + releaseKey(); + releaseCatalog(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test("settings refresh starts independent workspace discoveries together and shares equivalent catalogues", async () => { + let release!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + let refreshing = false; + const started: string[] = []; + let sharedFetches = 0; + const cwds = [resolveSnapshotCwd("/refresh-a"), resolveSnapshotCwd("/refresh-b")]; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + pi: createExtraClient("pi", { + async isAvailable() { + return true; + }, + async fetchCatalog(options) { + if (refreshing && options.scope === "workspace") { + started.push(options.cwd); + await pending; + } + return { models: [], modes: [] }; + }, + }), + codex: createExtraClient("codex", { + async isAvailable() { + return true; + }, + async fetchCatalog() { + sharedFetches++; + return { models: [], modes: [] }; + }, + }), + }, + }); + let refresh: Promise | undefined; + try { + await Promise.all( + cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd, providers: ["pi", "codex"] })), + ); + expect(sharedFetches).toBe(1); + refreshing = true; + refresh = manager.refreshSettingsSnapshot({ providers: ["pi", "codex"] }); + await expect.poll(() => [...started].sort()).toEqual([...cwds].sort()); + expect(sharedFetches).toBe(2); + release(); + await refresh; + } finally { + release(); + await refresh; + await manager.shutdown(); + manager.destroy(); + } +}); + +test("bounds each provider across workspaces without blocking another provider", async () => { + let release!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + let refreshing = false; + let peerFetches = 0; + let active = 0; + let peak = 0; + const started = new Set(); + const updated = new Set(); + const cwds = Array.from({ length: 16 }, (_, index) => resolveSnapshotCwd(`/bounded-${index}`)); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async isAvailable() { + return true; + }, + async fetchCatalog() { + peerFetches++; + return { models: [], modes: [] }; + }, + }), + pi: createExtraClient("pi", { + async isAvailable(_signal, options) { + if (refreshing && options?.scope === "workspace") { + started.add(options.cwd); + peak = Math.max(peak, ++active); + await pending; + active--; + } + return true; + }, + async fetchCatalog() { + return { + models: [{ provider: "pi", id: refreshing ? "new" : "old", label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + const requests: Promise[] = []; + try { + await Promise.all(cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd, providers: ["pi"] }))); + manager.on("change", ({ current }) => { + if ( + current.records.find((record) => record.entry.provider === "pi")?.entry.models?.[0]?.id === + "new" + ) { + updated.add(current.cwd); + } + }); + refreshing = true; + requests.push(manager.refreshSettingsSnapshot({ providers: ["pi"] })); + await expect.poll(() => started.size).toBeGreaterThanOrEqual(4); + requests.push(manager.warmUpSnapshotForCwd({ cwd: "/concurrent-read", providers: ["pi"] })); + expect(peak).toBe(4); + requests.push( + manager.warmUpSnapshotForCwd({ cwd: "/independent-provider", providers: ["codex"] }), + ); + await expect.poll(() => peerFetches).toBe(1); + release(); + await Promise.all(requests); + expect(started.size).toBe(cwds.length + 1); + expect(peak).toBe(4); + // Other sessions receive refreshed views without initiating a scoped read. + expect(cwds.every((cwd) => updated.has(cwd))).toBe(true); + } finally { + release(); + await Promise.all(requests); + await manager.shutdown(); + manager.destroy(); + } +}); + +test.each(["configuration", "shutdown", "destroy"])( + "discards queued discovery after %s", + async (action) => { + let release!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const calls: string[] = []; + let keys = 0; + const cwds = Array.from({ length: 5 }, (_, index) => resolveSnapshotCwd(`/queued-${index}`)); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { ...PUBLICATION_PROVIDERS, pi: { enabled: true, label: "Initial" } }, + extraClients: { + pi: createExtraClient("pi", { + async getCatalogCacheKey() { + keys++; + return undefined; + }, + async isAvailable() { + return true; + }, + async fetchCatalog(options) { + if (options.scope === "workspace") calls.push(options.cwd); + await pending; + return { models: [], modes: [] }; + }, + }), + }, + }); + const requests = cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd, providers: ["pi"] })); + let transitions = 0; + manager.on("change", () => { + transitions++; + }); + try { + await expect.poll(() => keys).toBe(5); + await expect.poll(() => calls.length).toBeGreaterThanOrEqual(4); + expect(calls).toEqual(cwds.slice(0, 4)); + let queuedSettled = false; + void requests[4]!.then(() => { + queuedSettled = true; + return undefined; + }); + if (action === "configuration") { + manager.applyMutableProviderConfig({ + ...PUBLICATION_PROVIDERS, + pi: { enabled: true, label: "Updated" }, + }); + await expect.poll(() => queuedSettled).toBe(true); + // Replacing configuration must not reset the budget for still-active probes. + expect(calls).toEqual(cwds.slice(0, 4)); + release(); + await manager.warmUpSnapshotForCwd({ cwd: cwds[4], providers: ["pi"] }); + await Promise.all(requests); + expect(calls.filter((cwd) => cwd === cwds[4])).toHaveLength(1); + } else { + const beforeStop = transitions; + if (action === "shutdown") await manager.shutdown(); + else manager.destroy(); + await expect.poll(() => queuedSettled).toBe(true); + release(); + await Promise.all(requests); + expect(calls).toEqual(cwds.slice(0, 4)); + expect(transitions).toBe(beforeStop); + } + } finally { + release(); + await Promise.all(requests); + await manager.shutdown(); + manager.destroy(); + } + }, +); + +test("settings invalidation discards pending discovery even when the provider returns the same key", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + const call = ++calls; + if (call === 1) { + started(); + await pending; + } + return { + models: [{ provider: "codex", id: `model-${call}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + try { + const old = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + await began; + await manager.refreshSettingsSnapshot({ providers: ["codex"] }); + release(); + await old; + expect( + ( + await manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }) + ).models?.[0]?.id, + ).toBe("model-2"); + expect(calls).toBe(2); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test("an unchanged provider publishes a pending key failure after an unrelated config reload", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + const config = { + codex: { enabled: true }, + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: config, + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + started(); + await pending; + throw new Error("unreadable config"); + }, + }), + }, + }); + try { + const read = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + await began; + manager.applyMutableProviderConfig( + { ...config, claude: { enabled: false, label: "Changed" } }, + { replace: true }, + ); + release(); + expect(await read).toMatchObject({ status: "error", error: "unreadable config" }); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test("a concurrent cached read cannot swallow a force refresh while its key is resolving", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let keyCalls = 0; + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + if (++keyCalls === 2) { + started(); + await pending; + } + return "host"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + return { + models: [{ provider: "codex", id: `model-${++calls}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + const cwd = resolveSnapshotCwd("/project"); + try { + await manager.getProvider({ provider: "codex", cwd, wait: true }); + const refresh = manager.refreshSnapshotForCwd({ providers: ["codex"], cwd }); + await began; + const read = manager.getProvider({ provider: "codex", cwd, wait: true }); + release(); + await refresh; + expect((await read).models?.[0]?.id).toBe("model-2"); + expect(calls).toBe(2); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test.each(["workspace", "settings"])( + "%s refresh retains the settled catalogue and publishes only the next result", + async (scope) => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + getCatalogCacheKey: async () => "shared", + async isAvailable() { + return true; + }, + async fetchCatalog() { + if (++calls === 2) { + started(); + await pending; + } + return { + models: [{ provider: "codex", id: "astra", label: `Astra ${calls}` }], + modes: [], + }; + }, + }), + }, + }); + try { + await manager.getProvider({ provider: "codex", cwd: "/project", wait: true }); + const before = manager.getSnapshot("/project"); + await manager.warmUpSnapshotForCwd({ cwd: "/project", providers: ["codex"] }); + const statuses: string[] = []; + manager.on("change", ({ current }) => { + if (current.cwd !== resolveSnapshotCwd("/project")) return; + statuses.push( + current.records.find(({ entry }) => entry.provider === "codex")!.entry.status, + ); + }); + const refresh = + scope === "workspace" + ? manager.refreshSnapshotForCwd({ cwd: "/project", providers: ["codex"] }) + : manager.refreshSettingsSnapshot({ providers: ["codex"] }); + await began; + expect(statuses).toEqual([]); + expect(manager.getSnapshot("/project")).toBe(before); + expect( + await manager.getProvider({ provider: "codex", cwd: "/project", wait: false }), + ).toMatchObject({ status: "ready", models: [{ id: "astra", label: "Astra 1" }] }); + release(); + await refresh; + expect(statuses).toEqual(["ready"]); + expect( + await manager.getProvider({ provider: "codex", cwd: "/project", wait: true }), + ).toMatchObject({ status: "ready", models: [{ id: "astra", label: "Astra 2" }] }); + expect(calls).toBe(2); + } finally { + release(); + manager.destroy(); + } + }, +); + +test("publication detaches provider-retained arrays, thinking options and nested metadata", async () => { + const models: AgentModelDefinition[] = [ + { + provider: "codex", + id: "astra", + label: "Astra", + thinkingOptions: [{ id: "high", label: "High" }], + metadata: { nested: { label: "original" } }, + }, + ]; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ models, modes: [] }), + }), + }, + }); + try { + const published = await manager.getProvider({ provider: "codex", wait: true }); + const record = manager.getSnapshot().records.find(({ entry }) => entry.provider === "codex")!; + const originalRecord = structuredClone(record); + const original = structuredClone(published); + models.push({ provider: "codex", id: "late", label: "Late" }); + models[0]!.thinkingOptions![0]!.label = "mutated"; + (models[0]!.metadata!.nested as { label: string }).label = "mutated"; + expect(record).toEqual(originalRecord); + expect(published).toBe(record.entry); + expect(published).toEqual(original); + expect(await manager.getProvider({ provider: "codex" })).toEqual(original); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +test("snapshot records share provider results across targets while retaining target-specific catalogues", async () => { + const calls = { codex: 0, opencode: 0 }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: Object.fromEntries( + (["codex", "opencode"] as const).map((provider) => [ + provider, + createExtraClient(provider, { + isAvailable: async () => true, + fetchCatalog: async (options) => { + calls[provider]++; + const target = options.scope === "workspace" ? options.cwd : "global"; + const id = provider === "codex" ? "shared" : target; + return { models: [{ provider, id, label: id }], modes: [] }; + }, + }), + ]), + ), + }); + try { + const targets = [undefined, "/tmp/identity-a", "/tmp/identity-b"]; + await Promise.all( + targets.map((cwd) => + manager.listProviders({ cwd, providers: ["codex", "opencode"], wait: true }), + ), + ); + const snapshots = targets.map((cwd) => manager.getSnapshot(cwd)); + const shared = snapshots.map( + ({ records }) => records.find(({ entry }) => entry.provider === "codex")!, + ); + expect(shared[0]).toBe(shared[1]); + expect(shared[1]).toBe(shared[2]); + expect( + new Set( + snapshots.map( + ({ records }) => records.find(({ entry }) => entry.provider === "opencode")!.contentHash, + ), + ).size, + ).toBe(3); + expect(calls).toEqual({ codex: 1, opencode: 3 }); + expect(await manager.getProvider({ provider: "codex", cwd: targets[1] })).toBe( + shared[0]!.entry, + ); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +test("result identity covers content, metadata and status while unchanged refresh advances only freshness", async () => { + const model: AgentModelDefinition = { + provider: "codex", + id: "astra", + label: "Astra", + metadata: { revision: 1 }, + }; + let available = true; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => available, + fetchCatalog: async () => ({ models: [model], modes: [] }), + }), + }, + }); + const read = () => manager.getSnapshot().records.find(({ entry }) => entry.provider === "codex")!; + const refresh = () => manager.refreshSettingsSnapshot({ providers: ["codex"] }); + try { + await manager.getProvider({ provider: "codex", wait: true }); + const first = read(); + await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(Date.parse(first.entry.fetchedAt!))); + await refresh(); + const fresh = read(); + expect(fresh).not.toBe(first); + expect(fresh.contentHash).toBe(first.contentHash); + expect(Date.parse(fresh.entry.fetchedAt!)).toBeGreaterThan(Date.parse(first.entry.fetchedAt!)); + model.label = "Astra updated"; + await refresh(); + const content = read(); + expect(content.contentHash).not.toBe(fresh.contentHash); + model.metadata!.revision = 2; + await refresh(); + const metadata = read(); + expect(metadata.contentHash).not.toBe(content.contentHash); + available = false; + await refresh(); + const unavailable = read(); + expect(unavailable.entry.status).toBe("unavailable"); + expect(unavailable.contentHash).not.toBe(metadata.contentHash); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +const PUBLICATION_PROVIDERS = Object.fromEntries( + ["claude", "codex", "copilot", "opencode", "pi", "omp"].map((provider) => [ + provider, + { enabled: provider === "codex" }, + ]), +); + +test("reads retain the target snapshot and cold binding publishes only the resolved transition", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true }) }, + }); + const changes: unknown[] = []; + manager.on("change", (change) => changes.push(change)); + try { + const initial = manager.getSnapshot("/tmp/published"); + expect(manager.getSnapshot("/tmp/published")).toBe(initial); + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/published" }); + const ready = manager.getSnapshot("/tmp/published"); + expect(ready).not.toBe(initial); + expect(manager.getSnapshot("/tmp/published")).toBe(ready); + expect(changes).toEqual([{ previous: initial, current: ready }]); + } finally { + manager.destroy(); + } +}); + +test("identical key failures and unavailable results complete without duplicate transitions", async () => { + let keyFails = true; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + getCatalogCacheKey: async () => { + if (keyFails) throw new Error("same key failure"); + return "host"; + }, + }), + }, + }); + const changes: unknown[] = []; + manager.on("change", (change) => changes.push(change)); + try { + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/failure" }); + const failed = manager.getSnapshot("/tmp/failure"); + changes.length = 0; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/failure" }); + expect(changes).toEqual([]); + expect(manager.getSnapshot("/tmp/failure")).toBe(failed); + keyFails = false; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/failure" }); + const unavailable = manager.getSnapshot("/tmp/failure"); + changes.length = 0; + await manager.refreshSnapshotForCwd({ cwd: "/tmp/failure", providers: ["codex"] }); + expect(changes).toEqual([]); + expect(manager.getSnapshot("/tmp/failure")).toBe(unavailable); + } finally { + manager.destroy(); + } +}); + +test("shared result publication installs every target before notifying the first subscriber", async () => { + let label = "before"; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ + models: [{ provider: "codex", id: "astra", label }], + modes: [], + }), + }), + }, + }); + const targets = ["/tmp/atomic-a", "/tmp/atomic-b"]; + try { + await Promise.all(targets.map((cwd) => manager.warmUpSnapshotForCwd({ cwd }))); + const observed: ProviderSnapshot[][] = []; + manager.on("change", () => observed.push(targets.map((cwd) => manager.getSnapshot(cwd)))); + label = "after"; + await manager.refreshSnapshotForCwd({ cwd: targets[0]!, providers: ["codex"] }); + expect(observed).toHaveLength(2); + for (const snapshots of observed) { + expect(snapshots).toHaveLength(2); + for (const snapshot of snapshots) { + expect( + snapshot.records.find(({ entry }) => entry.provider === "codex")!.entry.models![0]!.label, + ).toBe("after"); + } + } + } finally { + manager.destroy(); + } +}); + +test("plugin runtime replacement invalidates only changed registrations and commits all targets together", async () => { + const calls: string[] = []; + const closed: string[] = []; + function registration(id: string, model: string): ProviderRegistration { + return { + id, + label: id, + getCatalogCacheKey: async () => "host", + async connect() { + let listener: ((event: ProviderEvent) => void) | undefined; + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type !== "catalog") return; + calls.push(model); + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { models: [{ id: model, label: model }], modes: [] }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = undefined; + }; + }, + async close() { + closed.push(model); + }, + }; + }, + }; + } + const first = registration("first-plugin", "first"); + const second = registration("second-plugin", "second"); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => { + calls.push("codex"); + return { models: [], modes: [] }; + }, + }), + }, + }); + const cwds = ["/tmp/plugin-install-a", "/tmp/plugin-install-b"]; + const warm = () => Promise.all(cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd }))); + try { + manager.replacePluginProviders([first, second]); + await warm(); + const clients = manager.getAgentManagerProviderState().clients; + const snapshots = cwds.map((cwd) => manager.getSnapshot(cwd)); + const observed: ProviderSnapshot[][] = []; + manager.on("change", () => observed.push(cwds.map((cwd) => manager.getSnapshot(cwd)))); + manager.replacePluginProviders([first, second]); + await warm(); + expect(calls.toSorted()).toEqual(["codex", "first", "second"]); + expect(manager.getSnapshot(cwds[0])).toBe(snapshots[0]); + expect(observed).toEqual([]); + manager.replacePluginProviders([registration("first-plugin", "restarted"), second]); + const loading = cwds.map((cwd) => manager.getSnapshot(cwd)); + expect(observed).toHaveLength(2); + for (const reads of observed) { + expect(reads[0]).toBe(loading[0]); + expect(reads[1]).toBe(loading[1]); + } + const nextClients = manager.getAgentManagerProviderState().clients; + expect(nextClients.codex).toBe(clients.codex); + expect(nextClients["second-plugin"]).toBe(clients["second-plugin"]); + expect(nextClients["first-plugin"]).not.toBe(clients["first-plugin"]); + expect(loading[0]!.records.find(({ entry }) => entry.provider === "codex")).toBe( + snapshots[0]!.records.find(({ entry }) => entry.provider === "codex"), + ); + expect(loading[0]!.records.find(({ entry }) => entry.provider === "second-plugin")).toBe( + snapshots[0]!.records.find(({ entry }) => entry.provider === "second-plugin"), + ); + expect( + loading[0]!.records.find(({ entry }) => entry.provider === "first-plugin")!.entry.status, + ).toBe("loading"); + await warm(); + expect(calls.toSorted()).toEqual(["codex", "first", "restarted", "second"]); + expect( + manager.getSnapshot(cwds[0]).records.find(({ entry }) => entry.provider === "first-plugin")! + .entry.models![0]!.id, + ).toBe("restarted"); + manager.replacePluginProviders([second]); + await warm(); + expect(manager.hasProvider("first-plugin")).toBe(false); + expect(closed.toSorted()).toEqual(["first", "restarted"]); + await manager.shutdown(); + expect(closed.toSorted()).toEqual(["first", "restarted", "second"]); + expect(calls.toSorted()).toEqual(["codex", "first", "restarted", "second"]); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +test("binding a settled catalogue publishes once and rebinding an equal settled result publishes nothing", async () => { + let key = "first"; + let probes = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + getCatalogCacheKey: async () => key, + isAvailable: async () => { + probes++; + return false; + }, + }), + }, + }); + const transitions: unknown[] = []; + manager.on("change", (transition) => transitions.push(transition)); + try { + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-a" }); + transitions.length = 0; + const initial = manager.getSnapshot("/tmp/bind-b"); + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-b" }); + expect(transitions).toEqual([ + { previous: initial, current: manager.getSnapshot("/tmp/bind-b") }, + ]); + expect(probes).toBe(1); + key = "second"; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-b" }); + const retained = manager.getSnapshot("/tmp/bind-a"); + transitions.length = 0; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-a" }); + expect(transitions).toEqual([]); + expect(manager.getSnapshot("/tmp/bind-a")).toBe(retained); + expect(probes).toBe(2); + } finally { + manager.destroy(); + } +}); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 5217a1c738..8eb9f20c8a 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -1,9 +1,11 @@ +import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { isDeepStrictEqual } from "node:util"; import type { Logger } from "pino"; +import pLimit, { type LimitFunction } from "p-limit"; import { expandTilde } from "../../utils/path.js"; import { withTimeout } from "../../utils/promise-timeout.js"; @@ -90,7 +92,23 @@ function omitProviderOverrides( return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; } -type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void; +/** Published values are shared read-only; provider-owned data is detached at publication. */ +export interface ProviderSnapshotRecord { + readonly entry: ProviderSnapshotEntry; + readonly contentHash: string; +} + +export interface ProviderSnapshot { + readonly cwd: string; + readonly records: readonly ProviderSnapshotRecord[]; +} + +export interface ProviderSnapshotTransition { + readonly previous: ProviderSnapshot; + readonly current: ProviderSnapshot; +} + +type ProviderSnapshotChangeListener = (transition: ProviderSnapshotTransition) => void; export interface ProviderSnapshotManagerOptions { logger: Logger; @@ -126,10 +144,9 @@ interface ApplyMutableProviderConfigOptions { replace?: boolean; } -export interface StagedMutableProviderConfig { +export interface PreparedMutableProviderConfig { agentManagerState: AgentManagerProviderState; - publish(): void; - rollback(): void; + commit(): void; } interface ProviderSnapshotProviderOptions { @@ -182,18 +199,32 @@ interface ProviderLoadOptions { catalogScope: ProviderCatalogScope; force: boolean; } -interface ProviderLoad { +interface CatalogBinding { + key?: string; + failure?: ProviderSnapshotRecord; + /** Refresh intent survives reads that supersede an unfinished key lookup. */ + force: boolean; promise: Promise; } -interface MutableProviderState { - baseProviderOverrides: Record | undefined; - runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; - providerOverrides: Record | undefined; - providerRegistry: Record; - providerClients: Record; - snapshots: Map>; - providerLoads: Map>; +interface ProviderCatalog { + result?: ProviderSnapshotRecord; + stale?: boolean; + load?: Promise; +} + +interface RegistryGeneration { + definitions: Record; + order: readonly AgentProvider[]; + providerStates: ReadonlyMap< + AgentProvider, + { initial: ProviderSnapshotRecord; discoveryLimit: LimitFunction } + >; +} + +interface Target { + bindings: Map; + snapshot: ProviderSnapshot; } type ProviderCatalogScope = { scope: "global" } | { scope: "workspace"; cwd: string }; @@ -204,8 +235,8 @@ interface ProviderSnapshotTarget { } export class ProviderSnapshotManager { - private readonly snapshots = new Map>(); - private readonly providerLoads = new Map>(); + private readonly catalogs = new Map>(); + private readonly targets = new Map(); private readonly events = new EventEmitter(); private destroyed = false; private refreshTimeoutMs: number; @@ -219,7 +250,7 @@ export class ProviderSnapshotManager { private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; private baseProviderOverrides: Record | undefined; - private providerRegistry: Record; + private generation: RegistryGeneration; private providerClients: Record; private readonly ownedClients = new Set(); private readonly pluginProviders: PluginAgentClientRegistry; @@ -242,7 +273,10 @@ export class ProviderSnapshotManager { options.diagnosticTimeoutMs, this.refreshTimeoutMs, ); - this.providerRegistry = this.buildRegistry(); + this.generation = this.createGeneration( + this.buildRegistry(this.runtimeSettings, this.providerOverrides), + this.providerOverrides, + ); this.providerClients = { ...this.extraClients, ...this.pluginProviders.clients(), @@ -250,7 +284,7 @@ export class ProviderSnapshotManager { for (const client of Object.values(this.providerClients)) this.ownedClients.add(client); } - getSnapshot(cwd?: string): ProviderSnapshotEntry[] { + getSnapshot(cwd?: string): ProviderSnapshot { const target = resolveProviderSnapshotTarget(cwd); return this.getSnapshotForTarget(target); } @@ -259,9 +293,8 @@ export class ProviderSnapshotManager { const snapshotCwd = resolveSnapshotCwd(options.cwd); const target = createWorkspaceSnapshotTarget(snapshotCwd); const providers = this.resolveRefreshProviders(options.providers); - this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false }); - this.emitChange(snapshotCwd); - await this.refreshProviders(target, providers ?? this.getProviderIds()); + const providersToRefresh = providers ?? this.getProviderIds(); + await this.refreshProviders(target, providersToRefresh); } async refreshSettingsSnapshot( @@ -272,10 +305,21 @@ export class ProviderSnapshotManager { const providers = this.resolveRefreshProviders(options.providers); const providersToRefresh = providers ?? this.getProviderIds(); - this.clearCachedProviders(providers); - this.resetSnapshotToLoading(homeCwd, providers, { preserveExisting: false }); - this.emitChange(homeCwd); + this.getOrCreateTarget(homeCwd); + for (const catalogs of this.catalogs.values()) { + for (const provider of providersToRefresh) { + const catalog = catalogs.get(provider); + if (catalog) catalogs.set(provider, { result: catalog.result, stale: true }); + } + } + // Refresh each known target: provider keys coalesce reads, while target-scoped + // providers must discover again in their own execution context. await this.refreshProviders(target, providersToRefresh); + await Promise.all( + [...this.targets.keys()] + .filter((cwd) => cwd !== homeCwd) + .map((cwd) => this.warmUp(createWorkspaceSnapshotTarget(cwd), providersToRefresh)), + ); } async warmUpSnapshotForCwd(options: ProviderSnapshotWarmUpOptions): Promise { @@ -302,17 +346,24 @@ export class ProviderSnapshotManager { } hasProvider(provider: AgentProvider): boolean { - return Object.prototype.hasOwnProperty.call(this.providerRegistry, provider); + return Object.prototype.hasOwnProperty.call(this.generation.definitions, provider); } getProviderLabel(provider: AgentProvider): string { - return this.providerRegistry[provider]?.label ?? provider; + return this.generation.definitions[provider]?.label ?? provider; } getAgentManagerProviderState(): AgentManagerProviderState { + return this.createAgentManagerState(this.generation.definitions, this.providerClients); + } + + private createAgentManagerState( + definitions: Record, + providerClients: Record, + ): AgentManagerProviderState { const providerDefinitions: AgentManagerProviderState["providerDefinitions"] = {}; const clients: AgentManagerProviderState["clients"] = {}; - for (const [provider, definition] of Object.entries(this.providerRegistry)) { + for (const [provider, definition] of Object.entries(definitions)) { providerDefinitions[provider] = { enabled: definition.enabled, derivedFromProviderId: definition.derivedFromProviderId, @@ -321,7 +372,7 @@ export class ProviderSnapshotManager { applyToolPolicy: definition.applyToolPolicy, }; if (definition.enabled) { - clients[provider] = this.ensureClient(provider, definition); + clients[provider] = this.ensureClient(provider, definition, providerClients); } } for (const [provider, client] of Object.entries(this.extraClients)) { @@ -337,7 +388,7 @@ export class ProviderSnapshotManager { ): AgentManagerProviderState { for (const registration of registrations) { if ( - (this.providerRegistry[registration.id] || this.extraClients[registration.id]) && + (this.generation.definitions[registration.id] || this.extraClients[registration.id]) && !this.pluginProviders.has(registration.id) ) { throw new Error( @@ -345,36 +396,39 @@ export class ProviderSnapshotManager { ); } } + const previousPlugins = this.pluginProviders.definitions(); + const clients = { ...this.providerClients }; + // Materialize fallible installed clients before retiring any plugin runtime. + this.createAgentManagerState(this.generation.definitions, clients); this.pluginProviders.replace(registrations); - this.providerRegistry = this.buildRegistry(); - this.providerClients = { - ...this.extraClients, - ...this.pluginProviders.clients(), - } as Record; - for (const client of Object.values(this.providerClients)) this.ownedClients.add(client); - - for (const cwd of this.snapshots.keys()) { - this.providerLoads.delete(cwd); - this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); - this.emitChange(cwd); - const target = - cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY - ? createGlobalSnapshotTarget() - : createWorkspaceSnapshotTarget(cwd); - const providers = this.resolveProvidersToWarm(cwd); - if (providers.length > 0) void this.warmUp(target, providers); + const plugins = this.pluginProviders.definitions(); + const definitions = { ...this.generation.definitions }; + const changed = new Set(); + for (const provider of new Set([...Object.keys(previousPlugins), ...Object.keys(plugins)])) { + if (previousPlugins[provider] !== plugins[provider]) changed.add(provider); + delete definitions[provider]; + delete clients[provider]; } - - return this.getAgentManagerProviderState(); + Object.assign(definitions, plugins); + Object.assign(clients, this.pluginProviders.clients()); + for (const client of Object.values(clients)) this.ownedClients.add(client); + const generation = this.createGeneration(definitions, this.providerOverrides); + const state = this.createAgentManagerState(definitions, clients); + this.installGeneration(generation, clients, changed); + return state; } - private ensureClient(provider: AgentProvider, definition: ProviderDefinition): AgentClient { - const existing = this.providerClients[provider]; + private ensureClient( + provider: AgentProvider, + definition: ProviderDefinition, + clients = this.providerClients, + ): AgentClient { + const existing = clients[provider]; if (existing) { return existing; } const client = definition.createClient(this.logger); - this.providerClients[provider] = client; + clients[provider] = client; this.ownedClients.add(client); return client; } @@ -385,7 +439,10 @@ export class ProviderSnapshotManager { await this.warmUpSnapshotForCwd({ cwd: input.cwd, providers: input.providers }); } const providerFilter = input.providers ? new Set(input.providers) : null; - const entries = this.getSnapshotForTarget(target); + const snapshot = input.wait + ? this.getOrCreateTarget(target.snapshotCwd).snapshot + : this.getSnapshotForTarget(target, input.providers); + const entries = snapshot.records.map(({ entry }) => entry); return providerFilter ? entries.filter((entry) => providerFilter.has(entry.provider)) : entries; } @@ -488,7 +545,7 @@ export class ProviderSnapshotManager { } async getProviderDiagnostic(provider: AgentProvider): Promise { - const definition = this.providerRegistry[provider]; + const definition = this.generation.definitions[provider]; if (!definition) { return { provider, @@ -499,7 +556,7 @@ export class ProviderSnapshotManager { } const baseDiagnosticPromise = this.getBaseProviderDiagnostic(provider, definition); - const snapshotEntryPromise = this.refreshDiagnosticSnapshotEntry(provider, definition); + const snapshotEntryPromise = this.refreshDiagnosticSnapshotEntry(provider); const [baseDiagnostic, entry] = await Promise.all([ baseDiagnosticPromise, snapshotEntryPromise, @@ -515,124 +572,76 @@ export class ProviderSnapshotManager { mutableProviders: MutableDaemonConfig["providers"] | undefined, options: ApplyMutableProviderConfigOptions = {}, ): AgentManagerProviderState { - const staged = this.stageMutableProviderConfig(mutableProviders, options); - try { - staged.publish(); - return staged.agentManagerState; - } catch (error) { - staged.rollback(); - throw error; - } + const prepared = this.prepareMutableProviderConfig(mutableProviders, options); + prepared.commit(); + return prepared.agentManagerState; } - stageMutableProviderConfig( + prepareMutableProviderConfig( mutableProviders: MutableDaemonConfig["providers"] | undefined, options: ApplyMutableProviderConfigOptions = {}, - ): StagedMutableProviderConfig { - const previous = this.captureMutableProviderState(); - const snapshotCwds = Array.from(this.snapshots.keys()); - try { - if (options.replace) { - this.baseProviderOverrides = undefined; - this.runtimeSettings = undefined; - } else { - this.baseProviderOverrides = omitProviderOverrides( - this.baseProviderOverrides, - options.removeProviders ?? [], - ); - } - this.providerOverrides = applyMutableProviderConfigToOverrides( - this.baseProviderOverrides, - mutableProviders, - ); - // The mutable config is the complete provider source after startup. Keeping - // startup-derived runtime settings here would retain removed command/env fields. - const nextRegistry = this.buildRegistry(); - const changedProviders = this.findChangedProviders(previous, nextRegistry); - - this.providerRegistry = nextRegistry; - this.providerClients = { ...previous.providerClients }; - for (const provider of changedProviders) delete this.providerClients[provider]; - for (const provider of Object.keys(nextRegistry)) { - if (!changedProviders.has(provider)) { - nextRegistry[provider] = previous.providerRegistry[provider]!; - } - } - Object.assign(this.providerClients, this.extraClients, this.pluginProviders.clients()); - const providersToRefresh = [...changedProviders].filter((provider) => nextRegistry[provider]); - - for (const cwd of snapshotCwds) { - const loads = new Map(this.providerLoads.get(cwd)); - for (const provider of changedProviders) loads.delete(provider); - this.providerLoads.set(cwd, loads); - this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd, changedProviders)); - } - - return { - agentManagerState: this.getAgentManagerProviderState(), - publish: () => { - if (changedProviders.size === 0) return; - for (const cwd of snapshotCwds) { - this.emitChange(cwd); - const target = - cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY - ? createGlobalSnapshotTarget() - : createWorkspaceSnapshotTarget(cwd); - const providers = this.resolveProvidersToWarm(cwd, providersToRefresh); - if (providers.length > 0) void this.warmUp(target, providers); - } - }, - rollback: () => this.restoreMutableProviderState(previous), - }; - } catch (error) { - this.restoreMutableProviderState(previous); - throw error; - } - } - - private findChangedProviders( - previous: MutableProviderState, - nextRegistry: Record, - ): Set { - const providers = new Set([ - ...Object.keys(previous.providerRegistry), - ...Object.keys(nextRegistry), - ]); + ): PreparedMutableProviderConfig { + const baseProviderOverrides = options.replace + ? undefined + : omitProviderOverrides(this.baseProviderOverrides, options.removeProviders ?? []); + const runtimeSettings = options.replace ? undefined : this.runtimeSettings; + const providerOverrides = applyMutableProviderConfigToOverrides( + baseProviderOverrides, + mutableProviders, + ); + const definitions = this.buildRegistry(runtimeSettings, providerOverrides); const changed = new Set(); - for (const provider of providers) { - const before = previous.providerRegistry[provider]; - const after = nextRegistry[provider]; + const clients = { ...this.providerClients }; + for (const provider of new Set([...this.generation.order, ...Object.keys(definitions)])) { + const before = this.generation.definitions[provider]; + const after = definitions[provider]; if (!before || !after || !isDeepStrictEqual(before.configuration, after.configuration)) { changed.add(provider); + delete clients[provider]; + } else { + definitions[provider] = before; } } - return changed; - } - - private captureMutableProviderState(): MutableProviderState { + Object.assign(clients, this.extraClients, this.pluginProviders.clients()); + const generation = this.createGeneration(definitions, providerOverrides); + const agentManagerState = this.createAgentManagerState(definitions, clients); return { - baseProviderOverrides: this.baseProviderOverrides, - runtimeSettings: this.runtimeSettings, - providerOverrides: this.providerOverrides, - providerRegistry: this.providerRegistry, - providerClients: this.providerClients, - // Preserve the inner map identities: in-flight refreshes close over them. - // Staging replaces active maps instead of mutating these originals. - snapshots: new Map(this.snapshots), - providerLoads: new Map(this.providerLoads), + agentManagerState, + commit: () => { + this.baseProviderOverrides = baseProviderOverrides; + this.runtimeSettings = runtimeSettings; + this.providerOverrides = providerOverrides; + this.installGeneration(generation, clients, changed); + }, }; } - private restoreMutableProviderState(previous: MutableProviderState): void { - this.baseProviderOverrides = previous.baseProviderOverrides; - this.runtimeSettings = previous.runtimeSettings; - this.providerOverrides = previous.providerOverrides; - this.providerRegistry = previous.providerRegistry; - this.providerClients = previous.providerClients; - this.snapshots.clear(); - for (const [cwd, entries] of previous.snapshots) this.snapshots.set(cwd, entries); - this.providerLoads.clear(); - for (const [cwd, loads] of previous.providerLoads) this.providerLoads.set(cwd, loads); + private installGeneration( + generation: RegistryGeneration, + clients: Record, + changed: ReadonlySet, + ): void { + for (const provider of changed) { + this.generation.providerStates.get(provider)?.discoveryLimit.clearQueue(); + } + this.generation = generation; + this.providerClients = clients; + for (const [key, catalogs] of this.catalogs) { + for (const provider of changed) catalogs.delete(provider); + if (catalogs.size === 0) this.catalogs.delete(key); + } + for (const target of this.targets.values()) { + for (const provider of changed) target.bindings.delete(provider); + } + this.publishTargets(this.targets.keys()); + const providers = [...changed].filter((provider) => generation.definitions[provider]); + if (providers.length === 0) return; + for (const cwd of this.targets.keys()) { + void this.warmUp( + resolveProviderSnapshotTarget(cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY ? undefined : cwd), + providers, + ); + } } setRefreshTimeoutMs(refreshTimeoutMs: number | undefined): void { @@ -651,6 +660,8 @@ export class ProviderSnapshotManager { } async shutdown(): Promise { + this.destroyed = true; + for (const state of this.generation.providerStates.values()) state.discoveryLimit.clearQueue(); // Materialize a client per enabled provider so provider-owned resources // (background processes, sockets, etc.) get a chance to release even when // a given provider hasn't been touched yet during this daemon's lifetime. @@ -660,15 +671,19 @@ export class ProviderSnapshotManager { destroy(): void { this.destroyed = true; + for (const state of this.generation.providerStates.values()) state.discoveryLimit.clearQueue(); + this.catalogs.clear(); + this.targets.clear(); this.events.removeAllListeners(); - this.snapshots.clear(); - this.providerLoads.clear(); } - private buildRegistry(): Record { + private buildRegistry( + runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, + providerOverrides: Record | undefined, + ): Record { const registry = buildProviderRegistry(this.logger, { - runtimeSettings: this.runtimeSettings, - providerOverrides: this.providerOverrides, + runtimeSettings, + providerOverrides, workspaceGitService: this.workspaceGitService, managedProcesses: this.managedProcesses, openCodeBridge: this.openCodeBridge, @@ -715,12 +730,15 @@ export class ProviderSnapshotManager { }; } - private getSnapshotForTarget(target: ProviderSnapshotTarget): ProviderSnapshotEntry[] { - const providersToWarm = this.resolveProvidersToWarm(target.snapshotCwd); + private getSnapshotForTarget( + target: ProviderSnapshotTarget, + providers?: AgentProvider[], + ): ProviderSnapshot { + const providersToWarm = this.resolveProvidersToWarm(target.snapshotCwd, providers); if (providersToWarm.length > 0) { void this.warmUp(target, providersToWarm); } - return entriesToArray(this.getOrCreateSnapshot(target.snapshotCwd)); + return this.getOrCreateTarget(target.snapshotCwd).snapshot; } private async getReadyProvider( @@ -740,7 +758,7 @@ export class ProviderSnapshotManager { } private requireProvider(provider: AgentProvider): ProviderDefinition { - const definition = this.providerRegistry[provider]; + const definition = this.generation.definitions[provider]; if (!definition) { throw new Error(`Provider ${provider} is not configured`); } @@ -749,24 +767,15 @@ export class ProviderSnapshotManager { private async refreshDiagnosticSnapshotEntry( provider: AgentProvider, - definition: ProviderDefinition, ): Promise { try { const target = createGlobalSnapshotTarget(); - this.resetSnapshotToLoading(target.snapshotCwd, [provider], { preserveExisting: false }); - this.emitChange(target.snapshotCwd); await this.refreshProviders(target, [provider]); return await this.getProvider({ provider, wait: false }); } catch (error) { return { - provider, + ...this.generation.providerStates.get(provider)!.initial.entry, status: "error", - enabled: definition.enabled, - source: this.getProviderSource(provider), - label: definition.label, - description: definition.description, - iconSvg: definition.iconSvg, - defaultModeId: definition.defaultModeId, error: toErrorMessage(error), }; } @@ -797,74 +806,40 @@ export class ProviderSnapshotManager { } } - private getProviderSource(provider: AgentProvider): ProviderSnapshotEntry["source"] { - if (this.pluginProviders.has(provider)) return "custom"; - const isBuiltin = BUILTIN_PROVIDER_IDS.includes(provider); - return !isBuiltin && this.providerOverrides?.[provider]?.extends ? "custom" : "builtin"; - } - - private createLoadingEntries(): Map { - const entries = new Map(); - for (const provider of this.getProviderIds()) { - const definition = this.providerRegistry[provider]; - entries.set(provider, { - provider, - status: definition?.enabled === false ? "unavailable" : "loading", - enabled: definition?.enabled ?? true, - source: this.getProviderSource(provider), - label: definition?.label, - description: definition?.description, - iconSvg: definition?.iconSvg, - defaultModeId: definition?.defaultModeId ?? null, - }); - } - return entries; - } - - private reconcileSnapshotForRegistry( - cwd: string, - changedProviders?: ReadonlySet, - ): Map { - const existing = this.snapshots.get(cwd); - const entries = new Map(); - - for (const provider of this.getProviderIds()) { - const definition = this.providerRegistry[provider]; - const current = existing?.get(provider); - if (current && changedProviders && !changedProviders.has(provider)) { - entries.set(provider, current); + private createGeneration( + definitions: Record, + overrides: Record | undefined, + ): RegistryGeneration { + const order = Object.keys(definitions); + const providerStates = new Map< + AgentProvider, + { initial: ProviderSnapshotRecord; discoveryLimit: LimitFunction } + >(); + for (const provider of order) { + const definition = definitions[provider]!; + const previous = this.generation?.providerStates.get(provider); + if (this.generation?.definitions[provider] === definition) { + providerStates.set(provider, previous!); continue; } - const metadata = { - provider, - enabled: definition?.enabled ?? true, - source: this.getProviderSource(provider), - label: definition?.label, - description: definition?.description, - iconSvg: definition?.iconSvg, - defaultModeId: definition?.defaultModeId ?? null, - }; - - if (!definition?.enabled) { - entries.set(provider, { - ...metadata, - status: "unavailable", - enabled: false, - }); - continue; - } - - entries.set(provider, { - ...metadata, - status: "loading", - enabled: true, - models: current?.models, - modes: current?.modes, - fetchedAt: current?.fetchedAt, + const custom = + this.pluginProviders.has(provider) || + (!BUILTIN_PROVIDER_IDS.includes(provider) && !!overrides?.[provider]?.extends); + providerStates.set(provider, { + discoveryLimit: previous?.discoveryLimit ?? pLimit({ concurrency: 4, rejectOnClear: true }), + initial: identifyEntry({ + provider, + status: definition.enabled ? "loading" : "unavailable", + enabled: definition.enabled, + source: custom ? "custom" : "builtin", + label: definition.label, + description: definition.description, + iconSvg: definition.iconSvg, + defaultModeId: definition.defaultModeId ?? null, + }), }); } - - return entries; + return { definitions, order, providerStates }; } private async warmUp(target: ProviderSnapshotTarget, providers?: AgentProvider[]): Promise { @@ -891,60 +866,9 @@ export class ProviderSnapshotManager { } private resolveProvidersToWarm(cwd: string, providers?: AgentProvider[]): AgentProvider[] { - const providersToInspect = providers ?? this.getProviderIds(); - const snapshot = this.snapshots.get(cwd); - if (!snapshot) { - this.resetSnapshotToLoading(cwd, providers); - return providersToInspect; - } - - const missingProviders = providersToInspect.filter((provider) => !snapshot.has(provider)); - if (missingProviders.length > 0) { - this.resetSnapshotToLoading(cwd, missingProviders); - } - - return providersToInspect.filter((provider) => snapshot.get(provider)?.status === "loading"); - } - - private clearCachedProviders(providers?: AgentProvider[]): void { - const providerSet = providers ? new Set(providers) : null; - const loadingEntries = this.createLoadingEntries(); - - for (const [cwd, providerLoads] of Array.from(this.providerLoads.entries())) { - if (!providerSet) { - this.providerLoads.delete(cwd); - continue; - } - - for (const provider of providerSet) { - providerLoads.delete(provider); - } - if (providerLoads.size === 0) { - this.providerLoads.delete(cwd); - } - } - - for (const [cwd, snapshot] of this.snapshots.entries()) { - if (!providerSet) { - snapshot.clear(); - for (const [provider, entry] of loadingEntries) { - snapshot.set(provider, entry); - } - this.emitChange(cwd); - continue; - } - - let changed = false; - for (const provider of providerSet) { - const loadingEntry = loadingEntries.get(provider); - if (!loadingEntry) continue; - snapshot.set(provider, loadingEntry); - changed = true; - } - if (changed) { - this.emitChange(cwd); - } - } + this.getOrCreateTarget(cwd); + // Identity is provider-owned and may change without a daemon config reload. + return providers ?? this.getProviderIds(); } private async loadProviders(options: ProviderLoadOptions): Promise { @@ -954,93 +878,137 @@ export class ProviderSnapshotManager { } private loadProvider(options: ProviderLoadOptions & { provider: AgentProvider }): Promise { - const definition = this.providerRegistry[options.provider]; - if (!definition) { - return Promise.resolve(); - } - - const existingLoad = this.getProviderLoad(options.snapshotCwd, options.provider); - if (existingLoad && !options.force) { - return existingLoad.promise; - } - const existingEntry = this.snapshots.get(options.snapshotCwd)?.get(options.provider); - if (existingEntry && existingEntry.status !== "loading" && !options.force) { - return Promise.resolve(); - } - - const load: ProviderLoad = { + if (this.destroyed || !this.generation.definitions[options.provider]) return Promise.resolve(); + const { bindings } = this.getOrCreateTarget(options.snapshotCwd); + const binding: CatalogBinding = { + key: bindings.get(options.provider)?.key, + failure: bindings.get(options.provider)?.failure, + force: options.force || bindings.get(options.provider)?.force === true, promise: Promise.resolve(), }; - this.setProviderLoad(options.snapshotCwd, options.provider, load); - load.promise = Promise.resolve() - .then(() => - this.refreshProvider({ - snapshotCwd: options.snapshotCwd, - catalogScope: options.catalogScope, - provider: options.provider, - definition, - load, - force: options.force, - }), - ) - .finally(() => { - const providerLoads = this.providerLoads.get(options.snapshotCwd); - if (providerLoads?.get(options.provider) === load) { - providerLoads.delete(options.provider); - } - if (providerLoads?.size === 0) { - this.providerLoads.delete(options.snapshotCwd); - } + bindings.set(options.provider, binding); + binding.promise = this.resolveCatalog({ ...options, force: binding.force }, binding); + return binding.promise; + } + + private async resolveCatalog( + options: ProviderLoadOptions & { provider: AgentProvider }, + binding: CatalogBinding, + ): Promise { + const { provider, snapshotCwd, force } = options; + const definition = this.generation.definitions[provider]!; + const initial = this.generation.providerStates.get(provider)!.initial.entry; + this.getOrCreateTarget(snapshotCwd); + if (!definition.enabled) return; + const currentBinding = () => this.targets.get(snapshotCwd)?.bindings.get(provider); + const client = this.ensureClient(provider, definition); + const catalogOptions = createFetchCatalogOptions(options.catalogScope, force); + let key: string; + try { + const sharedKey = client.getCatalogCacheKey + ? await withTimeout( + client.getCatalogCacheKey(catalogOptions), + this.refreshTimeoutMs, + `Timed out resolving ${provider} catalogue key`, + ) + : await Promise.resolve(undefined); + key = JSON.stringify( + sharedKey === undefined ? ["target", snapshotCwd] : ["provider", sharedKey], + ); + } catch (error) { + if (currentBinding() !== binding) return currentBinding()?.promise; + binding.force = false; + binding.key = undefined; + binding.failure = identifyEntry({ + ...this.generation.providerStates.get(provider)!.initial.entry, + status: "error", + error: toErrorMessage(error), }); - return load.promise; + this.publishTargets([snapshotCwd]); + return; + } + // Only the latest key resolution for this target may bind it to a catalogue. + if (currentBinding() !== binding) return currentBinding()?.promise; + binding.force = false; + binding.key = key; + binding.failure = undefined; + let catalogs = this.catalogs.get(key); + if (!catalogs) { + catalogs = new Map(); + this.catalogs.set(key, catalogs); + } + let catalog = catalogs.get(provider); + if (!catalog) { + catalog = {}; + catalogs.set(provider, catalog); + } + this.publishTargets([snapshotCwd]); + if (!force && (catalog.load || (catalog.result && !catalog.stale))) return catalog.load; + catalog.stale = false; + + const current = catalog; + const isCurrent = (): boolean => + !this.destroyed && this.catalogs.get(key)?.get(provider) === current && current.load === load; + const load = this.generation.providerStates + .get(provider)! + .discoveryLimit(() => { + if (!isCurrent()) return; + return this.refreshProvider({ + catalogOptions, + provider, + definition, + initial, + client, + publish: (entry) => { + if (!isCurrent()) return false; + current.result = identifyEntry(structuredClone(entry)); + const boundTargets = [...this.targets].flatMap(([cwd, target]) => + target.bindings.get(provider)?.key === key ? [cwd] : [], + ); + this.publishTargets(boundTargets); + return true; + }, + }); + }) + .finally(() => { + if (current.load === load) current.load = undefined; + }); + current.load = load; + return load; } private async refreshProvider(options: { - snapshotCwd: string; - catalogScope: ProviderCatalogScope; + catalogOptions: FetchCatalogOptions; provider: AgentProvider; definition: ProviderDefinition; - load: ProviderLoad; - force: boolean; + initial: ProviderSnapshotEntry; + client: AgentClient; + publish: (entry: ProviderSnapshotEntry) => boolean; }): Promise { - const { snapshotCwd, catalogScope, provider, definition, load, force } = options; - const base = { + const { + catalogOptions, provider, - source: this.getProviderSource(provider), - label: definition.label, - description: definition.description, - iconSvg: definition.iconSvg, - defaultModeId: definition.defaultModeId, - }; - const setEntry = (entry: ProviderSnapshotEntry) => { - if (!this.isCurrentProviderLoad(snapshotCwd, provider, load)) { - return false; - } - // A config transaction may replace the map while this unchanged provider is loading. - this.getOrCreateSnapshot(snapshotCwd).set(provider, entry); - this.emitChange(snapshotCwd); - return true; - }; + definition, + initial: base, + client, + publish: setEntry, + } = options; try { - if (!definition.enabled) { - setEntry({ ...base, status: "unavailable", enabled: false }); - return; - } - - const client = this.ensureClient(provider, definition); const catalog = await runProviderRefreshWithDeadline({ label: definition.label, timeoutMs: this.refreshTimeoutMs, operation: async (context) => { const available = await context.runActivity("availability", () => - raceProviderRefreshAbort(context.signal, client.isAvailable(context.signal)), + raceProviderRefreshAbort( + context.signal, + client.isAvailable(context.signal, catalogOptions), + ), ); if (!available) { return null; } - const catalogOptions = createFetchCatalogOptions(catalogScope, force); return await definition.fetchCatalog(catalogOptions, client, context); }, }); @@ -1052,7 +1020,7 @@ export class ProviderSnapshotManager { setEntry({ ...base, defaultModeId: - catalog.defaultModeId === undefined ? definition.defaultModeId : catalog.defaultModeId, + catalog.defaultModeId === undefined ? base.defaultModeId : catalog.defaultModeId, status: "ready", enabled: true, models: catalog.models, @@ -1068,93 +1036,64 @@ export class ProviderSnapshotManager { }); if (emitted) { this.logger.warn( - { err: error, provider, cwd: snapshotCwd }, + { err: error, provider, target: catalogOptions }, "Failed to refresh provider snapshot", ); } } } - private getProviderLoad(cwdKey: string, provider: AgentProvider): ProviderLoad | undefined { - return this.providerLoads.get(cwdKey)?.get(provider); - } - - private setProviderLoad(cwdKey: string, provider: AgentProvider, load: ProviderLoad): void { - let providerLoads = this.providerLoads.get(cwdKey); - if (!providerLoads) { - providerLoads = new Map(); - this.providerLoads.set(cwdKey, providerLoads); - } - providerLoads.set(provider, load); - } - - private isCurrentProviderLoad( - cwdKey: string, - provider: AgentProvider, - load: ProviderLoad, - ): boolean { - return this.providerLoads.get(cwdKey)?.get(provider) === load; - } - - private emitChange(cwdKey: string): void { - if (this.destroyed) { - return; - } - const snapshot = this.snapshots.get(cwdKey); - if (!snapshot) { - return; - } - this.events.emit("change", entriesToArray(snapshot), cwdKey); - } - - private getOrCreateSnapshot(cwdKey: string): Map { - const existing = this.snapshots.get(cwdKey); - if (existing) { - return existing; - } - - const created = this.createLoadingEntries(); - this.snapshots.set(cwdKey, created); - return created; - } - - private resetSnapshotToLoading( - cwdKey: string, - providers?: AgentProvider[], - options: { preserveExisting?: boolean } = {}, - ): Map { - const snapshot = this.getOrCreateSnapshot(cwdKey); - const loadingEntries = this.createLoadingEntries(); - const preserveExisting = options.preserveExisting ?? true; - - if (!providers) { - snapshot.clear(); - for (const [provider, entry] of loadingEntries) { - snapshot.set(provider, entry); - } - return snapshot; - } - - for (const provider of providers) { - const loadingEntry = loadingEntries.get(provider); - if (!loadingEntry) continue; - const existing = snapshot.get(provider); - snapshot.set(provider, { - ...loadingEntry, - ...(preserveExisting - ? { - models: existing?.models, - modes: existing?.modes, - fetchedAt: existing?.fetchedAt, - } - : {}), + private publishTargets(cwds: Iterable): void { + if (this.destroyed) return; + const transitions: ProviderSnapshotTransition[] = []; + for (const cwd of cwds) { + const target = this.targets.get(cwd)!; + const records = this.generation.order.map((provider) => { + const binding = target.bindings.get(provider); + const result = binding?.key + ? this.catalogs.get(binding.key)?.get(provider)?.result + : undefined; + return binding?.failure ?? result ?? this.generation.providerStates.get(provider)!.initial; }); + const previous = target.snapshot; + if (sameSnapshotRecords(previous.records, records)) continue; + const current = { cwd, records }; + target.snapshot = current; + transitions.push({ previous, current }); } - return snapshot; + for (const transition of transitions) { + for (const listener of this.events.listeners("change")) { + try { + listener(transition); + } catch (error) { + this.logger.error( + { err: error, cwd: transition.current.cwd }, + "Provider snapshot subscriber failed", + ); + } + } + } + } + + private getOrCreateTarget(cwd: string): Target { + let target = this.targets.get(cwd); + if (!target) { + target = { + bindings: new Map(), + snapshot: { + cwd, + records: this.generation.order.map( + (provider) => this.generation.providerStates.get(provider)!.initial, + ), + }, + }; + this.targets.set(cwd, target); + } + return target; } private getProviderIds(): AgentProvider[] { - return Object.keys(this.providerRegistry); + return [...this.generation.order]; } private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined { @@ -1220,18 +1159,12 @@ export function isGlobalProviderSnapshotKey(cwd: string): boolean { return cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY; } -function entriesToArray( - entries: Map, -): ProviderSnapshotEntry[] { - return Array.from(entries.values(), cloneEntry); -} - -function cloneEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry { - return { - ...entry, - models: entry.models?.map((model) => ({ ...model })), - modes: entry.modes?.map((mode) => ({ ...mode })), - }; +function identifyEntry(entry: ProviderSnapshotEntry): ProviderSnapshotRecord { + const { fetchedAt: _fetchedAt, ...content } = entry; + const contentHash = createHash("sha256") + .update(JSON.stringify(["paseo.provider-result/1", content])) + .digest("base64url"); + return { entry, contentHash }; } function toErrorMessage(error: unknown): string { @@ -1250,3 +1183,21 @@ function formatProviderStatus(entry: ProviderSnapshotEntry): string { if (entry.status === "unavailable") return "Unavailable"; return "Loading"; } + +export function sameSnapshotRecords( + previous: readonly ProviderSnapshotRecord[], + current: readonly ProviderSnapshotRecord[], +): boolean { + return ( + previous.length === current.length && + previous.every((before, index) => { + const after = current[index]!; + return ( + before === after || + (before.entry.provider === after.entry.provider && + before.contentHash === after.contentHash && + before.entry.fetchedAt === after.entry.fetchedAt) + ); + }) + ); +} diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index a5d1bf95da..a7469a75f3 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1563,6 +1563,11 @@ export class ClaudeAgentClient implements AgentClient { }); } + async getCatalogCacheKey(_options: FetchCatalogOptions): Promise { + // This client discovers through host configuration, independent of project cwd. + return "host"; + } + async fetchCatalog( _options: FetchCatalogOptions, context?: ProviderRefreshContext, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 0cc71bf1b8..adff09dd9c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -7083,6 +7083,11 @@ export class CodexAppServerAgentClient implements AgentClient { }); } + async getCatalogCacheKey(_options: FetchCatalogOptions): Promise { + // This client discovers through host configuration, independent of project cwd. + return "host"; + } + async fetchCatalog( _options: FetchCatalogOptions, context?: ProviderRefreshContext, diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 5851a26cfa..52477b3b83 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -165,6 +165,7 @@ describe("PluginService", () => { id: "plugin-agent", label: "Plugin agent", icon: "icon.svg", + async getCatalogCacheKey(options) { return options.scope === "workspace" ? "runtime:" + options.cwd : undefined; }, async connect() { throw new Error("not opened by this test"); }, }); return () => {}; @@ -178,8 +179,16 @@ describe("PluginService", () => { await service.installDirectory({ path: directory }); expect(service.getProviderRegistrations()).toMatchObject([ - { id: "plugin-agent", icon: iconSvg }, + { id: "plugin-agent", icon: iconSvg, getCatalogCacheKey: expect.any(Function) }, ]); + const provider = service.getProviderRegistrations()[0]!; + expect(await provider.getCatalogCacheKey!({ scope: "workspace", cwd: "/project-a" })).toBe( + "runtime:/project-a", + ); + expect( + await provider.getCatalogCacheKey!({ scope: "workspace", cwd: "/project-b", force: true }), + ).toBe("runtime:/project-b"); + expect(await provider.getCatalogCacheKey!({ scope: "global" })).toBeUndefined(); }); it("publishes provider registrations only while their plugin is running", async () => { diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index bb6f59ecdd..acfc9eb722 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -29,6 +29,7 @@ interface PluginRuntimePort { clearLogs(pluginId: string): void; getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[]; connectProvider: PluginRuntime["connectProvider"]; + getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"]; validatePlugin?(path: string): Promise; startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise; stopPluginById(pluginId: string): Promise; @@ -456,6 +457,13 @@ export class PluginService { id: provider.id, label: provider.label, description: provider.description, + getCatalogCacheKey: provider.hasCatalogCacheKey + ? (options) => { + if (!this.runtime.getProviderCatalogCacheKey) + throw new Error("Plugin runtime cannot resolve catalogue keys"); + return this.runtime.getProviderCatalogCacheKey(pluginId, provider.id, options); + } + : undefined, icon: provider.iconPath ? await readPluginProviderIcon(pluginDirectory, provider.iconPath) : undefined, diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 1a63845d63..41462b999e 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -1,5 +1,6 @@ import type { ProviderConnectRequest, + ProviderCatalogOptions, ProviderEvent, ProviderInput, } from "@getpaseo/plugin/provider"; @@ -7,6 +8,7 @@ import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/provi import { z } from "zod"; export interface PluginProviderMetadata { + hasCatalogCacheKey?: boolean; id: string; label: string; description?: string; @@ -21,6 +23,12 @@ export type PluginProcessRequest = appVersion: string; settingsDirectory?: string; } + | { + type: "provider.catalog_key"; + requestId: string; + providerId: string; + options: ProviderCatalogOptions; + } | { type: "invoke"; requestId: string; method: string; input: unknown } | { type: "provider.connect"; @@ -70,6 +78,7 @@ const providerMetadataSchema = z label: z.string().min(1), description: z.string().optional(), iconPath: z.string().optional(), + hasCatalogCacheKey: z.boolean().optional(), }) .strict(); const providerConnectRequestSchema = z @@ -95,6 +104,23 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis settingsDirectory: z.string().optional(), }) .strict(), + z + .object({ + type: z.literal("provider.catalog_key"), + requestId: z.string().min(1), + providerId: z.string().min(1), + options: z.discriminatedUnion("scope", [ + z.object({ scope: z.literal("global"), force: z.boolean().optional() }).strict(), + z + .object({ + scope: z.literal("workspace"), + cwd: z.string(), + force: z.boolean().optional(), + }) + .strict(), + ]), + }) + .strict(), z .object({ type: z.literal("invoke"), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index b048ee1135..473fcc7e0b 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -113,6 +113,12 @@ function registerProvider(provider: ProviderRegistration): void { if (typeof provider.connect !== "function") { throw new Error(`Plugin provider ${id} must implement connect()`); } + if ( + provider.getCatalogCacheKey !== undefined && + typeof provider.getCatalogCacheKey !== "function" + ) { + throw new Error(`Invalid catalogue key callback for plugin provider ${id}`); + } if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`); providers.set(id, { ...provider, id }); } @@ -123,6 +129,7 @@ function providerMetadata(provider: ProviderRegistration) { label: provider.label, description: provider.description, iconPath: provider.icon, + hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined, }; } @@ -323,7 +330,9 @@ process.on("message", (rawMessage: unknown) => { return; } if (stopping) { - if (message.type === "provider.connect") { + if (message.type === "provider.catalog_key") { + send({ type: "error", requestId: message.requestId, error: "Plugin is stopping" }); + } else if (message.type === "provider.connect") { send({ type: "provider.connect_failed", connectionId: message.connectionId, @@ -341,6 +350,19 @@ process.on("message", (rawMessage: unknown) => { } return; } + if (message.type === "provider.catalog_key") { + void (async () => { + const provider = providers.get(message.providerId); + if (!provider) throw new Error(`Unknown provider: ${message.providerId}`); + const output = await provider.getCatalogCacheKey?.(message.options); + if (output !== undefined && typeof output !== "string") + throw new Error("Invalid catalogue key"); + send({ type: "result", requestId: message.requestId, output }); + })().catch((error) => + send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); + return; + } if (message.type === "provider.connect") { void connectProvider(message).catch((error) => { if (stopping) return; diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 102d7b3812..e487368501 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -8,6 +8,7 @@ import { PROVIDER_CAPABILITIES, requireProviderCapabilities, type ProviderConnectRequest, + type ProviderCatalogOptions, type ProviderConnection, type ProviderEvent, type ProviderInput, @@ -415,16 +416,42 @@ export class PluginRuntime { if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); if (!loaded.methods.has(method)) throw new Error(`Plugin ${pluginId} does not contribute RPC ${method}`); + return this.request(loaded, { type: "invoke", requestId: randomUUID(), method, input }); + } + + async getProviderCatalogCacheKey( + pluginId: string, + providerId: string, + options: ProviderCatalogOptions, + ): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + const output = await this.request(loaded, { + type: "provider.catalog_key", + requestId: randomUUID(), + providerId, + options, + }); + if (output !== undefined && typeof output !== "string") + throw new Error("Invalid catalogue key from plugin"); + return output; + } + + private request( + loaded: LoadedPlugin, + message: Extract, + ): Promise { const child = loaded.child; + const pluginId = loaded.id; if (!child) throw new Error(`Plugin ${pluginId} has no server entry`); - const requestId = randomUUID(); + const requestId = message.requestId; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { loaded.pending.delete(requestId); - reject(new Error(`Plugin RPC timed out: ${pluginId}.${method}`)); + reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`)); }, REQUEST_TIMEOUT_MS); loaded.pending.set(requestId, { resolve, reject, timeout }); - void send(child, { type: "invoke", requestId, method, input }).catch((error) => { + void send(child, message).catch((error) => { clearTimeout(timeout); loaded.pending.delete(requestId); reject(error); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 3ab519e4ec..ea16a7f56f 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -42,6 +42,8 @@ import { asGitHubService, asWorkspaceGitService, asDaemonConfigStore, + findByType, + createProviderSnapshot, createProviderSnapshotManagerStub, } from "./test-utils/session-stubs.js"; import { isPlatform } from "../test-utils/platform.js"; @@ -2147,13 +2149,15 @@ describe("session provider refresh cwd routing", () => { getSnapshot, warmUpSnapshotForCwd, } = createProviderSnapshotManagerStub(); - getSnapshot.mockReturnValue([ - { - provider: "codex", - status: "loading", - enabled: true, - }, - ]); + getSnapshot.mockReturnValue( + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: true, + }, + ]), + ); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -2178,13 +2182,15 @@ describe("session provider refresh cwd routing", () => { const messages: unknown[] = []; const { manager: providerSnapshotManager, warmUpSnapshotForCwd } = createProviderSnapshotManagerStub(); - providerSnapshotManager.getSnapshot = vi.fn(() => [ - { - provider: "codex", - status: "loading", - enabled: false, - }, - ]); + providerSnapshotManager.getSnapshot = vi.fn(() => + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: false, + }, + ]), + ); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -2209,13 +2215,15 @@ describe("session provider refresh cwd routing", () => { const messages: unknown[] = []; const { manager: providerSnapshotManager, warmUpSnapshotForCwd } = createProviderSnapshotManagerStub(); - providerSnapshotManager.getSnapshot = vi.fn(() => [ - { - provider: "codex", - status: "loading", - enabled: false, - }, - ]); + providerSnapshotManager.getSnapshot = vi.fn(() => + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: false, + }, + ]), + ); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -2244,23 +2252,27 @@ describe("session provider refresh cwd routing", () => { getSnapshot, warmUpSnapshotForCwd, } = createProviderSnapshotManagerStub(); - getSnapshot.mockReturnValueOnce([ - { - provider: "codex", - status: "loading", - enabled: true, - }, - ]); - getSnapshot.mockReturnValue([ - { - provider: "codex", - status: "ready", - enabled: true, - models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }], - modes: [], - fetchedAt: "2026-05-28T00:00:00.000Z", - }, - ]); + getSnapshot.mockReturnValueOnce( + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: true, + }, + ]), + ); + getSnapshot.mockReturnValue( + createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }], + modes: [], + fetchedAt: "2026-05-28T00:00:00.000Z", + }, + ]), + ); warmUpSnapshotForCwd.mockReturnValue(warmupDeferred.promise); const session = createSessionForTest({ messages, providerSnapshotManager }); @@ -5694,3 +5706,54 @@ describe("agent config setters", () => { }); }); }); + +test("provider snapshots preserve versionless visibility while capabilities update independently", async () => { + const messages: SessionOutboundMessage[] = []; + const { manager } = createProviderSnapshotManagerStub(); + manager.getSnapshot = () => + createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + modes: [{ id: "default", label: "Default", icon: "Sparkles" }], + }, + { provider: "plugin-provider", status: "ready", enabled: true }, + ]); + const session = createSessionForTest({ messages, providerSnapshotManager: manager }); + const read = async () => { + messages.length = 0; + await session.handleMessage({ + type: "get_providers_snapshot_request", + requestId: "visibility", + }); + return findByType(messages, "get_providers_snapshot_response")!.payload; + }; + const versionless = await read(); + expect(versionless.entries.map((entry) => entry.provider)).toEqual(["codex"]); + expect(versionless.entries[0]!.modes![0]!.icon).toBe("ShieldCheck"); + session.updateClientCapabilities({ + [CLIENT_CAPS.customModeIcons]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + }); + const iconsOnly = await read(); + expect(iconsOnly.entries.map((entry) => entry.provider)).toEqual(["codex"]); + expect(iconsOnly.entries[0]!.modes![0]!.icon).toBe("Sparkles"); + expect(iconsOnly.snapshotHash).toBeUndefined(); + session.updateAppVersion("0.1.45"); + expect((await read()).entries.map((entry) => entry.provider)).toEqual([ + "codex", + "plugin-provider", + ]); + session.updateClientCapabilities({ + [CLIENT_CAPS.compactProviderSnapshots]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + }); + const references = await read(); + expect(references.entries).toEqual([]); + expect(references.compactSnapshot!.entries.map((entry) => entry.provider)).toEqual([ + "codex", + "plugin-provider", + ]); + expect(references.compactSnapshot!.entries[0]!.modes![0]!.icon).toBe("ShieldCheck"); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 433c6db141..bc59f68ee5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -922,6 +922,8 @@ export class Session { isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons), supportsCompactProviderSnapshots: () => this.supports(CLIENT_CAPS.compactProviderSnapshots), + supportsProviderSnapshotReferences: () => + this.supports(CLIENT_CAPS.providerSnapshotReferences), listProviderAvailability: () => this.agentManager.listProviderAvailability(), listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config), }, diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index 63000ca0d0..653f46917c 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -5,23 +5,25 @@ import { type ProviderCatalogSessionHost, } from "./provider-catalog-session.js"; import { createStub } from "../../test-utils/class-mocks.js"; -import { findByType } from "../../test-utils/session-stubs.js"; +import { createProviderSnapshot, findByType } from "../../test-utils/session-stubs.js"; import type { SessionOutboundMessage } from "../../messages.js"; import { GLOBAL_PROVIDER_SNAPSHOT_KEY, type ProviderSnapshotManager, + type ProviderSnapshot, + type ProviderSnapshotTransition, } from "../../agent/provider-snapshot-manager.js"; import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js"; -import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; +import { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; -type SnapshotChangeHandler = (entries: ProviderSnapshotEntry[], cwd: string) => void; +type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void; interface MakeOptions { visibleProviders?: Set; supportsCustomModeIcons?: boolean; supportsCompactProviderSnapshots?: boolean; - snapshot?: { [K in keyof ProviderSnapshotManager]?: unknown }; + snapshot?: Partial; usage?: { [K in keyof ProviderUsageService]?: unknown }; host?: Partial; } @@ -51,6 +53,7 @@ function makeSubsystem(options: MakeOptions = {}) { emit: (msg) => emitted.push(msg), isProviderVisibleToClient: (provider) => visible.has(provider), supportsCustomModeIcons: () => options.supportsCustomModeIcons ?? false, + supportsProviderSnapshotReferences: () => false, supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false, listProviderAvailability: async () => [], listDraftFeatures: async () => [], @@ -70,11 +73,11 @@ function makeSubsystem(options: MakeOptions = {}) { logger: pino({ level: "silent" }), }); function pushSnapshotChange( - entries: ProviderSnapshotEntry[], - cwd = GLOBAL_PROVIDER_SNAPSHOT_KEY, + current: ProviderSnapshot, + previous = createProviderSnapshot([], current.cwd), ): void { if (!changeHandler) throw new Error("start() must run before a snapshot change"); - changeHandler(entries, cwd); + changeHandler({ previous, current }); } return { subsystem, emitted, pushSnapshotChange }; } @@ -87,7 +90,7 @@ describe("ProviderCatalogSession", () => { }); subsystem.start(); - pushSnapshotChange(makeEntries()); + pushSnapshotChange(createProviderSnapshot(makeEntries())); const push = findByType(emitted, "providers_snapshot_update"); expect(push?.payload.entries.map((entry) => entry.provider)).toEqual(["codex"]); @@ -101,11 +104,11 @@ describe("ProviderCatalogSession", () => { const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({ visibleProviders: new Set(["codex"]), supportsCustomModeIcons: false, - snapshot: { getSnapshot: () => makeEntries() }, + snapshot: { getSnapshot: () => createProviderSnapshot(makeEntries()) }, }); subsystem.start(); - pushSnapshotChange(makeEntries()); + pushSnapshotChange(createProviderSnapshot(makeEntries())); await subsystem.handleGetProvidersSnapshotRequest({ type: "get_providers_snapshot_request", requestId: "g1", @@ -117,7 +120,7 @@ describe("ProviderCatalogSession", () => { }); it("returns the canonical cwd used by snapshot updates", async () => { - const getSnapshot = vi.fn((_cwd?: string) => makeEntries()); + const getSnapshot = vi.fn((cwd?: string) => createProviderSnapshot(makeEntries(), cwd)); const { subsystem, emitted } = makeSubsystem({ snapshot: { getSnapshot }, }); @@ -140,7 +143,7 @@ describe("ProviderCatalogSession", () => { }); subsystem.start(); - pushSnapshotChange(makeEntries()); + pushSnapshotChange(createProviderSnapshot(makeEntries())); const push = findByType(emitted, "providers_snapshot_update"); expect(push?.payload.entries).toEqual([]); @@ -151,7 +154,7 @@ describe("ProviderCatalogSession", () => { it("preserves custom mode icons when the client supports them", async () => { const { subsystem, emitted } = makeSubsystem({ supportsCustomModeIcons: true, - snapshot: { getSnapshot: () => makeEntries() }, + snapshot: { getSnapshot: () => createProviderSnapshot(makeEntries()) }, }); await subsystem.handleGetProvidersSnapshotRequest({ @@ -184,7 +187,7 @@ describe("ProviderCatalogSession", () => { ]; const { subsystem, emitted } = makeSubsystem({ supportsCompactProviderSnapshots: true, - snapshot: { getSnapshot: () => entries }, + snapshot: { getSnapshot: () => createProviderSnapshot(entries) }, }); await subsystem.handleGetProvidersSnapshotRequest({ @@ -221,7 +224,10 @@ describe("ProviderCatalogSession", () => { // warmUpSnapshotForCwd is intentionally unstubbed: createStub throws if it is called, // so the disabled short-circuit is proven by the absence of a throw. const { subsystem, emitted } = makeSubsystem({ - snapshot: { getSnapshot: () => [{ provider: "codex", status: "loading", enabled: false }] }, + snapshot: { + getSnapshot: () => + createProviderSnapshot([{ provider: "codex", status: "loading", enabled: false }]), + }, }); await subsystem.handleListProviderModelsRequest({ @@ -237,22 +243,23 @@ describe("ProviderCatalogSession", () => { it("hides compatibility-only entries from list_provider_models", async () => { const { subsystem, emitted } = makeSubsystem({ snapshot: { - getSnapshot: () => [ - { - provider: "codex", - status: "ready", - enabled: true, - models: [ - { provider: "codex", id: "gpt-5.4", label: "GPT 5.4" }, - { - provider: "codex", - id: "gpt-5.4-legacy", - label: "GPT 5.4 legacy", - isSelectable: false, - }, - ], - }, - ], + getSnapshot: () => + createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + models: [ + { provider: "codex", id: "gpt-5.4", label: "GPT 5.4" }, + { + provider: "codex", + id: "gpt-5.4-legacy", + label: "GPT 5.4 legacy", + isSelectable: false, + }, + ], + }, + ]), }, }); @@ -267,7 +274,9 @@ describe("ProviderCatalogSession", () => { }); it("preserves missing cwd as the semantic global snapshot for model list reads", async () => { - const getSnapshot = vi.fn(() => [{ provider: "codex", status: "loading", enabled: true }]); + const getSnapshot = vi.fn(() => + createProviderSnapshot([{ provider: "codex", status: "loading", enabled: true }]), + ); const warmUpSnapshotForCwd = vi.fn(async () => {}); const { subsystem } = makeSubsystem({ snapshot: { getSnapshot, warmUpSnapshotForCwd }, @@ -326,3 +335,333 @@ describe("ProviderCatalogSession", () => { expect(res?.payload.requestId).toBe("f1"); }); }); + +it("announces shared content without retransmitting models or hashing discovery freshness", async () => { + const { ProviderSnapshotManager: Manager } = + await import("../../agent/provider-snapshot-manager.js"); + let probes = 0; + let fetches = 0; + const manager = new Manager({ + logger: pino({ level: "silent" }), + providerOverrides: Object.fromEntries( + ["codex", "claude", "pi", "opencode", "copilot", "omp"].map((provider) => [ + provider, + { enabled: provider === "codex" }, + ]), + ), + extraClients: { + codex: { + provider: "codex", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + probes++; + return true; + }, + async fetchCatalog() { + fetches++; + return { models: [{ provider: "codex", id: "astra", label: "Astra" }], modes: [] }; + }, + async createSession() { + throw new Error("Not used"); + }, + async resumeSession() { + throw new Error("Not used"); + }, + }, + }, + }); + const createSession = (emitted: SessionOutboundMessage[], references: boolean) => + new ProviderCatalogSession({ + providerSnapshotManager: manager, + logger: pino({ level: "silent" }), + providerUsageService: new ProviderUsageService({ + logger: pino({ level: "silent" }), + fetchers: [], + }), + host: { + emit(message) { + emitted.push(message); + }, + isProviderVisibleToClient: () => true, + supportsCustomModeIcons: () => true, + supportsCompactProviderSnapshots: () => true, + supportsProviderSnapshotReferences: () => references, + listProviderAvailability: async () => [], + listDraftFeatures: async () => [], + }, + }); + const emitted: SessionOutboundMessage[] = []; + const peerMessages: SessionOutboundMessage[] = []; + const session = createSession(emitted, true); + const peer = createSession(peerMessages, false); + try { + await Promise.all( + [undefined, "/tmp/catalog-a", "/tmp/catalog-b"].map((cwd) => + manager.getProvider({ provider: "codex", cwd, wait: true }), + ), + ); + expect({ probes, fetches }).toEqual({ probes: 1, fetches: 1 }); + session.start(); + peer.start(); + await session.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "cold", + }); + const first = findByType(emitted, "get_providers_snapshot_response")!.payload; + expect( + first.compactSnapshot?.entries.find((entry) => entry.provider === "codex")?.fetchedAt, + ).toBeUndefined(); + expect(first.fetchedAt?.codex).toEqual(expect.any(String)); + await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(Date.parse(first.fetchedAt!.codex!))); + emitted.length = 0; + await manager.refreshSnapshotForCwd({ cwd: "/tmp/catalog-a", providers: ["codex"] }); + expect({ probes, fetches }).toEqual({ probes: 2, fetches: 2 }); + const pushes = emitted.filter((message) => message.type === "providers_snapshot_update"); + expect(pushes).toHaveLength(3); + const peerPushes = peerMessages.filter( + (message) => message.type === "providers_snapshot_update", + ); + expect(peerPushes).toHaveLength(3); + expect( + peerPushes.every(({ payload }) => + payload.compactSnapshot?.entries.some( + (entry) => entry.provider === "codex" && entry.models?.[0]?.id === "astra", + ), + ), + ).toBe(true); + expect(peerPushes[0]!.payload.snapshotHash).not.toBe(pushes[0]!.payload.snapshotHash); + expect(pushes.map(({ payload }) => payload.snapshotHash)).toEqual( + Array(3).fill(first.snapshotHash), + ); + expect( + pushes.flatMap(({ payload }) => (payload.compactSnapshot ? [payload.compactSnapshot] : [])), + ).toEqual([]); + expect(pushes.flatMap(({ payload }) => payload.entries)).toEqual([]); + await session.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "warm", + ifNoneMatch: first.snapshotHash, + }); + const warm = findByType(emitted, "get_providers_snapshot_response")!.payload; + expect(warm.notModified).toBe(true); + expect(warm.compactSnapshot).toBeUndefined(); + expect(warm.fetchedAt?.codex).toEqual(expect.any(String)); + } finally { + session.dispose(); + peer.dispose(); + manager.destroy(); + } +}); + +it.each([false, true])( + "conditional hits do not read model bodies (references=%s)", + async (references) => { + let modelReads = 0; + const entries: ProviderSnapshotEntry[] = [ + { + provider: "codex", + status: "ready", + enabled: true, + modes: [{ id: "default", label: "Default", icon: "Sparkles" }], + get models() { + modelReads++; + return [{ provider: "codex", id: "astra", label: "Astra" }]; + }, + }, + ]; + const snapshot: ProviderSnapshot = { + cwd: GLOBAL_PROVIDER_SNAPSHOT_KEY, + records: [{ entry: entries[0]!, contentHash: "fixture" }], + }; + const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }); + subsystem.start(); + await subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "cold", + }); + const first = findByType(emitted, "get_providers_snapshot_response")!.payload; + expect(modelReads).toBeGreaterThan(0); + modelReads = 0; + await subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "hit", + ifNoneMatch: first.snapshotHash, + }); + expect(modelReads).toBe(0); + pushSnapshotChange(snapshot); + expect(modelReads === 0).toBe(references); + subsystem.dispose(); + }, +); + +it.each([false, true])( + "snapshot identity follows the client view and its freshness policy (references=%s)", + async (references) => { + const entries = makeEntries(); + entries[0]!.fetchedAt = "2026-09-06T10:00:00.000Z"; + entries[0]!.models = [{ provider: "codex", id: "astra", label: "Astra" }]; + let snapshot = createProviderSnapshot(entries); + const clients = [ + makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + supportsCustomModeIcons: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + visibleProviders: new Set(["codex", "claude"]), + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + ]; + const read = async (client: ReturnType, ifNoneMatch?: string) => { + client.emitted.length = 0; + await client.subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "view", + ifNoneMatch, + }); + return findByType(client.emitted, "get_providers_snapshot_response")!.payload; + }; + const views = await Promise.all(clients.map((client) => read(client))); + expect(views[0]!.snapshotHash).toBe(views[1]!.snapshotHash); + expect(views[0]!.compactSnapshot).toEqual(views[1]!.compactSnapshot); + expect(new Set(views.map((view) => view.snapshotHash)).size).toBe(3); + expect(expandProviderSnapshot(views[0]!.compactSnapshot!)[0]!.modes![0]!.icon).toBe( + "ShieldCheck", + ); + expect(expandProviderSnapshot(views[2]!.compactSnapshot!)[0]!.modes![0]!.icon).toBe("Sparkles"); + expect( + expandProviderSnapshot(views[3]!.compactSnapshot!).map((entry) => entry.provider), + ).toEqual(["codex", "claude"]); + expect(views[0]!.compactSnapshot!.entries[0]!.fetchedAt).toBe( + references ? undefined : entries[0]!.fetchedAt, + ); + + // Hidden provider changes do not invalidate the visible result. + snapshot = createProviderSnapshot([ + entries[0]!, + { ...entries[1]!, status: "error", error: "hidden" }, + ]); + expect((await read(clients[0]!, views[0]!.snapshotHash)).notModified).toBe(true); + // Same content hash, new embedded timestamp: only reference views can reuse the body. + snapshot = createProviderSnapshot([ + { ...entries[0]!, fetchedAt: "2026-09-06T10:01:00.000Z" }, + entries[1]!, + ]); + expect(snapshot.records[0]!.contentHash).toBe( + createProviderSnapshot(entries).records[0]!.contentHash, + ); + const fresh = await read(clients[0]!, views[0]!.snapshotHash); + expect(fresh.snapshotHash === views[0]!.snapshotHash).toBe(references); + expect(fresh.notModified === true).toBe(references); + expect(fresh.fetchedAt?.codex).toBe(references ? "2026-09-06T10:01:00.000Z" : undefined); + + snapshot = { ...snapshot, records: snapshot.records.toReversed() }; + expect((await read(clients[3]!)).snapshotHash).not.toBe(views[3]!.snapshotHash); + }, +); + +it("reference capability alone preserves full legacy responses and does not alias embedded compact hashes", async () => { + const snapshot = createProviderSnapshot(makeEntries()); + const clients = [ + makeSubsystem({ + host: { supportsProviderSnapshotReferences: () => true }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => true }, + snapshot: { getSnapshot: () => snapshot }, + }), + ]; + for (const client of clients) { + client.subsystem.start(); + client.pushSnapshotChange(snapshot); + await client.subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "format", + }); + client.subsystem.dispose(); + } + const views = clients.map( + ({ emitted }) => findByType(emitted, "get_providers_snapshot_response")!.payload, + ); + expect(views[0]!.snapshotHash).toBeUndefined(); + expect(views[0]!.entries).toEqual( + findByType(clients[0]!.emitted, "providers_snapshot_update")!.payload.entries, + ); + expect(views[0]!.entries).toHaveLength(1); + expect(views[1]!.snapshotHash).not.toBe(views[2]!.snapshotHash); + expect(expandProviderSnapshot(views[1]!.compactSnapshot!)).toEqual(views[0]!.entries); + expect(expandProviderSnapshot(views[2]!.compactSnapshot!)).toEqual(views[0]!.entries); +}); + +it.each(["full", "embedded", "references"])( + "suppresses hidden-only transitions but publishes visible freshness for %s clients", + (format) => { + const before = createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + fetchedAt: "2026-09-06T10:00:00.000Z", + models: [{ provider: "codex", id: "astra", label: "Astra" }], + }, + { provider: "claude", status: "loading", enabled: true }, + ]); + const hidden = createProviderSnapshot([ + before.records[0]!.entry, + { provider: "claude", status: "error", enabled: true, error: "hidden" }, + ]); + const fresh = createProviderSnapshot([ + { ...before.records[0]!.entry, fetchedAt: "2026-09-06T10:01:00.000Z" }, + hidden.records[1]!.entry, + ]); + const client = makeSubsystem({ + supportsCompactProviderSnapshots: format !== "full", + host: { supportsProviderSnapshotReferences: () => format === "references" }, + }); + client.subsystem.start(); + client.pushSnapshotChange(hidden, before); + expect(client.emitted).toEqual([]); + client.pushSnapshotChange(fresh, hidden); + const update = findByType(client.emitted, "providers_snapshot_update")!.payload; + expect(client.emitted).toHaveLength(1); + const representedFreshness = + update.fetchedAt?.codex ?? + update.compactSnapshot?.entries[0]?.fetchedAt ?? + update.entries[0]?.fetchedAt; + expect(representedFreshness).toBe("2026-09-06T10:01:00.000Z"); + client.subsystem.dispose(); + }, +); diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index ed7509a916..f82f1ac56c 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -1,15 +1,15 @@ import type pino from "pino"; import { createHash } from "node:crypto"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; -import { - compactProviderSnapshot, - type CompactProviderSnapshot, -} from "@getpaseo/protocol/provider-snapshot-codec"; +import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import { isGlobalProviderSnapshotKey, resolveSnapshotCwd, + sameSnapshotRecords, type ProviderSnapshotManager, + type ProviderSnapshot, + type ProviderSnapshotTransition, } from "../../agent/provider-snapshot-manager.js"; import { filterSelectableAgentModels, @@ -47,6 +47,7 @@ export interface ProviderCatalogSessionHost { // COMPAT(customModeIcons): reads clientCapabilities live. supportsCustomModeIcons(): boolean; supportsCompactProviderSnapshots(): boolean; + supportsProviderSnapshotReferences(): boolean; listProviderAvailability(): Promise; listDraftFeatures(config: AgentSessionConfig): Promise; } @@ -58,19 +59,6 @@ export interface ProviderCatalogSessionOptions { logger: pino.Logger; } -interface EncodedProviderSnapshot { - compactSnapshot: CompactProviderSnapshot; - snapshotHash: string; -} - -function encodeProviderSnapshot(entries: ProviderSnapshotEntry[]): EncodedProviderSnapshot { - const compactSnapshot = compactProviderSnapshot(entries); - const snapshotHash = createHash("sha256") - .update(JSON.stringify(compactSnapshot)) - .digest("base64url"); - return { compactSnapshot, snapshotHash }; -} - /** * A client's provider catalog surface: model / mode / feature listing, the providers * snapshot push + pull, provider diagnostics, and usage. The snapshot PUSH (start) and @@ -93,33 +81,13 @@ export class ProviderCatalogSession { } start(): void { - const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { - // COMPAT(providersSnapshot): keep provider visibility gating for older clients. - const visibleEntries = entries.filter((entry) => - this.host.isProviderVisibleToClient(entry.provider), - ); - const snapshotCwd = isGlobalProviderSnapshotKey(cwd) ? undefined : cwd; - const clientEntries = this.downgradeEntryModesForClient(visibleEntries); - if (this.host.supportsCompactProviderSnapshots()) { - const encoded = encodeProviderSnapshot(clientEntries); - this.host.emit({ - type: "providers_snapshot_update", - payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: [], - ...encoded, - generatedAt: new Date().toISOString(), - }, - }); - return; - } + const handleProviderSnapshotChange = (transition: ProviderSnapshotTransition) => { + const previous = this.visibleSnapshot(transition.previous); + const current = this.visibleSnapshot(transition.current); + if (sameSnapshotRecords(previous.records, current.records)) return; this.host.emit({ type: "providers_snapshot_update", - payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: clientEntries, - generatedAt: new Date().toISOString(), - }, + payload: this.snapshotPayload(current), }); }; this.providerSnapshotManager.on("change", handleProviderSnapshotChange); @@ -138,20 +106,79 @@ export class ProviderCatalogSession { // COMPAT(customModeIcons): rewrite icons unknown to v0.1.83 clients (whose MODE_ICONS // map is a closed enum and would render `undefined`, crashing in render). Drop // this and the cap gate when floor >= v0.1.84. - private downgradeModeIconsForClient(modes: T[]): T[] { - if (this.host.supportsCustomModeIcons()) return modes; + private downgradeModeIconsForClient( + modes: T[], + customModeIcons = this.host.supportsCustomModeIcons(), + ): T[] { + if (customModeIcons) return modes; return modes.map((mode) => mode.icon && !LEGACY_MODE_ICONS.has(mode.icon) ? { ...mode, icon: "ShieldCheck" } : mode, ); } - private downgradeEntryModesForClient( - entries: T[], - ): T[] { - if (this.host.supportsCustomModeIcons()) return entries; - return entries.map((entry) => - entry.modes ? { ...entry, modes: this.downgradeModeIconsForClient(entry.modes) } : entry, - ); + private visibleSnapshot(snapshot: ProviderSnapshot): ProviderSnapshot { + // COMPAT(providersSnapshot): use the shell's current visibility policy for both sides. + return { + ...snapshot, + records: snapshot.records.filter(({ entry }) => + this.host.isProviderVisibleToClient(entry.provider), + ), + }; + } + + private snapshotPayload(snapshot: ProviderSnapshot, request?: { ifNoneMatch?: string }) { + const { records } = snapshot; + const customModeIcons = this.host.supportsCustomModeIcons(); + const compact = this.host.supportsCompactProviderSnapshots(); + const references = this.host.supportsProviderSnapshotReferences(); + const envelope = { + ...(!isGlobalProviderSnapshotKey(snapshot.cwd) ? { cwd: snapshot.cwd } : {}), + generatedAt: new Date().toISOString(), + }; + const clientEntries = () => + records.map(({ entry }) => + entry.modes && !customModeIcons + ? { ...entry, modes: this.downgradeModeIconsForClient(entry.modes, customModeIcons) } + : entry, + ); + if (!compact) { + return { ...envelope, entries: clientEntries() }; + } + + // COMPAT(providerSnapshotReferences): added in v0.7.2, remove legacy full + // pushes and embedded freshness after 2027-03-06 once the client floor supports references. + const snapshotHash = createHash("sha256") + .update( + JSON.stringify([ + "paseo.providers-snapshot/1", + references ? "references" : "embedded", + customModeIcons ? "icons" : "legacy-icons", + records.map(({ entry, contentHash }) => [ + entry.provider, + contentHash, + references ? null : (entry.fetchedAt ?? null), + ]), + ]), + ) + .digest("base64url"); + const fetchedAt = references + ? Object.fromEntries( + records.flatMap(({ entry }) => + entry.fetchedAt ? [[entry.provider, entry.fetchedAt]] : [], + ), + ) + : undefined; + const identified = { ...envelope, entries: [], snapshotHash, fetchedAt }; + if (request && request.ifNoneMatch === snapshotHash) { + return { ...identified, notModified: true as const }; + } + if (!request && references) return identified; + + const entries = clientEntries(); + const content = references + ? entries.map(({ fetchedAt: _fetchedAt, ...entry }) => entry) + : entries; + return { ...identified, compactSnapshot: compactProviderSnapshot(content) }; } private emitProviderDisabledResponse( @@ -290,7 +317,7 @@ export class ProviderCatalogSession { ): Promise { const manager = this.providerSnapshotManager; const findEntry = () => - manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider); + manager.getSnapshot(cwd).records.find(({ entry }) => entry.provider === provider)?.entry; let entry = findEntry(); if (entry && !entry.enabled) { @@ -391,37 +418,12 @@ export class ProviderCatalogSession { async handleGetProvidersSnapshotRequest( msg: Extract, ): Promise { - // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow. - const snapshotCwd = msg.cwd?.trim() ? resolveSnapshotCwd(expandTilde(msg.cwd)) : undefined; - const entries = this.providerSnapshotManager - .getSnapshot(snapshotCwd) - .filter((entry) => this.host.isProviderVisibleToClient(entry.provider)); - const clientEntries = this.downgradeEntryModesForClient(entries); - - if (this.host.supportsCompactProviderSnapshots()) { - const encoded = encodeProviderSnapshot(clientEntries); - const notModified = msg.ifNoneMatch === encoded.snapshotHash; - this.host.emit({ - type: "get_providers_snapshot_response", - payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: [], - ...(!notModified ? { compactSnapshot: encoded.compactSnapshot } : {}), - snapshotHash: encoded.snapshotHash, - ...(notModified ? { notModified: true } : {}), - generatedAt: new Date().toISOString(), - requestId: msg.requestId, - }, - }); - return; - } - + const cwd = msg.cwd?.trim() ? resolveSnapshotCwd(expandTilde(msg.cwd)) : undefined; + const snapshot = this.visibleSnapshot(this.providerSnapshotManager.getSnapshot(cwd)); this.host.emit({ type: "get_providers_snapshot_response", payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: clientEntries, - generatedAt: new Date().toISOString(), + ...this.snapshotPayload(snapshot, { ifNoneMatch: msg.ifNoneMatch }), requestId: msg.requestId, }, }); diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index a490874ff7..15ed7ecc09 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -3,23 +3,14 @@ import { vi } from "vitest"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; -import type { - AgentMode, - AgentModelDefinition, - AgentProvider, - ProviderSnapshotEntry, -} from "../agent/agent-sdk-types.js"; -import type { - AgentManagerProviderState, - ProviderDiagnosticResult, - ResolvedProviderCreateConfig, +import type { ProviderSnapshotEntry } from "../agent/agent-sdk-types.js"; +import type { ProviderSnapshot } from "../agent/provider-snapshot-manager.js"; +import { + GLOBAL_PROVIDER_SNAPSHOT_KEY, + ProviderSnapshotManager, } from "../agent/provider-snapshot-manager.js"; -import { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import type { SessionOptions } from "../session.js"; -import type { - HubExecutionAgentValidationIssue, - SessionOutboundMessage, -} from "@getpaseo/protocol/messages"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { asInternals, createStub } from "./class-mocks.js"; // --------------------------------------------------------------------------- @@ -100,15 +91,15 @@ export function asWorkspaceGitService(stub: { } export function asServiceProxy(stub: { - [K in keyof SessionOptions["serviceProxy"]]?: unknown; -}): SessionOptions["serviceProxy"] { - return createStub(stub); + [K in keyof NonNullable]?: unknown; +}): NonNullable { + return createStub>(stub); } export function asWorkspaceScriptRuntimeStore(stub: { - [K in keyof SessionOptions["scriptRuntimeStore"]]?: unknown; -}): SessionOptions["scriptRuntimeStore"] { - return createStub(stub); + [K in keyof NonNullable]?: unknown; +}): NonNullable { + return createStub>(stub); } // --------------------------------------------------------------------------- @@ -149,74 +140,96 @@ export function findByType( // --------------------------------------------------------------------------- export interface ProviderSnapshotManagerSpies { - getSnapshot: ReturnType>; - refreshSnapshotForCwd: ReturnType>>; - refreshSettingsSnapshot: ReturnType>>; - warmUpSnapshotForCwd: ReturnType>>; - listRegisteredProviderIds: ReturnType>; - hasProvider: ReturnType>; - getProviderLabel: ReturnType>; - getAgentManagerProviderState: ReturnType>; - listProviders: ReturnType>>; - getProvider: ReturnType>>; + getSnapshot: ReturnType>; + refreshSnapshotForCwd: ReturnType>; + refreshSettingsSnapshot: ReturnType< + typeof vi.fn + >; + warmUpSnapshotForCwd: ReturnType>; + listRegisteredProviderIds: ReturnType< + typeof vi.fn + >; + hasProvider: ReturnType>; + getProviderLabel: ReturnType>; + getAgentManagerProviderState: ReturnType< + typeof vi.fn + >; + listProviders: ReturnType>; + getProvider: ReturnType>; validateAgentConfiguration: ReturnType< - typeof vi.fn<[unknown], Promise> + typeof vi.fn >; - listModels: ReturnType>>; - listModes: ReturnType>>; - resolveCreateConfig: ReturnType>>; - resolveDefaultModel: ReturnType>>; - getProviderDiagnostic: ReturnType< - typeof vi.fn<[AgentProvider], Promise> + listModels: ReturnType>; + listModes: ReturnType>; + resolveCreateConfig: ReturnType>; + resolveDefaultModel: ReturnType>; + getProviderDiagnostic: ReturnType>; + applyMutableProviderConfig: ReturnType< + typeof vi.fn >; - applyMutableProviderConfig: ReturnType>; - destroy: ReturnType>; + destroy: ReturnType>; } export function createProviderSnapshotManagerStub(): { manager: ProviderSnapshotManager; } & ProviderSnapshotManagerSpies { - const getSnapshot = vi.fn<[cwd?: string], ProviderSnapshotEntry[]>(() => []); - const refreshSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); - const refreshSettingsSnapshot = vi.fn<[unknown], Promise>(async () => {}); - const warmUpSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); - const listRegisteredProviderIds = vi.fn<[], AgentProvider[]>(() => []); - const hasProvider = vi.fn<[AgentProvider], boolean>(() => false); - const getProviderLabel = vi.fn<[AgentProvider], string>((provider) => { + const getSnapshot = vi.fn((cwd) => + createProviderSnapshot([], cwd), + ); + const refreshSnapshotForCwd = vi.fn( + async () => {}, + ); + const refreshSettingsSnapshot = vi.fn( + async () => {}, + ); + const warmUpSnapshotForCwd = vi.fn( + async () => {}, + ); + const listRegisteredProviderIds = vi.fn( + () => [], + ); + const hasProvider = vi.fn(() => false); + const getProviderLabel = vi.fn((provider) => { try { return getAgentProviderDefinition(provider).label; } catch { return provider; } }); - const getAgentManagerProviderState = vi.fn<[], AgentManagerProviderState>(() => ({ + const getAgentManagerProviderState = vi.fn< + ProviderSnapshotManager["getAgentManagerProviderState"] + >(() => ({ providerDefinitions: {}, clients: {}, })); - const listProviders = vi.fn<[unknown], Promise>(async () => []); - const getProvider = vi.fn<[unknown], Promise>(async () => { + const listProviders = vi.fn(async () => []); + const getProvider = vi.fn(async () => { throw new Error("createProviderSnapshotManagerStub: getProvider not stubbed"); }); - const validateAgentConfiguration = vi.fn<[unknown], Promise>( + const validateAgentConfiguration = vi.fn( async () => [], ); - const listModels = vi.fn<[unknown], Promise>(async () => []); - const listModes = vi.fn<[unknown], Promise>(async () => []); - const resolveCreateConfig = vi.fn<[unknown], Promise>(async () => ({ + const listModels = vi.fn(async () => []); + const listModes = vi.fn(async () => []); + const resolveCreateConfig = vi.fn(async () => ({ modeId: undefined, featureValues: undefined, })); - const resolveDefaultModel = vi.fn<[unknown], Promise>(async () => undefined); - const getProviderDiagnostic = vi.fn<[AgentProvider], Promise>( + const resolveDefaultModel = vi.fn( + async () => undefined, + ); + const getProviderDiagnostic = vi.fn( async (provider) => ({ provider, diagnostic: "No diagnostic available for this provider." }), ); - const applyMutableProviderConfig = vi.fn<[unknown], AgentManagerProviderState>(() => ({ - providerDefinitions: {}, - clients: {}, - })); + const applyMutableProviderConfig = vi.fn( + () => ({ + providerDefinitions: {}, + clients: {}, + }), + ); const on = vi.fn(); const off = vi.fn(); - const destroy = vi.fn<[], void>(); + const destroy = vi.fn(); const stub = { getSnapshot, refreshSnapshotForCwd, @@ -275,3 +288,16 @@ export function createAgentRequestsStub(): SessionOptions["agentRequests"] { send: (input) => input.send(), }; } + +export function createProviderSnapshot( + entries: ProviderSnapshotEntry[], + cwd = GLOBAL_PROVIDER_SNAPSHOT_KEY, +): ProviderSnapshot { + return { + cwd, + records: entries.map((entry) => { + const { fetchedAt: _fetchedAt, ...content } = entry; + return { entry, contentHash: JSON.stringify(content) }; + }), + }; +} diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 8ba01afbe6..cbc67341f5 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -171,6 +171,18 @@ test("PR routing declares stable behavior ownership", () => { app: ["packages/app/**", "packages/expo-two-way-audio/**"], sdk: ["packages/client/**", "packages/highlight/**", "packages/protocol/**"], browser: [ + "packages/server/src/server/agent/provider-snapshot-manager.ts", + "packages/server/src/server/session/provider/provider-catalog-session.ts", + "packages/client/src/compat/normalize-provider-models.ts", + "packages/protocol/src/client-capabilities.ts", + "packages/server/src/server/agent/provider-registry.ts", + "packages/server/src/server/agent/agent-sdk-types.ts", + "packages/server/src/server/agent/providers/codex-app-server-agent.ts", + "packages/server/src/server/agent/providers/claude/agent.ts", + "packages/server/src/server/agent/plugin-provider.ts", + "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts", + "packages/server/src/executable-resolution/**", + "packages/plugin/src/provider.ts", "packages/app/src/!(desktop)/**", "packages/app/e2e/browser/**", "packages/app/e2e/support/**", @@ -239,6 +251,18 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => { "packages/app/package.json", ]); assert.deepEqual(filters.browser, [ + "packages/server/src/server/agent/provider-snapshot-manager.ts", + "packages/server/src/server/session/provider/provider-catalog-session.ts", + "packages/client/src/compat/normalize-provider-models.ts", + "packages/protocol/src/client-capabilities.ts", + "packages/server/src/server/agent/provider-registry.ts", + "packages/server/src/server/agent/agent-sdk-types.ts", + "packages/server/src/server/agent/providers/codex-app-server-agent.ts", + "packages/server/src/server/agent/providers/claude/agent.ts", + "packages/server/src/server/agent/plugin-provider.ts", + "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts", + "packages/server/src/executable-resolution/**", + "packages/plugin/src/provider.ts", "packages/app/src/!(desktop)/**", "packages/app/e2e/browser/**", "packages/app/e2e/support/**",