diff --git a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts index 368d590f..68d9b8ff 100644 --- a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts +++ b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts @@ -332,17 +332,27 @@ export function useCodexAccountSnapshot(options: { [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( () => ({ snapshot, - loading, - rateLimitsLoading, + loading: effectiveLoading, + rateLimitsLoading: effectiveRateLimitsLoading, error, refresh, startChatgptLogin: (mode) => runAction(() => api.startCodexChatgptLogin({ mode })), cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()), logout: () => runAction(() => api.logoutCodexAccount()), }), - [error, loading, rateLimitsLoading, refresh, runAction, snapshot] + [effectiveLoading, effectiveRateLimitsLoading, error, refresh, runAction, snapshot] ); } diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 2e4b3cca..79c2131f 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -1,5 +1,6 @@ import { execCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv'; +import { CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE } from '@shared/types/cliInstaller'; import { createLogger } from '@shared/utils/logger'; import { createDefaultCliExtensionCapabilities, @@ -413,7 +414,7 @@ function createRuntimeStatusErrorProviderStatus( return { ...createDefaultProviderStatus(providerId), verificationState: 'error', - statusMessage: 'Provider status unavailable', + statusMessage: CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE, detailMessage, }; } diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 1a89b335..8cf34f46 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -41,6 +41,11 @@ interface StoredApiKeyAccessOptions { allowedStoredApiKeyEnvVarNames?: readonly string[]; } +interface CodexLaunchSnapshotRefreshOptions { + refreshRuntimeMissing?: boolean; + refreshBlockedLaunch?: boolean; +} + const PROVIDER_CAPABILITIES: Record< CliProviderId, Pick @@ -605,6 +610,7 @@ export class ProviderConnectionService { const snapshot = await this.getCodexLaunchSnapshot(env, { refreshRuntimeMissing: true, + refreshBlockedLaunch: true, }); applyCodexRuntimeContextEnv(env, snapshot); const readiness = evaluateCodexLaunchReadiness({ @@ -687,6 +693,7 @@ export class ProviderConnectionService { const snapshot = await this.getCodexLaunchSnapshot(env, { refreshRuntimeMissing: true, + refreshBlockedLaunch: true, }); applyCodexRuntimeContextEnv(env, snapshot); const readiness = evaluateCodexLaunchReadiness({ @@ -771,6 +778,7 @@ export class ProviderConnectionService { const snapshot = await this.getCodexLaunchSnapshot(env, { refreshRuntimeMissing: true, + refreshBlockedLaunch: true, }); const runtimeEnv = { ...env }; applyCodexRuntimeContextEnv(runtimeEnv, snapshot); @@ -882,6 +890,7 @@ export class ProviderConnectionService { const snapshot = await this.getCodexLaunchSnapshot(env, { refreshRuntimeMissing: true, + refreshBlockedLaunch: true, }); const readiness = evaluateCodexLaunchReadiness({ preferredAuthMode: snapshot.preferredAuthMode, @@ -1267,10 +1276,21 @@ export class ProviderConnectionService { private async getCodexLaunchSnapshot( env: NodeJS.ProcessEnv, - options?: { refreshRuntimeMissing?: boolean } + options?: CodexLaunchSnapshotRefreshOptions ): Promise { 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; } @@ -1280,7 +1300,7 @@ export class ProviderConnectionService { env ); } 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; diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index e4410c3c..0333ea62 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -4,7 +4,10 @@ import { api } from '@renderer/api'; 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 { 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( currentProvider: CliProviderStatus | undefined, incomingProvider: CliProviderStatus @@ -203,6 +228,10 @@ function shouldPreserveCurrentProviderStatus( return false; } + if (shouldKeepConnectedProviderDuringStatusUnavailable(currentProvider, incomingProvider)) { + return true; + } + if (hasOpenCodeModels(currentProvider) && isOpenCodeRuntimeMissingSnapshot(incomingProvider)) { return true; } @@ -242,6 +271,10 @@ function mergePreservedHydratedProviderStatus( incomingProvider: CliProviderStatus, currentProvider: CliProviderStatus ): CliProviderStatus { + if (shouldKeepConnectedProviderDuringStatusUnavailable(currentProvider, incomingProvider)) { + return currentProvider; + } + if (isDeferredMultimodelProviderStatus(incomingProvider)) { return currentProvider; } @@ -638,17 +671,23 @@ function createProviderStatusErrorSnapshot(params: { backend: null, } satisfies CliProviderStatus); - return { + const errorProvider: CliProviderStatus = { ...currentProvider, providerId: params.providerId, displayName: currentProvider.displayName ?? getProviderDisplayName(params.providerId), authenticated: false, authMethod: null, - verificationState: 'error', - modelCatalogRefreshState: 'error', + verificationState: 'error' as const, + modelCatalogRefreshState: 'error' as const, statusMessage: params.message, detailMessage: null, }; + + if (shouldKeepConnectedProviderDuringStatusUnavailable(currentProvider, errorProvider)) { + return currentProvider; + } + + return errorProvider; } // ============================================================================= diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 5bc39708..84dd53b7 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -36,6 +36,7 @@ export type CliFlavor = 'claude' | 'agent_teams_orchestrator'; export type CliProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; 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_UNAVAILABLE_MESSAGE = 'Provider status unavailable'; export interface CliProviderConnectionInfo { supportsOAuth: boolean; diff --git a/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts b/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts index 73d4ebf9..39864530 100644 --- a/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts +++ b/test/features/codex-account/renderer/useCodexAccountSnapshot.test.ts @@ -233,7 +233,7 @@ describe('useCodexAccountSnapshot', () => { }); expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled(); - expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false'); + expect(host.firstElementChild?.getAttribute('data-loading')).toBe('true'); await act(async () => { vi.advanceTimersByTime(20_000); diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 2edded8e..acbf467c 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -1426,6 +1426,82 @@ describe('ProviderConnectionService', () => { 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 () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index f506b31a..e2c6c970 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -65,6 +65,7 @@ import { } from '@renderer/store/slices/cliInstallerSlice'; import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE, + CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE, type CliProviderId, } from '@shared/types/cliInstaller'; 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', () => { const current = createMultimodelStatus([ createMultimodelProvider({ @@ -1470,6 +1541,41 @@ describe('cliInstallerSlice', () => { 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 () => { useStore.setState({ cliStatus: createMultimodelStatus([