fix(cli): prevent stale status hydration

This commit is contained in:
777genius 2026-05-22 00:18:59 +03:00
parent 13b14762bc
commit b5ca3eed68
5 changed files with 520 additions and 48 deletions

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

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