perf(startup): defer provider status checks

This commit is contained in:
777genius 2026-05-23 14:23:57 +03:00
parent 64fdfd2422
commit a4861fa77d
15 changed files with 471 additions and 87 deletions

View file

@ -916,7 +916,9 @@ let shutdownComplete = false;
const startupTimers = new Set<ReturnType<typeof setTimeout>>();
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 {

View file

@ -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<CliInstallationStatus> | null = null;
const statusInFlight = new Map<CliInstallerProviderStatusMode, Promise<CliInstallationStatus>>();
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
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<CliProviderId>(['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<CliInstallerGetStatusOptions> {
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<IpcResult<CliInstallationStatus>> {
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<void> {
statusCacheGeneration += 1;
cachedStatus = null;
statusInFlight = null;
cachedStatus.clear();
statusInFlight.clear();
providerStatusInFlight.clear();
ClaudeBinaryResolver.clearCache();
CodexBinaryResolver.clearCache();

View file

@ -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<CliInstallationStatus> {
async getStatus(options: CliInstallerGetStatusOptions = {}): Promise<CliInstallationStatus> {
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<typeof setTimeout> | null = null;
try {
await Promise.race([
this.gatherStatus(ref, runDiag, generation),
this.gatherStatus(ref, runDiag, generation, providerStatusMode),
new Promise<void>((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<void> {
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.

View file

@ -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<CliInstallationStatus> => {
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
getStatus: async (options?: CliInstallerGetStatusOptions): Promise<CliInstallationStatus> => {
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS, options);
},
getProviderStatus: async (providerId: CliProviderId) => {
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);

View file

@ -260,6 +260,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
useEffect(() => {
void refreshCliStatusForCurrentMode({
multimodelEnabled,
providerStatusMode: 'defer',
bootstrapCliStatus,
fetchCliStatus,
});

View file

@ -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<void>;
bootstrapCliStatus: (options?: {
multimodelEnabled?: boolean;
providerStatusMode?: CliInstallerProviderStatusMode;
}) => Promise<void>;
fetchCliStatus: () => Promise<void>;
fetchCliProviderStatus: (
providerId: CliProviderId,

View file

@ -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<TeamProvisioningProgress['state']> =
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<typeof setTimeout> | null = null;
let runtimeStatusTimer: ReturnType<typeof setTimeout> | 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

View file

@ -659,7 +659,10 @@ export interface CliInstallerSlice {
codexRuntimeError: string | null;
// Actions
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
bootstrapCliStatus: (options?: {
multimodelEnabled?: boolean;
providerStatusMode?: 'full' | 'defer';
}) => Promise<void>;
fetchCliStatus: () => Promise<void>;
fetchCliProviderStatus: (
providerId: CliProviderId,
@ -714,13 +717,15 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
bootstrapCliStatus: async (options) => {
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<Record<CliProviderId, boolean>>;
set({
@ -731,7 +736,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
});
try {
const metadata = await api.cliInstaller.getStatus();
const metadata = await api.cliInstaller.getStatus(
providerStatusMode === 'defer' ? { providerStatusMode } : undefined
);
if (metadata.flavor !== 'agent_teams_orchestrator') {
set((state) => {
if (epoch !== cliStatusEpoch) {
@ -756,9 +763,10 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
const nextProviderLoading = Object.fromEntries(
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
providerId,
!isHydratedMultimodelProviderStatus(
metadata.providers.find((provider) => provider.providerId === providerId)
),
hydrateProviders &&
!isHydratedMultimodelProviderStatus(
metadata.providers.find((provider) => provider.providerId === providerId)
),
])
) as Partial<Record<CliProviderId, boolean>>;
const pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter(
@ -800,7 +808,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
return;
}
if (pendingProviderIds.length === 0) {
if (!hydrateProviders || pendingProviderIds.length === 0) {
return;
}
@ -818,14 +826,16 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
}
try {
await Promise.allSettled(
MULTIMODEL_PROVIDER_IDS.map((providerId) =>
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 });

View file

@ -1,16 +1,27 @@
import type { CliInstallerProviderStatusMode } from '@shared/types';
interface RefreshCliStatusOptions {
multimodelEnabled: boolean;
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
providerStatusMode?: CliInstallerProviderStatusMode;
bootstrapCliStatus: (options?: {
multimodelEnabled?: boolean;
providerStatusMode?: CliInstallerProviderStatusMode;
}) => Promise<void>;
fetchCliStatus: () => Promise<void>;
}
export function refreshCliStatusForCurrentMode({
multimodelEnabled,
providerStatusMode,
bootstrapCliStatus,
fetchCliStatus,
}: RefreshCliStatusOptions): Promise<void> {
if (multimodelEnabled) {
return bootstrapCliStatus({ multimodelEnabled: true });
return bootstrapCliStatus(
providerStatusMode
? { multimodelEnabled: true, providerStatusMode }
: { multimodelEnabled: true }
);
}
return fetchCliStatus();

View file

@ -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<CliInstallationStatus>;
getStatus: (options?: CliInstallerGetStatusOptions) => Promise<CliInstallationStatus>;
/** Get current runtime/auth status for a single provider */
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
/** Start on-demand model verification for a single runtime provider */

View file

@ -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<CliInstallationStatus>();
const fullRequest = deferred<CliInstallationStatus>();
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<IpcResult<CliInstallationStatus>>;
const fullInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise<
IpcResult<CliInstallationStatus>
>;
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<CliInstallationStatus>;
const cachedFull = (await ipcMain.invoke(
CLI_INSTALLER_GET_STATUS
)) as IpcResult<CliInstallationStatus>;
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<CliInstallationStatus>;
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<CliInstallationStatus>;
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<CliProviderStatus | null>();
service.getStatus

View file

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

View file

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

View file

@ -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<CliInstallationStatus['providers'][number]>(

View file

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