perf(startup): defer provider status checks
This commit is contained in:
parent
64fdfd2422
commit
a4861fa77d
15 changed files with 471 additions and 87 deletions
|
|
@ -916,7 +916,9 @@ let shutdownComplete = false;
|
||||||
const startupTimers = new Set<ReturnType<typeof setTimeout>>();
|
const startupTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
|
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 STARTUP_RECOVERY_CONCURRENCY = 1;
|
||||||
const appStartupStartedAt = Date.now();
|
const appStartupStartedAt = Date.now();
|
||||||
let appStartupSteps: AppStartupStep[] = [
|
let appStartupSteps: AppStartupStep[] = [
|
||||||
|
|
@ -2456,10 +2458,12 @@ function runPostRendererStartupTasks(): void {
|
||||||
);
|
);
|
||||||
|
|
||||||
scheduleStartupTask(() => {
|
scheduleStartupTask(() => {
|
||||||
void teamProvisioningService.warmup();
|
|
||||||
teamDataService.startProcessHealthPolling();
|
teamDataService.startProcessHealthPolling();
|
||||||
void schedulerService?.start();
|
void schedulerService?.start();
|
||||||
}, 5000);
|
}, STARTUP_BACKGROUND_SERVICE_DELAY_MS);
|
||||||
|
scheduleStartupTask(() => {
|
||||||
|
void teamProvisioningService.warmup();
|
||||||
|
}, STARTUP_CLI_WARMUP_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleRendererRecovery(win: BrowserWindow): void {
|
function scheduleRendererRecovery(win: BrowserWindow): void {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
||||||
import type { CliInstallerService } from '../services';
|
import type { CliInstallerService } from '../services';
|
||||||
import type {
|
import type {
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
|
CliInstallerGetStatusOptions,
|
||||||
|
CliInstallerProviderStatusMode,
|
||||||
CliProviderId,
|
CliProviderId,
|
||||||
CliProviderStatus,
|
CliProviderStatus,
|
||||||
IpcResult,
|
IpcResult,
|
||||||
|
|
@ -33,12 +35,16 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||||
const logger = createLogger('IPC:cliInstaller');
|
const logger = createLogger('IPC:cliInstaller');
|
||||||
|
|
||||||
let service: CliInstallerService;
|
let service: CliInstallerService;
|
||||||
let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
const statusInFlight = new Map<CliInstallerProviderStatusMode, Promise<CliInstallationStatus>>();
|
||||||
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
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;
|
let statusCacheGeneration = 0;
|
||||||
const STATUS_CACHE_TTL_MS = 5_000;
|
const STATUS_CACHE_TTL_MS = 5_000;
|
||||||
const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']);
|
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 {
|
function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean {
|
||||||
return FRONTEND_MULTIMODEL_PROVIDER_IDS.has(providerId);
|
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.
|
* Initializes CLI installer handlers with the service instance.
|
||||||
*/
|
*/
|
||||||
|
|
@ -92,32 +131,36 @@ export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
async function handleGetStatus(
|
async function handleGetStatus(
|
||||||
_event: IpcMainInvokeEvent
|
_event: IpcMainInvokeEvent,
|
||||||
|
options?: CliInstallerGetStatusOptions
|
||||||
): Promise<IpcResult<CliInstallationStatus>> {
|
): Promise<IpcResult<CliInstallationStatus>> {
|
||||||
try {
|
try {
|
||||||
|
const normalizedOptions = normalizeGetStatusOptions(options);
|
||||||
|
const cacheKey = normalizedOptions.providerStatusMode;
|
||||||
const latestSnapshot = service.getLatestStatusSnapshot();
|
const latestSnapshot = service.getLatestStatusSnapshot();
|
||||||
if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) {
|
const cached = cachedStatus.get(cacheKey);
|
||||||
if (latestSnapshot) {
|
if (cached && Date.now() - cached.at < STATUS_CACHE_TTL_MS) {
|
||||||
cachedStatus = { value: latestSnapshot, at: Date.now() };
|
if (latestSnapshot && canUseLatestSnapshotForCacheKey(cacheKey, latestSnapshot)) {
|
||||||
|
cachedStatus.set(cacheKey, { value: latestSnapshot, at: Date.now() });
|
||||||
return { success: true, data: latestSnapshot };
|
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 startedAt = Date.now();
|
||||||
const generation = statusCacheGeneration;
|
const generation = statusCacheGeneration;
|
||||||
const request = service
|
const request = service
|
||||||
.getStatus()
|
.getStatus(normalizedOptions)
|
||||||
.then((status) => {
|
.then((status) => {
|
||||||
if (generation === statusCacheGeneration) {
|
if (generation === statusCacheGeneration) {
|
||||||
cachedStatus = { value: status, at: Date.now() };
|
cachedStatus.set(cacheKey, { value: status, at: Date.now() });
|
||||||
}
|
}
|
||||||
return status;
|
return status;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (generation === statusCacheGeneration) {
|
if (generation === statusCacheGeneration) {
|
||||||
cachedStatus = null;
|
cachedStatus.delete(cacheKey);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
})
|
})
|
||||||
|
|
@ -126,14 +169,14 @@ async function handleGetStatus(
|
||||||
if (ms >= 2000) {
|
if (ms >= 2000) {
|
||||||
logger.warn(`cliInstaller:getStatus slow ms=${ms}`);
|
logger.warn(`cliInstaller:getStatus slow ms=${ms}`);
|
||||||
}
|
}
|
||||||
if (statusInFlight === request) {
|
if (statusInFlight.get(cacheKey) === request) {
|
||||||
statusInFlight = null;
|
statusInFlight.delete(cacheKey);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
statusInFlight = request;
|
statusInFlight.set(cacheKey, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await statusInFlight;
|
const status = await statusInFlight.get(cacheKey)!;
|
||||||
return { success: true, data: status };
|
return { success: true, data: status };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = getErrorMessage(error);
|
const msg = getErrorMessage(error);
|
||||||
|
|
@ -143,42 +186,44 @@ async function handleGetStatus(
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void {
|
function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void {
|
||||||
if (!cachedStatus || !providerStatus) {
|
if (!providerStatus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
for (const [cacheKey, cached] of cachedStatus) {
|
||||||
cachedStatus.value.flavor === 'agent_teams_orchestrator' &&
|
if (
|
||||||
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
cached.value.flavor === 'agent_teams_orchestrator' &&
|
||||||
) {
|
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
||||||
return;
|
) {
|
||||||
|
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(
|
async function handleGetProviderStatus(
|
||||||
|
|
@ -248,8 +293,8 @@ async function handleVerifyProviderModels(
|
||||||
|
|
||||||
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||||
statusCacheGeneration += 1;
|
statusCacheGeneration += 1;
|
||||||
cachedStatus = null;
|
cachedStatus.clear();
|
||||||
statusInFlight = null;
|
statusInFlight.clear();
|
||||||
providerStatusInFlight.clear();
|
providerStatusInFlight.clear();
|
||||||
ClaudeBinaryResolver.clearCache();
|
ClaudeBinaryResolver.clearCache();
|
||||||
CodexBinaryResolver.clearCache();
|
CodexBinaryResolver.clearCache();
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,9 @@ import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
|
CliInstallerGetStatusOptions,
|
||||||
CliInstallerProgress,
|
CliInstallerProgress,
|
||||||
|
CliInstallerProviderStatusMode,
|
||||||
CliPlatform,
|
CliPlatform,
|
||||||
CliProviderId,
|
CliProviderId,
|
||||||
CliProviderModelAvailability,
|
CliProviderModelAvailability,
|
||||||
|
|
@ -868,8 +870,9 @@ export class CliInstallerService {
|
||||||
// Public: getStatus
|
// Public: getStatus
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async getStatus(): Promise<CliInstallationStatus> {
|
async getStatus(options: CliInstallerGetStatusOptions = {}): Promise<CliInstallationStatus> {
|
||||||
const statusStartedAt = Date.now();
|
const statusStartedAt = Date.now();
|
||||||
|
const providerStatusMode: CliInstallerProviderStatusMode = options.providerStatusMode ?? 'full';
|
||||||
const generation = ++this.statusGatherGeneration;
|
const generation = ++this.statusGatherGeneration;
|
||||||
const result = this.createInitialStatus();
|
const result = this.createInitialStatus();
|
||||||
this.latestProviderSignatures.clear();
|
this.latestProviderSignatures.clear();
|
||||||
|
|
@ -882,7 +885,7 @@ export class CliInstallerService {
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.gatherStatus(ref, runDiag, generation),
|
this.gatherStatus(ref, runDiag, generation, providerStatusMode),
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -1023,7 +1026,8 @@ export class CliInstallerService {
|
||||||
private async gatherStatus(
|
private async gatherStatus(
|
||||||
ref: { current: CliInstallationStatus },
|
ref: { current: CliInstallationStatus },
|
||||||
diag: CliInstallerStatusRunDiag,
|
diag: CliInstallerStatusRunDiag,
|
||||||
generation: number
|
generation: number,
|
||||||
|
providerStatusMode: CliInstallerProviderStatusMode
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
resetGatherDiag(diag);
|
resetGatherDiag(diag);
|
||||||
const shellEnvStartedAt = Date.now();
|
const shellEnvStartedAt = Date.now();
|
||||||
|
|
@ -1048,6 +1052,14 @@ export class CliInstallerService {
|
||||||
r.installedVersion = versionProbe.version;
|
r.installedVersion = versionProbe.version;
|
||||||
r.launchError = null;
|
r.launchError = null;
|
||||||
r.authStatusChecking = true;
|
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.rememberHealthyStatus(r);
|
||||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||||
|
|
||||||
|
|
@ -1186,6 +1198,28 @@ export class CliInstallerService {
|
||||||
result.authMethod = null;
|
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.
|
* 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.
|
* Wrapped in its own timeout to prevent slow auth from blocking the overall status.
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,7 @@ import type {
|
||||||
ClaudeRootFolderSelection,
|
ClaudeRootFolderSelection,
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
|
CliInstallerGetStatusOptions,
|
||||||
CliInstallerProgress,
|
CliInstallerProgress,
|
||||||
CliProviderId,
|
CliProviderId,
|
||||||
ConflictCheckResult,
|
ConflictCheckResult,
|
||||||
|
|
@ -1554,8 +1555,8 @@ const electronAPI: ElectronAPI = {
|
||||||
|
|
||||||
// ===== CLI Installer API =====
|
// ===== CLI Installer API =====
|
||||||
cliInstaller: {
|
cliInstaller: {
|
||||||
getStatus: async (): Promise<CliInstallationStatus> => {
|
getStatus: async (options?: CliInstallerGetStatusOptions): Promise<CliInstallationStatus> => {
|
||||||
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
|
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS, options);
|
||||||
},
|
},
|
||||||
getProviderStatus: async (providerId: CliProviderId) => {
|
getProviderStatus: async (providerId: CliProviderId) => {
|
||||||
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
|
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshCliStatusForCurrentMode({
|
void refreshCliStatusForCurrentMode({
|
||||||
multimodelEnabled,
|
multimodelEnabled,
|
||||||
|
providerStatusMode: 'defer',
|
||||||
bootstrapCliStatus,
|
bootstrapCliStatus,
|
||||||
fetchCliStatus,
|
fetchCliStatus,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
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(): {
|
export function useCliInstaller(): {
|
||||||
cliStatus: CliInstallationStatus | null;
|
cliStatus: CliInstallationStatus | null;
|
||||||
|
|
@ -37,7 +42,10 @@ export function useCliInstaller(): {
|
||||||
codexRuntimeStatus: CodexRuntimeStatus | null;
|
codexRuntimeStatus: CodexRuntimeStatus | null;
|
||||||
codexRuntimeStatusLoading: boolean;
|
codexRuntimeStatusLoading: boolean;
|
||||||
codexRuntimeError: string | null;
|
codexRuntimeError: string | null;
|
||||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
bootstrapCliStatus: (options?: {
|
||||||
|
multimodelEnabled?: boolean;
|
||||||
|
providerStatusMode?: CliInstallerProviderStatusMode;
|
||||||
|
}) => Promise<void>;
|
||||||
fetchCliStatus: () => Promise<void>;
|
fetchCliStatus: () => Promise<void>;
|
||||||
fetchCliProviderStatus: (
|
fetchCliProviderStatus: (
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
|
||||||
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
||||||
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
|
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
|
||||||
const TASK_LOG_ACTIVITY_PULSE_MS = 3_500;
|
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']> =
|
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
|
||||||
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
||||||
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
|
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
|
||||||
|
|
@ -209,6 +210,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const cleanupFns: (() => void)[] = [];
|
const cleanupFns: (() => void)[] = [];
|
||||||
cleanupFns.push(installTeamRefreshFanoutDebugBridge());
|
cleanupFns.push(installTeamRefreshFanoutDebugBridge());
|
||||||
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
useStore.getState().subscribeProvisioningProgress();
|
useStore.getState().subscribeProvisioningProgress();
|
||||||
cleanupFns.push(() => {
|
cleanupFns.push(() => {
|
||||||
useStore.getState().unsubscribeProvisioningProgress();
|
useStore.getState().unsubscribeProvisioningProgress();
|
||||||
|
|
@ -241,19 +243,24 @@ export function initializeNotificationListeners(): () => void {
|
||||||
cliStatusTimer = setTimeout(() => {
|
cliStatusTimer = setTimeout(() => {
|
||||||
const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
|
const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
|
||||||
if (multimodelEnabled) {
|
if (multimodelEnabled) {
|
||||||
void useStore.getState().bootstrapCliStatus({ multimodelEnabled: true });
|
void useStore
|
||||||
|
.getState()
|
||||||
|
.bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' });
|
||||||
} else {
|
} else {
|
||||||
void useStore.getState().fetchCliStatus();
|
void useStore.getState().fetchCliStatus();
|
||||||
}
|
}
|
||||||
cliStatusTimer = null;
|
cliStatusTimer = null;
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
}
|
}
|
||||||
if (api.openCodeRuntime) {
|
runtimeStatusTimer = setTimeout(() => {
|
||||||
void useStore.getState().fetchOpenCodeRuntimeStatus();
|
if (api.openCodeRuntime) {
|
||||||
}
|
void useStore.getState().fetchOpenCodeRuntimeStatus();
|
||||||
if (api.codexRuntime) {
|
}
|
||||||
void useStore.getState().fetchCodexRuntimeStatus();
|
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
|
// Remaining fetches have no data dependency on each other — run in parallel
|
||||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||||
|
|
@ -267,6 +274,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
})();
|
})();
|
||||||
cleanupFns.push(() => {
|
cleanupFns.push(() => {
|
||||||
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
||||||
|
if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer);
|
||||||
});
|
});
|
||||||
// TODO(task-change-presence): re-enable this only after the board uses a bounded
|
// 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
|
// batch/priority presence pipeline. The old one-task-per-tick poll was accurate
|
||||||
|
|
|
||||||
|
|
@ -659,7 +659,10 @@ export interface CliInstallerSlice {
|
||||||
codexRuntimeError: string | null;
|
codexRuntimeError: string | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
bootstrapCliStatus: (options?: {
|
||||||
|
multimodelEnabled?: boolean;
|
||||||
|
providerStatusMode?: 'full' | 'defer';
|
||||||
|
}) => Promise<void>;
|
||||||
fetchCliStatus: () => Promise<void>;
|
fetchCliStatus: () => Promise<void>;
|
||||||
fetchCliProviderStatus: (
|
fetchCliProviderStatus: (
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
|
|
@ -714,13 +717,15 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
bootstrapCliStatus: async (options) => {
|
bootstrapCliStatus: async (options) => {
|
||||||
if (!api.cliInstaller) return;
|
if (!api.cliInstaller) return;
|
||||||
const multimodelEnabled = options?.multimodelEnabled ?? true;
|
const multimodelEnabled = options?.multimodelEnabled ?? true;
|
||||||
|
const providerStatusMode = options?.providerStatusMode ?? 'full';
|
||||||
|
const hydrateProviders = providerStatusMode !== 'defer';
|
||||||
if (!multimodelEnabled) {
|
if (!multimodelEnabled) {
|
||||||
return get().fetchCliStatus();
|
return get().fetchCliStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
const epoch = ++cliStatusEpoch;
|
const epoch = ++cliStatusEpoch;
|
||||||
const providerLoading = Object.fromEntries(
|
const providerLoading = Object.fromEntries(
|
||||||
MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, true])
|
MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, hydrateProviders])
|
||||||
) as Partial<Record<CliProviderId, boolean>>;
|
) as Partial<Record<CliProviderId, boolean>>;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
|
|
@ -731,7 +736,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const metadata = await api.cliInstaller.getStatus();
|
const metadata = await api.cliInstaller.getStatus(
|
||||||
|
providerStatusMode === 'defer' ? { providerStatusMode } : undefined
|
||||||
|
);
|
||||||
if (metadata.flavor !== 'agent_teams_orchestrator') {
|
if (metadata.flavor !== 'agent_teams_orchestrator') {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (epoch !== cliStatusEpoch) {
|
if (epoch !== cliStatusEpoch) {
|
||||||
|
|
@ -756,9 +763,10 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
const nextProviderLoading = Object.fromEntries(
|
const nextProviderLoading = Object.fromEntries(
|
||||||
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
|
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
|
||||||
providerId,
|
providerId,
|
||||||
!isHydratedMultimodelProviderStatus(
|
hydrateProviders &&
|
||||||
metadata.providers.find((provider) => provider.providerId === providerId)
|
!isHydratedMultimodelProviderStatus(
|
||||||
),
|
metadata.providers.find((provider) => provider.providerId === providerId)
|
||||||
|
),
|
||||||
])
|
])
|
||||||
) as Partial<Record<CliProviderId, boolean>>;
|
) as Partial<Record<CliProviderId, boolean>>;
|
||||||
const pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter(
|
const pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter(
|
||||||
|
|
@ -800,7 +808,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingProviderIds.length === 0) {
|
if (!hydrateProviders || pendingProviderIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -818,14 +826,16 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled(
|
if (hydrateProviders) {
|
||||||
MULTIMODEL_PROVIDER_IDS.map((providerId) =>
|
await Promise.allSettled(
|
||||||
get().fetchCliProviderStatus(providerId, {
|
MULTIMODEL_PROVIDER_IDS.map((providerId) =>
|
||||||
silent: false,
|
get().fetchCliProviderStatus(providerId, {
|
||||||
epoch,
|
silent: false,
|
||||||
})
|
epoch,
|
||||||
)
|
})
|
||||||
);
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (epoch === cliStatusEpoch) {
|
if (epoch === cliStatusEpoch) {
|
||||||
set({ cliStatusLoading: false });
|
set({ cliStatusLoading: false });
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,27 @@
|
||||||
|
import type { CliInstallerProviderStatusMode } from '@shared/types';
|
||||||
|
|
||||||
interface RefreshCliStatusOptions {
|
interface RefreshCliStatusOptions {
|
||||||
multimodelEnabled: boolean;
|
multimodelEnabled: boolean;
|
||||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
providerStatusMode?: CliInstallerProviderStatusMode;
|
||||||
|
bootstrapCliStatus: (options?: {
|
||||||
|
multimodelEnabled?: boolean;
|
||||||
|
providerStatusMode?: CliInstallerProviderStatusMode;
|
||||||
|
}) => Promise<void>;
|
||||||
fetchCliStatus: () => Promise<void>;
|
fetchCliStatus: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refreshCliStatusForCurrentMode({
|
export function refreshCliStatusForCurrentMode({
|
||||||
multimodelEnabled,
|
multimodelEnabled,
|
||||||
|
providerStatusMode,
|
||||||
bootstrapCliStatus,
|
bootstrapCliStatus,
|
||||||
fetchCliStatus,
|
fetchCliStatus,
|
||||||
}: RefreshCliStatusOptions): Promise<void> {
|
}: RefreshCliStatusOptions): Promise<void> {
|
||||||
if (multimodelEnabled) {
|
if (multimodelEnabled) {
|
||||||
return bootstrapCliStatus({ multimodelEnabled: true });
|
return bootstrapCliStatus(
|
||||||
|
providerStatusMode
|
||||||
|
? { multimodelEnabled: true, providerStatusMode }
|
||||||
|
: { multimodelEnabled: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchCliStatus();
|
return fetchCliStatus();
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,16 @@ export interface CliInstallerProgress {
|
||||||
status?: CliInstallationStatus;
|
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
|
// Preload API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -360,7 +370,7 @@ export interface CliInstallerProgress {
|
||||||
*/
|
*/
|
||||||
export interface CliInstallerAPI {
|
export interface CliInstallerAPI {
|
||||||
/** Get current CLI installation status */
|
/** Get current CLI installation status */
|
||||||
getStatus: () => Promise<CliInstallationStatus>;
|
getStatus: (options?: CliInstallerGetStatusOptions) => Promise<CliInstallationStatus>;
|
||||||
/** Get current runtime/auth status for a single provider */
|
/** Get current runtime/auth status for a single provider */
|
||||||
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
|
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
|
||||||
/** Start on-demand model verification for a single runtime provider */
|
/** Start on-demand model verification for a single runtime provider */
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,112 @@ describe('cliInstaller IPC handlers', () => {
|
||||||
expect(cached.data?.providers[0]?.statusMessage).toBe('ChatGPT account ready');
|
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 () => {
|
it('does not let a stale in-flight provider refresh patch the cache after invalidation', async () => {
|
||||||
const staleProviderRequest = deferred<CliProviderStatus | null>();
|
const staleProviderRequest = deferred<CliProviderStatus | null>();
|
||||||
service.getStatus
|
service.getStatus
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,70 @@ describe('CliInstallerService', () => {
|
||||||
expect(service.getLatestStatusSnapshot()?.authLoggedIn).toBe(false);
|
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 () => {
|
it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { act } from 'react';
|
import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||||
|
|
@ -346,7 +347,10 @@ describe('ExtensionStoreView provider loading placeholders', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({ multimodelEnabled: true });
|
expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({
|
||||||
|
multimodelEnabled: true,
|
||||||
|
providerStatusMode: 'defer',
|
||||||
|
});
|
||||||
expect(storeState.fetchCliStatus).not.toHaveBeenCalled();
|
expect(storeState.fetchCliStatus).not.toHaveBeenCalled();
|
||||||
expect(storeState.fetchApiKeys).not.toHaveBeenCalled();
|
expect(storeState.fetchApiKeys).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -888,6 +888,67 @@ describe('cliInstallerSlice', () => {
|
||||||
expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled();
|
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 () => {
|
it('drops global loading once metadata is ready and keeps only unresolved providers loading', async () => {
|
||||||
let resolveCodexStatus!: (value: CliInstallationStatus['providers'][number]) => void;
|
let resolveCodexStatus!: (value: CliInstallationStatus['providers'][number]) => void;
|
||||||
const pendingCodexStatus = new Promise<CliInstallationStatus['providers'][number]>(
|
const pendingCodexStatus = new Promise<CliInstallationStatus['providers'][number]>(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
describe('refreshCliStatusForCurrentMode', () => {
|
describe('refreshCliStatusForCurrentMode', () => {
|
||||||
it('uses provider-first bootstrap when multimodel is enabled', async () => {
|
it('uses provider-first bootstrap when multimodel is enabled', async () => {
|
||||||
|
|
@ -17,6 +16,24 @@ describe('refreshCliStatusForCurrentMode', () => {
|
||||||
expect(fetchCliStatus).not.toHaveBeenCalled();
|
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 () => {
|
it('falls back to legacy status fetch when multimodel is disabled', async () => {
|
||||||
const bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
|
const bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
|
||||||
const fetchCliStatus = vi.fn().mockResolvedValue(undefined);
|
const fetchCliStatus = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue