fix(startup): hydrate deferred provider statuses
This commit is contained in:
parent
08725c4e33
commit
ef77f36b8f
10 changed files with 329 additions and 75 deletions
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue