diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 54d401da..fd967b39 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -15,6 +15,7 @@ import { CLI_INSTALLER_VERIFY_PROVIDER_MODELS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload } from '@preload/constants/ipcChannels'; +import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; @@ -44,7 +45,6 @@ const cachedStatus = new Map< let statusCacheGeneration = 0; const STATUS_CACHE_TTL_MS = 5_000; const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set(['anthropic', 'codex', 'opencode']); -const DEFERRED_PROVIDER_STATUS_MESSAGE = 'Provider status will refresh when needed.'; function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean { return FRONTEND_MULTIMODEL_PROVIDER_IDS.has(providerId); @@ -81,7 +81,7 @@ function isDeferredProviderStatusSnapshot(status: CliInstallationStatus): boolea provider.supported === false && provider.authenticated === false && provider.verificationState === 'unknown' && - provider.statusMessage === DEFERRED_PROVIDER_STATUS_MESSAGE + provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE ) ); } diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index a8bf195d..1bbf7d43 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -28,6 +28,7 @@ import { getShellPreferredHome, resolveInteractiveShellEnvBestEffort, } from '@main/utils/shellEnv'; +import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; @@ -173,6 +174,7 @@ function parseClaudeAuthStatusStdout(stdout: string): { loggedIn?: boolean; auth /** NDJSON: strip C0 controls (except \\t \\n \\r) so logs stay valid text and tiny. */ function stripControlForDiag(s: string): string { + // eslint-disable-next-line no-control-regex -- Strip raw terminal C0 controls before diagnostic logging. return s.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '\uFFFD'); } @@ -1214,7 +1216,7 @@ export class CliInstallerService { verificationState: 'unknown', modelVerificationState: 'idle', modelCatalogRefreshState: 'idle', - statusMessage: 'Provider status will refresh when needed.', + statusMessage: CLI_PROVIDER_STATUS_DEFERRED_MESSAGE, detailMessage: null, models: [], modelAvailability: [], diff --git a/src/renderer/components/common/GlobalProviderStatusHeader.tsx b/src/renderer/components/common/GlobalProviderStatusHeader.tsx index e21bffdd..5f37cc24 100644 --- a/src/renderer/components/common/GlobalProviderStatusHeader.tsx +++ b/src/renderer/components/common/GlobalProviderStatusHeader.tsx @@ -10,6 +10,7 @@ import { formatProviderStatusText } from '@renderer/components/runtime/providerC import { useStore } from '@renderer/store'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; +import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller'; import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -27,7 +28,8 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo return ( providerLoading || (!provider.authenticated && - provider.statusMessage === 'Checking...' && + (provider.statusMessage === 'Checking...' || + provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) && provider.models.length === 0 && provider.backend == null) ); diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 936075a0..0c4c2f55 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -53,6 +53,7 @@ import { resolveProjectPathById } from '@renderer/utils/projectLookup'; import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName'; import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog'; +import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller'; import { AlertTriangle, CheckCircle, @@ -448,7 +449,8 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo return ( providerLoading || (!provider.authenticated && - provider.statusMessage === 'Checking...' && + (provider.statusMessage === 'Checking...' || + provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) && provider.models.length === 0 && provider.backend == null) ); @@ -503,6 +505,14 @@ function formatRuntimeLabel( : runtimeLabel; } +function isPendingMultimodelProviderStatus(provider: CliProviderStatus): boolean { + return ( + !provider.authenticated && + (provider.statusMessage === 'Checking...' || + provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) + ); +} + function formatRuntimeAuthSummary( cliStatus: NonNullable['cliStatus']>, visibleProviders: readonly CliProviderStatus[] @@ -512,11 +522,7 @@ function formatRuntimeAuthSummary( return null; } - if ( - visibleProviders.every( - (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated - ) - ) { + if (visibleProviders.every(isPendingMultimodelProviderStatus)) { return 'Checking providers...'; } const denominator = visibleProviders.length; @@ -543,9 +549,7 @@ function isCheckingMultimodelStatus( return ( isMultimodelRuntimeStatus(cliStatus) && visibleProviders.length > 0 && - visibleProviders.every( - (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated - ) + visibleProviders.every(isPendingMultimodelProviderStatus) ); } diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index ea9daa48..ef2c0337 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -42,6 +42,7 @@ import { resolveProjectPathById } from '@renderer/utils/projectLookup'; import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName'; import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog'; +import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller'; import { AlertTriangle, CheckCircle, @@ -87,7 +88,8 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo return ( providerLoading || (!provider.authenticated && - provider.statusMessage === 'Checking...' && + (provider.statusMessage === 'Checking...' || + provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) && provider.models.length === 0 && provider.backend == null) ); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 3d651676..34654f3d 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -21,6 +21,7 @@ import { create } from 'zustand'; import { createChangeReviewSlice } from './slices/changeReviewSlice'; import { createCliInstallerSlice, + getIncompleteMultimodelProviderIds, getModelOnlyFallbackProviderIds, mergeCliStatusPreservingHydratedProviders, reconcileMultimodelProviderLoading, @@ -96,6 +97,7 @@ const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000; const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000; const TASK_LOG_ACTIVITY_PULSE_MS = 3_500; const STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS = 30_000; +const STARTUP_PROVIDER_STATUS_IDLE_DELAY_MS = 30_000; const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet = new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']); export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout'; @@ -211,6 +213,7 @@ export function initializeNotificationListeners(): () => void { cleanupFns.push(installTeamRefreshFanoutDebugBridge()); let cliStatusTimer: ReturnType | null = null; let runtimeStatusTimer: ReturnType | null = null; + let deferredProviderStatusTimer: ReturnType | null = null; useStore.getState().subscribeProvisioningProgress(); cleanupFns.push(() => { useStore.getState().unsubscribeProvisioningProgress(); @@ -242,9 +245,21 @@ export function initializeNotificationListeners(): () => void { cliStatusTimer = setTimeout(() => { const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true; if (multimodelEnabled) { - void useStore - .getState() - .bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' }); + void (async () => { + await useStore + .getState() + .bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' }); + if (deferredProviderStatusTimer) { + clearTimeout(deferredProviderStatusTimer); + } + deferredProviderStatusTimer = setTimeout(() => { + const providerIds = getIncompleteMultimodelProviderIds(useStore.getState().cliStatus); + for (const providerId of providerIds) { + void useStore.getState().fetchCliProviderStatus(providerId, { silent: false }); + } + deferredProviderStatusTimer = null; + }, STARTUP_PROVIDER_STATUS_IDLE_DELAY_MS); + })(); } else { void useStore.getState().fetchCliStatus(); } @@ -272,6 +287,7 @@ export function initializeNotificationListeners(): () => void { cleanupFns.push(() => { if (cliStatusTimer) clearTimeout(cliStatusTimer); if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer); + if (deferredProviderStatusTimer) clearTimeout(deferredProviderStatusTimer); }); // TODO(task-change-presence): re-enable this only after the board uses a bounded // batch/priority presence pipeline. The old one-task-per-tick poll was accurate diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index b24b84a2..e4410c3c 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -4,6 +4,7 @@ import { api } from '@renderer/api'; import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze'; +import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller'; import { createLogger } from '@shared/utils/logger'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; @@ -109,11 +110,25 @@ function isOpenCodeSummaryOnlyCatalogStatus(provider: CliProviderStatus | undefi return provider.runtimeCapabilities?.modelCatalog?.dynamic === true; } +function isDeferredMultimodelProviderStatus(provider: CliProviderStatus | undefined): boolean { + return ( + provider?.supported === false && + provider.authenticated === false && + provider.authMethod === null && + provider.verificationState === 'unknown' && + provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE + ); +} + function isHydratedMultimodelProviderStatus(provider: CliProviderStatus | undefined): boolean { if (!provider) { return false; } + if (isDeferredMultimodelProviderStatus(provider)) { + return false; + } + if (isModelOnlyFallbackProviderStatus(provider)) { return false; } @@ -223,6 +238,17 @@ function mergeProviderCatalogCache( }; } +function mergePreservedHydratedProviderStatus( + incomingProvider: CliProviderStatus, + currentProvider: CliProviderStatus +): CliProviderStatus { + if (isDeferredMultimodelProviderStatus(incomingProvider)) { + return currentProvider; + } + + return mergeProviderCatalogCache(incomingProvider, currentProvider); +} + export function getIncompleteMultimodelProviderIds( status: CliInstallationStatus | null ): CliProviderId[] { @@ -442,7 +468,7 @@ export function mergeCliStatusPreservingHydratedProviders( return incomingProvider; } if (shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) { - return mergeProviderCatalogCache(incomingProvider, currentProvider); + return mergePreservedHydratedProviderStatus(incomingProvider, currentProvider); } // Preserve the current reference when content is identical so the // providers array stays reference-stable across steady-state IPC polls. @@ -724,12 +750,25 @@ export const createCliInstallerSlice: StateCreator [providerId, hydrateProviders]) + MULTIMODEL_PROVIDER_IDS.map((providerId) => [ + providerId, + shouldMarkIncompleteProvidersLoading && + initialStatus.installed && + !isHydratedMultimodelProviderStatus( + initialStatus.providers.find((provider) => provider.providerId === providerId) + ), + ]) ) as Partial>; set({ - cliStatus: createLoadingMultimodelCliStatus(), + cliStatus: initialStatus, cliStatusLoading: true, cliProviderStatusLoading: providerLoading, cliStatusError: null, @@ -760,18 +799,7 @@ export const createCliInstallerSlice: StateCreator [ - providerId, - hydrateProviders && - !isHydratedMultimodelProviderStatus( - metadata.providers.find((provider) => provider.providerId === providerId) - ), - ]) - ) as Partial>; - const pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter( - (providerId) => nextProviderLoading[providerId] === true - ); + let pendingProviderIds: CliProviderId[] = []; set((state) => { if (epoch !== cliStatusEpoch || !state.cliStatus) { @@ -779,6 +807,17 @@ export const createCliInstallerSlice: StateCreator [ + providerId, + !isHydratedMultimodelProviderStatus( + nextCliStatus.providers.find((provider) => provider.providerId === providerId) + ), + ]) + ) as Partial>; + pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter( + (providerId) => nextProviderLoading[providerId] === true + ); const nextAuthState = isMultimodelCliStatus(nextCliStatus) ? buildMultimodelCliAuthState({ status: nextCliStatus, diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index ca0eb178..391f51e7 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -35,6 +35,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 interface CliProviderConnectionInfo { supportsOAuth: boolean; diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 8f96e6e3..69b84ada 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -1,6 +1,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; +import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; @@ -313,6 +314,30 @@ function createCodexNativeRolloutProvider( }; } +function createDeferredMultimodelProvider( + providerId: 'anthropic' | 'codex' | 'opencode', + displayName: string +): Record { + return { + providerId, + displayName, + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: CLI_PROVIDER_STATUS_DEFERRED_MESSAGE, + models: [], + modelAvailability: [], + canLoginFromUi: providerId !== 'opencode', + capabilities: { + teamLaunch: false, + oneShot: false, + }, + backend: null, + availableBackends: [], + }; +} + describe('CLI status visibility during completed install state', () => { afterEach(() => { document.body.innerHTML = ''; @@ -515,6 +540,49 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('shows deferred multimodel provider snapshots as pending instead of disconnected', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: false, + authStatusChecking: true, + providers: [ + createDeferredMultimodelProvider('anthropic', 'Anthropic'), + createDeferredMultimodelProvider('codex', 'Codex'), + createDeferredMultimodelProvider('opencode', 'OpenCode'), + ], + }); + storeState.cliProviderStatusLoading = { + anthropic: true, + codex: true, + opencode: true, + }; + + 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 providers...'); + expect(host.textContent).toContain('Checking...'); + expect(host.textContent).not.toContain('Providers: 0/3 connected'); + expect(host.textContent).not.toContain('Models unavailable for this runtime build'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows an OpenCode install action on the dashboard when the OpenCode CLI is missing', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 2a27ae66..f506b31a 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -63,10 +63,13 @@ import { mergeCliStatusPreservingHydratedProviders, reconcileMultimodelProviderLoading, } from '@renderer/store/slices/cliInstallerSlice'; +import { + CLI_PROVIDER_STATUS_DEFERRED_MESSAGE, + type CliProviderId, +} from '@shared/types/cliInstaller'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import type { CliInstallationStatus } from '@shared/types'; -import type { CliProviderId } from '@shared/types/cliInstaller'; function createMultimodelProvider( overrides: Partial & { @@ -128,6 +131,30 @@ function createMultimodelStatus( }; } +function createDeferredProvider( + providerId: CliProviderId, + displayName: string +): CliInstallationStatus['providers'][number] { + return createMultimodelProvider({ + providerId, + displayName, + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: CLI_PROVIDER_STATUS_DEFERRED_MESSAGE, + models: [], + canLoginFromUi: providerId !== 'opencode', + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + backend: null, + availableBackends: [], + }); +} + describe('cliInstallerSlice', () => { beforeEach(() => { vi.clearAllMocks(); @@ -344,6 +371,31 @@ describe('cliInstallerSlice', () => { expect(getModelOnlyFallbackProviderIds(status)).toEqual([]); }); + it('keeps deferred startup provider snapshots incomplete until idle hydration runs', () => { + const status = createMultimodelStatus([ + createDeferredProvider('anthropic', 'Anthropic'), + createDeferredProvider('codex', 'Codex'), + createDeferredProvider('opencode', 'OpenCode'), + ]); + + expect(getIncompleteMultimodelProviderIds(status)).toEqual([ + 'anthropic', + 'codex', + 'opencode', + ]); + expect( + reconcileMultimodelProviderLoading(status, { + anthropic: false, + codex: false, + opencode: false, + }) + ).toEqual({ + anthropic: true, + codex: true, + opencode: true, + }); + }); + it('clears loading for hydrated providers while keeping pending providers marked', () => { const status = createMultimodelStatus([ createMultimodelProvider({ @@ -511,6 +563,57 @@ describe('cliInstallerSlice', () => { ); }); + it('does not let deferred startup snapshots overwrite hydrated provider state', () => { + 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' }, + }), + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: true, + authMethod: 'opencode_managed', + statusMessage: 'OpenCode ready', + models: ['opencode/big-pickle'], + canLoginFromUi: false, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }), + ]); + const incoming = createMultimodelStatus([ + createDeferredProvider('anthropic', 'Anthropic'), + createDeferredProvider('codex', 'Codex'), + createDeferredProvider('opencode', 'OpenCode'), + ]); + + const merged = mergeCliStatusPreservingHydratedProviders(current, incoming); + + expect(merged.providers.find((provider) => provider.providerId === 'anthropic')).toMatchObject( + { + authenticated: true, + authMethod: 'oauth_token', + statusMessage: 'Connected via Anthropic subscription', + models: ['claude-sonnet-4-5'], + } + ); + expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject( + { + authenticated: true, + authMethod: 'opencode_managed', + statusMessage: 'OpenCode ready', + models: ['opencode/big-pickle'], + } + ); + expect(merged.providers.find((provider) => provider.providerId === 'codex')).toMatchObject({ + statusMessage: CLI_PROVIDER_STATUS_DEFERRED_MESSAGE, + }); + }); + it('drops hydrated hidden Gemini when a fresh frontend status omits it', () => { const current = createMultimodelStatus([ createMultimodelProvider({ @@ -890,43 +993,9 @@ describe('cliInstallerSlice', () => { it('does not hydrate pending providers when startup asks to defer provider status checks', async () => { const mockStatus = createMultimodelStatus([ - createMultimodelProvider({ - providerId: 'anthropic', - displayName: 'Anthropic', - supported: false, - authenticated: false, - authMethod: null, - verificationState: 'unknown', - statusMessage: 'Provider status will refresh when needed.', - models: [], - backend: null, - availableBackends: [], - }), - createMultimodelProvider({ - providerId: 'codex', - displayName: 'Codex', - supported: false, - authenticated: false, - authMethod: null, - verificationState: 'unknown', - statusMessage: 'Provider status will refresh when needed.', - models: [], - backend: null, - availableBackends: [], - }), - createMultimodelProvider({ - providerId: 'opencode', - displayName: 'OpenCode', - supported: false, - authenticated: false, - authMethod: null, - verificationState: 'unknown', - statusMessage: 'Provider status will refresh when needed.', - models: [], - backend: null, - availableBackends: [], - canLoginFromUi: false, - }), + createDeferredProvider('anthropic', 'Anthropic'), + createDeferredProvider('codex', 'Codex'), + createDeferredProvider('opencode', 'OpenCode'), ]); vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); @@ -937,15 +1006,66 @@ describe('cliInstallerSlice', () => { expect(api.cliInstaller.getStatus).toHaveBeenCalledWith({ providerStatusMode: 'defer' }); expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled(); expect(useStore.getState().cliStatusLoading).toBe(false); + expect(useStore.getState().cliProviderStatusLoading).toEqual({ + anthropic: true, + codex: true, + opencode: true, + }); + expect(useStore.getState().cliStatus?.authStatusChecking).toBe(true); + expect( + useStore.getState().cliStatus?.providers.map((provider) => provider.statusMessage) + ).toEqual([ + CLI_PROVIDER_STATUS_DEFERRED_MESSAGE, + CLI_PROVIDER_STATUS_DEFERRED_MESSAGE, + CLI_PROVIDER_STATUS_DEFERRED_MESSAGE, + ]); + }); + + it('preserves hydrated providers during deferred startup refreshes', async () => { + const currentStatus = 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' }, + }), + createDeferredProvider('codex', 'Codex'), + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: true, + authMethod: 'opencode_managed', + statusMessage: 'OpenCode ready', + models: ['opencode/big-pickle'], + canLoginFromUi: false, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }), + ]); + const deferredStatus = createMultimodelStatus([ + createDeferredProvider('anthropic', 'Anthropic'), + createDeferredProvider('codex', 'Codex'), + createDeferredProvider('opencode', 'OpenCode'), + ]); + useStore.setState({ cliStatus: currentStatus }); + vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(deferredStatus); + + await useStore + .getState() + .bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' }); + + expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled(); expect(useStore.getState().cliProviderStatusLoading).toEqual({ anthropic: false, - codex: false, + codex: true, opencode: false, }); - expect(useStore.getState().cliStatus?.providers.map((provider) => provider.statusMessage)).toEqual([ - 'Provider status will refresh when needed.', - 'Provider status will refresh when needed.', - 'Provider status will refresh when needed.', + expect(useStore.getState().cliStatus?.providers).toEqual([ + currentStatus.providers[0], + deferredStatus.providers[1], + currentStatus.providers[2], ]); });