fix(startup): hydrate deferred provider statuses

This commit is contained in:
777genius 2026-05-23 16:31:46 +03:00
parent 08725c4e33
commit ef77f36b8f
10 changed files with 329 additions and 75 deletions

View file

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

View file

@ -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: [],

View file

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

View file

@ -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<ReturnType<typeof useCliInstaller>['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)
);
}

View file

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

View file

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

View file

@ -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<AppState, [], [], CliInstalle
}
const epoch = ++cliStatusEpoch;
const currentStatus = get().cliStatus;
const initialStatus =
providerStatusMode === 'defer' && currentStatus?.flavor === 'agent_teams_orchestrator'
? currentStatus
: createLoadingMultimodelCliStatus();
const shouldMarkIncompleteProvidersLoading = hydrateProviders || providerStatusMode === 'defer';
const providerLoading = Object.fromEntries(
MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, hydrateProviders])
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
providerId,
shouldMarkIncompleteProvidersLoading &&
initialStatus.installed &&
!isHydratedMultimodelProviderStatus(
initialStatus.providers.find((provider) => provider.providerId === providerId)
),
])
) as Partial<Record<CliProviderId, boolean>>;
set({
cliStatus: createLoadingMultimodelCliStatus(),
cliStatus: initialStatus,
cliStatusLoading: true,
cliProviderStatusLoading: providerLoading,
cliStatusError: null,
@ -760,18 +799,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
return;
}
const nextProviderLoading = Object.fromEntries(
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
providerId,
hydrateProviders &&
!isHydratedMultimodelProviderStatus(
metadata.providers.find((provider) => provider.providerId === providerId)
),
])
) as Partial<Record<CliProviderId, boolean>>;
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<AppState, [], [], CliInstalle
}
const nextCliStatus = mergeCliStatusPreservingHydratedProviders(state.cliStatus, metadata);
const nextProviderLoading = Object.fromEntries(
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
providerId,
!isHydratedMultimodelProviderStatus(
nextCliStatus.providers.find((provider) => provider.providerId === providerId)
),
])
) as Partial<Record<CliProviderId, boolean>>;
pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter(
(providerId) => nextProviderLoading[providerId] === true
);
const nextAuthState = isMultimodelCliStatus(nextCliStatus)
? buildMultimodelCliAuthState({
status: nextCliStatus,

View file

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

View file

@ -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<string, unknown> {
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';

View file

@ -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<CliInstallationStatus['providers'][number]> & {
@ -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],
]);
});