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:
Mohamed Boudra
2026-04-16 13:14:02 +08:00
committed by GitHub
parent 5c6f175db5
commit 814098852c
12 changed files with 1044 additions and 147 deletions
@@ -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 {
+4 -1
View File
@@ -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: {
+1
View File
@@ -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(),
});