fix(cli): prevent stale status hydration
This commit is contained in:
parent
13b14762bc
commit
b5ca3eed68
5 changed files with 520 additions and 48 deletions
|
|
@ -233,8 +233,11 @@ async function handleVerifyProviderModels(
|
|||
providerId: CliProviderId
|
||||
): Promise<IpcResult<CliProviderStatus | null>> {
|
||||
try {
|
||||
const generation = statusCacheGeneration;
|
||||
const status = await service.verifyProviderModels(providerId);
|
||||
patchCachedProviderStatus(status);
|
||||
if (generation === statusCacheGeneration) {
|
||||
patchCachedProviderStatus(status);
|
||||
}
|
||||
return { success: true, data: status };
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ const GET_STATUS_TIMEOUT_MS = 30_000;
|
|||
/** Overall timeout for the auth status check (covers both attempts + retry delay) (ms) */
|
||||
const AUTH_TOTAL_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Initial multimodel provider status budget for startup metadata (final status hydrates async). */
|
||||
const MULTIMODEL_PROVIDER_STATUS_INITIAL_TIMEOUT_MS = 1_500;
|
||||
const GET_STATUS_TIMING_LOG_THRESHOLD_MS = 2_000;
|
||||
|
||||
/** Max retries for EBUSY (antivirus scanning the new binary) */
|
||||
const EBUSY_MAX_RETRIES = 3;
|
||||
|
||||
|
|
@ -397,6 +401,12 @@ interface CliInstallerStatusRunDiag {
|
|||
authStdoutTail: string;
|
||||
authTimedOut: boolean;
|
||||
gatherError: string | null;
|
||||
shellEnvMs: number | null;
|
||||
binaryResolveMs: number | null;
|
||||
versionProbeMs: number | null;
|
||||
providerInitialWaitMs: number | null;
|
||||
totalMs: number | null;
|
||||
diagWriteScheduled: boolean;
|
||||
}
|
||||
|
||||
function createCliInstallerRunDiag(): CliInstallerStatusRunDiag {
|
||||
|
|
@ -408,6 +418,12 @@ function createCliInstallerRunDiag(): CliInstallerStatusRunDiag {
|
|||
authStdoutTail: '',
|
||||
authTimedOut: false,
|
||||
gatherError: null,
|
||||
shellEnvMs: null,
|
||||
binaryResolveMs: null,
|
||||
versionProbeMs: null,
|
||||
providerInitialWaitMs: null,
|
||||
totalMs: null,
|
||||
diagWriteScheduled: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -419,6 +435,16 @@ function resetGatherDiag(diag: CliInstallerStatusRunDiag): void {
|
|||
diag.authStdoutTail = '';
|
||||
diag.authTimedOut = false;
|
||||
diag.gatherError = null;
|
||||
diag.shellEnvMs = null;
|
||||
diag.binaryResolveMs = null;
|
||||
diag.versionProbeMs = null;
|
||||
diag.providerInitialWaitMs = null;
|
||||
diag.totalMs = null;
|
||||
diag.diagWriteScheduled = false;
|
||||
}
|
||||
|
||||
function cloneCliInstallerRunDiag(diag: CliInstallerStatusRunDiag): CliInstallerStatusRunDiag {
|
||||
return { ...diag };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -437,6 +463,7 @@ export class CliInstallerService {
|
|||
private latestStatusSnapshot: CliInstallationStatus | null = null;
|
||||
private lastHealthyStatusSnapshot: CliInstallationStatus | null = null;
|
||||
private lastHealthyStatusObservedAt = 0;
|
||||
private statusGatherGeneration = 0;
|
||||
private readonly latestProviderSignatures = new Map<CliProviderId, string | null>();
|
||||
|
||||
private rememberHealthyStatus(status: CliInstallationStatus): void {
|
||||
|
|
@ -524,9 +551,53 @@ export class CliInstallerService {
|
|||
authStdoutTail: clipTailForDiag(diag.authStdoutTail, DIAG_AUTH_STDOUT_TAIL),
|
||||
authProbeTimedOut: diag.authTimedOut,
|
||||
gatherThrownError: diag.gatherError,
|
||||
shellEnvMs: diag.shellEnvMs,
|
||||
binaryResolveMs: diag.binaryResolveMs,
|
||||
versionProbeMs: diag.versionProbeMs,
|
||||
providerInitialWaitMs: diag.providerInitialWaitMs,
|
||||
totalMs: diag.totalMs,
|
||||
diagWriteScheduled: diag.diagWriteScheduled,
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleCliInstallerStatusDiag(
|
||||
r: CliInstallationStatus,
|
||||
diag: CliInstallerStatusRunDiag
|
||||
): void {
|
||||
const statusForDiag = cloneCliInstallationStatus(r);
|
||||
const diagForWrite = cloneCliInstallerRunDiag(diag);
|
||||
|
||||
queueMicrotask(() => {
|
||||
const writeStartedAt = Date.now();
|
||||
void this.writeCliInstallerStatusDiag(statusForDiag, diagForWrite)
|
||||
.then(() => {
|
||||
const diagWriteMs = Date.now() - writeStartedAt;
|
||||
if (diagWriteMs >= GET_STATUS_TIMING_LOG_THRESHOLD_MS) {
|
||||
logger.warn(`getStatus diagnostic write slow diagWriteMs=${diagWriteMs}`);
|
||||
}
|
||||
})
|
||||
.catch((diagErr) => {
|
||||
logger.error('writeCliInstallerStatusDiag failed:', getErrorMessage(diagErr));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private logGetStatusTimingIfSlow(diag: CliInstallerStatusRunDiag): void {
|
||||
const totalMs = diag.totalMs ?? 0;
|
||||
if (totalMs < GET_STATUS_TIMING_LOG_THRESHOLD_MS && !diag.authTimedOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`getStatus timing totalMs=${totalMs}` +
|
||||
` shellEnvMs=${diag.shellEnvMs ?? 'n/a'}` +
|
||||
` binaryResolveMs=${diag.binaryResolveMs ?? 'n/a'}` +
|
||||
` versionProbeMs=${diag.versionProbeMs ?? 'n/a'}` +
|
||||
` providerInitialWaitMs=${diag.providerInitialWaitMs ?? 'n/a'}` +
|
||||
` diagWriteScheduled=${diag.diagWriteScheduled}`
|
||||
);
|
||||
}
|
||||
|
||||
setMainWindow(window: BrowserWindow | null): void {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
|
|
@ -536,6 +607,7 @@ export class CliInstallerService {
|
|||
}
|
||||
|
||||
invalidateStatusCache(): void {
|
||||
this.statusGatherGeneration += 1;
|
||||
this.latestStatusSnapshot = null;
|
||||
this.latestProviderSignatures.clear();
|
||||
this.modelAvailabilityService.invalidate();
|
||||
|
|
@ -614,6 +686,14 @@ export class CliInstallerService {
|
|||
});
|
||||
}
|
||||
|
||||
private publishStatusSnapshotIfCurrent(status: CliInstallationStatus, generation: number): void {
|
||||
if (generation !== this.statusGatherGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.publishStatusSnapshot(status);
|
||||
}
|
||||
|
||||
private buildProviderModelAvailabilityContext(
|
||||
binaryPath: string,
|
||||
installedVersion: string | null,
|
||||
|
|
@ -740,6 +820,18 @@ export class CliInstallerService {
|
|||
};
|
||||
}
|
||||
|
||||
private updateLatestProviderStatusIfCurrent(
|
||||
providerStatus: CliProviderStatus,
|
||||
generation: number
|
||||
): boolean {
|
||||
if (generation !== this.statusGatherGeneration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.updateLatestProviderStatus(providerStatus);
|
||||
return true;
|
||||
}
|
||||
|
||||
private getLatestProviderStatusForModelVerification(
|
||||
providerId: CliProviderId,
|
||||
binaryPath: string,
|
||||
|
|
@ -777,6 +869,8 @@ export class CliInstallerService {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getStatus(): Promise<CliInstallationStatus> {
|
||||
const statusStartedAt = Date.now();
|
||||
const generation = ++this.statusGatherGeneration;
|
||||
const result = this.createInitialStatus();
|
||||
this.latestProviderSignatures.clear();
|
||||
this.latestStatusSnapshot = cloneCliInstallationStatus(result);
|
||||
|
|
@ -788,7 +882,7 @@ export class CliInstallerService {
|
|||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
this.gatherStatus(ref, runDiag),
|
||||
this.gatherStatus(ref, runDiag, generation),
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
logger.warn(
|
||||
|
|
@ -806,16 +900,19 @@ export class CliInstallerService {
|
|||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
try {
|
||||
await this.writeCliInstallerStatusDiag(result, runDiag);
|
||||
} catch (diagErr) {
|
||||
logger.error('writeCliInstallerStatusDiag failed:', getErrorMessage(diagErr));
|
||||
}
|
||||
runDiag.totalMs = Date.now() - statusStartedAt;
|
||||
runDiag.diagWriteScheduled = true;
|
||||
this.scheduleCliInstallerStatusDiag(result, runDiag);
|
||||
this.logGetStatusTimingIfSlow(runDiag);
|
||||
}
|
||||
}
|
||||
|
||||
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
|
|
@ -828,6 +925,7 @@ export class CliInstallerService {
|
|||
return fullStatus.providers.find((provider) => provider.providerId === providerId) ?? null;
|
||||
}
|
||||
|
||||
const generation = this.statusGatherGeneration;
|
||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||
if (!versionProbe.ok) {
|
||||
return null;
|
||||
|
|
@ -837,18 +935,24 @@ export class CliInstallerService {
|
|||
binaryPath,
|
||||
providerId,
|
||||
(hydratedProviderStatus) => {
|
||||
this.updateLatestProviderStatus(hydratedProviderStatus);
|
||||
if (!this.updateLatestProviderStatusIfCurrent(hydratedProviderStatus, generation)) {
|
||||
return;
|
||||
}
|
||||
if (this.latestStatusSnapshot) {
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
}
|
||||
}
|
||||
);
|
||||
this.updateLatestProviderStatus(providerStatus);
|
||||
this.updateLatestProviderStatusIfCurrent(providerStatus, generation);
|
||||
return providerStatus;
|
||||
}
|
||||
|
||||
async verifyProviderModels(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
|
|
@ -860,6 +964,7 @@ export class CliInstallerService {
|
|||
return this.getProviderStatus(providerId);
|
||||
}
|
||||
|
||||
const generation = this.statusGatherGeneration;
|
||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||
if (!versionProbe.ok) {
|
||||
return null;
|
||||
|
|
@ -875,8 +980,10 @@ export class CliInstallerService {
|
|||
modelVerificationState: 'idle' as const,
|
||||
modelAvailability: [],
|
||||
};
|
||||
this.updateLatestProviderStatus(nextProviderStatus);
|
||||
if (this.latestStatusSnapshot) {
|
||||
if (
|
||||
this.updateLatestProviderStatusIfCurrent(nextProviderStatus, generation) &&
|
||||
this.latestStatusSnapshot
|
||||
) {
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
}
|
||||
return nextProviderStatus;
|
||||
|
|
@ -888,13 +995,19 @@ export class CliInstallerService {
|
|||
binaryPath,
|
||||
versionProbe.version
|
||||
) ?? (await this.multimodelBridgeService.getProviderStatus(binaryPath, providerId));
|
||||
if (generation !== this.statusGatherGeneration) {
|
||||
return providerStatus;
|
||||
}
|
||||
|
||||
const nextProviderStatus = this.applyProviderModelAvailabilityToProvider(
|
||||
binaryPath,
|
||||
versionProbe.version,
|
||||
providerStatus
|
||||
);
|
||||
this.updateLatestProviderStatus(nextProviderStatus);
|
||||
if (this.latestStatusSnapshot) {
|
||||
if (
|
||||
this.updateLatestProviderStatusIfCurrent(nextProviderStatus, generation) &&
|
||||
this.latestStatusSnapshot
|
||||
) {
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
}
|
||||
return nextProviderStatus;
|
||||
|
|
@ -909,32 +1022,43 @@ export class CliInstallerService {
|
|||
*/
|
||||
private async gatherStatus(
|
||||
ref: { current: CliInstallationStatus },
|
||||
diag: CliInstallerStatusRunDiag
|
||||
diag: CliInstallerStatusRunDiag,
|
||||
generation: number
|
||||
): Promise<void> {
|
||||
resetGatherDiag(diag);
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
const shellEnvStartedAt = Date.now();
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
diag.shellEnvMs = Date.now() - shellEnvStartedAt;
|
||||
|
||||
const r = ref.current;
|
||||
const binaryResolveStartedAt = Date.now();
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
diag.binaryResolveMs = Date.now() - binaryResolveStartedAt;
|
||||
if (binaryPath) {
|
||||
r.binaryPath = binaryPath;
|
||||
const versionProbeStartedAt = Date.now();
|
||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||
diag.versionProbeMs = Date.now() - versionProbeStartedAt;
|
||||
if (versionProbe.ok) {
|
||||
r.installed = true;
|
||||
r.installedVersion = versionProbe.version;
|
||||
r.launchError = null;
|
||||
r.authStatusChecking = true;
|
||||
this.rememberHealthyStatus(r);
|
||||
this.publishStatusSnapshot(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
|
||||
// Auth and GCS version check are independent — run in parallel.
|
||||
// Both mutate `r` directly so partial results survive the outer timeout.
|
||||
await Promise.all([
|
||||
this.checkAuthStatus(binaryPath, r, diag),
|
||||
this.checkAuthStatus(binaryPath, r, diag, generation),
|
||||
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
|
||||
]);
|
||||
this.rememberHealthyStatus(r);
|
||||
this.publishStatusSnapshot(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
} else {
|
||||
const recoveredHealthyStatus = this.getRecoverableHealthyStatus(binaryPath);
|
||||
if (recoveredHealthyStatus) {
|
||||
|
|
@ -944,7 +1068,7 @@ export class CliInstallerService {
|
|||
Object.assign(r, recoveredHealthyStatus, {
|
||||
launchError: null,
|
||||
});
|
||||
this.publishStatusSnapshot(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -963,7 +1087,7 @@ export class CliInstallerService {
|
|||
if (r.supportsSelfUpdate) {
|
||||
await this.fetchLatestVersion(r);
|
||||
}
|
||||
this.publishStatusSnapshot(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
}
|
||||
} else {
|
||||
// No binary — still check latest version for "install" prompt
|
||||
|
|
@ -973,7 +1097,7 @@ export class CliInstallerService {
|
|||
if (r.supportsSelfUpdate) {
|
||||
await this.fetchLatestVersion(r);
|
||||
}
|
||||
this.publishStatusSnapshot(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1071,33 +1195,70 @@ export class CliInstallerService {
|
|||
private async checkAuthStatus(
|
||||
binaryPath: string,
|
||||
result: CliInstallationStatus,
|
||||
diag: CliInstallerStatusRunDiag
|
||||
diag: CliInstallerStatusRunDiag,
|
||||
generation: number
|
||||
): Promise<void> {
|
||||
if (result.flavor === 'agent_teams_orchestrator') {
|
||||
result.authStatusChecking = true;
|
||||
try {
|
||||
const providers = await this.multimodelBridgeService.getProviderStatuses(
|
||||
binaryPath,
|
||||
(providersSnapshot) => {
|
||||
const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot);
|
||||
result.providers = frontendProviders;
|
||||
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||
result.authMethod =
|
||||
getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||
this.publishStatusSnapshot(result);
|
||||
let statusTarget = result;
|
||||
const applyProviders = (providersSnapshot: CliProviderStatus[], final: boolean): void => {
|
||||
if (generation !== this.statusGatherGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = statusTarget;
|
||||
const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot);
|
||||
target.providers = frontendProviders;
|
||||
target.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||
target.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||
if (final) {
|
||||
target.authStatusChecking = false;
|
||||
this.rememberHealthyStatus(target);
|
||||
}
|
||||
this.publishStatusSnapshot(target);
|
||||
};
|
||||
|
||||
const completion = this.multimodelBridgeService
|
||||
.getProviderStatuses(binaryPath, (providersSnapshot) => {
|
||||
applyProviders(providersSnapshot, false);
|
||||
})
|
||||
.then((providers) => {
|
||||
applyProviders(providers, true);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== this.statusGatherGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = getErrorMessage(error);
|
||||
diag.authLastError = msg;
|
||||
result.authStatusChecking = false;
|
||||
logger.warn(`Provider status check failed for claude-multimodel: ${msg}`);
|
||||
this.publishStatusSnapshot(result);
|
||||
});
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<'timeout'>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
statusTarget = cloneCliInstallationStatus(result);
|
||||
resolve('timeout');
|
||||
}, MULTIMODEL_PROVIDER_STATUS_INITIAL_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
});
|
||||
|
||||
const providerInitialWaitStartedAt = Date.now();
|
||||
const outcome = await Promise.race([completion.then(() => 'completed' as const), timeout]);
|
||||
diag.providerInitialWaitMs = Date.now() - providerInitialWaitStartedAt;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (outcome === 'timeout') {
|
||||
diag.authTimedOut = true;
|
||||
logger.warn(
|
||||
`Provider status check still running after ${MULTIMODEL_PROVIDER_STATUS_INITIAL_TIMEOUT_MS}ms; returning partial CLI status`
|
||||
);
|
||||
const frontendProviders = filterFrontendMultimodelProviders(providers);
|
||||
result.providers = frontendProviders;
|
||||
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||
result.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||
result.authStatusChecking = false;
|
||||
this.publishStatusSnapshot(result);
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
diag.authLastError = msg;
|
||||
result.authStatusChecking = false;
|
||||
logger.warn(`Provider status check failed for claude-multimodel: ${msg}`);
|
||||
this.publishStatusSnapshotIfCurrent(result, generation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,17 @@ import {
|
|||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||
CLI_INSTALLER_GET_STATUS,
|
||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||
CLI_INSTALLER_VERIFY_PROVIDER_MODELS,
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
import type { CliInstallerService } from '@main/services';
|
||||
import type { CliInstallationStatus, CliProviderId, CliProviderStatus, IpcResult } from '@shared/types';
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
CliProviderId,
|
||||
CliProviderStatus,
|
||||
IpcResult,
|
||||
} from '@shared/types';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
type IpcHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown;
|
||||
|
|
@ -328,4 +334,70 @@ describe('cliInstaller IPC handlers', () => {
|
|||
'ChatGPT account ready'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let a stale model verification patch the cache after invalidation', async () => {
|
||||
const staleVerificationRequest = deferred<CliProviderStatus | null>();
|
||||
service.getStatus
|
||||
.mockResolvedValueOnce(
|
||||
status([
|
||||
provider({ providerId: 'anthropic' }),
|
||||
provider({ providerId: 'codex', statusMessage: 'Checking...' }),
|
||||
])
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
status([
|
||||
provider({ providerId: 'anthropic' }),
|
||||
provider({
|
||||
providerId: 'codex',
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
}),
|
||||
])
|
||||
);
|
||||
service.verifyProviderModels.mockReturnValueOnce(staleVerificationRequest.promise);
|
||||
|
||||
const initial = (await ipcMain.invoke(
|
||||
CLI_INSTALLER_GET_STATUS
|
||||
)) as IpcResult<CliInstallationStatus>;
|
||||
expect(initial.success).toBe(true);
|
||||
expect(initial.data?.authLoggedIn).toBe(false);
|
||||
|
||||
const staleVerificationInvoke = ipcMain.invoke(
|
||||
CLI_INSTALLER_VERIFY_PROVIDER_MODELS,
|
||||
'codex'
|
||||
) as Promise<IpcResult<CliProviderStatus | null>>;
|
||||
await vi.waitFor(() => expect(service.verifyProviderModels).toHaveBeenCalledTimes(1));
|
||||
|
||||
await ipcMain.invoke(CLI_INSTALLER_INVALIDATE_STATUS);
|
||||
const fresh = (await ipcMain.invoke(
|
||||
CLI_INSTALLER_GET_STATUS
|
||||
)) as IpcResult<CliInstallationStatus>;
|
||||
expect(fresh.success).toBe(true);
|
||||
expect(fresh.data?.authLoggedIn).toBe(true);
|
||||
|
||||
staleVerificationRequest.resolve(
|
||||
provider({
|
||||
providerId: 'codex',
|
||||
verificationState: 'error',
|
||||
statusMessage: 'Stale model verification failed',
|
||||
})
|
||||
);
|
||||
await expect(staleVerificationInvoke).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { statusMessage: 'Stale model verification failed' },
|
||||
});
|
||||
|
||||
const cached = (await ipcMain.invoke(
|
||||
CLI_INSTALLER_GET_STATUS
|
||||
)) as IpcResult<CliInstallationStatus>;
|
||||
|
||||
expect(service.getStatus).toHaveBeenCalledTimes(2);
|
||||
expect(cached.success).toBe(true);
|
||||
expect(cached.data?.authLoggedIn).toBe(true);
|
||||
expect(cached.data?.providers.find((entry) => entry.providerId === 'codex')?.statusMessage).toBe(
|
||||
'ChatGPT account ready'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/cliAuthDiagLog', () => ({
|
||||
appendCliAuthDiag: vi.fn(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
import {
|
||||
CliInstallerService,
|
||||
isVersionOlder,
|
||||
|
|
@ -88,6 +92,9 @@ import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMult
|
|||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '@main/services/team/cliFlavor';
|
||||
import { execCli } from '@main/utils/childProcess';
|
||||
import { appendCliAuthDiag } from '@main/utils/cliAuthDiagLog';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
/**
|
||||
* Helper: allow expected console.error/warn calls in tests where service logs errors.
|
||||
|
|
@ -98,6 +105,42 @@ function allowConsoleLogs(): void {
|
|||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
}
|
||||
|
||||
function createTestProviderStatus(
|
||||
providerId: CliProviderId,
|
||||
authenticated: boolean,
|
||||
authMethod: string | null
|
||||
): CliProviderStatus {
|
||||
return {
|
||||
providerId,
|
||||
displayName: providerId,
|
||||
supported: true,
|
||||
authenticated,
|
||||
authMethod,
|
||||
verificationState: authenticated ? 'verified' : 'unknown',
|
||||
modelVerificationState: 'idle',
|
||||
modelCatalogRefreshState: 'idle',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
runtimeCapabilities: null,
|
||||
subscriptionRateLimits: null,
|
||||
canLoginFromUi: providerId !== 'opencode',
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: undefined as never,
|
||||
},
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: null,
|
||||
connection: null,
|
||||
modelCatalog: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CliInstallerService', () => {
|
||||
let service: CliInstallerService;
|
||||
|
||||
|
|
@ -128,6 +171,33 @@ describe('CliInstallerService', () => {
|
|||
expect(status.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block getStatus on diagnostic file writes', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
||||
displayName: 'Claude CLI',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: true,
|
||||
showBinaryPath: true,
|
||||
});
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(null);
|
||||
|
||||
let resolveDiag!: (value: string | null) => void;
|
||||
vi.mocked(appendCliAuthDiag).mockReturnValueOnce(
|
||||
new Promise<string | null>((resolve) => {
|
||||
resolveDiag = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.installed).toBe(false);
|
||||
await Promise.resolve();
|
||||
expect(appendCliAuthDiag).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveDiag(null);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it('includes frontend-visible providers in unavailable multimodel bootstrap status', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
||||
|
|
@ -1017,6 +1087,172 @@ describe('CliInstallerService', () => {
|
|||
expect(status.authLoggedIn).toBe(true);
|
||||
expect(status.authMethod).toBe('api_key');
|
||||
});
|
||||
|
||||
it('returns multimodel metadata before provider status hydration finishes', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.useFakeTimers();
|
||||
|
||||
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.45', stderr: '' });
|
||||
|
||||
let resolveProviders!: (providers: CliProviderStatus[]) => void;
|
||||
const providerStatuses = new Promise<CliProviderStatus[]>((resolve) => {
|
||||
resolveProviders = resolve;
|
||||
});
|
||||
const providerStatusesSpy = vi.spyOn(
|
||||
ClaudeMultimodelBridgeService.prototype,
|
||||
'getProviderStatuses'
|
||||
).mockReturnValue(providerStatuses);
|
||||
|
||||
const statusPromise = service.getStatus();
|
||||
await vi.advanceTimersByTimeAsync(1_600);
|
||||
|
||||
const status = await statusPromise;
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.installedVersion).toBe('0.0.45');
|
||||
expect(status.authStatusChecking).toBe(true);
|
||||
expect(status.providers.every((provider) => provider.statusMessage === 'Checking...')).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
resolveProviders([
|
||||
createTestProviderStatus('anthropic', true, 'oauth_token'),
|
||||
createTestProviderStatus('codex', false, null),
|
||||
createTestProviderStatus('opencode', false, null),
|
||||
]);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const latest = service.getLatestStatusSnapshot();
|
||||
expect(latest?.authStatusChecking).toBe(false);
|
||||
expect(latest?.authLoggedIn).toBe(true);
|
||||
expect(latest?.authMethod).toBe('oauth_token');
|
||||
expect(status.authStatusChecking).toBe(true);
|
||||
expect(status.authLoggedIn).toBe(false);
|
||||
expect(status.providers.every((provider) => provider.statusMessage === 'Checking...')).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
providerStatusesSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not publish stale background provider hydration after status invalidation', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.useFakeTimers();
|
||||
|
||||
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.45', stderr: '' });
|
||||
|
||||
let resolveProviders!: (providers: CliProviderStatus[]) => void;
|
||||
const providerStatuses = new Promise<CliProviderStatus[]>((resolve) => {
|
||||
resolveProviders = resolve;
|
||||
});
|
||||
const providerStatusesSpy = vi.spyOn(
|
||||
ClaudeMultimodelBridgeService.prototype,
|
||||
'getProviderStatuses'
|
||||
).mockReturnValue(providerStatuses);
|
||||
|
||||
const statusPromise = service.getStatus();
|
||||
await vi.advanceTimersByTimeAsync(1_600);
|
||||
await statusPromise;
|
||||
|
||||
service.invalidateStatusCache();
|
||||
expect(service.getLatestStatusSnapshot()).toBeNull();
|
||||
|
||||
resolveProviders([
|
||||
createTestProviderStatus('anthropic', true, 'oauth_token'),
|
||||
createTestProviderStatus('codex', false, null),
|
||||
createTestProviderStatus('opencode', false, null),
|
||||
]);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(service.getLatestStatusSnapshot()).toBeNull();
|
||||
|
||||
providerStatusesSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not let stale explicit provider refresh mutate a newer status snapshot', 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).mockResolvedValue({ stdout: '0.0.45', stderr: '' });
|
||||
|
||||
const providerStatusesSpy = vi
|
||||
.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses')
|
||||
.mockResolvedValueOnce([
|
||||
createTestProviderStatus('anthropic', false, null),
|
||||
{
|
||||
...createTestProviderStatus('codex', false, null),
|
||||
statusMessage: 'initial codex state',
|
||||
},
|
||||
createTestProviderStatus('opencode', false, null),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
createTestProviderStatus('anthropic', false, null),
|
||||
{
|
||||
...createTestProviderStatus('codex', true, 'chatgpt'),
|
||||
statusMessage: 'fresh codex state',
|
||||
},
|
||||
createTestProviderStatus('opencode', false, null),
|
||||
]);
|
||||
|
||||
let resolveStaleProvider!: (provider: CliProviderStatus) => void;
|
||||
const staleProvider = new Promise<CliProviderStatus>((resolve) => {
|
||||
resolveStaleProvider = resolve;
|
||||
});
|
||||
const providerStatusSpy = vi
|
||||
.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatus')
|
||||
.mockReturnValue(staleProvider);
|
||||
|
||||
await service.getStatus();
|
||||
const staleRefresh = service.getProviderStatus('codex');
|
||||
await vi.waitFor(() => {
|
||||
expect(providerStatusSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
service.invalidateStatusCache();
|
||||
await service.getStatus();
|
||||
|
||||
resolveStaleProvider({
|
||||
...createTestProviderStatus('codex', false, null),
|
||||
verificationState: 'error',
|
||||
statusMessage: 'stale codex state',
|
||||
});
|
||||
await staleRefresh;
|
||||
|
||||
const latestCodex = service
|
||||
.getLatestStatusSnapshot()
|
||||
?.providers.find((provider) => provider.providerId === 'codex');
|
||||
expect(latestCodex?.statusMessage).toBe('fresh codex state');
|
||||
expect(latestCodex?.authenticated).toBe(true);
|
||||
|
||||
providerStatusesSpy.mockRestore();
|
||||
providerStatusSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth parallelism', () => {
|
||||
|
|
|
|||
|
|
@ -371,7 +371,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows multimodel status without exposing the legacy runtime toggle', async () => {
|
||||
it('does not expose the legacy runtime toggle or multimodel banner label', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -382,7 +382,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Multimodel');
|
||||
expect(host.textContent).not.toContain('Multimodel');
|
||||
expect(host.textContent).toContain('Login');
|
||||
|
||||
const toggle = host.querySelector('[data-testid="multimodel-toggle"]');
|
||||
|
|
|
|||
Loading…
Reference in a new issue