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
|
providerId: CliProviderId
|
||||||
): Promise<IpcResult<CliProviderStatus | null>> {
|
): Promise<IpcResult<CliProviderStatus | null>> {
|
||||||
try {
|
try {
|
||||||
|
const generation = statusCacheGeneration;
|
||||||
const status = await service.verifyProviderModels(providerId);
|
const status = await service.verifyProviderModels(providerId);
|
||||||
patchCachedProviderStatus(status);
|
if (generation === statusCacheGeneration) {
|
||||||
|
patchCachedProviderStatus(status);
|
||||||
|
}
|
||||||
return { success: true, data: status };
|
return { success: true, data: status };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = getErrorMessage(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) */
|
/** Overall timeout for the auth status check (covers both attempts + retry delay) (ms) */
|
||||||
const AUTH_TOTAL_TIMEOUT_MS = 15_000;
|
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) */
|
/** Max retries for EBUSY (antivirus scanning the new binary) */
|
||||||
const EBUSY_MAX_RETRIES = 3;
|
const EBUSY_MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
|
@ -397,6 +401,12 @@ interface CliInstallerStatusRunDiag {
|
||||||
authStdoutTail: string;
|
authStdoutTail: string;
|
||||||
authTimedOut: boolean;
|
authTimedOut: boolean;
|
||||||
gatherError: string | null;
|
gatherError: string | null;
|
||||||
|
shellEnvMs: number | null;
|
||||||
|
binaryResolveMs: number | null;
|
||||||
|
versionProbeMs: number | null;
|
||||||
|
providerInitialWaitMs: number | null;
|
||||||
|
totalMs: number | null;
|
||||||
|
diagWriteScheduled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCliInstallerRunDiag(): CliInstallerStatusRunDiag {
|
function createCliInstallerRunDiag(): CliInstallerStatusRunDiag {
|
||||||
|
|
@ -408,6 +418,12 @@ function createCliInstallerRunDiag(): CliInstallerStatusRunDiag {
|
||||||
authStdoutTail: '',
|
authStdoutTail: '',
|
||||||
authTimedOut: false,
|
authTimedOut: false,
|
||||||
gatherError: null,
|
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.authStdoutTail = '';
|
||||||
diag.authTimedOut = false;
|
diag.authTimedOut = false;
|
||||||
diag.gatherError = null;
|
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 latestStatusSnapshot: CliInstallationStatus | null = null;
|
||||||
private lastHealthyStatusSnapshot: CliInstallationStatus | null = null;
|
private lastHealthyStatusSnapshot: CliInstallationStatus | null = null;
|
||||||
private lastHealthyStatusObservedAt = 0;
|
private lastHealthyStatusObservedAt = 0;
|
||||||
|
private statusGatherGeneration = 0;
|
||||||
private readonly latestProviderSignatures = new Map<CliProviderId, string | null>();
|
private readonly latestProviderSignatures = new Map<CliProviderId, string | null>();
|
||||||
|
|
||||||
private rememberHealthyStatus(status: CliInstallationStatus): void {
|
private rememberHealthyStatus(status: CliInstallationStatus): void {
|
||||||
|
|
@ -524,9 +551,53 @@ export class CliInstallerService {
|
||||||
authStdoutTail: clipTailForDiag(diag.authStdoutTail, DIAG_AUTH_STDOUT_TAIL),
|
authStdoutTail: clipTailForDiag(diag.authStdoutTail, DIAG_AUTH_STDOUT_TAIL),
|
||||||
authProbeTimedOut: diag.authTimedOut,
|
authProbeTimedOut: diag.authTimedOut,
|
||||||
gatherThrownError: diag.gatherError,
|
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 {
|
setMainWindow(window: BrowserWindow | null): void {
|
||||||
this.mainWindow = window;
|
this.mainWindow = window;
|
||||||
}
|
}
|
||||||
|
|
@ -536,6 +607,7 @@ export class CliInstallerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateStatusCache(): void {
|
invalidateStatusCache(): void {
|
||||||
|
this.statusGatherGeneration += 1;
|
||||||
this.latestStatusSnapshot = null;
|
this.latestStatusSnapshot = null;
|
||||||
this.latestProviderSignatures.clear();
|
this.latestProviderSignatures.clear();
|
||||||
this.modelAvailabilityService.invalidate();
|
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(
|
private buildProviderModelAvailabilityContext(
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
installedVersion: string | null,
|
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(
|
private getLatestProviderStatusForModelVerification(
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
|
|
@ -777,6 +869,8 @@ export class CliInstallerService {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async getStatus(): Promise<CliInstallationStatus> {
|
async getStatus(): Promise<CliInstallationStatus> {
|
||||||
|
const statusStartedAt = Date.now();
|
||||||
|
const generation = ++this.statusGatherGeneration;
|
||||||
const result = this.createInitialStatus();
|
const result = this.createInitialStatus();
|
||||||
this.latestProviderSignatures.clear();
|
this.latestProviderSignatures.clear();
|
||||||
this.latestStatusSnapshot = cloneCliInstallationStatus(result);
|
this.latestStatusSnapshot = cloneCliInstallationStatus(result);
|
||||||
|
|
@ -788,7 +882,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),
|
this.gatherStatus(ref, runDiag, generation),
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -806,16 +900,19 @@ export class CliInstallerService {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
try {
|
runDiag.totalMs = Date.now() - statusStartedAt;
|
||||||
await this.writeCliInstallerStatusDiag(result, runDiag);
|
runDiag.diagWriteScheduled = true;
|
||||||
} catch (diagErr) {
|
this.scheduleCliInstallerStatusDiag(result, runDiag);
|
||||||
logger.error('writeCliInstallerStatusDiag failed:', getErrorMessage(diagErr));
|
this.logGetStatusTimingIfSlow(runDiag);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
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();
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
|
|
@ -828,6 +925,7 @@ export class CliInstallerService {
|
||||||
return fullStatus.providers.find((provider) => provider.providerId === providerId) ?? null;
|
return fullStatus.providers.find((provider) => provider.providerId === providerId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generation = this.statusGatherGeneration;
|
||||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||||
if (!versionProbe.ok) {
|
if (!versionProbe.ok) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -837,18 +935,24 @@ export class CliInstallerService {
|
||||||
binaryPath,
|
binaryPath,
|
||||||
providerId,
|
providerId,
|
||||||
(hydratedProviderStatus) => {
|
(hydratedProviderStatus) => {
|
||||||
this.updateLatestProviderStatus(hydratedProviderStatus);
|
if (!this.updateLatestProviderStatusIfCurrent(hydratedProviderStatus, generation)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.latestStatusSnapshot) {
|
if (this.latestStatusSnapshot) {
|
||||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.updateLatestProviderStatus(providerStatus);
|
this.updateLatestProviderStatusIfCurrent(providerStatus, generation);
|
||||||
return providerStatus;
|
return providerStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyProviderModels(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
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();
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
|
|
@ -860,6 +964,7 @@ export class CliInstallerService {
|
||||||
return this.getProviderStatus(providerId);
|
return this.getProviderStatus(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generation = this.statusGatherGeneration;
|
||||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||||
if (!versionProbe.ok) {
|
if (!versionProbe.ok) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -875,8 +980,10 @@ export class CliInstallerService {
|
||||||
modelVerificationState: 'idle' as const,
|
modelVerificationState: 'idle' as const,
|
||||||
modelAvailability: [],
|
modelAvailability: [],
|
||||||
};
|
};
|
||||||
this.updateLatestProviderStatus(nextProviderStatus);
|
if (
|
||||||
if (this.latestStatusSnapshot) {
|
this.updateLatestProviderStatusIfCurrent(nextProviderStatus, generation) &&
|
||||||
|
this.latestStatusSnapshot
|
||||||
|
) {
|
||||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||||
}
|
}
|
||||||
return nextProviderStatus;
|
return nextProviderStatus;
|
||||||
|
|
@ -888,13 +995,19 @@ export class CliInstallerService {
|
||||||
binaryPath,
|
binaryPath,
|
||||||
versionProbe.version
|
versionProbe.version
|
||||||
) ?? (await this.multimodelBridgeService.getProviderStatus(binaryPath, providerId));
|
) ?? (await this.multimodelBridgeService.getProviderStatus(binaryPath, providerId));
|
||||||
|
if (generation !== this.statusGatherGeneration) {
|
||||||
|
return providerStatus;
|
||||||
|
}
|
||||||
|
|
||||||
const nextProviderStatus = this.applyProviderModelAvailabilityToProvider(
|
const nextProviderStatus = this.applyProviderModelAvailabilityToProvider(
|
||||||
binaryPath,
|
binaryPath,
|
||||||
versionProbe.version,
|
versionProbe.version,
|
||||||
providerStatus
|
providerStatus
|
||||||
);
|
);
|
||||||
this.updateLatestProviderStatus(nextProviderStatus);
|
if (
|
||||||
if (this.latestStatusSnapshot) {
|
this.updateLatestProviderStatusIfCurrent(nextProviderStatus, generation) &&
|
||||||
|
this.latestStatusSnapshot
|
||||||
|
) {
|
||||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||||
}
|
}
|
||||||
return nextProviderStatus;
|
return nextProviderStatus;
|
||||||
|
|
@ -909,32 +1022,43 @@ export class CliInstallerService {
|
||||||
*/
|
*/
|
||||||
private async gatherStatus(
|
private async gatherStatus(
|
||||||
ref: { current: CliInstallationStatus },
|
ref: { current: CliInstallationStatus },
|
||||||
diag: CliInstallerStatusRunDiag
|
diag: CliInstallerStatusRunDiag,
|
||||||
|
generation: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
resetGatherDiag(diag);
|
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 r = ref.current;
|
||||||
|
const binaryResolveStartedAt = Date.now();
|
||||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
|
diag.binaryResolveMs = Date.now() - binaryResolveStartedAt;
|
||||||
if (binaryPath) {
|
if (binaryPath) {
|
||||||
r.binaryPath = binaryPath;
|
r.binaryPath = binaryPath;
|
||||||
|
const versionProbeStartedAt = Date.now();
|
||||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||||
|
diag.versionProbeMs = Date.now() - versionProbeStartedAt;
|
||||||
if (versionProbe.ok) {
|
if (versionProbe.ok) {
|
||||||
r.installed = true;
|
r.installed = true;
|
||||||
r.installedVersion = versionProbe.version;
|
r.installedVersion = versionProbe.version;
|
||||||
r.launchError = null;
|
r.launchError = null;
|
||||||
r.authStatusChecking = true;
|
r.authStatusChecking = true;
|
||||||
this.rememberHealthyStatus(r);
|
this.rememberHealthyStatus(r);
|
||||||
this.publishStatusSnapshot(r);
|
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||||
|
|
||||||
// Auth and GCS version check are independent — run in parallel.
|
// Auth and GCS version check are independent — run in parallel.
|
||||||
// Both mutate `r` directly so partial results survive the outer timeout.
|
// Both mutate `r` directly so partial results survive the outer timeout.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.checkAuthStatus(binaryPath, r, diag),
|
this.checkAuthStatus(binaryPath, r, diag, generation),
|
||||||
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
|
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
this.rememberHealthyStatus(r);
|
this.rememberHealthyStatus(r);
|
||||||
this.publishStatusSnapshot(r);
|
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||||
} else {
|
} else {
|
||||||
const recoveredHealthyStatus = this.getRecoverableHealthyStatus(binaryPath);
|
const recoveredHealthyStatus = this.getRecoverableHealthyStatus(binaryPath);
|
||||||
if (recoveredHealthyStatus) {
|
if (recoveredHealthyStatus) {
|
||||||
|
|
@ -944,7 +1068,7 @@ export class CliInstallerService {
|
||||||
Object.assign(r, recoveredHealthyStatus, {
|
Object.assign(r, recoveredHealthyStatus, {
|
||||||
launchError: null,
|
launchError: null,
|
||||||
});
|
});
|
||||||
this.publishStatusSnapshot(r);
|
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -963,7 +1087,7 @@ export class CliInstallerService {
|
||||||
if (r.supportsSelfUpdate) {
|
if (r.supportsSelfUpdate) {
|
||||||
await this.fetchLatestVersion(r);
|
await this.fetchLatestVersion(r);
|
||||||
}
|
}
|
||||||
this.publishStatusSnapshot(r);
|
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No binary — still check latest version for "install" prompt
|
// No binary — still check latest version for "install" prompt
|
||||||
|
|
@ -973,7 +1097,7 @@ export class CliInstallerService {
|
||||||
if (r.supportsSelfUpdate) {
|
if (r.supportsSelfUpdate) {
|
||||||
await this.fetchLatestVersion(r);
|
await this.fetchLatestVersion(r);
|
||||||
}
|
}
|
||||||
this.publishStatusSnapshot(r);
|
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1071,33 +1195,70 @@ export class CliInstallerService {
|
||||||
private async checkAuthStatus(
|
private async checkAuthStatus(
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
result: CliInstallationStatus,
|
result: CliInstallationStatus,
|
||||||
diag: CliInstallerStatusRunDiag
|
diag: CliInstallerStatusRunDiag,
|
||||||
|
generation: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (result.flavor === 'agent_teams_orchestrator') {
|
if (result.flavor === 'agent_teams_orchestrator') {
|
||||||
result.authStatusChecking = true;
|
result.authStatusChecking = true;
|
||||||
try {
|
let statusTarget = result;
|
||||||
const providers = await this.multimodelBridgeService.getProviderStatuses(
|
const applyProviders = (providersSnapshot: CliProviderStatus[], final: boolean): void => {
|
||||||
binaryPath,
|
if (generation !== this.statusGatherGeneration) {
|
||||||
(providersSnapshot) => {
|
return;
|
||||||
const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot);
|
}
|
||||||
result.providers = frontendProviders;
|
|
||||||
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
const target = statusTarget;
|
||||||
result.authMethod =
|
const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot);
|
||||||
getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
target.providers = frontendProviders;
|
||||||
this.publishStatusSnapshot(result);
|
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);
|
this.publishStatusSnapshotIfCurrent(result, generation);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,17 @@ import {
|
||||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||||
CLI_INSTALLER_GET_STATUS,
|
CLI_INSTALLER_GET_STATUS,
|
||||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||||
|
CLI_INSTALLER_VERIFY_PROVIDER_MODELS,
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
import type { CliInstallerService } from '@main/services';
|
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';
|
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||||
|
|
||||||
type IpcHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown;
|
type IpcHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown;
|
||||||
|
|
@ -328,4 +334,70 @@ describe('cliInstaller IPC handlers', () => {
|
||||||
'ChatGPT account ready'
|
'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 {
|
import {
|
||||||
CliInstallerService,
|
CliInstallerService,
|
||||||
isVersionOlder,
|
isVersionOlder,
|
||||||
|
|
@ -88,6 +92,9 @@ import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMult
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '@main/services/team/cliFlavor';
|
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '@main/services/team/cliFlavor';
|
||||||
import { execCli } from '@main/utils/childProcess';
|
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.
|
* 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(() => {});
|
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', () => {
|
describe('CliInstallerService', () => {
|
||||||
let service: CliInstallerService;
|
let service: CliInstallerService;
|
||||||
|
|
||||||
|
|
@ -128,6 +171,33 @@ describe('CliInstallerService', () => {
|
||||||
expect(status.updateAvailable).toBe(false);
|
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 () => {
|
it('includes frontend-visible providers in unavailable multimodel bootstrap status', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
||||||
|
|
@ -1017,6 +1087,172 @@ describe('CliInstallerService', () => {
|
||||||
expect(status.authLoggedIn).toBe(true);
|
expect(status.authLoggedIn).toBe(true);
|
||||||
expect(status.authMethod).toBe('api_key');
|
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', () => {
|
describe('auth parallelism', () => {
|
||||||
|
|
|
||||||
|
|
@ -371,7 +371,7 @@ describe('CLI status visibility during completed install state', () => {
|
||||||
window.localStorage.clear();
|
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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
@ -382,7 +382,7 @@ describe('CLI status visibility during completed install state', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain('Multimodel');
|
expect(host.textContent).not.toContain('Multimodel');
|
||||||
expect(host.textContent).toContain('Login');
|
expect(host.textContent).toContain('Login');
|
||||||
|
|
||||||
const toggle = host.querySelector('[data-testid="multimodel-toggle"]');
|
const toggle = host.querySelector('[data-testid="multimodel-toggle"]');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue