mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Remember model selections across workspace creation (#4401)
* 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.
This commit is contained in:
@@ -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/**"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+43
-6
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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<string, unknown> |
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void> {
|
||||
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<string> {
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
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<unknown> {
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<DaemonClient>({ 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();
|
||||
}
|
||||
});
|
||||
@@ -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<DaemonClient>({ 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<DaemonClient>({ 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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -35,7 +35,6 @@ interface AgentInputDraftComposerOptions {
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
initialFeatureValues?: Record<string, unknown>;
|
||||
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<ProviderSelectionState>(
|
||||
() => ({
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<string, string>();
|
||||
const stats = { getAllKeysCalls: 0 };
|
||||
@@ -109,21 +103,17 @@ function snapshotBytes(values: Map<string, string>): number {
|
||||
);
|
||||
}
|
||||
|
||||
function expectIndexMatchesSnapshots(values: Map<string, string>): 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<string, string>): 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<typeof snapshot>) => void;
|
||||
let began!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
began = resolve;
|
||||
});
|
||||
const oldClient = {
|
||||
getProvidersSnapshot() {
|
||||
began();
|
||||
return new Promise<ReturnType<typeof snapshot>>((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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
interface ProviderSnapshotIndexEntry {
|
||||
@@ -40,30 +42,31 @@ interface ProviderSnapshotIndexEntry {
|
||||
writtenAt: string;
|
||||
}
|
||||
|
||||
interface ProviderSnapshotIndex {
|
||||
version: typeof CACHE_INDEX_VERSION;
|
||||
entries: ProviderSnapshotIndexEntry[];
|
||||
}
|
||||
|
||||
const StoredProviderSnapshotSchema: z.ZodType<StoredProviderSnapshot> = 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<CachedProviderSnapshot | null>;
|
||||
readHash(serverId: string, hash: string): Promise<CachedProviderSnapshot | null>;
|
||||
materialize(serverId: string, snapshot: SnapshotPayload): Promise<SnapshotPayload>;
|
||||
write(input: {
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
hash: string;
|
||||
generatedAt: string;
|
||||
compactSnapshot: CompactProviderSnapshot;
|
||||
fetchedAt?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
const nextIndex: ProviderSnapshotIndex = { version: CACHE_INDEX_VERSION, entries };
|
||||
await storage.setItem(CACHE_INDEX_KEY, JSON.stringify(nextIndex));
|
||||
index = nextIndex;
|
||||
}
|
||||
const bodies = new Map<string, CachedProviderSnapshot>();
|
||||
|
||||
async function reconcileCache(): Promise<void> {
|
||||
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<ProviderSnapshotIndex> {
|
||||
async function ensureCacheIndex(): Promise<ProviderSnapshotIndexEntry[]> {
|
||||
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<void> {
|
||||
async function writeRecord(
|
||||
key: string,
|
||||
input: StoredProviderSnapshot,
|
||||
requiredBody?: { key: string; input: StoredProviderSnapshot },
|
||||
): Promise<void> {
|
||||
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<ProviderSnapshotIndexEntry & { value?: string }> = 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<StoredProviderSnapshot | null> {
|
||||
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<CachedProviderSnapshot | null> {
|
||||
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<T>(operation: () => Promise<T>, fallback: T): Promise<T> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<DaemonClient, "getProvidersSnapshot">;
|
||||
|
||||
export async function fetchProvidersSnapshot(input: {
|
||||
client: SnapshotClient;
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
queryClient?: QueryClient;
|
||||
cache?: ProviderSnapshotCache;
|
||||
signal?: AbortSignal;
|
||||
snapshot?: Snapshot;
|
||||
}): Promise<Snapshot> {
|
||||
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<DaemonClient, "refreshProvidersSnapshot">;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
|
||||
|
||||
interface ServerDataPushClient {
|
||||
getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"];
|
||||
on<TType extends ServerDataEventType>(
|
||||
type: TType,
|
||||
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => 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<ServerDataPushClient, "getProvidersSnapshot">;
|
||||
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<void> {
|
||||
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) => {
|
||||
|
||||
@@ -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<AgentProvider, AgentProviderDefinition>;
|
||||
agentDefinition?: AgentProviderDefinition;
|
||||
@@ -87,59 +80,6 @@ export interface UseAgentFormStateResult {
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<ProviderSnapshotCache["write"]>[0][];
|
||||
} {
|
||||
const writes: Parameters<ProviderSnapshotCache["write"]>[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])],
|
||||
|
||||
@@ -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<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
|
||||
type RefreshProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["refreshProvidersSnapshot"]>>;
|
||||
|
||||
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<GetProvidersSnapshotResult> {
|
||||
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<RefreshProvidersSnapshotResult> {
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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<AgentProvider, AgentModelDefinition[] | null>;
|
||||
@@ -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<ProviderSnapshotEntry["statu
|
||||
]);
|
||||
export const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["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<AgentProvider, AgentProviderDefinition>;
|
||||
}
|
||||
|
||||
export type AgentFormAction =
|
||||
| AgentFormInputs
|
||||
| { type: "REQUEST_RESOLUTION" }
|
||||
| {
|
||||
type: "COMPLETE_RESOLUTION";
|
||||
@@ -77,8 +88,6 @@ export type AgentFormAction =
|
||||
providerModelsByProvider: ProviderModelsByProvider;
|
||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||
}
|
||||
| { 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<AgentFormAction, { type: "COMPLETE_RESOLUTION" }>;
|
||||
@@ -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, AgentProviderDefinition>;
|
||||
}): 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,
|
||||
|
||||
@@ -518,6 +518,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
runtimeGeneration,
|
||||
capabilities: appCapabilities,
|
||||
trace: nativePerformanceTrace,
|
||||
providerSnapshots: "wire",
|
||||
} satisfies Omit<DaemonClientConfig, "url">;
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return new DaemonClient({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
function buildComposerConfig(input: {
|
||||
serverId: string;
|
||||
isConnected: boolean;
|
||||
workspaceDirectory: string | null;
|
||||
sourceDirectory: string | null;
|
||||
initialSetup?: WorkspaceDraftTabSetup | null;
|
||||
}): Parameters<typeof useAgentInputDraft>[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,
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | undefined>;
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -34,6 +36,10 @@ export interface ProviderRegistration {
|
||||
connect(request: ProviderConnectRequest): Promise<ProviderConnection>;
|
||||
}
|
||||
|
||||
export type ProviderCatalogOptions =
|
||||
| { scope: "global"; force?: boolean }
|
||||
| { scope: "workspace"; cwd: string; force?: boolean };
|
||||
|
||||
export interface ProviderConnectRequest {
|
||||
versions: readonly number[];
|
||||
capabilities: readonly string[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -30,8 +30,10 @@ async function enumerateCandidatesViaSystemWhich(name: string): Promise<string[]
|
||||
killSignal: "SIGKILL",
|
||||
});
|
||||
return Array.from(new Set(stdout.trim().split("\n").filter(Boolean)));
|
||||
} catch {
|
||||
return [];
|
||||
} catch (error) {
|
||||
// which exits 1 for a missing command. A failed lookup is not evidence of absence.
|
||||
if (error instanceof Error && "code" in error && error.code === 1) return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -748,6 +748,10 @@ export interface AgentClient {
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentResumeSessionOptions,
|
||||
): Promise<AgentSession>;
|
||||
/** 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<string | undefined>;
|
||||
/**
|
||||
* 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<ImportedProviderSession>;
|
||||
/**
|
||||
* 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<boolean>;
|
||||
isAvailable(signal?: AbortSignal, options?: FetchCatalogOptions): Promise<boolean>;
|
||||
getDiagnostic?(): Promise<{ diagnostic: string }>;
|
||||
/**
|
||||
* Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close().
|
||||
|
||||
@@ -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<ProviderSnapshotManager["getSnapshot"]>;
|
||||
}> = [];
|
||||
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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -837,7 +837,10 @@ class PluginAgentClient implements AgentClient {
|
||||
private readonly rootsBySession = new Map<string, PluginAgentSession>();
|
||||
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));
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1563,6 +1563,11 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getCatalogCacheKey(_options: FetchCatalogOptions): Promise<string> {
|
||||
// This client discovers through host configuration, independent of project cwd.
|
||||
return "host";
|
||||
}
|
||||
|
||||
async fetchCatalog(
|
||||
_options: FetchCatalogOptions,
|
||||
context?: ProviderRefreshContext,
|
||||
|
||||
@@ -7083,6 +7083,11 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getCatalogCacheKey(_options: FetchCatalogOptions): Promise<string> {
|
||||
// This client discovers through host configuration, independent of project cwd.
|
||||
return "host";
|
||||
}
|
||||
|
||||
async fetchCatalog(
|
||||
_options: FetchCatalogOptions,
|
||||
context?: ProviderRefreshContext,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<void>;
|
||||
startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>;
|
||||
stopPluginById(pluginId: string): Promise<boolean>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PluginProcessRequest> = 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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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<PluginProcessRequest, { requestId: string }>,
|
||||
): Promise<unknown> {
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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<string>;
|
||||
supportsCustomModeIcons?: boolean;
|
||||
supportsCompactProviderSnapshots?: boolean;
|
||||
snapshot?: { [K in keyof ProviderSnapshotManager]?: unknown };
|
||||
snapshot?: Partial<ProviderSnapshotManager>;
|
||||
usage?: { [K in keyof ProviderUsageService]?: unknown };
|
||||
host?: Partial<ProviderCatalogSessionHost>;
|
||||
}
|
||||
@@ -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<typeof makeSubsystem>, 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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<ProviderAvailability[]>;
|
||||
listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]>;
|
||||
}
|
||||
@@ -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<T extends { icon?: string }>(modes: T[]): T[] {
|
||||
if (this.host.supportsCustomModeIcons()) return modes;
|
||||
private downgradeModeIconsForClient<T extends { icon?: string }>(
|
||||
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<T extends { modes?: { icon?: string }[] }>(
|
||||
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<ProviderSnapshotEntry | undefined> {
|
||||
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<SessionInboundMessage, { type: "get_providers_snapshot_request" }>,
|
||||
): Promise<void> {
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<SessionOptions["serviceProxy"]>(stub);
|
||||
[K in keyof NonNullable<SessionOptions["serviceProxy"]>]?: unknown;
|
||||
}): NonNullable<SessionOptions["serviceProxy"]> {
|
||||
return createStub<NonNullable<SessionOptions["serviceProxy"]>>(stub);
|
||||
}
|
||||
|
||||
export function asWorkspaceScriptRuntimeStore(stub: {
|
||||
[K in keyof SessionOptions["scriptRuntimeStore"]]?: unknown;
|
||||
}): SessionOptions["scriptRuntimeStore"] {
|
||||
return createStub<SessionOptions["scriptRuntimeStore"]>(stub);
|
||||
[K in keyof NonNullable<SessionOptions["scriptRuntimeStore"]>]?: unknown;
|
||||
}): NonNullable<SessionOptions["scriptRuntimeStore"]> {
|
||||
return createStub<NonNullable<SessionOptions["scriptRuntimeStore"]>>(stub);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -149,74 +140,96 @@ export function findByType<T extends SessionOutboundMessage["type"]>(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProviderSnapshotManagerSpies {
|
||||
getSnapshot: ReturnType<typeof vi.fn<[cwd?: string], ProviderSnapshotEntry[]>>;
|
||||
refreshSnapshotForCwd: ReturnType<typeof vi.fn<[unknown], Promise<void>>>;
|
||||
refreshSettingsSnapshot: ReturnType<typeof vi.fn<[unknown], Promise<void>>>;
|
||||
warmUpSnapshotForCwd: ReturnType<typeof vi.fn<[unknown], Promise<void>>>;
|
||||
listRegisteredProviderIds: ReturnType<typeof vi.fn<[], AgentProvider[]>>;
|
||||
hasProvider: ReturnType<typeof vi.fn<[AgentProvider], boolean>>;
|
||||
getProviderLabel: ReturnType<typeof vi.fn<[AgentProvider], string>>;
|
||||
getAgentManagerProviderState: ReturnType<typeof vi.fn<[], AgentManagerProviderState>>;
|
||||
listProviders: ReturnType<typeof vi.fn<[unknown], Promise<ProviderSnapshotEntry[]>>>;
|
||||
getProvider: ReturnType<typeof vi.fn<[unknown], Promise<ProviderSnapshotEntry>>>;
|
||||
getSnapshot: ReturnType<typeof vi.fn<ProviderSnapshotManager["getSnapshot"]>>;
|
||||
refreshSnapshotForCwd: ReturnType<typeof vi.fn<ProviderSnapshotManager["refreshSnapshotForCwd"]>>;
|
||||
refreshSettingsSnapshot: ReturnType<
|
||||
typeof vi.fn<ProviderSnapshotManager["refreshSettingsSnapshot"]>
|
||||
>;
|
||||
warmUpSnapshotForCwd: ReturnType<typeof vi.fn<ProviderSnapshotManager["warmUpSnapshotForCwd"]>>;
|
||||
listRegisteredProviderIds: ReturnType<
|
||||
typeof vi.fn<ProviderSnapshotManager["listRegisteredProviderIds"]>
|
||||
>;
|
||||
hasProvider: ReturnType<typeof vi.fn<ProviderSnapshotManager["hasProvider"]>>;
|
||||
getProviderLabel: ReturnType<typeof vi.fn<ProviderSnapshotManager["getProviderLabel"]>>;
|
||||
getAgentManagerProviderState: ReturnType<
|
||||
typeof vi.fn<ProviderSnapshotManager["getAgentManagerProviderState"]>
|
||||
>;
|
||||
listProviders: ReturnType<typeof vi.fn<ProviderSnapshotManager["listProviders"]>>;
|
||||
getProvider: ReturnType<typeof vi.fn<ProviderSnapshotManager["getProvider"]>>;
|
||||
validateAgentConfiguration: ReturnType<
|
||||
typeof vi.fn<[unknown], Promise<HubExecutionAgentValidationIssue[]>>
|
||||
typeof vi.fn<ProviderSnapshotManager["validateAgentConfiguration"]>
|
||||
>;
|
||||
listModels: ReturnType<typeof vi.fn<[unknown], Promise<AgentModelDefinition[]>>>;
|
||||
listModes: ReturnType<typeof vi.fn<[unknown], Promise<AgentMode[]>>>;
|
||||
resolveCreateConfig: ReturnType<typeof vi.fn<[unknown], Promise<ResolvedProviderCreateConfig>>>;
|
||||
resolveDefaultModel: ReturnType<typeof vi.fn<[unknown], Promise<string | undefined>>>;
|
||||
getProviderDiagnostic: ReturnType<
|
||||
typeof vi.fn<[AgentProvider], Promise<ProviderDiagnosticResult>>
|
||||
listModels: ReturnType<typeof vi.fn<ProviderSnapshotManager["listModels"]>>;
|
||||
listModes: ReturnType<typeof vi.fn<ProviderSnapshotManager["listModes"]>>;
|
||||
resolveCreateConfig: ReturnType<typeof vi.fn<ProviderSnapshotManager["resolveCreateConfig"]>>;
|
||||
resolveDefaultModel: ReturnType<typeof vi.fn<ProviderSnapshotManager["resolveDefaultModel"]>>;
|
||||
getProviderDiagnostic: ReturnType<typeof vi.fn<ProviderSnapshotManager["getProviderDiagnostic"]>>;
|
||||
applyMutableProviderConfig: ReturnType<
|
||||
typeof vi.fn<ProviderSnapshotManager["applyMutableProviderConfig"]>
|
||||
>;
|
||||
applyMutableProviderConfig: ReturnType<typeof vi.fn<[unknown], AgentManagerProviderState>>;
|
||||
destroy: ReturnType<typeof vi.fn<[], void>>;
|
||||
destroy: ReturnType<typeof vi.fn<ProviderSnapshotManager["destroy"]>>;
|
||||
}
|
||||
|
||||
export function createProviderSnapshotManagerStub(): {
|
||||
manager: ProviderSnapshotManager;
|
||||
} & ProviderSnapshotManagerSpies {
|
||||
const getSnapshot = vi.fn<[cwd?: string], ProviderSnapshotEntry[]>(() => []);
|
||||
const refreshSnapshotForCwd = vi.fn<[unknown], Promise<void>>(async () => {});
|
||||
const refreshSettingsSnapshot = vi.fn<[unknown], Promise<void>>(async () => {});
|
||||
const warmUpSnapshotForCwd = vi.fn<[unknown], Promise<void>>(async () => {});
|
||||
const listRegisteredProviderIds = vi.fn<[], AgentProvider[]>(() => []);
|
||||
const hasProvider = vi.fn<[AgentProvider], boolean>(() => false);
|
||||
const getProviderLabel = vi.fn<[AgentProvider], string>((provider) => {
|
||||
const getSnapshot = vi.fn<ProviderSnapshotManager["getSnapshot"]>((cwd) =>
|
||||
createProviderSnapshot([], cwd),
|
||||
);
|
||||
const refreshSnapshotForCwd = vi.fn<ProviderSnapshotManager["refreshSnapshotForCwd"]>(
|
||||
async () => {},
|
||||
);
|
||||
const refreshSettingsSnapshot = vi.fn<ProviderSnapshotManager["refreshSettingsSnapshot"]>(
|
||||
async () => {},
|
||||
);
|
||||
const warmUpSnapshotForCwd = vi.fn<ProviderSnapshotManager["warmUpSnapshotForCwd"]>(
|
||||
async () => {},
|
||||
);
|
||||
const listRegisteredProviderIds = vi.fn<ProviderSnapshotManager["listRegisteredProviderIds"]>(
|
||||
() => [],
|
||||
);
|
||||
const hasProvider = vi.fn<ProviderSnapshotManager["hasProvider"]>(() => false);
|
||||
const getProviderLabel = vi.fn<ProviderSnapshotManager["getProviderLabel"]>((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<ProviderSnapshotEntry[]>>(async () => []);
|
||||
const getProvider = vi.fn<[unknown], Promise<ProviderSnapshotEntry>>(async () => {
|
||||
const listProviders = vi.fn<ProviderSnapshotManager["listProviders"]>(async () => []);
|
||||
const getProvider = vi.fn<ProviderSnapshotManager["getProvider"]>(async () => {
|
||||
throw new Error("createProviderSnapshotManagerStub: getProvider not stubbed");
|
||||
});
|
||||
const validateAgentConfiguration = vi.fn<[unknown], Promise<HubExecutionAgentValidationIssue[]>>(
|
||||
const validateAgentConfiguration = vi.fn<ProviderSnapshotManager["validateAgentConfiguration"]>(
|
||||
async () => [],
|
||||
);
|
||||
const listModels = vi.fn<[unknown], Promise<AgentModelDefinition[]>>(async () => []);
|
||||
const listModes = vi.fn<[unknown], Promise<AgentMode[]>>(async () => []);
|
||||
const resolveCreateConfig = vi.fn<[unknown], Promise<ResolvedProviderCreateConfig>>(async () => ({
|
||||
const listModels = vi.fn<ProviderSnapshotManager["listModels"]>(async () => []);
|
||||
const listModes = vi.fn<ProviderSnapshotManager["listModes"]>(async () => []);
|
||||
const resolveCreateConfig = vi.fn<ProviderSnapshotManager["resolveCreateConfig"]>(async () => ({
|
||||
modeId: undefined,
|
||||
featureValues: undefined,
|
||||
}));
|
||||
const resolveDefaultModel = vi.fn<[unknown], Promise<string | undefined>>(async () => undefined);
|
||||
const getProviderDiagnostic = vi.fn<[AgentProvider], Promise<ProviderDiagnosticResult>>(
|
||||
const resolveDefaultModel = vi.fn<ProviderSnapshotManager["resolveDefaultModel"]>(
|
||||
async () => undefined,
|
||||
);
|
||||
const getProviderDiagnostic = vi.fn<ProviderSnapshotManager["getProviderDiagnostic"]>(
|
||||
async (provider) => ({ provider, diagnostic: "No diagnostic available for this provider." }),
|
||||
);
|
||||
const applyMutableProviderConfig = vi.fn<[unknown], AgentManagerProviderState>(() => ({
|
||||
providerDefinitions: {},
|
||||
clients: {},
|
||||
}));
|
||||
const applyMutableProviderConfig = vi.fn<ProviderSnapshotManager["applyMutableProviderConfig"]>(
|
||||
() => ({
|
||||
providerDefinitions: {},
|
||||
clients: {},
|
||||
}),
|
||||
);
|
||||
const on = vi.fn();
|
||||
const off = vi.fn();
|
||||
const destroy = vi.fn<[], void>();
|
||||
const destroy = vi.fn<ProviderSnapshotManager["destroy"]>();
|
||||
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) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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/**",
|
||||
|
||||
Reference in New Issue
Block a user