fix(runtime): preserve provider status during refresh failures
This commit is contained in:
parent
a06423a574
commit
a7606032fc
8 changed files with 265 additions and 12 deletions
|
|
@ -332,17 +332,27 @@ export function useCodexAccountSnapshot(options: {
|
||||||
[applySnapshot, electronMode, options.enabled]
|
[applySnapshot, electronMode, options.enabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const waitingForInitialRefresh =
|
||||||
|
electronMode &&
|
||||||
|
options.enabled &&
|
||||||
|
initialRefreshDelayMs > 0 &&
|
||||||
|
snapshot === null &&
|
||||||
|
!initialRefreshAttempted;
|
||||||
|
const effectiveLoading = loading || waitingForInitialRefresh;
|
||||||
|
const effectiveRateLimitsLoading =
|
||||||
|
rateLimitsLoading || (waitingForInitialRefresh && options.includeRateLimits === true);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
snapshot,
|
snapshot,
|
||||||
loading,
|
loading: effectiveLoading,
|
||||||
rateLimitsLoading,
|
rateLimitsLoading: effectiveRateLimitsLoading,
|
||||||
error,
|
error,
|
||||||
refresh,
|
refresh,
|
||||||
startChatgptLogin: (mode) => runAction(() => api.startCodexChatgptLogin({ mode })),
|
startChatgptLogin: (mode) => runAction(() => api.startCodexChatgptLogin({ mode })),
|
||||||
cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()),
|
cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()),
|
||||||
logout: () => runAction(() => api.logoutCodexAccount()),
|
logout: () => runAction(() => api.logoutCodexAccount()),
|
||||||
}),
|
}),
|
||||||
[error, loading, rateLimitsLoading, refresh, runAction, snapshot]
|
[effectiveLoading, effectiveRateLimitsLoading, error, refresh, runAction, snapshot]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||||
|
import { CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE } from '@shared/types/cliInstaller';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import {
|
import {
|
||||||
createDefaultCliExtensionCapabilities,
|
createDefaultCliExtensionCapabilities,
|
||||||
|
|
@ -413,7 +414,7 @@ function createRuntimeStatusErrorProviderStatus(
|
||||||
return {
|
return {
|
||||||
...createDefaultProviderStatus(providerId),
|
...createDefaultProviderStatus(providerId),
|
||||||
verificationState: 'error',
|
verificationState: 'error',
|
||||||
statusMessage: 'Provider status unavailable',
|
statusMessage: CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE,
|
||||||
detailMessage,
|
detailMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ interface StoredApiKeyAccessOptions {
|
||||||
allowedStoredApiKeyEnvVarNames?: readonly string[];
|
allowedStoredApiKeyEnvVarNames?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CodexLaunchSnapshotRefreshOptions {
|
||||||
|
refreshRuntimeMissing?: boolean;
|
||||||
|
refreshBlockedLaunch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const PROVIDER_CAPABILITIES: Record<
|
const PROVIDER_CAPABILITIES: Record<
|
||||||
CliProviderId,
|
CliProviderId,
|
||||||
Pick<CliProviderConnectionInfo, 'supportsOAuth' | 'supportsApiKey' | 'configurableAuthModes'>
|
Pick<CliProviderConnectionInfo, 'supportsOAuth' | 'supportsApiKey' | 'configurableAuthModes'>
|
||||||
|
|
@ -605,6 +610,7 @@ export class ProviderConnectionService {
|
||||||
|
|
||||||
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||||
refreshRuntimeMissing: true,
|
refreshRuntimeMissing: true,
|
||||||
|
refreshBlockedLaunch: true,
|
||||||
});
|
});
|
||||||
applyCodexRuntimeContextEnv(env, snapshot);
|
applyCodexRuntimeContextEnv(env, snapshot);
|
||||||
const readiness = evaluateCodexLaunchReadiness({
|
const readiness = evaluateCodexLaunchReadiness({
|
||||||
|
|
@ -687,6 +693,7 @@ export class ProviderConnectionService {
|
||||||
|
|
||||||
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||||
refreshRuntimeMissing: true,
|
refreshRuntimeMissing: true,
|
||||||
|
refreshBlockedLaunch: true,
|
||||||
});
|
});
|
||||||
applyCodexRuntimeContextEnv(env, snapshot);
|
applyCodexRuntimeContextEnv(env, snapshot);
|
||||||
const readiness = evaluateCodexLaunchReadiness({
|
const readiness = evaluateCodexLaunchReadiness({
|
||||||
|
|
@ -771,6 +778,7 @@ export class ProviderConnectionService {
|
||||||
|
|
||||||
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||||
refreshRuntimeMissing: true,
|
refreshRuntimeMissing: true,
|
||||||
|
refreshBlockedLaunch: true,
|
||||||
});
|
});
|
||||||
const runtimeEnv = { ...env };
|
const runtimeEnv = { ...env };
|
||||||
applyCodexRuntimeContextEnv(runtimeEnv, snapshot);
|
applyCodexRuntimeContextEnv(runtimeEnv, snapshot);
|
||||||
|
|
@ -882,6 +890,7 @@ export class ProviderConnectionService {
|
||||||
|
|
||||||
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||||
refreshRuntimeMissing: true,
|
refreshRuntimeMissing: true,
|
||||||
|
refreshBlockedLaunch: true,
|
||||||
});
|
});
|
||||||
const readiness = evaluateCodexLaunchReadiness({
|
const readiness = evaluateCodexLaunchReadiness({
|
||||||
preferredAuthMode: snapshot.preferredAuthMode,
|
preferredAuthMode: snapshot.preferredAuthMode,
|
||||||
|
|
@ -1267,10 +1276,21 @@ export class ProviderConnectionService {
|
||||||
|
|
||||||
private async getCodexLaunchSnapshot(
|
private async getCodexLaunchSnapshot(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
options?: { refreshRuntimeMissing?: boolean }
|
options?: CodexLaunchSnapshotRefreshOptions
|
||||||
): Promise<CodexAccountSnapshotDto> {
|
): Promise<CodexAccountSnapshotDto> {
|
||||||
let snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
let snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
||||||
if (!options?.refreshRuntimeMissing || snapshot.appServerState !== 'runtime-missing') {
|
const readiness = evaluateCodexLaunchReadiness({
|
||||||
|
preferredAuthMode: snapshot.preferredAuthMode,
|
||||||
|
managedAccount: snapshot.managedAccount,
|
||||||
|
apiKey: snapshot.apiKey,
|
||||||
|
appServerState: snapshot.appServerState,
|
||||||
|
appServerStatusMessage: snapshot.appServerStatusMessage,
|
||||||
|
localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent,
|
||||||
|
});
|
||||||
|
const shouldRefresh =
|
||||||
|
(options?.refreshRuntimeMissing === true && snapshot.appServerState === 'runtime-missing') ||
|
||||||
|
(options?.refreshBlockedLaunch === true && !readiness.launchAllowed);
|
||||||
|
if (!shouldRefresh) {
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1280,7 +1300,7 @@ export class ProviderConnectionService {
|
||||||
env
|
env
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the original runtime-missing snapshot so callers still report the concrete issue.
|
// Keep the original blocked snapshot so callers still report the concrete issue.
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshot;
|
return snapshot;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
|
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
|
||||||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
import {
|
||||||
|
CLI_PROVIDER_STATUS_DEFERRED_MESSAGE,
|
||||||
|
CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE,
|
||||||
|
} from '@shared/types/cliInstaller';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
|
|
@ -195,6 +198,28 @@ function isOpenCodeRuntimeMissingSnapshot(provider: CliProviderStatus | undefine
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isProviderStatusUnavailableSnapshot(provider: CliProviderStatus | undefined): boolean {
|
||||||
|
return (
|
||||||
|
provider?.verificationState === 'error' &&
|
||||||
|
provider.statusMessage === CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldKeepConnectedProviderDuringStatusUnavailable(
|
||||||
|
currentProvider: CliProviderStatus | undefined,
|
||||||
|
incomingProvider: CliProviderStatus | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!currentProvider || !incomingProvider) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
isHydratedMultimodelProviderStatus(currentProvider) &&
|
||||||
|
currentProvider.authenticated &&
|
||||||
|
isProviderStatusUnavailableSnapshot(incomingProvider)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function shouldPreserveCurrentProviderStatus(
|
function shouldPreserveCurrentProviderStatus(
|
||||||
currentProvider: CliProviderStatus | undefined,
|
currentProvider: CliProviderStatus | undefined,
|
||||||
incomingProvider: CliProviderStatus
|
incomingProvider: CliProviderStatus
|
||||||
|
|
@ -203,6 +228,10 @@ function shouldPreserveCurrentProviderStatus(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldKeepConnectedProviderDuringStatusUnavailable(currentProvider, incomingProvider)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasOpenCodeModels(currentProvider) && isOpenCodeRuntimeMissingSnapshot(incomingProvider)) {
|
if (hasOpenCodeModels(currentProvider) && isOpenCodeRuntimeMissingSnapshot(incomingProvider)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -242,6 +271,10 @@ function mergePreservedHydratedProviderStatus(
|
||||||
incomingProvider: CliProviderStatus,
|
incomingProvider: CliProviderStatus,
|
||||||
currentProvider: CliProviderStatus
|
currentProvider: CliProviderStatus
|
||||||
): CliProviderStatus {
|
): CliProviderStatus {
|
||||||
|
if (shouldKeepConnectedProviderDuringStatusUnavailable(currentProvider, incomingProvider)) {
|
||||||
|
return currentProvider;
|
||||||
|
}
|
||||||
|
|
||||||
if (isDeferredMultimodelProviderStatus(incomingProvider)) {
|
if (isDeferredMultimodelProviderStatus(incomingProvider)) {
|
||||||
return currentProvider;
|
return currentProvider;
|
||||||
}
|
}
|
||||||
|
|
@ -638,17 +671,23 @@ function createProviderStatusErrorSnapshot(params: {
|
||||||
backend: null,
|
backend: null,
|
||||||
} satisfies CliProviderStatus);
|
} satisfies CliProviderStatus);
|
||||||
|
|
||||||
return {
|
const errorProvider: CliProviderStatus = {
|
||||||
...currentProvider,
|
...currentProvider,
|
||||||
providerId: params.providerId,
|
providerId: params.providerId,
|
||||||
displayName: currentProvider.displayName ?? getProviderDisplayName(params.providerId),
|
displayName: currentProvider.displayName ?? getProviderDisplayName(params.providerId),
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
verificationState: 'error',
|
verificationState: 'error' as const,
|
||||||
modelCatalogRefreshState: 'error',
|
modelCatalogRefreshState: 'error' as const,
|
||||||
statusMessage: params.message,
|
statusMessage: params.message,
|
||||||
detailMessage: null,
|
detailMessage: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (shouldKeepConnectedProviderDuringStatusUnavailable(currentProvider, errorProvider)) {
|
||||||
|
return currentProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export type CliFlavor = 'claude' | 'agent_teams_orchestrator';
|
||||||
export type CliProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
|
export type CliProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
|
||||||
export type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key';
|
export type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key';
|
||||||
export const CLI_PROVIDER_STATUS_DEFERRED_MESSAGE = 'Provider status will refresh when needed.';
|
export const CLI_PROVIDER_STATUS_DEFERRED_MESSAGE = 'Provider status will refresh when needed.';
|
||||||
|
export const CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE = 'Provider status unavailable';
|
||||||
|
|
||||||
export interface CliProviderConnectionInfo {
|
export interface CliProviderConnectionInfo {
|
||||||
supportsOAuth: boolean;
|
supportsOAuth: boolean;
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ describe('useCodexAccountSnapshot', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||||
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false');
|
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('true');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(20_000);
|
vi.advanceTimersByTime(20_000);
|
||||||
|
|
|
||||||
|
|
@ -1426,6 +1426,82 @@ describe('ProviderConnectionService', () => {
|
||||||
expect(refreshSnapshot).toHaveBeenCalledWith({ forceRefreshToken: true });
|
expect(refreshSnapshot).toHaveBeenCalledWith({ forceRefreshToken: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes a stale blocked Codex snapshot before reporting an auth issue', async () => {
|
||||||
|
const { ProviderConnectionService } =
|
||||||
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
|
||||||
|
const staleMissingAuthSnapshot = createCodexSnapshot({
|
||||||
|
effectiveAuthMode: null,
|
||||||
|
launchAllowed: false,
|
||||||
|
launchIssueMessage:
|
||||||
|
'Codex native requires OPENAI_API_KEY or CODEX_API_KEY, or a connected ChatGPT account.',
|
||||||
|
launchReadinessState: 'missing_auth',
|
||||||
|
managedAccount: null,
|
||||||
|
requiresOpenaiAuth: true,
|
||||||
|
localAccountArtifactsPresent: true,
|
||||||
|
localActiveChatgptAccountPresent: true,
|
||||||
|
});
|
||||||
|
const refreshSnapshot = vi.fn().mockResolvedValue(createCodexSnapshot());
|
||||||
|
const service = new ProviderConnectionService(
|
||||||
|
{
|
||||||
|
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getConfig: () => createConfig('auto'),
|
||||||
|
} as never
|
||||||
|
);
|
||||||
|
service.setCodexAccountFeature({
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue(staleMissingAuthSnapshot),
|
||||||
|
refreshSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issue = await service.getConfiguredConnectionIssue({}, 'codex');
|
||||||
|
|
||||||
|
expect(issue).toBeNull();
|
||||||
|
expect(refreshSnapshot).toHaveBeenCalledWith({ forceRefreshToken: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not refresh a stale Codex auth snapshot when launch env already provides an API key', async () => {
|
||||||
|
const { ProviderConnectionService } =
|
||||||
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
|
||||||
|
const refreshSnapshot = vi.fn().mockResolvedValue(createCodexSnapshot());
|
||||||
|
const service = new ProviderConnectionService(
|
||||||
|
{
|
||||||
|
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getConfig: () => createConfig('auto'),
|
||||||
|
} as never
|
||||||
|
);
|
||||||
|
service.setCodexAccountFeature({
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue(
|
||||||
|
createCodexSnapshot({
|
||||||
|
effectiveAuthMode: null,
|
||||||
|
launchAllowed: false,
|
||||||
|
launchIssueMessage:
|
||||||
|
'Codex native requires OPENAI_API_KEY or CODEX_API_KEY, or a connected ChatGPT account.',
|
||||||
|
launchReadinessState: 'missing_auth',
|
||||||
|
managedAccount: null,
|
||||||
|
requiresOpenaiAuth: true,
|
||||||
|
localAccountArtifactsPresent: true,
|
||||||
|
localActiveChatgptAccountPresent: true,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
refreshSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issue = await service.getConfiguredConnectionIssue(
|
||||||
|
{
|
||||||
|
CODEX_API_KEY: 'native-key',
|
||||||
|
},
|
||||||
|
'codex'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(issue).toBeNull();
|
||||||
|
expect(refreshSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('refreshes a runtime-missing Codex snapshot before mutating strict launch env', async () => {
|
it('refreshes a runtime-missing Codex snapshot before mutating strict launch env', async () => {
|
||||||
const { ProviderConnectionService } =
|
const { ProviderConnectionService } =
|
||||||
await import('@main/services/runtime/ProviderConnectionService');
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ import {
|
||||||
} from '@renderer/store/slices/cliInstallerSlice';
|
} from '@renderer/store/slices/cliInstallerSlice';
|
||||||
import {
|
import {
|
||||||
CLI_PROVIDER_STATUS_DEFERRED_MESSAGE,
|
CLI_PROVIDER_STATUS_DEFERRED_MESSAGE,
|
||||||
|
CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE,
|
||||||
type CliProviderId,
|
type CliProviderId,
|
||||||
} from '@shared/types/cliInstaller';
|
} from '@shared/types/cliInstaller';
|
||||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
@ -614,6 +615,76 @@ describe('cliInstallerSlice', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not let a scoped runtime-status error overwrite a connected provider', () => {
|
||||||
|
const current = createMultimodelStatus([
|
||||||
|
createMultimodelProvider({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
displayName: 'Anthropic',
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'oauth_token',
|
||||||
|
statusMessage: 'Connected via Anthropic subscription',
|
||||||
|
models: ['claude-sonnet-4-5'],
|
||||||
|
backend: { kind: 'anthropic', label: 'Anthropic' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const incoming = createMultimodelStatus([
|
||||||
|
createMultimodelProvider({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
displayName: 'Anthropic',
|
||||||
|
supported: false,
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
verificationState: 'error',
|
||||||
|
statusMessage: CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE,
|
||||||
|
models: [],
|
||||||
|
backend: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
|
||||||
|
|
||||||
|
expect(merged.providers[0]).toBe(current.providers[0]);
|
||||||
|
expect(merged.authLoggedIn).toBe(true);
|
||||||
|
expect(merged.authMethod).toBe('oauth_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a real disconnected provider snapshot to replace a connected provider', () => {
|
||||||
|
const current = createMultimodelStatus([
|
||||||
|
createMultimodelProvider({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
displayName: 'Anthropic',
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'oauth_token',
|
||||||
|
statusMessage: 'Connected via Anthropic subscription',
|
||||||
|
models: ['claude-sonnet-4-5'],
|
||||||
|
backend: { kind: 'anthropic', label: 'Anthropic' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const incoming = createMultimodelStatus([
|
||||||
|
createMultimodelProvider({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
displayName: 'Anthropic',
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
verificationState: 'verified',
|
||||||
|
statusMessage: null,
|
||||||
|
models: [],
|
||||||
|
backend: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
|
||||||
|
|
||||||
|
expect(merged.providers[0]).toMatchObject({
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
verificationState: 'verified',
|
||||||
|
statusMessage: null,
|
||||||
|
});
|
||||||
|
expect(merged.authLoggedIn).toBe(false);
|
||||||
|
expect(merged.authMethod).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('drops hydrated hidden Gemini when a fresh frontend status omits it', () => {
|
it('drops hydrated hidden Gemini when a fresh frontend status omits it', () => {
|
||||||
const current = createMultimodelStatus([
|
const current = createMultimodelStatus([
|
||||||
createMultimodelProvider({
|
createMultimodelProvider({
|
||||||
|
|
@ -1470,6 +1541,41 @@ describe('cliInstallerSlice', () => {
|
||||||
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
|
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps an already connected provider visible when a status refresh errors', async () => {
|
||||||
|
useStore.setState({
|
||||||
|
cliStatus: createMultimodelStatus([
|
||||||
|
createMultimodelProvider({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
displayName: 'Anthropic',
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'oauth_token',
|
||||||
|
statusMessage: 'Connected via Anthropic subscription',
|
||||||
|
models: ['claude-sonnet-4-5'],
|
||||||
|
backend: { kind: 'anthropic', label: 'Anthropic' },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
vi.mocked(api.cliInstaller.getProviderStatus).mockRejectedValue(
|
||||||
|
new Error(CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE)
|
||||||
|
);
|
||||||
|
|
||||||
|
await useStore.getState().fetchCliProviderStatus('anthropic');
|
||||||
|
|
||||||
|
const provider = useStore
|
||||||
|
.getState()
|
||||||
|
.cliStatus?.providers.find((candidate) => candidate.providerId === 'anthropic');
|
||||||
|
expect(useStore.getState().cliStatusError).toBe(CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE);
|
||||||
|
expect(provider).toMatchObject({
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'oauth_token',
|
||||||
|
verificationState: 'verified',
|
||||||
|
statusMessage: 'Connected via Anthropic subscription',
|
||||||
|
models: ['claude-sonnet-4-5'],
|
||||||
|
});
|
||||||
|
expect(useStore.getState().cliStatus?.authLoggedIn).toBe(true);
|
||||||
|
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores hidden Gemini provider failures without keeping global auth checking active', async () => {
|
it('ignores hidden Gemini provider failures without keeping global auth checking active', async () => {
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
cliStatus: createMultimodelStatus([
|
cliStatus: createMultimodelStatus([
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue