diff --git a/src/main/index.ts b/src/main/index.ts index 5025e682..5ac5bf6d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -916,7 +916,9 @@ let shutdownComplete = false; const startupTimers = new Set>(); const SHUTDOWN_STEP_TIMEOUT_MS = 5_000; -const STARTUP_RECOVERY_DELAY_MS = 10_000; +const STARTUP_RECOVERY_DELAY_MS = 60_000; +const STARTUP_CLI_WARMUP_DELAY_MS = 90_000; +const STARTUP_BACKGROUND_SERVICE_DELAY_MS = 5_000; const STARTUP_RECOVERY_CONCURRENCY = 1; const appStartupStartedAt = Date.now(); let appStartupSteps: AppStartupStep[] = [ @@ -2456,10 +2458,12 @@ function runPostRendererStartupTasks(): void { ); scheduleStartupTask(() => { - void teamProvisioningService.warmup(); teamDataService.startProcessHealthPolling(); void schedulerService?.start(); - }, 5000); + }, STARTUP_BACKGROUND_SERVICE_DELAY_MS); + scheduleStartupTask(() => { + void teamProvisioningService.warmup(); + }, STARTUP_CLI_WARMUP_DELAY_MS); } function scheduleRendererRecovery(win: BrowserWindow): void { diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 11ede3b6..54d401da 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -24,6 +24,8 @@ import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver'; import type { CliInstallerService } from '../services'; import type { CliInstallationStatus, + CliInstallerGetStatusOptions, + CliInstallerProviderStatusMode, CliProviderId, CliProviderStatus, IpcResult, @@ -33,12 +35,16 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron'; const logger = createLogger('IPC:cliInstaller'); let service: CliInstallerService; -let statusInFlight: Promise | null = null; +const statusInFlight = new Map>(); const providerStatusInFlight = new Map>(); -let cachedStatus: { value: CliInstallationStatus; at: number } | null = null; +const cachedStatus = new Map< + CliInstallerProviderStatusMode, + { value: CliInstallationStatus; at: number } +>(); 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); @@ -54,6 +60,39 @@ function getCachedStatusAuthenticatedProvider( ); } +function normalizeGetStatusOptions(options: unknown): Required { + if ( + typeof options === 'object' && + options !== null && + (options as CliInstallerGetStatusOptions).providerStatusMode === 'defer' + ) { + return { providerStatusMode: 'defer' }; + } + + return { providerStatusMode: 'full' }; +} + +function isDeferredProviderStatusSnapshot(status: CliInstallationStatus): boolean { + return ( + status.flavor === 'agent_teams_orchestrator' && + status.providers.length > 0 && + status.providers.every( + (provider) => + provider.supported === false && + provider.authenticated === false && + provider.verificationState === 'unknown' && + provider.statusMessage === DEFERRED_PROVIDER_STATUS_MESSAGE + ) + ); +} + +function canUseLatestSnapshotForCacheKey( + cacheKey: CliInstallerProviderStatusMode, + status: CliInstallationStatus +): boolean { + return cacheKey === 'defer' || !isDeferredProviderStatusSnapshot(status); +} + /** * Initializes CLI installer handlers with the service instance. */ @@ -92,32 +131,36 @@ export function removeCliInstallerHandlers(ipcMain: IpcMain): void { // ============================================================================= async function handleGetStatus( - _event: IpcMainInvokeEvent + _event: IpcMainInvokeEvent, + options?: CliInstallerGetStatusOptions ): Promise> { try { + const normalizedOptions = normalizeGetStatusOptions(options); + const cacheKey = normalizedOptions.providerStatusMode; const latestSnapshot = service.getLatestStatusSnapshot(); - if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) { - if (latestSnapshot) { - cachedStatus = { value: latestSnapshot, at: Date.now() }; + const cached = cachedStatus.get(cacheKey); + if (cached && Date.now() - cached.at < STATUS_CACHE_TTL_MS) { + if (latestSnapshot && canUseLatestSnapshotForCacheKey(cacheKey, latestSnapshot)) { + cachedStatus.set(cacheKey, { value: latestSnapshot, at: Date.now() }); return { success: true, data: latestSnapshot }; } - return { success: true, data: cachedStatus.value }; + return { success: true, data: cached.value }; } - if (!statusInFlight) { + if (!statusInFlight.has(cacheKey)) { const startedAt = Date.now(); const generation = statusCacheGeneration; const request = service - .getStatus() + .getStatus(normalizedOptions) .then((status) => { if (generation === statusCacheGeneration) { - cachedStatus = { value: status, at: Date.now() }; + cachedStatus.set(cacheKey, { value: status, at: Date.now() }); } return status; }) .catch((err) => { if (generation === statusCacheGeneration) { - cachedStatus = null; + cachedStatus.delete(cacheKey); } throw err; }) @@ -126,14 +169,14 @@ async function handleGetStatus( if (ms >= 2000) { logger.warn(`cliInstaller:getStatus slow ms=${ms}`); } - if (statusInFlight === request) { - statusInFlight = null; + if (statusInFlight.get(cacheKey) === request) { + statusInFlight.delete(cacheKey); } }); - statusInFlight = request; + statusInFlight.set(cacheKey, request); } - const status = await statusInFlight; + const status = await statusInFlight.get(cacheKey)!; return { success: true, data: status }; } catch (error) { const msg = getErrorMessage(error); @@ -143,42 +186,44 @@ async function handleGetStatus( } function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void { - if (!cachedStatus || !providerStatus) { + if (!providerStatus) { return; } - if ( - cachedStatus.value.flavor === 'agent_teams_orchestrator' && - !isFrontendMultimodelProviderId(providerStatus.providerId) - ) { - return; + for (const [cacheKey, cached] of cachedStatus) { + if ( + cached.value.flavor === 'agent_teams_orchestrator' && + !isFrontendMultimodelProviderId(providerStatus.providerId) + ) { + continue; + } + + const hasProvider = cached.value.providers.some( + (provider) => provider.providerId === providerStatus.providerId + ); + const nextProviders = hasProvider + ? cached.value.providers.map((provider) => + provider.providerId === providerStatus.providerId ? providerStatus : provider + ) + : [...cached.value.providers, providerStatus]; + const authenticatedProvider = + cached.value.flavor === 'agent_teams_orchestrator' + ? getCachedStatusAuthenticatedProvider(nextProviders) + : (nextProviders.find((provider) => provider.authenticated) ?? null); + + cachedStatus.set(cacheKey, { + value: { + ...cached.value, + providers: nextProviders, + authLoggedIn: + cached.value.flavor === 'agent_teams_orchestrator' + ? authenticatedProvider !== null + : nextProviders.some((provider) => provider.authenticated), + authMethod: authenticatedProvider?.authMethod ?? null, + }, + at: Date.now(), + }); } - - const hasProvider = cachedStatus.value.providers.some( - (provider) => provider.providerId === providerStatus.providerId - ); - const nextProviders = hasProvider - ? cachedStatus.value.providers.map((provider) => - provider.providerId === providerStatus.providerId ? providerStatus : provider - ) - : [...cachedStatus.value.providers, providerStatus]; - const authenticatedProvider = - cachedStatus.value.flavor === 'agent_teams_orchestrator' - ? getCachedStatusAuthenticatedProvider(nextProviders) - : (nextProviders.find((provider) => provider.authenticated) ?? null); - - cachedStatus = { - value: { - ...cachedStatus.value, - providers: nextProviders, - authLoggedIn: - cachedStatus.value.flavor === 'agent_teams_orchestrator' - ? authenticatedProvider !== null - : nextProviders.some((provider) => provider.authenticated), - authMethod: authenticatedProvider?.authMethod ?? null, - }, - at: Date.now(), - }; } async function handleGetProviderStatus( @@ -248,8 +293,8 @@ async function handleVerifyProviderModels( function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult { statusCacheGeneration += 1; - cachedStatus = null; - statusInFlight = null; + cachedStatus.clear(); + statusInFlight.clear(); providerStatusInFlight.clear(); ClaudeBinaryResolver.clearCache(); CodexBinaryResolver.clearCache(); diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 5a6cf741..73de41f6 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -49,7 +49,9 @@ import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor import type { CliInstallationStatus, + CliInstallerGetStatusOptions, CliInstallerProgress, + CliInstallerProviderStatusMode, CliPlatform, CliProviderId, CliProviderModelAvailability, @@ -868,8 +870,9 @@ export class CliInstallerService { // Public: getStatus // --------------------------------------------------------------------------- - async getStatus(): Promise { + async getStatus(options: CliInstallerGetStatusOptions = {}): Promise { const statusStartedAt = Date.now(); + const providerStatusMode: CliInstallerProviderStatusMode = options.providerStatusMode ?? 'full'; const generation = ++this.statusGatherGeneration; const result = this.createInitialStatus(); this.latestProviderSignatures.clear(); @@ -882,7 +885,7 @@ export class CliInstallerService { let timer: ReturnType | null = null; try { await Promise.race([ - this.gatherStatus(ref, runDiag, generation), + this.gatherStatus(ref, runDiag, generation, providerStatusMode), new Promise((resolve) => { timer = setTimeout(() => { logger.warn( @@ -1023,7 +1026,8 @@ export class CliInstallerService { private async gatherStatus( ref: { current: CliInstallationStatus }, diag: CliInstallerStatusRunDiag, - generation: number + generation: number, + providerStatusMode: CliInstallerProviderStatusMode ): Promise { resetGatherDiag(diag); const shellEnvStartedAt = Date.now(); @@ -1048,6 +1052,14 @@ export class CliInstallerService { r.installedVersion = versionProbe.version; r.launchError = null; r.authStatusChecking = true; + + if (r.flavor === 'agent_teams_orchestrator' && providerStatusMode === 'defer') { + r.authStatusChecking = false; + this.markProvidersDeferred(r); + this.publishStatusSnapshotIfCurrent(r, generation); + return; + } + this.rememberHealthyStatus(r); this.publishStatusSnapshotIfCurrent(r, generation); @@ -1186,6 +1198,28 @@ export class CliInstallerService { result.authMethod = null; } + private markProvidersDeferred(result: CliInstallationStatus): void { + if (result.flavor !== 'agent_teams_orchestrator') { + return; + } + + result.providers = result.providers.map((provider) => ({ + ...provider, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + modelVerificationState: 'idle', + modelCatalogRefreshState: 'idle', + statusMessage: 'Provider status will refresh when needed.', + detailMessage: null, + models: [], + modelAvailability: [], + backend: null, + })); + result.authLoggedIn = false; + result.authMethod = null; + } + /** * Check auth status with retry — covers stale lock files after Ctrl+C interruption. * Wrapped in its own timeout to prevent slow auth from blocking the overall status. diff --git a/src/preload/index.ts b/src/preload/index.ts index 6125eb55..c921733d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -273,6 +273,7 @@ import type { ClaudeRootFolderSelection, ClaudeRootInfo, CliInstallationStatus, + CliInstallerGetStatusOptions, CliInstallerProgress, CliProviderId, ConflictCheckResult, @@ -1554,8 +1555,8 @@ const electronAPI: ElectronAPI = { // ===== CLI Installer API ===== cliInstaller: { - getStatus: async (): Promise => { - return invokeIpcWithResult(CLI_INSTALLER_GET_STATUS); + getStatus: async (options?: CliInstallerGetStatusOptions): Promise => { + return invokeIpcWithResult(CLI_INSTALLER_GET_STATUS, options); }, getProviderStatus: async (providerId: CliProviderId) => { return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId); diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 1dac5996..0e368433 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -260,6 +260,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { useEffect(() => { void refreshCliStatusForCurrentMode({ multimodelEnabled, + providerStatusMode: 'defer', bootstrapCliStatus, fetchCliStatus, }); diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index d6a89cf2..b9bc465d 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -9,7 +9,12 @@ import { useStore } from '@renderer/store'; import { useShallow } from 'zustand/react/shallow'; import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts'; -import type { CliInstallationStatus, CliProviderId, OpenCodeRuntimeStatus } from '@shared/types'; +import type { + CliInstallationStatus, + CliInstallerProviderStatusMode, + CliProviderId, + OpenCodeRuntimeStatus, +} from '@shared/types'; export function useCliInstaller(): { cliStatus: CliInstallationStatus | null; @@ -37,7 +42,10 @@ export function useCliInstaller(): { codexRuntimeStatus: CodexRuntimeStatus | null; codexRuntimeStatusLoading: boolean; codexRuntimeError: string | null; - bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise; + bootstrapCliStatus: (options?: { + multimodelEnabled?: boolean; + providerStatusMode?: CliInstallerProviderStatusMode; + }) => Promise; fetchCliStatus: () => Promise; fetchCliProviderStatus: ( providerId: CliProviderId, diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index ee6d8b53..d0d655d1 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -95,6 +95,7 @@ const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000; 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 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'; @@ -209,6 +210,7 @@ export function initializeNotificationListeners(): () => void { const cleanupFns: (() => void)[] = []; cleanupFns.push(installTeamRefreshFanoutDebugBridge()); let cliStatusTimer: ReturnType | null = null; + let runtimeStatusTimer: ReturnType | null = null; useStore.getState().subscribeProvisioningProgress(); cleanupFns.push(() => { useStore.getState().unsubscribeProvisioningProgress(); @@ -241,19 +243,24 @@ export function initializeNotificationListeners(): () => void { cliStatusTimer = setTimeout(() => { const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true; if (multimodelEnabled) { - void useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); + void useStore + .getState() + .bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' }); } else { void useStore.getState().fetchCliStatus(); } cliStatusTimer = null; }, delayMs); } - if (api.openCodeRuntime) { - void useStore.getState().fetchOpenCodeRuntimeStatus(); - } - if (api.codexRuntime) { - void useStore.getState().fetchCodexRuntimeStatus(); - } + runtimeStatusTimer = setTimeout(() => { + if (api.openCodeRuntime) { + void useStore.getState().fetchOpenCodeRuntimeStatus(); + } + if (api.codexRuntime) { + void useStore.getState().fetchCodexRuntimeStatus(); + } + runtimeStatusTimer = null; + }, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS); // Remaining fetches have no data dependency on each other — run in parallel // to avoid blocking teams/notifications behind a slow repository scan. @@ -267,6 +274,7 @@ export function initializeNotificationListeners(): () => void { })(); cleanupFns.push(() => { if (cliStatusTimer) clearTimeout(cliStatusTimer); + if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer); }); // 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 4172dd2b..b24b84a2 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -659,7 +659,10 @@ export interface CliInstallerSlice { codexRuntimeError: string | null; // Actions - bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise; + bootstrapCliStatus: (options?: { + multimodelEnabled?: boolean; + providerStatusMode?: 'full' | 'defer'; + }) => Promise; fetchCliStatus: () => Promise; fetchCliProviderStatus: ( providerId: CliProviderId, @@ -714,13 +717,15 @@ export const createCliInstallerSlice: StateCreator { if (!api.cliInstaller) return; const multimodelEnabled = options?.multimodelEnabled ?? true; + const providerStatusMode = options?.providerStatusMode ?? 'full'; + const hydrateProviders = providerStatusMode !== 'defer'; if (!multimodelEnabled) { return get().fetchCliStatus(); } const epoch = ++cliStatusEpoch; const providerLoading = Object.fromEntries( - MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, true]) + MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, hydrateProviders]) ) as Partial>; set({ @@ -731,7 +736,9 @@ export const createCliInstallerSlice: StateCreator { if (epoch !== cliStatusEpoch) { @@ -756,9 +763,10 @@ export const createCliInstallerSlice: StateCreator [ providerId, - !isHydratedMultimodelProviderStatus( - metadata.providers.find((provider) => provider.providerId === providerId) - ), + hydrateProviders && + !isHydratedMultimodelProviderStatus( + metadata.providers.find((provider) => provider.providerId === providerId) + ), ]) ) as Partial>; const pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter( @@ -800,7 +808,7 @@ export const createCliInstallerSlice: StateCreator - get().fetchCliProviderStatus(providerId, { - silent: false, - epoch, - }) - ) - ); + if (hydrateProviders) { + await Promise.allSettled( + MULTIMODEL_PROVIDER_IDS.map((providerId) => + get().fetchCliProviderStatus(providerId, { + silent: false, + epoch, + }) + ) + ); + } } finally { if (epoch === cliStatusEpoch) { set({ cliStatusLoading: false }); diff --git a/src/renderer/utils/refreshCliStatus.ts b/src/renderer/utils/refreshCliStatus.ts index 52b0d357..7c02247b 100644 --- a/src/renderer/utils/refreshCliStatus.ts +++ b/src/renderer/utils/refreshCliStatus.ts @@ -1,16 +1,27 @@ +import type { CliInstallerProviderStatusMode } from '@shared/types'; + interface RefreshCliStatusOptions { multimodelEnabled: boolean; - bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise; + providerStatusMode?: CliInstallerProviderStatusMode; + bootstrapCliStatus: (options?: { + multimodelEnabled?: boolean; + providerStatusMode?: CliInstallerProviderStatusMode; + }) => Promise; fetchCliStatus: () => Promise; } export function refreshCliStatusForCurrentMode({ multimodelEnabled, + providerStatusMode, bootstrapCliStatus, fetchCliStatus, }: RefreshCliStatusOptions): Promise { if (multimodelEnabled) { - return bootstrapCliStatus({ multimodelEnabled: true }); + return bootstrapCliStatus( + providerStatusMode + ? { multimodelEnabled: true, providerStatusMode } + : { multimodelEnabled: true } + ); } return fetchCliStatus(); diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 994048f9..ca0eb178 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -351,6 +351,16 @@ export interface CliInstallerProgress { status?: CliInstallationStatus; } +export type CliInstallerProviderStatusMode = 'full' | 'defer'; + +export interface CliInstallerGetStatusOptions { + /** + * `defer` keeps startup lightweight by checking only the runtime binary/version. + * Explicit refreshes should keep the default `full` mode. + */ + providerStatusMode?: CliInstallerProviderStatusMode; +} + // ============================================================================= // Preload API // ============================================================================= @@ -360,7 +370,7 @@ export interface CliInstallerProgress { */ export interface CliInstallerAPI { /** Get current CLI installation status */ - getStatus: () => Promise; + getStatus: (options?: CliInstallerGetStatusOptions) => Promise; /** Get current runtime/auth status for a single provider */ getProviderStatus: (providerId: CliProviderId) => Promise; /** Start on-demand model verification for a single runtime provider */ diff --git a/test/main/ipc/cliInstaller.test.ts b/test/main/ipc/cliInstaller.test.ts index 02d3e864..44fecd33 100644 --- a/test/main/ipc/cliInstaller.test.ts +++ b/test/main/ipc/cliInstaller.test.ts @@ -275,6 +275,112 @@ describe('cliInstaller IPC handlers', () => { expect(cached.data?.providers[0]?.statusMessage).toBe('ChatGPT account ready'); }); + it('keeps lightweight startup status cache separate from full provider status cache', async () => { + const deferredStartupStatus = status([ + provider({ + providerId: 'anthropic', + supported: false, + statusMessage: 'Provider status will refresh when needed.', + }), + provider({ + providerId: 'codex', + supported: false, + statusMessage: 'Provider status will refresh when needed.', + }), + ]); + const fullStatus = status([ + provider({ + providerId: 'anthropic', + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + statusMessage: 'Connected', + }), + provider({ + providerId: 'codex', + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + statusMessage: 'ChatGPT account ready', + }), + ]); + const startupRequest = deferred(); + const fullRequest = deferred(); + service.getStatus.mockImplementation((options?: { providerStatusMode?: string }) => + options?.providerStatusMode === 'defer' ? startupRequest.promise : fullRequest.promise + ); + + const startupInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS, { + providerStatusMode: 'defer', + }) as Promise>; + const fullInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise< + IpcResult + >; + await vi.waitFor(() => expect(service.getStatus).toHaveBeenCalledTimes(2)); + + startupRequest.resolve(deferredStartupStatus); + fullRequest.resolve(fullStatus); + + await expect(startupInvoke).resolves.toMatchObject({ + success: true, + data: { authLoggedIn: false }, + }); + await expect(fullInvoke).resolves.toMatchObject({ + success: true, + data: { authLoggedIn: true, authMethod: 'oauth_token' }, + }); + + const cachedStartup = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS, { + providerStatusMode: 'defer', + })) as IpcResult; + const cachedFull = (await ipcMain.invoke( + CLI_INSTALLER_GET_STATUS + )) as IpcResult; + + expect(service.getStatus).toHaveBeenCalledTimes(2); + expect(cachedStartup.data?.authLoggedIn).toBe(false); + expect(cachedStartup.data?.providers[0]?.statusMessage).toBe( + 'Provider status will refresh when needed.' + ); + expect(cachedFull.data?.authLoggedIn).toBe(true); + expect(cachedFull.data?.providers[1]?.statusMessage).toBe('ChatGPT account ready'); + }); + + it('does not replace a cached full provider status with a deferred startup snapshot', async () => { + const fullStatus = status([ + provider({ + providerId: 'anthropic', + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + statusMessage: 'Connected', + }), + ]); + const deferredStartupStatus = status([ + provider({ + providerId: 'anthropic', + supported: false, + verificationState: 'unknown', + statusMessage: 'Provider status will refresh when needed.', + }), + ]); + service.getStatus.mockResolvedValueOnce(fullStatus); + + const first = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; + expect(first.success).toBe(true); + expect(first.data?.providers[0]?.statusMessage).toBe('Connected'); + + service.getLatestStatusSnapshot.mockReturnValue(deferredStartupStatus); + const cached = (await ipcMain.invoke( + CLI_INSTALLER_GET_STATUS + )) as IpcResult; + + expect(service.getStatus).toHaveBeenCalledTimes(1); + expect(cached.success).toBe(true); + expect(cached.data?.authLoggedIn).toBe(true); + expect(cached.data?.providers[0]?.statusMessage).toBe('Connected'); + }); + it('does not let a stale in-flight provider refresh patch the cache after invalidation', async () => { const staleProviderRequest = deferred(); service.getStatus diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 6729d965..22ccd333 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -325,6 +325,70 @@ describe('CliInstallerService', () => { expect(service.getLatestStatusSnapshot()?.authLoggedIn).toBe(false); }); + it('defers multimodel provider status probes during lightweight startup status checks', async () => { + allowConsoleLogs(); + vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator'); + vi.mocked(getCliFlavorUiOptions).mockReturnValue({ + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + }); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/agent_teams_orchestrator'); + vi.mocked(execCli).mockResolvedValueOnce({ stdout: '0.0.46', stderr: '' }); + const getProviderStatusesSpy = vi + .spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses') + .mockResolvedValue([ + createTestProviderStatus('anthropic', true, 'oauth_token'), + createTestProviderStatus('codex', true, 'chatgpt'), + createTestProviderStatus('opencode', true, 'opencode_managed'), + ]); + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn(), isDestroyed: () => false }, + }; + service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); + + const status = await service.getStatus({ providerStatusMode: 'defer' }); + const statusEvents = mockWindow.webContents.send.mock.calls + .filter((call: unknown[]) => call[0] === 'cliInstaller:progress') + .map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } }) + .filter((event) => event.type === 'status'); + + expect(status.installed).toBe(true); + expect(status.authStatusChecking).toBe(false); + expect(status.authLoggedIn).toBe(false); + expect(status.providers).toHaveLength(3); + expect( + status.providers.every( + (provider) => provider.statusMessage === 'Provider status will refresh when needed.' + ) + ).toBe(true); + expect(statusEvents.length).toBeGreaterThan(0); + expect( + statusEvents.every((event) => + event.status?.providers?.every( + (provider) => + typeof provider === 'object' && + provider !== null && + 'statusMessage' in provider && + 'models' in provider && + (provider as { statusMessage?: string }).statusMessage === + 'Provider status will refresh when needed.' && + Array.isArray((provider as { models?: unknown[] }).models) && + (provider as { models?: unknown[] }).models?.length === 0 + ) + ) + ).toBe(true); + expect(getProviderStatusesSpy).not.toHaveBeenCalled(); + expect(execCli).toHaveBeenCalledTimes(1); + expect(execCli).toHaveBeenCalledWith( + '/mock/agent_teams_orchestrator', + ['--version'], + expect.objectContaining({ timeout: expect.any(Number) }) + ); + }); + it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => { allowConsoleLogs(); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); diff --git a/test/renderer/components/extensions/ExtensionStoreView.test.ts b/test/renderer/components/extensions/ExtensionStoreView.test.ts index 6c95403e..2f4e28dc 100644 --- a/test/renderer/components/extensions/ExtensionStoreView.test.ts +++ b/test/renderer/components/extensions/ExtensionStoreView.test.ts @@ -1,5 +1,6 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; @@ -346,7 +347,10 @@ describe('ExtensionStoreView provider loading placeholders', () => { await Promise.resolve(); }); - expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({ multimodelEnabled: true }); + expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({ + multimodelEnabled: true, + providerStatusMode: 'defer', + }); expect(storeState.fetchCliStatus).not.toHaveBeenCalled(); expect(storeState.fetchApiKeys).not.toHaveBeenCalled(); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index e86929f2..2a27ae66 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -888,6 +888,67 @@ describe('cliInstallerSlice', () => { expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled(); }); + 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, + }), + ]); + vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); + + await useStore + .getState() + .bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' }); + + 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: false, + codex: false, + 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.', + ]); + }); + it('drops global loading once metadata is ready and keeps only unresolved providers loading', async () => { let resolveCodexStatus!: (value: CliInstallationStatus['providers'][number]) => void; const pendingCodexStatus = new Promise( diff --git a/test/renderer/utils/refreshCliStatus.test.ts b/test/renderer/utils/refreshCliStatus.test.ts index 52c195f2..30a5b15e 100644 --- a/test/renderer/utils/refreshCliStatus.test.ts +++ b/test/renderer/utils/refreshCliStatus.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; - import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; +import { describe, expect, it, vi } from 'vitest'; describe('refreshCliStatusForCurrentMode', () => { it('uses provider-first bootstrap when multimodel is enabled', async () => { @@ -17,6 +16,24 @@ describe('refreshCliStatusForCurrentMode', () => { expect(fetchCliStatus).not.toHaveBeenCalled(); }); + it('passes deferred provider status mode to multimodel bootstrap when requested', async () => { + const bootstrapCliStatus = vi.fn().mockResolvedValue(undefined); + const fetchCliStatus = vi.fn().mockResolvedValue(undefined); + + await refreshCliStatusForCurrentMode({ + multimodelEnabled: true, + providerStatusMode: 'defer', + bootstrapCliStatus, + fetchCliStatus, + }); + + expect(bootstrapCliStatus).toHaveBeenCalledWith({ + multimodelEnabled: true, + providerStatusMode: 'defer', + }); + expect(fetchCliStatus).not.toHaveBeenCalled(); + }); + it('falls back to legacy status fetch when multimodel is disabled', async () => { const bootstrapCliStatus = vi.fn().mockResolvedValue(undefined); const fetchCliStatus = vi.fn().mockResolvedValue(undefined);