From 3265920ec685a1787699d9af27c59742ff1ab1e4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 1 Jun 2026 23:42:07 +0300 Subject: [PATCH] fix(codex): keep catalog hydration in loading state --- .../common/ProviderActivityStatusStrip.tsx | 22 ++--- .../components/dashboard/CliStatusBanner.tsx | 17 +--- .../runtime/providerConnectionUi.ts | 54 ++++++++++ .../settings/sections/CliStatusSection.tsx | 17 +--- .../store/slices/cliInstallerSlice.ts | 68 +++++++++++++ src/renderer/utils/teamModelAvailability.ts | 15 +-- .../cli/CliStatusVisibility.test.ts | 77 +++++++++++++++ .../runtime/providerConnectionUi.test.ts | 45 ++++++++- test/renderer/store/cliInstallerSlice.test.ts | 98 +++++++++++++++++++ 9 files changed, 362 insertions(+), 51 deletions(-) diff --git a/src/renderer/components/common/ProviderActivityStatusStrip.tsx b/src/renderer/components/common/ProviderActivityStatusStrip.tsx index 8fff1c39..8bbadc05 100644 --- a/src/renderer/components/common/ProviderActivityStatusStrip.tsx +++ b/src/renderer/components/common/ProviderActivityStatusStrip.tsx @@ -2,7 +2,10 @@ import { useEffect, useMemo, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { isElectronMode } from '@renderer/api'; -import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi'; +import { + formatProviderStatusText, + shouldMaskCodexNegativeBootstrapState, +} from '@renderer/components/runtime/providerConnectionUi'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; import { isTeamProviderModelVerificationPending } from '@renderer/utils/teamModelAvailability'; @@ -37,19 +40,6 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo return providerLoading || isTeamProviderModelVerificationPending(provider.providerId, provider); } -function shouldMaskCodexNegativeBootstrapState( - sourceProvider: CliProviderStatus | null, - mergedProvider: CliProviderStatus -): boolean { - return ( - sourceProvider?.providerId === 'codex' && - sourceProvider.statusMessage === 'Checking...' && - mergedProvider.providerId === 'codex' && - mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' && - mergedProvider.connection.codex.login.status === 'idle' - ); -} - function getActivityToneStyles(tone: 'loading' | 'checked' | 'error'): { borderColor: string; backgroundColor: string; @@ -139,7 +129,9 @@ function useProviderActivityDisplay({ const loading = isProviderCardLoading(provider, cliProviderStatusLoading[provider.providerId] === true) || (provider.providerId === 'codex' && codexSnapshotPending) || - shouldMaskCodexNegativeBootstrapState(sourceProvider, provider); + shouldMaskCodexNegativeBootstrapState(sourceProvider, provider, { + providerLoading: cliProviderStatusLoading[provider.providerId] === true, + }); return { provider, diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 38fc6aff..4669182b 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -38,6 +38,7 @@ import { isConnectionManagedRuntimeProvider, isOpenCodeCatalogHydrating, isProviderInventoryOnlyFallback, + shouldMaskCodexNegativeBootstrapState, shouldShowProviderConnectAction, shouldShowProviderStatusSkeleton, } from '@renderer/components/runtime/providerConnectionUi'; @@ -478,19 +479,6 @@ function isCodexSnapshotPending( return provider.providerId === 'codex' && codexSnapshotPending; } -function shouldMaskCodexNegativeBootstrapState( - sourceProvider: CliProviderStatus | null, - mergedProvider: CliProviderStatus -): boolean { - return ( - sourceProvider?.providerId === 'codex' && - sourceProvider.statusMessage === 'Checking...' && - mergedProvider.providerId === 'codex' && - mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' && - mergedProvider.connection.codex.login.status === 'idle' - ); -} - function getProviderStatusColor(statusText: string, authenticated: boolean): string { if (statusText === 'Checking...') { return 'var(--color-text-secondary)'; @@ -991,7 +979,8 @@ const InstalledBanner = ({ const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null; const maskNegativeBootstrapState = shouldMaskCodexNegativeBootstrapState( sourceProvider, - provider + provider, + { providerLoading } ); const showSkeleton = shouldShowProviderStatusSkeleton(provider, providerLoading) || diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts index f8ade973..dafed67f 100644 --- a/src/renderer/components/runtime/providerConnectionUi.ts +++ b/src/renderer/components/runtime/providerConnectionUi.ts @@ -240,6 +240,60 @@ export function isOpenCodeCatalogHydrating( ); } +export function shouldMaskCodexNegativeBootstrapState( + sourceProvider: + | Pick< + CliProviderStatus, + | 'providerId' + | 'authenticated' + | 'supported' + | 'verificationState' + | 'statusMessage' + | 'models' + | 'backend' + | 'modelCatalogRefreshState' + > + | null + | undefined, + mergedProvider: Pick, + options: { providerLoading?: boolean } = {} +): boolean { + if ( + mergedProvider.providerId !== 'codex' || + mergedProvider.connection?.codex?.launchReadinessState !== 'missing_auth' || + mergedProvider.connection.codex.login.status !== 'idle' + ) { + return false; + } + + if (options.providerLoading || mergedProvider.modelCatalogRefreshState === 'loading') { + return true; + } + + if (sourceProvider?.providerId !== 'codex') { + return false; + } + + if (sourceProvider.modelCatalogRefreshState === 'loading') { + return true; + } + + if ( + sourceProvider.statusMessage === 'Checking...' || + sourceProvider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE + ) { + return true; + } + + return ( + sourceProvider.supported === false && + sourceProvider.authenticated === false && + sourceProvider.verificationState === 'unknown' && + sourceProvider.models.length === 0 && + sourceProvider.backend == null + ); +} + function hasKnownProviderStatus( provider: Pick< CliProviderStatus, diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index 3e16b9fb..feb0ba89 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -25,6 +25,7 @@ import { getProviderDisconnectAction, isConnectionManagedRuntimeProvider, isOpenCodeCatalogHydrating, + shouldMaskCodexNegativeBootstrapState, shouldShowProviderConnectAction, shouldShowProviderStatusSkeleton, } from '@renderer/components/runtime/providerConnectionUi'; @@ -93,19 +94,6 @@ function isCodexSnapshotPending( return provider.providerId === 'codex' && codexSnapshotPending; } -function shouldMaskCodexNegativeBootstrapState( - sourceProvider: CliProviderStatus | null, - mergedProvider: CliProviderStatus -): boolean { - return ( - sourceProvider?.providerId === 'codex' && - sourceProvider.statusMessage === 'Checking...' && - mergedProvider.providerId === 'codex' && - mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' && - mergedProvider.connection.codex.login.status === 'idle' - ); -} - function getProviderStatusColor(statusText: string, authenticated: boolean): string { if (statusText === 'Checking...') { return 'var(--color-text-secondary)'; @@ -498,7 +486,8 @@ export const CliStatusSection = (): React.JSX.Element | null => { loadingCliProviderMap.get(provider.providerId) ?? null; const maskNegativeBootstrapState = shouldMaskCodexNegativeBootstrapState( sourceProvider, - provider + provider, + { providerLoading } ); const effectiveShowSkeleton = showSkeleton || maskNegativeBootstrapState; const statusText = effectiveShowSkeleton diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 0333ea62..3345b34e 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -29,6 +29,8 @@ const OPENCODE_PROVIDER_INSTALL_REFRESH_ATTEMPTS = 3; const OPENCODE_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700; const CODEX_PROVIDER_INSTALL_REFRESH_ATTEMPTS = 3; const CODEX_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700; +const CODEX_CATALOG_LOADING_REFRESH_ATTEMPTS = 3; +const CODEX_CATALOG_LOADING_REFRESH_RETRY_DELAY_MS = 2_000; export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = isGeminiUiFrozen() ? ['anthropic', 'codex', 'opencode'] @@ -168,6 +170,15 @@ function getProviderStatus( return status?.providers.find((provider) => provider.providerId === providerId); } +function isCodexCatalogLoadingSnapshot(provider: CliProviderStatus | undefined): boolean { + return ( + provider?.providerId === 'codex' && + provider.modelCatalog == null && + provider.modelCatalogRefreshState === 'loading' && + provider.runtimeCapabilities?.modelCatalog?.dynamic === true + ); +} + function hasOpenCodeModels(provider: CliProviderStatus | undefined): boolean { return ( provider?.providerId === 'opencode' && @@ -747,9 +758,60 @@ let cliStatusInFlight: Promise | null = null; const cliProviderStatusInFlight = new Map>(); let cliStatusEpoch = 0; const cliProviderStatusSeq = new Map(); +const codexCatalogLoadingRefreshAttempts = new Map(); +const codexCatalogLoadingRefreshTimers = new Map>(); let openCodeRuntimeStatusInFlight: Promise | null = null; let codexRuntimeStatusInFlight: Promise | null = null; +function clearCodexCatalogLoadingRefresh(providerId: CliProviderId): void { + const timer = codexCatalogLoadingRefreshTimers.get(providerId); + if (timer) { + clearTimeout(timer); + codexCatalogLoadingRefreshTimers.delete(providerId); + } + codexCatalogLoadingRefreshAttempts.delete(providerId); +} + +function scheduleCodexCatalogLoadingRefresh( + get: () => Pick, + providerId: CliProviderId +): void { + const provider = getProviderStatus(get().cliStatus, providerId); + if (!isCodexCatalogLoadingSnapshot(provider)) { + clearCodexCatalogLoadingRefresh(providerId); + return; + } + + if (codexCatalogLoadingRefreshTimers.has(providerId)) { + return; + } + + const attempts = codexCatalogLoadingRefreshAttempts.get(providerId) ?? 0; + if (attempts >= CODEX_CATALOG_LOADING_REFRESH_ATTEMPTS) { + return; + } + + codexCatalogLoadingRefreshAttempts.set(providerId, attempts + 1); + const timer = setTimeout(() => { + codexCatalogLoadingRefreshTimers.delete(providerId); + const latestProvider = getProviderStatus(get().cliStatus, providerId); + if (!isCodexCatalogLoadingSnapshot(latestProvider)) { + codexCatalogLoadingRefreshAttempts.delete(providerId); + return; + } + + void get().fetchCliProviderStatus(providerId, { silent: true }); + }, CODEX_CATALOG_LOADING_REFRESH_RETRY_DELAY_MS); + (timer as ReturnType & { unref?: () => void }).unref?.(); + codexCatalogLoadingRefreshTimers.set(providerId, timer); +} + +function scheduleCodexCatalogLoadingRefreshes( + get: () => Pick +): void { + scheduleCodexCatalogLoadingRefresh(get, 'codex'); +} + // ============================================================================= // Slice Creator // ============================================================================= @@ -877,6 +939,8 @@ export const createCliInstallerSlice: StateCreator { + clearCodexCatalogLoadingRefresh('codex'); await api.cliInstaller?.invalidateStatus(); }, diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 3fedefa5..64a32bfd 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -166,7 +166,13 @@ export function isTeamProviderModelVerificationPending( return true; } - if (providerStatus.verificationState === 'error') { + const verificationState = providerStatus.verificationState as + | 'verified' + | 'unknown' + | 'offline' + | 'error' + | undefined; + if (verificationState === 'error' || providerStatus.modelCatalogRefreshState === 'error') { return false; } @@ -174,14 +180,11 @@ export function isTeamProviderModelVerificationPending( const statusMessagePending = statusMessage === 'checking...' || statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE.toLowerCase(); - if (providerStatus.verificationState !== 'error' && statusMessagePending) { + if (statusMessagePending) { return true; } - if ( - providerStatus.verificationState !== 'error' && - providerStatus.modelCatalogRefreshState === 'loading' - ) { + if (providerStatus.modelCatalogRefreshState === 'loading') { return true; } diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 57a52ff2..faa60e6e 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -3045,6 +3045,83 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('keeps Codex on checking while its model catalog is loading and the live snapshot is only a negative auth result', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + codexAccountHookState.snapshot = { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + launchReadinessState: 'missing_auth', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from OPENAI_API_KEY', + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: new Date().toISOString(), + }; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: false, + providers: [ + createCodexNativeRolloutProvider({ + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + models: [], + modelCatalog: null, + modelCatalogRefreshState: 'loading', + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + }), + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Checking...'); + expect(host.textContent).not.toContain( + 'Reconnect ChatGPT to refresh the current Codex subscription session.' + ); + expect(host.textContent).not.toContain( + 'Models unavailable for this runtime build' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('explains missing Codex limits when ChatGPT mode is selected but Codex is not logged in', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; diff --git a/test/renderer/components/runtime/providerConnectionUi.test.ts b/test/renderer/components/runtime/providerConnectionUi.test.ts index 3a321fec..7cdba706 100644 --- a/test/renderer/components/runtime/providerConnectionUi.test.ts +++ b/test/renderer/components/runtime/providerConnectionUi.test.ts @@ -6,6 +6,7 @@ import { isConnectionManagedRuntimeProvider, isOpenCodeCatalogHydrating, isProviderInventoryOnlyFallback, + shouldMaskCodexNegativeBootstrapState, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; @@ -52,7 +53,8 @@ function createAnthropicProvider( } function createCodexProvider( - overrides?: Partial & { + overrides?: Partial & + Partial & { authenticated?: boolean; authMethod?: string | null; selectedBackendId?: string | null; @@ -71,7 +73,10 @@ function createCodexProvider( authMethod: overrides?.authMethod ?? 'api_key', verificationState: 'verified', statusMessage: overrides?.statusMessage ?? 'Codex native ready', - models: ['gpt-5-codex'], + models: overrides?.models ?? ['gpt-5-codex'], + modelCatalog: overrides?.modelCatalog, + modelCatalogRefreshState: overrides?.modelCatalogRefreshState, + runtimeCapabilities: overrides?.runtimeCapabilities, canLoginFromUi: overrides?.canLoginFromUi ?? false, capabilities: { teamLaunch: true, @@ -604,6 +609,42 @@ describe('providerConnectionUi', () => { ); }); + it('masks stale Codex reconnect state while provider catalog hydration is still loading', () => { + const provider = createCodexProvider({ + authenticated: false, + authMethod: null, + models: [], + modelCatalogRefreshState: 'loading', + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + launchReadinessState: 'missing_auth', + }, + }); + + expect(shouldMaskCodexNegativeBootstrapState(provider, provider)).toBe(true); + }); + it('surfaces native auth-required state from the selected backend option', () => { const provider = createCodexProvider({ authenticated: false, diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index e2c6c970..4bb2f469 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -1773,6 +1773,104 @@ describe('cliInstallerSlice', () => { expect(provider?.modelCatalog?.defaultModelId).toBe('gpt-5.4'); }); + it('retries Codex provider refresh when dynamic catalog hydration remains loading', async () => { + vi.useFakeTimers(); + + const loadingProvider = createMultimodelProvider({ + providerId: 'codex', + displayName: 'Codex', + authenticated: false, + authMethod: null, + statusMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.', + models: [], + modelCatalog: null, + modelCatalogRefreshState: 'loading', + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + backend: { kind: 'codex-native', label: 'Codex native' }, + }); + const readyProvider = createMultimodelProvider({ + providerId: 'codex', + displayName: 'Codex', + authenticated: true, + authMethod: 'chatgpt', + statusMessage: 'ChatGPT account ready', + models: ['gpt-5.4'], + modelCatalogRefreshState: 'ready', + modelCatalog: { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-17T00:00:00.000Z', + staleAt: '2026-05-17T00:10:00.000Z', + defaultModelId: 'gpt-5.4', + defaultLaunchModel: 'gpt-5.4', + models: [ + { + id: 'gpt-5.4', + launchModel: 'gpt-5.4', + displayName: 'GPT-5.4', + hidden: false, + supportedReasoningEfforts: ['medium'], + defaultReasoningEffort: 'medium', + inputModalities: ['text'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'skipped', + appServerState: 'healthy', + }, + }, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + backend: { kind: 'codex-native', label: 'Codex native' }, + }); + + useStore.setState({ + cliStatus: createMultimodelStatus([loadingProvider]), + }); + vi.mocked(api.cliInstaller.getProviderStatus) + .mockResolvedValueOnce(loadingProvider) + .mockResolvedValueOnce(readyProvider); + + await useStore.getState().fetchCliProviderStatus('codex'); + + expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1); + expect( + useStore + .getState() + .cliStatus?.providers.find((provider) => provider.providerId === 'codex') + ?.modelCatalogRefreshState + ).toBe('loading'); + + await vi.runOnlyPendingTimersAsync(); + + expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(2); + expect( + useStore + .getState() + .cliStatus?.providers.find((provider) => provider.providerId === 'codex') + ).toMatchObject({ + authenticated: true, + statusMessage: 'ChatGPT account ready', + models: ['gpt-5.4'], + modelCatalogRefreshState: 'ready', + }); + }); + it('keeps cached OpenCode model list when summary refresh only reports big-pickle', async () => { const currentProvider = createMultimodelProvider({ providerId: 'opencode',