Fix provider models per workspace (#1167)
* Scope provider snapshots by workspace * Fix provider snapshot cwd test on Windows
This commit is contained in:
@@ -237,7 +237,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(changes[0]?.cwd).toBe(homedir());
|
||||
expect(changes[0]?.cwd).toBe(projectCwd);
|
||||
expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready");
|
||||
expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading");
|
||||
|
||||
@@ -542,19 +542,19 @@ describe("ProviderSnapshotManager", () => {
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(fetchModels).toHaveBeenCalledWith(homedir(), false);
|
||||
expect(fetchModels).not.toHaveBeenCalledWith(homedir(), true);
|
||||
expect(fetchModels).toHaveBeenCalledWith(projectCwd, false);
|
||||
expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true);
|
||||
|
||||
loadingFetchModels.resolve([createModel("codex", "gpt-5.4")]);
|
||||
await warmUpPromise;
|
||||
|
||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(fetchModels).not.toHaveBeenCalledWith(homedir(), true);
|
||||
expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("settings refresh refreshes the single global provider state once", async () => {
|
||||
test("settings refresh clears workspace scopes and refreshes only home immediately", async () => {
|
||||
const fetchModels = vi
|
||||
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||
.mockImplementation(async (_cwd, force) => [
|
||||
@@ -585,22 +585,31 @@ describe("ProviderSnapshotManager", () => {
|
||||
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
||||
|
||||
expect(fetchModels.mock.calls).toEqual([
|
||||
[homedir(), false],
|
||||
[projectACwd, false],
|
||||
[projectBCwd, false],
|
||||
[homedir(), true],
|
||||
]);
|
||||
|
||||
const projectASnapshot = manager.getSnapshot(projectACwd);
|
||||
expect(getProviderEntry(projectASnapshot, "codex")).toMatchObject({
|
||||
const homeSnapshot = manager.getSnapshot();
|
||||
expect(getProviderEntry(homeSnapshot, "codex")).toMatchObject({
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
models: [createModel("codex", "refreshed")],
|
||||
});
|
||||
expect(fetchModels).toHaveBeenCalledTimes(3);
|
||||
|
||||
const projectASnapshot = manager.getSnapshot(projectACwd);
|
||||
expect(getProviderEntry(projectASnapshot, "codex")).toMatchObject({
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
});
|
||||
expect(getProviderEntry(projectASnapshot, "codex")?.models).toBeUndefined();
|
||||
expect(getProviderEntry(projectASnapshot, "claude")?.status).toBe("ready");
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("settings refresh updates workspace reads through the shared global provider state", async () => {
|
||||
test("settings refresh makes workspace reads refetch on demand", async () => {
|
||||
const fetchModels = vi
|
||||
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||
.mockImplementation(async (_cwd, force) => [
|
||||
@@ -624,19 +633,59 @@ describe("ProviderSnapshotManager", () => {
|
||||
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
||||
|
||||
expect(fetchModels.mock.calls).toEqual([
|
||||
[homedir(), false],
|
||||
[projectCwd, false],
|
||||
[homedir(), true],
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"refreshed",
|
||||
"initial",
|
||||
);
|
||||
});
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("settings refresh prevents stale in-flight workspace loads from repopulating the cache", async () => {
|
||||
const workspaceModels = deferred<AgentModelDefinition[]>();
|
||||
const fetchModels = vi
|
||||
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||
.mockImplementationOnce(async () => workspaceModels.promise)
|
||||
.mockImplementationOnce(async (_cwd, force) => [
|
||||
createModel("codex", force ? "home-refreshed" : "unexpected"),
|
||||
]);
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async (cwd, force) => fetchModels(cwd, force),
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
||||
workspaceModels.resolve([createModel("codex", "stale-workspace-model")]);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getProviderEntry(manager.getSnapshot(), "codex")?.models?.[0]?.id).toBe(
|
||||
"home-refreshed",
|
||||
);
|
||||
const projectEntry = getProviderEntry(manager.getSnapshot(projectCwd), "codex");
|
||||
expect(projectEntry).toMatchObject({
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
});
|
||||
expect(projectEntry?.models).toBeUndefined();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh marks a slow provider as error after the timeout", async () => {
|
||||
const fetchModels = deferred<AgentModelDefinition[]>();
|
||||
const { registry } = createRegistry([
|
||||
@@ -748,7 +797,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
expect(unavailableIsAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(errorFetchModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
await manager.refreshSettingsSnapshot({ providers: ["codex", "claude"] });
|
||||
await manager.refreshSnapshotForCwd({ cwd: projectCwd, providers: ["codex", "claude"] });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||
@@ -810,7 +859,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
expect(nextHandles.zai?.createClient).not.toHaveBeenCalled();
|
||||
expect(nextHandles.zai?.fetchModels).not.toHaveBeenCalled();
|
||||
|
||||
await manager.refreshSettingsSnapshot({ providers: ["zai"] });
|
||||
await manager.refreshSnapshotForCwd({ cwd: projectCwd, providers: ["zai"] });
|
||||
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({
|
||||
provider: "zai",
|
||||
@@ -858,7 +907,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("different cwd keys share the same global provider snapshot state", async () => {
|
||||
test("different cwd keys keep independent provider snapshot state", async () => {
|
||||
const seenCwds: string[] = [];
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
@@ -880,12 +929,12 @@ describe("ProviderSnapshotManager", () => {
|
||||
});
|
||||
|
||||
expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe(
|
||||
`model:${homedir()}`,
|
||||
`model:${projectACwd}`,
|
||||
);
|
||||
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
`model:${homedir()}`,
|
||||
`model:${projectBCwd}`,
|
||||
);
|
||||
expect(seenCwds).toEqual([homedir()]);
|
||||
expect(seenCwds).toEqual([projectACwd, projectBCwd]);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
@@ -917,7 +966,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("workspace cwd does not affect global provider model fetching", async () => {
|
||||
test("workspace cwd is normalized before provider model fetching", async () => {
|
||||
const seenCwds: string[] = [];
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
@@ -934,15 +983,18 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.getSnapshot("relative-provider-test/..");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(seenCwds).toHaveLength(1);
|
||||
expect(seenCwds).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(seenCwds).toEqual([homedir()]);
|
||||
expect(seenCwds).toEqual([
|
||||
resolve(homedir(), "paseo-provider-test"),
|
||||
resolve("relative-provider-test/.."),
|
||||
]);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("workspace refresh refreshes the shared global provider state with force true", async () => {
|
||||
test("workspace refresh refreshes only that cwd with force true", async () => {
|
||||
const fetchModels = vi
|
||||
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||
.mockImplementation(async (_cwd, force) => [
|
||||
@@ -960,17 +1012,18 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.getSnapshot(projectBCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(fetchModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await manager.refreshSnapshotForCwd({ cwd: projectACwd, providers: ["codex"] });
|
||||
|
||||
expect(fetchModels.mock.calls).toEqual([
|
||||
[homedir(), false],
|
||||
[homedir(), true],
|
||||
[projectACwd, false],
|
||||
[projectBCwd, false],
|
||||
[projectACwd, true],
|
||||
]);
|
||||
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"refreshed",
|
||||
"initial",
|
||||
);
|
||||
|
||||
manager.destroy();
|
||||
@@ -1013,7 +1066,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
provider: "codex",
|
||||
}),
|
||||
]),
|
||||
homedir(),
|
||||
projectCwd,
|
||||
);
|
||||
|
||||
manager.destroy();
|
||||
|
||||
@@ -44,8 +44,8 @@ export class ProviderSnapshotManager {
|
||||
this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
getSnapshot(_cwd?: string): ProviderSnapshotEntry[] {
|
||||
const resolvedCwd = resolveGlobalSnapshotCwd();
|
||||
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
||||
const resolvedCwd = resolveSnapshotCwd(cwd);
|
||||
const entries = this.snapshots.get(resolvedCwd);
|
||||
if (!entries) {
|
||||
const loadingEntries = this.resetSnapshotToLoading(resolvedCwd);
|
||||
@@ -54,9 +54,14 @@ export class ProviderSnapshotManager {
|
||||
}
|
||||
const missingProviders = this.getProviderIds().filter((provider) => !entries.has(provider));
|
||||
if (missingProviders.length > 0) {
|
||||
const loadingEntries = this.createLoadingEntries();
|
||||
for (const provider of missingProviders) {
|
||||
entries.set(provider, this.createUnprobedEntry(provider));
|
||||
const loadingEntry = loadingEntries.get(provider);
|
||||
if (loadingEntry) {
|
||||
entries.set(provider, loadingEntry);
|
||||
}
|
||||
}
|
||||
void this.warmUp(resolvedCwd, missingProviders);
|
||||
}
|
||||
const providerLoads = this.providerLoads.get(resolvedCwd);
|
||||
const loadingProviders = Array.from(entries.values())
|
||||
@@ -69,9 +74,9 @@ export class ProviderSnapshotManager {
|
||||
}
|
||||
|
||||
async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||
const snapshotCwd = resolveGlobalSnapshotCwd();
|
||||
const snapshotCwd = resolveSnapshotCwd(options.cwd);
|
||||
const providers = this.resolveRefreshProviders(options.providers);
|
||||
this.resetSnapshotToLoading(snapshotCwd, providers);
|
||||
this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false });
|
||||
this.emitChange(snapshotCwd);
|
||||
await this.refreshProviders(snapshotCwd, providers ?? this.getProviderIds());
|
||||
}
|
||||
@@ -79,17 +84,18 @@ export class ProviderSnapshotManager {
|
||||
async refreshSettingsSnapshot(
|
||||
options: Omit<ProviderSnapshotRefreshOptions, "cwd"> = {},
|
||||
): Promise<void> {
|
||||
const homeCwd = resolveGlobalSnapshotCwd();
|
||||
const homeCwd = resolveSnapshotCwd();
|
||||
const providers = this.resolveRefreshProviders(options.providers);
|
||||
const providersToRefresh = providers ?? this.getProviderIds();
|
||||
|
||||
this.resetSnapshotToLoading(homeCwd, providers);
|
||||
this.clearCachedProviders(providers);
|
||||
this.resetSnapshotToLoading(homeCwd, providers, { preserveExisting: false });
|
||||
this.emitChange(homeCwd);
|
||||
await this.refreshProviders(homeCwd, providersToRefresh);
|
||||
}
|
||||
|
||||
async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||
const snapshotCwd = resolveGlobalSnapshotCwd();
|
||||
const snapshotCwd = resolveSnapshotCwd(options.cwd);
|
||||
const providers = this.resolveRefreshProviders(options.providers);
|
||||
if (options.providers && providers?.length === 0) {
|
||||
return;
|
||||
@@ -155,18 +161,6 @@ export class ProviderSnapshotManager {
|
||||
return entries;
|
||||
}
|
||||
|
||||
private createUnprobedEntry(provider: AgentProvider): ProviderSnapshotEntry {
|
||||
const definition = this.providerRegistry[provider];
|
||||
return {
|
||||
provider,
|
||||
status: "unavailable",
|
||||
enabled: definition?.enabled ?? true,
|
||||
label: definition?.label,
|
||||
description: definition?.description,
|
||||
defaultModeId: definition?.defaultModeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private reconcileSnapshotForRegistry(cwd: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
const existing = this.snapshots.get(cwd);
|
||||
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
|
||||
@@ -214,6 +208,47 @@ export class ProviderSnapshotManager {
|
||||
await this.loadProviders({ cwd, providers, force: true });
|
||||
}
|
||||
|
||||
private clearCachedProviders(providers?: AgentProvider[]): void {
|
||||
const providerSet = providers ? new Set(providers) : null;
|
||||
const loadingEntries = this.createLoadingEntries();
|
||||
|
||||
for (const [cwd, providerLoads] of Array.from(this.providerLoads.entries())) {
|
||||
if (!providerSet) {
|
||||
this.providerLoads.delete(cwd);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const provider of providerSet) {
|
||||
providerLoads.delete(provider);
|
||||
}
|
||||
if (providerLoads.size === 0) {
|
||||
this.providerLoads.delete(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [cwd, snapshot] of this.snapshots.entries()) {
|
||||
if (!providerSet) {
|
||||
snapshot.clear();
|
||||
for (const [provider, entry] of loadingEntries) {
|
||||
snapshot.set(provider, entry);
|
||||
}
|
||||
this.emitChange(cwd);
|
||||
continue;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
for (const provider of providerSet) {
|
||||
const loadingEntry = loadingEntries.get(provider);
|
||||
if (!loadingEntry) continue;
|
||||
snapshot.set(provider, loadingEntry);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
this.emitChange(cwd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadProviders(options: ProviderLoadOptions): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
options.providers.map((provider) => this.loadProvider({ ...options, provider })),
|
||||
@@ -374,9 +409,11 @@ export class ProviderSnapshotManager {
|
||||
private resetSnapshotToLoading(
|
||||
cwdKey: string,
|
||||
providers?: AgentProvider[],
|
||||
options: { preserveExisting?: boolean } = {},
|
||||
): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||
const loadingEntries = this.createLoadingEntries();
|
||||
const preserveExisting = options.preserveExisting ?? true;
|
||||
|
||||
if (!providers) {
|
||||
snapshot.clear();
|
||||
@@ -392,9 +429,13 @@ export class ProviderSnapshotManager {
|
||||
const existing = snapshot.get(provider);
|
||||
snapshot.set(provider, {
|
||||
...loadingEntry,
|
||||
models: existing?.models,
|
||||
modes: existing?.modes,
|
||||
fetchedAt: existing?.fetchedAt,
|
||||
...(preserveExisting
|
||||
? {
|
||||
models: existing?.models,
|
||||
modes: existing?.modes,
|
||||
fetchedAt: existing?.fetchedAt,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return snapshot;
|
||||
@@ -424,10 +465,6 @@ export function resolveSnapshotCwd(cwd?: string | null): string {
|
||||
return resolve(expanded);
|
||||
}
|
||||
|
||||
function resolveGlobalSnapshotCwd(): string {
|
||||
return resolveSnapshotCwd();
|
||||
}
|
||||
|
||||
function entriesToArray(
|
||||
entries: Map<AgentProvider, ProviderSnapshotEntry>,
|
||||
): ProviderSnapshotEntry[] {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { execSync } from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
||||
import { homedir, tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { join, resolve as resolvePath } from "path";
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
@@ -986,6 +986,42 @@ describe("session provider refresh cwd routing", () => {
|
||||
expect(refreshSettingsSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("provider snapshot requests pass cwd through to provider discovery", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceCwd = resolvePath("/tmp/session-provider-snapshot");
|
||||
const fetchModels = vi.fn(async (options: ListModelsOptions) => [
|
||||
{
|
||||
provider: "codex" as const,
|
||||
id: `model:${options.cwd}`,
|
||||
label: `model:${options.cwd}`,
|
||||
},
|
||||
]);
|
||||
const providerDefinition = createTestProviderDefinition({
|
||||
fetchModels,
|
||||
fetchModes: vi.fn(async () => []),
|
||||
});
|
||||
const providerSnapshotManager = new ProviderSnapshotManager(
|
||||
{ codex: providerDefinition },
|
||||
pino({ level: "silent" }),
|
||||
);
|
||||
const session = createSessionForTest({ messages, providerSnapshotManager });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "get_providers_snapshot_request",
|
||||
cwd: workspaceCwd,
|
||||
requestId: "snapshot-workspace",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchModels).toHaveBeenCalledWith({
|
||||
cwd: workspaceCwd,
|
||||
force: false,
|
||||
});
|
||||
});
|
||||
|
||||
providerSnapshotManager.destroy();
|
||||
});
|
||||
|
||||
test("normalizes legacy model and mode list requests without cwd to home", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const session = createSessionForTest({ messages });
|
||||
|
||||
@@ -1237,10 +1237,11 @@ export class Session {
|
||||
const visibleEntries = entries.filter((entry) =>
|
||||
this.isProviderVisibleToClient(entry.provider),
|
||||
);
|
||||
const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd;
|
||||
this.emit({
|
||||
type: "providers_snapshot_update",
|
||||
payload: {
|
||||
cwd,
|
||||
...(snapshotCwd ? { cwd: snapshotCwd } : {}),
|
||||
entries: visibleEntries,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user