Skip to content
Merged
105 changes: 103 additions & 2 deletions packages/cli/src/ui/hooks/useProviderUpdates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe('useProviderUpdates', () => {
[PROVIDER_METADATA_NS]: {} as Record<string, unknown>,
} as Record<string, unknown>,
setValue: vi.fn(),
setValues: vi.fn(),
forScope: vi.fn(() => ({ path: '/tmp/settings.json' })),
isTrusted: true,
workspace: { settings: {} },
Expand Down Expand Up @@ -455,7 +456,7 @@ describe('useProviderUpdates', () => {
);
});

it('dismisses without persisting when user chooses "later"', async () => {
it('persists a cooldown (not a full update) when user chooses "later"', async () => {
(mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[
METADATA_KEY
] = {
Expand All @@ -478,15 +479,115 @@ describe('useProviderUpdates', () => {
expect(result.current.providerUpdateRequest).toBeDefined();
});

await result.current.providerUpdateRequest!.onConfirm('later');
// Pin Date.now so the persisted timestamp can be asserted exactly.
const postponedAt = Date.now();
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(postponedAt);
try {
await result.current.providerUpdateRequest!.onConfirm('later');
} finally {
dateNowSpy.mockRestore();
}

await waitFor(() => {
expect(result.current.providerUpdateRequest).toBeUndefined();
});
// "later" persists a postponement cooldown so the prompt does not reappear
// on every launch, but it must not apply the update. The single batched
// write must contain exactly these two keys — pinning the values the
// read-side guard compares against and bounding all persisted writes.
expect(mockSettings.setValues).toHaveBeenCalledTimes(1);
expect(mockSettings.setValues).toHaveBeenCalledWith([
{
scope: 'User',
key: `${PROVIDER_METADATA_NS}.${METADATA_KEY}.postponedVersion`,
value: chinaVersion,
},
{
scope: 'User',
key: `${PROVIDER_METADATA_NS}.${METADATA_KEY}.postponedAt`,
value: postponedAt,
},
]);
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled();
});

it('does not show prompt while the "later" cooldown is active', () => {
(mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[
METADATA_KEY
] = {
baseUrl: CODING_PLAN_CHINA_BASE_URL,
version: 'old-version-hash',
postponedVersion: chinaVersion,
postponedAt: Date.now(),
};
mockSettings.merged['modelProviders'] = {
[AuthType.USE_OPENAI]: chinaTemplate,
};

const { result } = renderHook(() =>
useProviderUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);

expect(result.current.providerUpdateRequest).toBeUndefined();
});

it('shows prompt again after the "later" cooldown expires', async () => {
(mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[
METADATA_KEY
] = {
baseUrl: CODING_PLAN_CHINA_BASE_URL,
version: 'old-version-hash',
postponedVersion: chinaVersion,
postponedAt: Date.now() - 25 * 60 * 60 * 1000,
};
mockSettings.merged['modelProviders'] = {
[AuthType.USE_OPENAI]: chinaTemplate,
};

const { result } = renderHook(() =>
useProviderUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);

await waitFor(() => {
expect(result.current.providerUpdateRequest).toBeDefined();
});
});

it('shows prompt for a newer version despite an active "later" cooldown', async () => {
(mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[
METADATA_KEY
] = {
baseUrl: CODING_PLAN_CHINA_BASE_URL,
version: 'old-version-hash',
postponedVersion: 'stale-postponed-hash',
postponedAt: Date.now(),
};
mockSettings.merged['modelProviders'] = {
[AuthType.USE_OPENAI]: chinaTemplate,
};

const { result } = renderHook(() =>
useProviderUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);

await waitFor(() => {
expect(result.current.providerUpdateRequest).toBeDefined();
});
});

it('persists ignoredVersion when user chooses "skip"', async () => {
(mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[
METADATA_KEY
Expand Down
52 changes: 51 additions & 1 deletion packages/cli/src/ui/hooks/useProviderUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,15 @@ interface ProviderMetadata {
version?: string;
baseUrl?: string;
ignoredVersion?: string;
postponedVersion?: string;
postponedAt?: number;
}

// "Later" suppresses re-prompting for the same version for this long, so a
// user who defers is not nagged on every launch. A new model-list version
// still re-prompts immediately (postponedVersion no longer matches).
const LATER_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24h

function getProviderMetadata(
settings: LoadedSettings,
metadataKey: string,
Expand Down Expand Up @@ -203,6 +210,16 @@ function findAllPendingUpdates(
if (metadata.version === currentVersion) continue;
if (metadata.ignoredVersion === currentVersion) continue;

// A "later" choice suppresses re-prompting for the same version while the
// cooldown is active. A new version (postponedVersion mismatch) re-prompts.
if (
metadata.postponedVersion === currentVersion &&
typeof metadata.postponedAt === 'number' &&
Date.now() - metadata.postponedAt < LATER_COOLDOWN_MS
) {
continue;
}

const existingModelIds = getInstalledOwnedModelIds(settings, provider);
const newModelIds = provider.models!.map((s) => s.id);
const diff = computeModelDiff(existingModelIds, newModelIds, currentModel);
Expand Down Expand Up @@ -368,10 +385,43 @@ export function useProviderUpdates(
p.currentVersion,
);
}
} else if (choice === 'later') {
// Persist a cooldown so "later" does not re-prompt on every launch.
Comment thread
yiliang114 marked this conversation as resolved.
// One batched write keeps the version/timestamp pair atomic, so a
// partial persist cannot invalidate the cooldown guard on next launch.
const persistScope = getPersistScopeForModelSelection(settings);
const postponedAt = Date.now();
Comment thread
yiliang114 marked this conversation as resolved.
try {
settings.setValues(
pendingList.flatMap((p) => [
{
scope: persistScope,
key: `${PROVIDER_METADATA_NS}.${p.metadataKey}.postponedVersion`,
value: p.currentVersion,
},
{
scope: persistScope,
key: `${PROVIDER_METADATA_NS}.${p.metadataKey}.postponedAt`,
value: postponedAt,
},
]),
);
} catch (error) {
addItem(
{
type: 'error',
text: t('Failed to save update postponement: {{message}}', {
message:
error instanceof Error ? error.message : String(error),
}),
},
Date.now(),
);
}
}
},
});
}, [settings, config, executeUpdate]);
}, [settings, config, executeUpdate, addItem]);

useEffect(() => {
checkForUpdates();
Expand Down
Loading