fix(runtime): preserve provider status during refresh failures

This commit is contained in:
777genius 2026-05-30 18:44:31 +03:00
parent a06423a574
commit a7606032fc
8 changed files with 265 additions and 12 deletions

View file

@ -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]
); );
} }

View file

@ -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,
}; };
} }

View file

@ -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;

View file

@ -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;
} }
// ============================================================================= // =============================================================================

View file

@ -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;

View file

@ -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);

View file

@ -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');

View file

@ -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([