Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions packages/cli/src/ui/hooks/useProviderUpdates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,143 @@ describe('useProviderUpdates', () => {
);
});

it('leaves the model selection alone across a multi-provider batch update', async () => {
// The worst case reported in #8863: several providers update in one
// confirmation, and each executeUpdate in the loop used to rewrite
// model.name in turn — the last provider in registry order won,
// regardless of the user's intent. Neither provider owns the current
// model here, so the whole batch must leave the selection untouched.
const foreignModel = {
id: 'my-own-model',
baseUrl: 'https://my-own-gateway.example.com/v1',
envKey: 'MY_OWN_KEY',
name: '[Mine] my-own-model',
};
mockConfig.getModel.mockReturnValue('my-own-model');
mockConfig.getContentGeneratorConfig.mockReturnValue({
authType: AuthType.USE_OPENAI,
baseUrl: foreignModel.baseUrl,
apiKeyEnvKey: foreignModel.envKey,
});
const metadataNs = mockSettings.merged[PROVIDER_METADATA_NS] as Record<
string,
unknown
>;
metadataNs[METADATA_KEY] = {
baseUrl: CODING_PLAN_CHINA_BASE_URL,
version: 'old-version-hash',
};
metadataNs[TOKEN_METADATA_KEY] = {
baseUrl: TOKEN_PLAN_BASE_URL,
version: 'old-version-hash',
};
mockSettings.merged['modelProviders'] = {
[AuthType.USE_OPENAI]: [foreignModel, ...chinaTemplate, ...tokenTemplate],
};
mockConfig.refreshAuth.mockResolvedValue(undefined);

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

await waitFor(() => {
expect(result.current.providerUpdateRequest).toBeDefined();
});
expect(result.current.providerUpdateRequest!.entries.length).toBe(2);

await result.current.providerUpdateRequest!.onConfirm('update');

await waitFor(() => {
expect(mockSettings.setValue).toHaveBeenCalledWith(
expect.anything(),
`${PROVIDER_METADATA_NS}.${METADATA_KEY}.version`,
chinaVersion,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
expect.anything(),
`${PROVIDER_METADATA_NS}.${TOKEN_METADATA_KEY}.version`,
tokenVersion,
);
});

expect(mockSettings.setValue).not.toHaveBeenCalledWith(
expect.anything(),
'model.name',
expect.anything(),
);
expect(mockSettings.setValue).not.toHaveBeenCalledWith(
expect.anything(),
'model.baseUrl',
expect.anything(),
);
expect(mockModelsConfig.syncAfterAuthRefresh).not.toHaveBeenCalled();
});

it('leaves the selection alone even for the active provider in a mixed batch', async () => {
// A batch mixing the ACTIVE provider (whose plan no longer offers the
// current model) with an inactive one: since #8889 a template update
// never applies the plan's model selection, so neither entry may touch
// model.name — while both updates still run to completion. This pins
// the batch-loop side of that invariant; the single-provider side is
// pinned by 'leaves the model selection alone when the previous model
// is gone' above.
const metadataNs = mockSettings.merged[PROVIDER_METADATA_NS] as Record<
string,
unknown
>;
metadataNs[METADATA_KEY] = {
baseUrl: CODING_PLAN_CHINA_BASE_URL,
version: 'old-version-hash',
};
metadataNs[TOKEN_METADATA_KEY] = {
baseUrl: TOKEN_PLAN_BASE_URL,
version: 'old-version-hash',
};
mockSettings.merged['modelProviders'] = {
[AuthType.USE_OPENAI]: [...chinaTemplate, ...tokenTemplate],
};
// Default mock credentials point at Coding Plan, so only the first entry
// is the active provider. The current model exists in neither template.
mockConfig.getModel.mockReturnValue('removed-model');
mockConfig.refreshAuth.mockResolvedValue(undefined);

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

await waitFor(() => {
expect(result.current.providerUpdateRequest).toBeDefined();
});
expect(result.current.providerUpdateRequest!.entries.length).toBe(2);

await result.current.providerUpdateRequest!.onConfirm('update');

// Both entries ran to completion, regardless of provider order.
await waitFor(() => {
expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalledTimes(2);
});

expect(mockSettings.setValue).not.toHaveBeenCalledWith(
expect.anything(),
'model.name',
expect.anything(),
);
expect(mockSettings.setValue).not.toHaveBeenCalledWith(
expect.anything(),
'model.baseUrl',
expect.anything(),
);
expect(mockModelsConfig.syncAfterAuthRefresh).not.toHaveBeenCalled();
});

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 Down
Loading