feat: provider model freshness TTL and diagnostic UI (#426)
* feat: add server-side TTL for provider snapshots and model list UI Provider snapshots cached forever, causing newly available models (e.g. OpenCode Go minimax, glm) to never appear in the picker. Server: ProviderSnapshotManager now returns stale data immediately and kicks off a background re-warm when the snapshot is older than 5 minutes. Injectable TTL/clock for testing. App: Provider settings rows show model count and are tappable to open a diagnostic sheet with a read-only model list (label + ID). * feat: add per-provider refresh for models and diagnostic * refactor: await refresh completion and clean up diagnostic sheet state - ProviderSnapshotManager.refresh is now async; session.ts awaits it so the RPC ACK reflects real completion instead of just queuing work. - Preserve models/modes/fetchedAt on entries during targeted refresh so the list no longer flashes empty mid-refresh. - Show "Updated Xs ago" next to the Models count, plus loading and error states for the list body. - Split resetSnapshotToLoading into full vs targeted branches. - Flatten nested ternary in model list rendering into renderModelsBody. - Drop redundant local refreshing state and cargo useMemo wrappers. * fix(app): widen isImeComposingKeyboardEvent type to accept optional fields TextInputKeyPressEventData has isComposing and keyCode as optional, so Pick<KeyboardEvent, ...> was too narrow and broke typecheck on main.
This commit is contained in:
@@ -2756,6 +2756,7 @@ export class DaemonClient {
|
||||
|
||||
async refreshProvidersSnapshot(options?: {
|
||||
cwd?: string;
|
||||
providers?: AgentProvider[];
|
||||
requestId?: string;
|
||||
}): Promise<RefreshProvidersSnapshotPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
@@ -2763,6 +2764,7 @@ export class DaemonClient {
|
||||
message: {
|
||||
type: "refresh_providers_snapshot_request",
|
||||
cwd: options?.cwd,
|
||||
providers: options?.providers,
|
||||
},
|
||||
responseType: "refresh_providers_snapshot_response",
|
||||
timeout: 5000,
|
||||
|
||||
@@ -45,6 +45,7 @@ const TEST_CAPABILITIES = {
|
||||
} as const;
|
||||
|
||||
describe("ProviderSnapshotManager", () => {
|
||||
const ttlMs = 5 * 60 * 1_000;
|
||||
const projectCwd = resolve("/tmp/project");
|
||||
const projectACwd = resolve("/tmp/project-a");
|
||||
const projectBCwd = resolve("/tmp/project-b");
|
||||
@@ -269,7 +270,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
manager.refresh(projectCwd);
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
@@ -291,6 +292,155 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh with providers only re-fetches matching providers", async () => {
|
||||
const codexFetchModels = vi
|
||||
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||
const claudeFetchModels = vi
|
||||
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("claude", "sonnet-4")]);
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: codexFetchModels,
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "claude",
|
||||
fetchModels: claudeFetchModels,
|
||||
fetchModes: async () => [createMode("default")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.1",
|
||||
);
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||
"sonnet-4",
|
||||
);
|
||||
});
|
||||
|
||||
manager.refresh({ cwd: projectCwd, providers: ["codex"] });
|
||||
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("loading");
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")).toMatchObject({
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
models: [createModel("claude", "sonnet-4")],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
});
|
||||
|
||||
expect(codexFetchModels).toHaveBeenCalledTimes(2);
|
||||
expect(claudeFetchModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh treats an empty providers list as a full refresh", async () => {
|
||||
const codexFetchModels = vi
|
||||
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||
const claudeFetchModels = vi
|
||||
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("claude", "sonnet-4")])
|
||||
.mockResolvedValueOnce([createModel("claude", "sonnet-4.5")]);
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: codexFetchModels,
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "claude",
|
||||
fetchModels: claudeFetchModels,
|
||||
fetchModes: async () => [createMode("default")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
manager.refresh({ cwd: projectCwd, providers: [] });
|
||||
|
||||
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "loading",
|
||||
label: "claude",
|
||||
description: "claude test provider",
|
||||
defaultModeId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||
"sonnet-4.5",
|
||||
);
|
||||
});
|
||||
|
||||
expect(codexFetchModels).toHaveBeenCalledTimes(2);
|
||||
expect(claudeFetchModels).toHaveBeenCalledTimes(2);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh ignores provider filters that are not in the registry", async () => {
|
||||
const codexFetchModels = vi
|
||||
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")]);
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: codexFetchModels,
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
manager.refresh({ cwd: projectCwd, providers: ["zai"] });
|
||||
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
models: [createModel("codex", "gpt-5.1")],
|
||||
});
|
||||
expect(codexFetchModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh during an in-flight refresh is a no-op", async () => {
|
||||
const fetchModels = deferred<AgentModelDefinition[]>();
|
||||
const fetchModes = deferred<AgentMode[]>();
|
||||
@@ -305,7 +455,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
const changes: ProviderSnapshotEntry[][] = [];
|
||||
manager.on("change", (entries) => changes.push(entries));
|
||||
|
||||
manager.refresh(projectCwd);
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
|
||||
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||
{
|
||||
@@ -317,9 +467,9 @@ describe("ProviderSnapshotManager", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
manager.refresh(projectCwd);
|
||||
manager.refresh(projectCwd);
|
||||
manager.refresh(projectCwd);
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
@@ -342,6 +492,247 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => {
|
||||
let now = 1_000;
|
||||
const fetchModels = vi
|
||||
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async (cwd) => fetchModels(cwd),
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||
ttlMs,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.1",
|
||||
);
|
||||
});
|
||||
|
||||
now += ttlMs + 1;
|
||||
|
||||
const staleSnapshot = manager.getSnapshot(projectCwd);
|
||||
|
||||
expect(getProviderEntry(staleSnapshot, "codex")).toMatchObject({
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
models: [createModel("codex", "gpt-5.1")],
|
||||
modes: [createMode("auto")],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
});
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("getSnapshot does not trigger a second warm-up while a stale re-warm is already in flight", async () => {
|
||||
let now = 2_000;
|
||||
const staleRefreshModels = deferred<AgentModelDefinition[]>();
|
||||
const fetchModels = vi
|
||||
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||
.mockImplementationOnce(async () => staleRefreshModels.promise);
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async (cwd) => fetchModels(cwd),
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||
ttlMs,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.1",
|
||||
);
|
||||
});
|
||||
|
||||
now += ttlMs + 1;
|
||||
|
||||
const firstStaleSnapshot = manager.getSnapshot(projectCwd);
|
||||
const secondStaleSnapshot = manager.getSnapshot(projectCwd);
|
||||
|
||||
expect(getProviderEntry(firstStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||
expect(getProviderEntry(secondStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
staleRefreshModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
});
|
||||
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("getSnapshot does not re-warm when the cached snapshot is still fresh", async () => {
|
||||
let now = 3_000;
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async () => [createModel("codex", "gpt-5.1")],
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||
ttlMs,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
now += ttlMs - 1;
|
||||
|
||||
const freshSnapshot = manager.getSnapshot(projectCwd);
|
||||
|
||||
expect(getProviderEntry(freshSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("getSnapshot re-warms snapshots in error and unavailable states after TTL", async () => {
|
||||
let now = 4_000;
|
||||
const unavailableFetchModels = vi
|
||||
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValue([createModel("codex", "gpt-5.2")]);
|
||||
const unavailableIsAvailable = vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
const errorFetchModels = vi
|
||||
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||
.mockRejectedValueOnce(new Error("model lookup failed"))
|
||||
.mockResolvedValueOnce([createModel("claude", "sonnet")]);
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
isAvailable: unavailableIsAvailable,
|
||||
fetchModels: async (cwd) => unavailableFetchModels(cwd),
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "claude",
|
||||
fetchModels: async (cwd) => errorFetchModels(cwd),
|
||||
fetchModes: async () => [createMode("default")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||
ttlMs,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("error");
|
||||
});
|
||||
|
||||
now += ttlMs + 1;
|
||||
|
||||
const staleSnapshot = manager.getSnapshot(projectCwd);
|
||||
|
||||
expect(getProviderEntry(staleSnapshot, "codex")?.status).toBe("unavailable");
|
||||
expect(getProviderEntry(staleSnapshot, "claude")?.status).toBe("error");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||
"sonnet",
|
||||
);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("getSnapshot respects an injected TTL", async () => {
|
||||
let now = 5_000;
|
||||
const customTtlMs = 100;
|
||||
const fetchModels = vi
|
||||
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async (cwd) => fetchModels(cwd),
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||
ttlMs: customTtlMs,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.1",
|
||||
);
|
||||
});
|
||||
|
||||
now += customTtlMs - 1;
|
||||
manager.getSnapshot(projectCwd);
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += 2;
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
});
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
|
||||
const codexModels = deferred<AgentModelDefinition[]>();
|
||||
const { registry, handles } = createRegistry([
|
||||
|
||||
@@ -7,19 +7,35 @@ import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
|
||||
const DEFAULT_CWD_KEY = "__default__";
|
||||
const DEFAULT_SNAPSHOT_TTL_MS = 300_000;
|
||||
|
||||
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
|
||||
type ProviderSnapshotManagerOptions = {
|
||||
ttlMs?: number;
|
||||
now?: () => number;
|
||||
};
|
||||
type ProviderSnapshotRefreshOptions = {
|
||||
cwd?: string;
|
||||
providers?: AgentProvider[];
|
||||
};
|
||||
|
||||
export class ProviderSnapshotManager {
|
||||
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
|
||||
private readonly lastCheckedAts = new Map<string, number>();
|
||||
private readonly warmUps = new Map<string, Promise<void>>();
|
||||
private readonly events = new EventEmitter();
|
||||
private destroyed = false;
|
||||
private readonly ttlMs: number;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly providerRegistry: Record<AgentProvider, ProviderDefinition>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
options: ProviderSnapshotManagerOptions = {},
|
||||
) {
|
||||
this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
@@ -29,17 +45,24 @@ export class ProviderSnapshotManager {
|
||||
void this.warmUp(cwd);
|
||||
return entriesToArray(loadingEntries);
|
||||
}
|
||||
if (this.shouldRevalidate(cwdKey)) {
|
||||
void this.warmUp(cwd);
|
||||
}
|
||||
return entriesToArray(entries);
|
||||
}
|
||||
|
||||
refresh(cwd?: string): void {
|
||||
async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise<void> {
|
||||
const { cwd } = options;
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
if (this.warmUps.has(cwdKey)) {
|
||||
const inFlight = this.warmUps.get(cwdKey);
|
||||
if (inFlight) {
|
||||
await inFlight;
|
||||
return;
|
||||
}
|
||||
this.resetSnapshotToLoading(cwdKey);
|
||||
const providers = this.resolveRefreshProviders(options.providers);
|
||||
this.resetSnapshotToLoading(cwdKey, providers);
|
||||
this.emitChange(cwdKey);
|
||||
void this.warmUp(cwd);
|
||||
await this.warmUp(cwd, providers);
|
||||
}
|
||||
|
||||
on(event: "change", listener: ProviderSnapshotChangeListener): this {
|
||||
@@ -56,6 +79,7 @@ export class ProviderSnapshotManager {
|
||||
this.destroyed = true;
|
||||
this.events.removeAllListeners();
|
||||
this.snapshots.clear();
|
||||
this.lastCheckedAts.clear();
|
||||
this.warmUps.clear();
|
||||
}
|
||||
|
||||
@@ -74,16 +98,21 @@ export class ProviderSnapshotManager {
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async warmUp(cwd?: string): Promise<void> {
|
||||
private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise<void> {
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
const inFlight = this.warmUps.get(cwdKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
const providersToRefresh = providers ?? this.getProviderIds();
|
||||
|
||||
const warmUpPromise = Promise.allSettled(
|
||||
this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
|
||||
).then(() => undefined);
|
||||
providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
|
||||
).then(() => {
|
||||
if (!providers) {
|
||||
this.lastCheckedAts.set(cwdKey, this.now());
|
||||
}
|
||||
});
|
||||
|
||||
this.warmUps.set(cwdKey, warmUpPromise);
|
||||
|
||||
@@ -107,13 +136,6 @@ export class ProviderSnapshotManager {
|
||||
}
|
||||
|
||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||
snapshot.set(provider, {
|
||||
provider,
|
||||
status: "loading",
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
|
||||
try {
|
||||
const client = definition.createClient(this.logger);
|
||||
@@ -174,6 +196,17 @@ export class ProviderSnapshotManager {
|
||||
this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey));
|
||||
}
|
||||
|
||||
private shouldRevalidate(cwdKey: string): boolean {
|
||||
if (this.warmUps.has(cwdKey)) {
|
||||
return false;
|
||||
}
|
||||
const lastCheckedAt = this.lastCheckedAts.get(cwdKey);
|
||||
if (lastCheckedAt === undefined) {
|
||||
return false;
|
||||
}
|
||||
return this.now() - lastCheckedAt > this.ttlMs;
|
||||
}
|
||||
|
||||
private getOrCreateSnapshot(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
const existing = this.snapshots.get(cwdKey);
|
||||
if (existing) {
|
||||
@@ -185,11 +218,31 @@ export class ProviderSnapshotManager {
|
||||
return created;
|
||||
}
|
||||
|
||||
private resetSnapshotToLoading(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
private resetSnapshotToLoading(
|
||||
cwdKey: string,
|
||||
providers?: AgentProvider[],
|
||||
): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||
snapshot.clear();
|
||||
for (const [provider, entry] of this.createLoadingEntries()) {
|
||||
snapshot.set(provider, entry);
|
||||
const loadingEntries = this.createLoadingEntries();
|
||||
|
||||
if (!providers) {
|
||||
snapshot.clear();
|
||||
for (const [provider, entry] of loadingEntries) {
|
||||
snapshot.set(provider, entry);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
for (const provider of providers) {
|
||||
const loadingEntry = loadingEntries.get(provider);
|
||||
if (!loadingEntry) continue;
|
||||
const existing = snapshot.get(provider);
|
||||
snapshot.set(provider, {
|
||||
...loadingEntry,
|
||||
models: existing?.models,
|
||||
modes: existing?.modes,
|
||||
fetchedAt: existing?.fetchedAt,
|
||||
});
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
@@ -197,6 +250,15 @@ export class ProviderSnapshotManager {
|
||||
private getProviderIds(): AgentProvider[] {
|
||||
return Object.keys(this.providerRegistry) as AgentProvider[];
|
||||
}
|
||||
|
||||
private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined {
|
||||
if (!providers || providers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const providerIds = new Set(this.getProviderIds());
|
||||
return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCwdKey(cwd?: string): string {
|
||||
|
||||
@@ -3218,7 +3218,10 @@ export class Session {
|
||||
private async handleRefreshProvidersSnapshotRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
|
||||
): Promise<void> {
|
||||
this.providerSnapshotManager?.refresh(msg.cwd ? expandTilde(msg.cwd) : undefined);
|
||||
await this.providerSnapshotManager?.refresh({
|
||||
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
|
||||
providers: msg.providers,
|
||||
});
|
||||
this.emit({
|
||||
type: "refresh_providers_snapshot_response",
|
||||
payload: {
|
||||
|
||||
@@ -852,6 +852,7 @@ export const GetProvidersSnapshotRequestMessageSchema = z.object({
|
||||
export const RefreshProvidersSnapshotRequestMessageSchema = z.object({
|
||||
type: z.literal("refresh_providers_snapshot_request"),
|
||||
cwd: z.string().optional(),
|
||||
providers: z.array(AgentProviderSchema).optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user