From b5ca3eed68cf26cad62fccf6929d787b79fe95e2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 00:18:59 +0300 Subject: [PATCH] fix(cli): prevent stale status hydration --- src/main/ipc/cliInstaller.ts | 5 +- .../infrastructure/CliInstallerService.ts | 249 ++++++++++++++---- test/main/ipc/cliInstaller.test.ts | 74 +++++- .../CliInstallerService.test.ts | 236 +++++++++++++++++ .../cli/CliStatusVisibility.test.ts | 4 +- 5 files changed, 520 insertions(+), 48 deletions(-) diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index aa27921d..11ede3b6 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -233,8 +233,11 @@ async function handleVerifyProviderModels( providerId: CliProviderId ): Promise> { 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); diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 24efe352..5a6cf741 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -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(); 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 { + 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 | null = null; try { await Promise.race([ - this.gatherStatus(ref, runDiag), + this.gatherStatus(ref, runDiag, generation), new Promise((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 { - 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 { - 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 { 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 { 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 | 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; } diff --git a/test/main/ipc/cliInstaller.test.ts b/test/main/ipc/cliInstaller.test.ts index bf841a97..02d3e864 100644 --- a/test/main/ipc/cliInstaller.test.ts +++ b/test/main/ipc/cliInstaller.test.ts @@ -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(); + 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; + expect(initial.success).toBe(true); + expect(initial.data?.authLoggedIn).toBe(false); + + const staleVerificationInvoke = ipcMain.invoke( + CLI_INSTALLER_VERIFY_PROVIDER_MODELS, + 'codex' + ) as Promise>; + 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; + 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; + + 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' + ); + }); }); diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index f02489ad..6729d965 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -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((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((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((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((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', () => { diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index cfd8fb2e..4d516c27 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -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"]');