From ac1c99ac1f7ad27030fe8850664b2ec8df98d2da Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 19:41:23 +0300 Subject: [PATCH] feat(cliInstaller): add model verification for providers - Introduced CLI_INSTALLER_VERIFY_PROVIDER_MODELS IPC channel for on-demand model verification. - Implemented handler for verifying provider models in the CliInstallerService. - Enhanced CLI installation status management with model verification state and availability. - Updated related components to support model verification feedback in the UI. --- src/main/ipc/cliInstaller.ts | 24 + src/main/ipc/teams.ts | 28 +- .../infrastructure/CliInstallerService.ts | 222 ++++++- .../runtime/ClaudeMultimodelBridgeService.ts | 2 + .../CliProviderModelAvailabilityService.ts | 292 +++++++++ .../services/runtime/providerModelProbe.ts | 119 ++++ .../services/team/TeamProvisioningService.ts | 572 ++++++++++++++++-- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 12 +- src/renderer/api/httpClient.ts | 5 +- .../components/dashboard/CliStatusBanner.tsx | 43 +- .../runtime/ProviderModelBadges.tsx | 97 +++ .../settings/sections/CliStatusSection.tsx | 40 +- .../team/dialogs/CreateTeamDialog.tsx | 343 +++++++++-- .../team/dialogs/LaunchTeamDialog.tsx | 391 +++++++++--- .../ProvisioningProviderStatusList.tsx | 219 ++++++- .../team/dialogs/TeamModelSelector.tsx | 120 ++-- .../dialogs/providerPrepareDiagnostics.ts | 378 ++++++++++++ .../team/dialogs/provisioningModelIssues.ts | 124 ++++ .../components/team/members/LeadModelRow.tsx | 16 +- .../team/members/MemberDraftRow.tsx | 36 +- .../team/members/MembersEditorSection.tsx | 4 + .../team/members/TeamRosterEditorSection.tsx | 6 + src/renderer/hooks/useCliInstaller.ts | 2 +- .../store/slices/cliInstallerSlice.ts | 18 +- src/renderer/utils/teamModelAvailability.ts | 300 ++++++++- src/renderer/utils/teamModelCatalog.ts | 85 ++- .../utils/teamProvisioningPresentation.ts | 98 ++- src/shared/types/api.ts | 4 +- src/shared/types/cliInstaller.ts | 17 + src/shared/types/team.ts | 1 + src/shared/utils/anthropicModelDefaults.ts | 3 + src/shared/utils/providerModelSelection.ts | 5 + src/shared/utils/providerModelVisibility.ts | 47 ++ .../CliInstallerService.test.ts | 156 +++++ ...liProviderModelAvailabilityService.test.ts | 153 +++++ .../team/TeamProvisioningService.test.ts | 173 ++++++ .../TeamProvisioningServicePrepare.test.ts | 182 ++++++ .../cli/CliStatusVisibility.test.ts | 70 +++ .../components/team/TeamModelSelector.test.ts | 74 ++- .../TeamModelSelectorDisabledState.test.ts | 308 ++++++++-- .../ProvisioningProviderStatusList.test.ts | 93 +++ .../providerPrepareDiagnostics.test.ts | 352 +++++++++++ .../dialogs/provisioningModelIssues.test.ts | 49 ++ test/renderer/store/cliInstallerSlice.test.ts | 1 + .../utils/teamModelAvailability.test.ts | 119 ++++ test/renderer/utils/teamModelCatalog.test.ts | 1 - .../teamProvisioningPresentation.test.ts | 124 ++++ 48 files changed, 5109 insertions(+), 422 deletions(-) create mode 100644 src/main/services/runtime/CliProviderModelAvailabilityService.ts create mode 100644 src/main/services/runtime/providerModelProbe.ts create mode 100644 src/renderer/components/runtime/ProviderModelBadges.tsx create mode 100644 src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts create mode 100644 src/renderer/components/team/dialogs/provisioningModelIssues.ts create mode 100644 src/shared/utils/anthropicModelDefaults.ts create mode 100644 src/shared/utils/providerModelSelection.ts create mode 100644 src/shared/utils/providerModelVisibility.ts create mode 100644 test/main/services/runtime/CliProviderModelAvailabilityService.test.ts create mode 100644 test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts create mode 100644 test/renderer/components/team/dialogs/provisioningModelIssues.test.ts create mode 100644 test/renderer/utils/teamModelAvailability.test.ts diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index c1ddec7b..2a2794e8 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -12,6 +12,7 @@ import { CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, + CLI_INSTALLER_VERIFY_PROVIDER_MODELS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload } from '@preload/constants/ipcChannels'; import { getErrorMessage } from '@shared/utils/errorHandling'; @@ -49,6 +50,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer export function registerCliInstallerHandlers(ipcMain: IpcMain): void { ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus); ipcMain.handle(CLI_INSTALLER_GET_PROVIDER_STATUS, handleGetProviderStatus); + ipcMain.handle(CLI_INSTALLER_VERIFY_PROVIDER_MODELS, handleVerifyProviderModels); ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall); ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus); @@ -61,6 +63,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void { export function removeCliInstallerHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS); ipcMain.removeHandler(CLI_INSTALLER_GET_PROVIDER_STATUS); + ipcMain.removeHandler(CLI_INSTALLER_VERIFY_PROVIDER_MODELS); ipcMain.removeHandler(CLI_INSTALLER_INSTALL); ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS); @@ -75,7 +78,12 @@ async function handleGetStatus( _event: IpcMainInvokeEvent ): Promise> { try { + const latestSnapshot = service.getLatestStatusSnapshot(); if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) { + if (latestSnapshot) { + cachedStatus = { value: latestSnapshot, at: Date.now() }; + return { success: true, data: latestSnapshot }; + } return { success: true, data: cachedStatus.value }; } @@ -172,9 +180,25 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise> { + try { + const status = await service.verifyProviderModels(providerId); + patchCachedProviderStatus(status); + return { success: true, data: status }; + } catch (error) { + const msg = getErrorMessage(error); + logger.error(`Error in cliInstaller:verifyProviderModels(${providerId}):`, msg); + return { success: false, error: msg }; + } +} + function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult { cachedStatus = null; providerStatusInFlight.clear(); ClaudeBinaryResolver.clearCache(); + service.invalidateStatusCache(); return { success: true, data: undefined }; } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index f35e2628..c9fed835 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1442,11 +1442,15 @@ async function handlePrepareProvisioning( _event: IpcMainInvokeEvent, cwd: unknown, providerId: unknown, - providerIds: unknown + providerIds: unknown, + selectedModels: unknown, + limitContext: unknown ): Promise> { let validatedCwd: string | undefined; let validatedProviderId: TeamLaunchRequest['providerId']; let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined; + let validatedSelectedModels: string[] | undefined; + let validatedLimitContext: boolean | undefined; if (cwd !== undefined) { if (typeof cwd !== 'string' || cwd.trim().length === 0) { return { success: false, error: 'cwd must be a non-empty string' }; @@ -1477,10 +1481,32 @@ async function handlePrepareProvisioning( } validatedProviderIds = normalized; } + if (selectedModels !== undefined) { + if (!Array.isArray(selectedModels)) { + return { success: false, error: 'selectedModels must be an array when provided' }; + } + const normalized = Array.from( + new Set( + selectedModels + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + ) + ); + validatedSelectedModels = normalized; + } + if (limitContext !== undefined) { + if (typeof limitContext !== 'boolean') { + return { success: false, error: 'limitContext must be a boolean when provided' }; + } + validatedLimitContext = limitContext; + } return wrapTeamHandler('prepareProvisioning', () => getTeamProvisioningService().prepareForProvisioning(validatedCwd, { providerId: validatedProviderId, providerIds: validatedProviderIds, + modelIds: validatedSelectedModels, + limitContext: validatedLimitContext, }) ); } diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 20df1a1b..3f472c2e 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -38,6 +38,11 @@ import { tmpdir } from 'os'; import { join, posix as pathPosix, win32 as pathWin32 } from 'path'; import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService'; +import { + CliProviderModelAvailabilityService, + type ProviderModelAvailabilityContext, + type ProviderModelAvailabilitySnapshot, +} from '../runtime/CliProviderModelAvailabilityService'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor'; @@ -45,6 +50,7 @@ import type { CliInstallationStatus, CliInstallerProgress, CliPlatform, + CliProviderModelAvailability, CliProviderId, CliProviderStatus, } from '@shared/types'; @@ -137,6 +143,8 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat launchError: status.launchError ?? null, providers: status.providers.map((provider) => ({ ...provider, + modelVerificationState: provider.modelVerificationState ?? 'idle', + modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [], capabilities: { ...provider.capabilities }, selectedBackendId: provider.selectedBackendId ?? null, resolvedBackendId: provider.resolvedBackendId ?? null, @@ -149,6 +157,12 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat }; } +function cloneProviderModelAvailability( + modelAvailability: CliProviderModelAvailability[] | undefined +): CliProviderModelAvailability[] { + return modelAvailability?.map((item) => ({ ...item })) ?? []; +} + // ============================================================================= // Helpers // ============================================================================= @@ -328,6 +342,13 @@ export class CliInstallerService { private mainWindow: BrowserWindow | null = null; private installing = false; private readonly multimodelBridgeService = new ClaudeMultimodelBridgeService(); + private readonly modelAvailabilityService = new CliProviderModelAvailabilityService( + (providerId, signature, snapshot) => { + this.handleProviderModelAvailabilityUpdate(providerId, signature, snapshot); + } + ); + private latestStatusSnapshot: CliInstallationStatus | null = null; + private readonly latestProviderSignatures = new Map(); private electronMetaForDiag(): Record { try { @@ -395,6 +416,16 @@ export class CliInstallerService { this.mainWindow = window; } + getLatestStatusSnapshot(): CliInstallationStatus | null { + return this.latestStatusSnapshot ? cloneCliInstallationStatus(this.latestStatusSnapshot) : null; + } + + invalidateStatusCache(): void { + this.latestStatusSnapshot = null; + this.latestProviderSignatures.clear(); + this.modelAvailabilityService.invalidate(); + } + /** * Env for CLI subprocesses: login-shell vars + consistent HOME/PATH + same config root as the app. */ @@ -428,8 +459,10 @@ export class CliInstallerService { authenticated: false, authMethod: null, verificationState: 'unknown' as const, + modelVerificationState: 'idle' as const, statusMessage: 'Checking...', models: [], + modelAvailability: [], canLoginFromUi: true, capabilities: { teamLaunch: false, @@ -457,12 +490,147 @@ export class CliInstallerService { }; } + private publishStatusSnapshot(status: CliInstallationStatus): void { + this.latestStatusSnapshot = cloneCliInstallationStatus(status); + for (const provider of this.latestStatusSnapshot.providers) { + if ( + provider.modelVerificationState === 'verifying' || + (provider.modelVerificationState === 'verified' && + (provider.modelAvailability?.length ?? 0) > 0) + ) { + this.latestProviderSignatures.set( + provider.providerId, + this.latestProviderSignatures.get(provider.providerId) ?? null + ); + } else { + this.latestProviderSignatures.set(provider.providerId, null); + } + } + this.sendProgress({ + type: 'status', + status: cloneCliInstallationStatus(this.latestStatusSnapshot), + }); + } + + private buildProviderModelAvailabilityContext( + binaryPath: string, + installedVersion: string | null, + provider: CliProviderStatus + ): ProviderModelAvailabilityContext { + return { + binaryPath, + installedVersion, + provider: { + providerId: provider.providerId, + models: [...provider.models], + supported: provider.supported, + authenticated: provider.authenticated, + authMethod: provider.authMethod, + selectedBackendId: provider.selectedBackendId ?? null, + resolvedBackendId: provider.resolvedBackendId ?? null, + capabilities: { ...provider.capabilities }, + backend: provider.backend ? { ...provider.backend } : null, + }, + }; + } + + private applyProviderModelAvailability( + binaryPath: string, + installedVersion: string | null, + providers: CliProviderStatus[] + ): CliProviderStatus[] { + return providers.map((provider) => { + const snapshot = this.modelAvailabilityService.getSnapshot( + this.buildProviderModelAvailabilityContext(binaryPath, installedVersion, provider) + ); + this.latestProviderSignatures.set(provider.providerId, snapshot.signature); + + return { + ...provider, + modelVerificationState: snapshot.modelVerificationState, + modelAvailability: cloneProviderModelAvailability(snapshot.modelAvailability), + }; + }); + } + + private applyProviderModelAvailabilityToProvider( + binaryPath: string, + installedVersion: string | null, + provider: CliProviderStatus + ): CliProviderStatus { + return this.applyProviderModelAvailability(binaryPath, installedVersion, [provider])[0]; + } + + private handleProviderModelAvailabilityUpdate( + providerId: CliProviderId, + signature: string, + snapshot: ProviderModelAvailabilitySnapshot + ): void { + if (!this.latestStatusSnapshot) { + return; + } + if (this.latestProviderSignatures.get(providerId) !== signature) { + return; + } + + const providerIndex = this.latestStatusSnapshot.providers.findIndex( + (provider) => provider.providerId === providerId + ); + if (providerIndex < 0) { + return; + } + + const nextProviders = [...this.latestStatusSnapshot.providers]; + nextProviders[providerIndex] = { + ...nextProviders[providerIndex], + modelVerificationState: snapshot.modelVerificationState, + modelAvailability: cloneProviderModelAvailability(snapshot.modelAvailability), + }; + this.latestStatusSnapshot = { + ...this.latestStatusSnapshot, + providers: nextProviders, + }; + this.publishStatusSnapshot(this.latestStatusSnapshot); + } + + private updateLatestProviderStatus(providerStatus: CliProviderStatus): void { + if ( + providerStatus.modelVerificationState !== 'verifying' && + !((providerStatus.modelAvailability?.length ?? 0) > 0) + ) { + this.latestProviderSignatures.set(providerStatus.providerId, null); + } + + if (!this.latestStatusSnapshot) { + return; + } + + const hasProvider = this.latestStatusSnapshot.providers.some( + (provider) => provider.providerId === providerStatus.providerId + ); + const nextProviders = hasProvider + ? this.latestStatusSnapshot.providers.map((provider) => + provider.providerId === providerStatus.providerId ? providerStatus : provider + ) + : [...this.latestStatusSnapshot.providers, providerStatus]; + const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null; + + this.latestStatusSnapshot = { + ...this.latestStatusSnapshot, + providers: nextProviders, + authLoggedIn: nextProviders.some((provider) => provider.authenticated), + authMethod: authenticatedProvider?.authMethod ?? null, + }; + } + // --------------------------------------------------------------------------- // Public: getStatus // --------------------------------------------------------------------------- async getStatus(): Promise { const result = this.createInitialStatus(); + this.latestProviderSignatures.clear(); + this.latestStatusSnapshot = cloneCliInstallationStatus(result); // Run the actual status gathering with an overall timeout. // On timeout, return whatever partial result was collected so far. @@ -516,7 +684,46 @@ export class CliInstallerService { return null; } - return this.multimodelBridgeService.getProviderStatus(binaryPath, providerId); + const providerStatus = await this.multimodelBridgeService.getProviderStatus( + binaryPath, + providerId + ); + this.updateLatestProviderStatus(providerStatus); + return providerStatus; + } + + async verifyProviderModels(providerId: CliProviderId): Promise { + await resolveInteractiveShellEnv(); + + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + return null; + } + + const flavor = getConfiguredCliFlavor(); + if (flavor !== 'agent_teams_orchestrator') { + return this.getProviderStatus(providerId); + } + + const versionProbe = await this.probeCliVersion(binaryPath); + if (!versionProbe.ok) { + return null; + } + + const providerStatus = await this.multimodelBridgeService.getProviderStatus( + binaryPath, + providerId + ); + const nextProviderStatus = this.applyProviderModelAvailabilityToProvider( + binaryPath, + versionProbe.version, + providerStatus + ); + this.updateLatestProviderStatus(nextProviderStatus); + if (this.latestStatusSnapshot) { + this.publishStatusSnapshot(this.latestStatusSnapshot); + } + return nextProviderStatus; } /** @@ -543,7 +750,7 @@ export class CliInstallerService { r.installedVersion = versionProbe.version; r.launchError = null; r.authStatusChecking = true; - this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); + this.publishStatusSnapshot(r); // Auth and GCS version check are independent — run in parallel. // Both mutate `r` directly so partial results survive the outer timeout. @@ -551,6 +758,7 @@ export class CliInstallerService { this.checkAuthStatus(binaryPath, r, diag), r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(), ]); + this.publishStatusSnapshot(r); } else { diag.versionError = versionProbe.error; r.installed = false; @@ -567,7 +775,7 @@ export class CliInstallerService { if (r.supportsSelfUpdate) { await this.fetchLatestVersion(r); } - this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); + this.publishStatusSnapshot(r); } } else { // No binary — still check latest version for "install" prompt @@ -577,7 +785,7 @@ export class CliInstallerService { if (r.supportsSelfUpdate) { await this.fetchLatestVersion(r); } - this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); + this.publishStatusSnapshot(r); } } @@ -641,8 +849,10 @@ export class CliInstallerService { authenticated: false, authMethod: null, verificationState: 'error', + modelVerificationState: 'idle', statusMessage: message, models: [], + modelAvailability: [], canLoginFromUi: false, backend: null, })); @@ -671,7 +881,7 @@ export class CliInstallerService { result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated); result.authMethod = providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null; - this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) }); + this.publishStatusSnapshot(result); } ); result.providers = providers; @@ -679,7 +889,7 @@ export class CliInstallerService { result.authMethod = providers.find((provider) => provider.authenticated)?.authMethod ?? null; result.authStatusChecking = false; - this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) }); + this.publishStatusSnapshot(result); } catch (error) { const msg = getErrorMessage(error); diag.authLastError = msg; diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 243de82d..28d99435 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -121,8 +121,10 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat authenticated: false, authMethod: null, verificationState: 'unknown', + modelVerificationState: 'idle', statusMessage: null, models: [], + modelAvailability: [], canLoginFromUi: true, capabilities: { teamLaunch: false, diff --git a/src/main/services/runtime/CliProviderModelAvailabilityService.ts b/src/main/services/runtime/CliProviderModelAvailabilityService.ts new file mode 100644 index 00000000..75ee0d79 --- /dev/null +++ b/src/main/services/runtime/CliProviderModelAvailabilityService.ts @@ -0,0 +1,292 @@ +import { execCli } from '@main/utils/childProcess'; +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; +import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility'; + +import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; +import { + buildProviderModelProbeArgs, + classifyProviderModelProbeFailure, + getProviderModelProbeTimeoutMs, + isProviderModelProbeSuccessOutput, + normalizeProviderModelProbeFailureReason, +} from './providerModelProbe'; + +import type { CliProviderId, CliProviderModelAvailability, CliProviderStatus } from '@shared/types'; + +const logger = createLogger('CliProviderModelAvailabilityService'); +const MODEL_PROBE_CONCURRENCY = 3; + +export interface ProviderModelAvailabilityContext { + binaryPath: string; + installedVersion: string | null; + provider: Pick< + CliProviderStatus, + | 'providerId' + | 'models' + | 'supported' + | 'authenticated' + | 'authMethod' + | 'selectedBackendId' + | 'resolvedBackendId' + | 'capabilities' + | 'backend' + >; +} + +export interface ProviderModelAvailabilitySnapshot { + signature: string | null; + modelVerificationState: 'idle' | 'verifying' | 'verified'; + modelAvailability: CliProviderModelAvailability[]; +} + +interface ProviderModelAvailabilityCacheEntry { + providerId: CliProviderId; + signature: string; + snapshot: ProviderModelAvailabilitySnapshot; + envPromise: Promise; +} + +type ProviderAvailabilityUpdateHandler = ( + providerId: CliProviderId, + signature: string, + snapshot: ProviderModelAvailabilitySnapshot +) => void; + +function cloneModelAvailabilitySnapshot( + snapshot: ProviderModelAvailabilitySnapshot +): ProviderModelAvailabilitySnapshot { + return { + signature: snapshot.signature, + modelVerificationState: snapshot.modelVerificationState, + modelAvailability: snapshot.modelAvailability.map((item) => ({ ...item })), + }; +} + +function createIdleSnapshot(): ProviderModelAvailabilitySnapshot { + return { + signature: null, + modelVerificationState: 'idle', + modelAvailability: [], + }; +} + +function createCheckingSnapshot( + signature: string, + models: string[] +): ProviderModelAvailabilitySnapshot { + return { + signature, + modelVerificationState: models.length > 0 ? 'verifying' : 'verified', + modelAvailability: models.map((modelId) => ({ + modelId, + status: 'checking', + reason: null, + checkedAt: null, + })), + }; +} + +function isFinalModelAvailabilityStatus(status: CliProviderModelAvailability['status']): boolean { + return status !== 'checking'; +} + +function buildProviderSignature( + context: ProviderModelAvailabilityContext, + visibleModels: string[] +): string { + return JSON.stringify({ + binaryPath: context.binaryPath, + installedVersion: context.installedVersion ?? null, + providerId: context.provider.providerId, + authMethod: context.provider.authMethod ?? null, + selectedBackendId: context.provider.selectedBackendId ?? null, + resolvedBackendId: context.provider.resolvedBackendId ?? null, + endpointLabel: context.provider.backend?.endpointLabel ?? null, + models: visibleModels, + }); +} + +function isProviderEligibleForModelVerification( + context: ProviderModelAvailabilityContext, + visibleModels: string[] +): boolean { + return ( + (context.provider.providerId === 'codex' || context.provider.providerId === 'gemini') && + visibleModels.length > 0 && + context.provider.supported === true && + context.provider.authenticated === true && + context.provider.capabilities.oneShot === true + ); +} + +function classifyFailedProbe( + modelId: string, + error: unknown +): Pick { + const message = getErrorMessage(error).trim(); + const normalizedReason = normalizeProviderModelProbeFailureReason(message); + const lower = message.toLowerCase(); + + if (classifyProviderModelProbeFailure(message) === 'unavailable') { + return { + status: 'unavailable', + reason: normalizedReason, + }; + } + + if ( + lower.includes('timeout') || + lower.includes('timed out') || + lower.includes('etimedout') || + lower.includes('econnreset') || + lower.includes('429') || + lower.includes('500') || + lower.includes('502') || + lower.includes('503') || + lower.includes('504') + ) { + return { + status: 'unknown', + reason: normalizedReason, + }; + } + + logger.warn(`Model probe inconclusive providerModel=${modelId}: ${message}`); + return { + status: 'unknown', + reason: normalizedReason, + }; +} + +export class CliProviderModelAvailabilityService { + private readonly cache = new Map(); + private readonly queue: Array<() => void> = []; + private activeProbeCount = 0; + + constructor(private readonly onUpdate?: ProviderAvailabilityUpdateHandler) {} + + invalidate(): void { + this.cache.clear(); + this.queue.length = 0; + } + + getSnapshot(context: ProviderModelAvailabilityContext): ProviderModelAvailabilitySnapshot { + const visibleModels = filterVisibleProviderRuntimeModels( + context.provider.providerId, + context.provider.models + ); + if (!isProviderEligibleForModelVerification(context, visibleModels)) { + return createIdleSnapshot(); + } + + const signature = buildProviderSignature(context, visibleModels); + const existing = this.cache.get(signature); + if (existing) { + return cloneModelAvailabilitySnapshot(existing.snapshot); + } + + const entry: ProviderModelAvailabilityCacheEntry = { + providerId: context.provider.providerId, + signature, + snapshot: createCheckingSnapshot(signature, visibleModels), + envPromise: buildProviderAwareCliEnv({ + binaryPath: context.binaryPath, + providerId: context.provider.providerId, + }).then((result) => result.env), + }; + this.cache.set(signature, entry); + this.startProbes(context, entry); + + return cloneModelAvailabilitySnapshot(entry.snapshot); + } + + private startProbes( + context: ProviderModelAvailabilityContext, + entry: ProviderModelAvailabilityCacheEntry + ): void { + for (const modelId of entry.snapshot.modelAvailability.map((item) => item.modelId)) { + this.enqueue(async () => { + const result = await this.probeModel(context, entry, modelId); + const index = entry.snapshot.modelAvailability.findIndex( + (item) => item.modelId === modelId + ); + if (index < 0) { + return; + } + + entry.snapshot.modelAvailability[index] = { + modelId, + checkedAt: new Date().toISOString(), + ...result, + }; + if ( + entry.snapshot.modelAvailability.every((item) => + isFinalModelAvailabilityStatus(item.status) + ) + ) { + entry.snapshot.modelVerificationState = 'verified'; + } + + this.onUpdate?.( + entry.providerId, + entry.signature, + cloneModelAvailabilitySnapshot(entry.snapshot) + ); + }); + } + } + + private enqueue(task: () => Promise): void { + this.queue.push(() => { + this.activeProbeCount += 1; + void task() + .catch((error) => { + logger.warn(`Model verification task failed: ${getErrorMessage(error)}`); + }) + .finally(() => { + this.activeProbeCount = Math.max(0, this.activeProbeCount - 1); + this.drainQueue(); + }); + }); + this.drainQueue(); + } + + private drainQueue(): void { + while (this.activeProbeCount < MODEL_PROBE_CONCURRENCY) { + const next = this.queue.shift(); + if (!next) { + return; + } + next(); + } + } + + private async probeModel( + context: ProviderModelAvailabilityContext, + entry: ProviderModelAvailabilityCacheEntry, + modelId: string + ): Promise> { + try { + const env = await entry.envPromise; + const { stdout } = await execCli(context.binaryPath, buildProviderModelProbeArgs(modelId), { + timeout: getProviderModelProbeTimeoutMs(context.provider.providerId), + env, + }); + const output = stdout.trim(); + if (isProviderModelProbeSuccessOutput(output)) { + return { + status: 'available', + reason: null, + }; + } + + return { + status: 'unknown', + reason: output || 'Model verification returned an unexpected response.', + }; + } catch (error) { + return classifyFailedProbe(modelId, error); + } + } +} diff --git a/src/main/services/runtime/providerModelProbe.ts b/src/main/services/runtime/providerModelProbe.ts new file mode 100644 index 00000000..0a4b4f7b --- /dev/null +++ b/src/main/services/runtime/providerModelProbe.ts @@ -0,0 +1,119 @@ +import type { CliProviderId, TeamProviderId } from '@shared/types'; + +const PROVIDER_MODEL_PROBE_TIMEOUT_MS = 60_000; +const PROVIDER_MODEL_PROBE_CODEX_TIMEOUT_MS = 60_000; +const PROVIDER_MODEL_PROBE_GEMINI_TIMEOUT_MS = 15_000; +const PROVIDER_MODEL_PROBE_PROMPT = 'Output only the single word PONG.'; + +type SupportedProviderId = CliProviderId | TeamProviderId; + +function resolveProbeProviderId(providerId: SupportedProviderId | undefined): SupportedProviderId { + return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; +} + +export function getProviderModelProbePrompt(): string { + return PROVIDER_MODEL_PROBE_PROMPT; +} + +export function getProviderModelProbeExpectedOutput(): string { + return 'PONG'; +} + +export function isProviderModelProbeSuccessOutput(output: string): boolean { + return new RegExp(`\\b${getProviderModelProbeExpectedOutput()}\\b`, 'i').test(output.trim()); +} + +export function classifyProviderModelProbeFailure(message: string): 'unavailable' | 'unknown' { + const lower = message.toLowerCase(); + + if ( + lower.includes('model is not supported') || + lower.includes('model not supported') || + lower.includes('unsupported model') || + lower.includes('model is not available') || + lower.includes('model not available') || + lower.includes('model unavailable') || + lower.includes('model not found') || + lower.includes('unknown model') || + lower.includes('invalid model') + ) { + return 'unavailable'; + } + + return 'unknown'; +} + +export function isProviderModelProbeTimeoutMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes('timeout running:') || + lower.includes('timed out') || + lower.includes('etimedout') || + lower.includes('did not complete') + ); +} + +export function normalizeProviderModelProbeFailureReason(message: string): string { + const trimmed = message.trim(); + if (!trimmed) { + return 'Model verification failed'; + } + + if ( + /The '[^']+' model is not supported when using Codex with a ChatGPT account\./i.test(trimmed) + ) { + return 'Not available with Codex ChatGPT subscription'; + } + if (/The requested model is not available for your account\./i.test(trimmed)) { + return 'Not available for this account'; + } + if (isProviderModelProbeTimeoutMessage(trimmed)) { + return 'Model verification timed out'; + } + + return trimmed; +} + +export function buildProviderModelProbeArgs(modelId: string): string[] { + return [ + '-p', + getProviderModelProbePrompt(), + '--output-format', + 'text', + '--model', + modelId, + '--max-turns', + '1', + '--no-session-persistence', + ]; +} + +export function getProviderModelProbeTimeoutMs( + providerId: SupportedProviderId | undefined +): number { + switch (resolveProbeProviderId(providerId)) { + case 'codex': + return PROVIDER_MODEL_PROBE_CODEX_TIMEOUT_MS; + case 'gemini': + return PROVIDER_MODEL_PROBE_GEMINI_TIMEOUT_MS; + case 'anthropic': + default: + return PROVIDER_MODEL_PROBE_TIMEOUT_MS; + } +} + +export function getProviderPreflightModel(providerId: TeamProviderId | undefined): string { + switch (resolveProbeProviderId(providerId)) { + case 'codex': + return 'gpt-5.4-mini'; + case 'gemini': + return 'gemini-2.5-flash-lite'; + case 'anthropic': + default: + return 'haiku'; + } +} + +export function buildProviderPreflightPingArgs(providerId: TeamProviderId | undefined): string[] { + return buildProviderModelProbeArgs(getProviderPreflightModel(providerId)); +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5753ed56..18b4de9f 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2,7 +2,7 @@ import { killTmuxPaneForCurrentPlatformSync } from '@features/tmux-installer/mai import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; -import { killProcessTree, spawnCli } from '@main/utils/childProcess'; +import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { encodePath, @@ -42,12 +42,14 @@ import { } from '@shared/utils/inboxNoise'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; +import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { parseAllTeammateMessages, type ParsedTeammateContent, } from '@shared/utils/teammateMessageParser'; import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName'; +import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { extractToolPreview, @@ -66,6 +68,15 @@ import { type GeminiRuntimeAuthState, resolveGeminiRuntimeAuth, } from '../runtime/geminiRuntimeAuth'; +import { + buildProviderPreflightPingArgs, + buildProviderModelProbeArgs, + classifyProviderModelProbeFailure, + getProviderModelProbeExpectedOutput, + getProviderModelProbeTimeoutMs, + isProviderModelProbeSuccessOutput, + normalizeProviderModelProbeFailureReason, +} from '../runtime/providerModelProbe'; import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; @@ -96,6 +107,7 @@ import { } from './TeamLaunchStateEvaluator'; import { TeamLaunchStateStore } from './TeamLaunchStateStore'; import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; +import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; @@ -185,9 +197,6 @@ const STDOUT_RING_LIMIT = 64 * 1024; const LOG_PROGRESS_THROTTLE_MS = 300; const UI_LOGS_TAIL_LIMIT = 128 * 1024; const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000; -const PREFLIGHT_TIMEOUT_MS = 60000; -const PREFLIGHT_CODEX_TIMEOUT_MS = 45000; -const PREFLIGHT_GEMINI_TIMEOUT_MS = 15000; const PREFLIGHT_BINARY_TIMEOUT_MS = 8000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; @@ -214,11 +223,6 @@ const HANDLED_STREAM_JSON_TYPES = new Set([ 'result', 'system', ]); -const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.'; -const PREFLIGHT_EXPECTED = 'PONG'; -const PREFLIGHT_CODEX_MODEL = 'gpt-5.4-mini'; -const PREFLIGHT_GEMINI_MODEL = 'gemini-2.5-flash-lite'; - function assertAppDeterministicBootstrapEnabled(): void { if (process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP === '1') { throw new Error( @@ -260,41 +264,36 @@ function classifyDeterministicBootstrapFailure(reason: string): { }; } -function getPreflightPingModel(providerId: TeamProviderId | undefined): string { - switch (resolveTeamProviderId(providerId)) { - case 'codex': - return PREFLIGHT_CODEX_MODEL; - case 'gemini': - return PREFLIGHT_GEMINI_MODEL; - case 'anthropic': - default: - return 'haiku'; - } -} - function getPreflightPingArgs(providerId: TeamProviderId | undefined): string[] { - return [ - '-p', - PREFLIGHT_PING_PROMPT, - '--output-format', - 'text', - '--model', - getPreflightPingModel(providerId), - '--max-turns', - '1', - '--no-session-persistence', - ]; + return buildProviderPreflightPingArgs(providerId); } function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number { - switch (resolveTeamProviderId(providerId)) { - case 'codex': - return PREFLIGHT_CODEX_TIMEOUT_MS; - case 'gemini': - return PREFLIGHT_GEMINI_TIMEOUT_MS; - case 'anthropic': - default: - return PREFLIGHT_TIMEOUT_MS; + return getProviderModelProbeTimeoutMs(providerId); +} + +interface ProviderModelListCommandResponse { + schemaVersion?: number; + providers?: Record< + string, + { + defaultModel?: string | null; + models?: (string | { id?: string; label?: string; description?: string })[]; + } + >; +} + +function extractJsonObjectFromCli(raw: string): T { + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed) as T; + } catch { + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)) as T; + } + throw new Error('No JSON object found in CLI output'); } } @@ -308,6 +307,21 @@ function isProbeTimeoutMessage(message: string): boolean { ); } +function isTransientModelProbeMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes('timeout') || + lower.includes('timed out') || + lower.includes('etimedout') || + lower.includes('econnreset') || + lower.includes('429') || + lower.includes('500') || + lower.includes('502') || + lower.includes('503') || + lower.includes('504') + ); +} + function getTeamProviderLabel(providerId: TeamProviderId): string { switch (providerId) { case 'codex': @@ -1045,11 +1059,68 @@ function extractBootstrapFailureReason(text: string): string | null { lower.includes('lookup failure') || lower.includes('validation error') || lower.includes('api error'))) || + lower.includes('model is not supported') || + lower.includes('model is not available') || + lower.includes('model not available') || + lower.includes('model unavailable') || + lower.includes('model not found') || + lower.includes('unknown model') || + lower.includes('invalid model') || + lower.includes('unsupported model') || + lower.includes('not supported when using codex with a chatgpt account') || lower.includes('please check the provided tool list'); if (!looksLikeBootstrapFailure) return null; return trimmed.slice(0, 280); } +function extractTranscriptTextContent(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + if (!Array.isArray(value)) { + return []; + } + const parts: string[] = []; + for (const item of value) { + if (!item || typeof item !== 'object') continue; + const record = item as { type?: unknown; text?: unknown; content?: unknown }; + if (record.type === 'text' && typeof record.text === 'string' && record.text.trim()) { + parts.push(record.text.trim()); + continue; + } + parts.push(...extractTranscriptTextContent(record.content)); + } + return parts; +} + +function extractTranscriptMessageText(record: unknown): string | null { + if (!record || typeof record !== 'object') { + return null; + } + const normalizedRecord = record as { + text?: unknown; + content?: unknown; + message?: unknown; + toolUseResult?: unknown; + }; + if (typeof normalizedRecord.text === 'string' && normalizedRecord.text.trim()) { + return normalizedRecord.text.trim(); + } + const fromContent = extractTranscriptTextContent(normalizedRecord.content); + if (fromContent.length > 0) { + return fromContent.join('\n'); + } + const fromToolUseResult = extractTranscriptTextContent(normalizedRecord.toolUseResult); + if (fromToolUseResult.length > 0) { + return fromToolUseResult.join('\n'); + } + if (normalizedRecord.message) { + return extractTranscriptMessageText(normalizedRecord.message); + } + return null; +} + function normalizeMemberDiagnosticText(memberName: string, text: string): string { return `${memberName}: ${text.trim()}`; } @@ -2090,6 +2161,7 @@ function normalizeSameTeamText(text: string): string { export class TeamProvisioningService { private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; + private static readonly BOOTSTRAP_FAILURE_TAIL_BYTES = 128 * 1024; private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000; private static readonly PENDING_INBOX_RELAY_TTL_MS = 2 * 60 * 1000; private static readonly SAME_TEAM_NATIVE_DELIVERY_GRACE_MS = 15_000; @@ -2114,6 +2186,7 @@ export class TeamProvisioningService { NativeSameTeamFingerprint[] >(); private readonly launchStateStore = new TeamLaunchStateStore(); + private readonly memberLogsFinder: TeamMemberLogsFinder; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; private helpOutputCache: string | null = null; private helpOutputCacheTime = 0; @@ -2143,7 +2216,13 @@ export class TeamProvisioningService { _sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(), private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(), private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore() - ) {} + ) { + this.memberLogsFinder = new TeamMemberLogsFinder( + this.configReader, + this.inboxReader, + this.membersMetaStore + ); + } setCrossTeamSender( sender: @@ -3447,6 +3526,10 @@ export class TeamProvisioningService { run: ProvisioningRun, options?: { force?: boolean } ): Promise { + if (!run.expectedMembers || run.expectedMembers.length === 0) { + return; + } + await this.reconcileBootstrapTranscriptFailures(run); if (this.shouldSkipMemberSpawnAudit(run)) { return; } @@ -3462,6 +3545,33 @@ export class TeamProvisioningService { await this.auditMemberSpawnStatuses(run); } + private async reconcileBootstrapTranscriptFailures(run: ProvisioningRun): Promise { + for (const memberName of run.expectedMembers ?? []) { + const current = run.memberSpawnStatuses.get(memberName); + if ( + !current || + current.launchState === 'failed_to_start' || + current.launchState === 'confirmed_alive' || + current.runtimeAlive === true || + current.hardFailure === true || + current.agentToolAccepted !== true + ) { + continue; + } + const acceptedAtMs = + current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason( + run.teamName, + memberName, + Number.isFinite(acceptedAtMs) ? acceptedAtMs : null + ); + if (!transcriptFailureReason) { + continue; + } + this.setMemberSpawnStatus(run, memberName, 'error', transcriptFailureReason); + } + } + private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000; private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000; @@ -3507,7 +3617,13 @@ export class TeamProvisioningService { async prepareForProvisioning( cwd?: string, - opts?: { forceFresh?: boolean; providerId?: TeamProviderId; providerIds?: TeamProviderId[] } + opts?: { + forceFresh?: boolean; + providerId?: TeamProviderId; + providerIds?: TeamProviderId[]; + modelIds?: string[]; + limitContext?: boolean; + } ): Promise { const targetCwdForValidation = cwd?.trim() || process.cwd(); await this.validatePrepareCwd(targetCwdForValidation); @@ -3535,7 +3651,11 @@ export class TeamProvisioningService { } const warnings: string[] = []; + const details: string[] = []; const blockingMessages: string[] = []; + const selectedModelIds = Array.from( + new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean)) + ); for (const providerId of providerIds) { const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId); @@ -3555,32 +3675,47 @@ export class TeamProvisioningService { } if (!probeResult.warning) { + if (selectedModelIds.length > 0) { + const modelVerification = await this.verifySelectedProviderModels({ + claudePath: probeResult.claudePath, + cwd: targetCwd, + providerId, + modelIds: selectedModelIds, + limitContext: opts?.limitContext === true, + }); + details.push(...modelVerification.details); + warnings.push(...modelVerification.warnings); + blockingMessages.push(...modelVerification.blockingMessages); + } continue; } - const prefixedWarning = - providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning; - const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); - if (authSource === 'configured_api_key_missing') { - blockingMessages.push(prefixedWarning); - } else if ( - (authSource === 'none' || - authSource === 'codex_runtime' || - authSource === 'gemini_runtime') && - isAuthFailure - ) { - blockingMessages.push(prefixedWarning); - } else if (isBinaryProbeWarning(probeResult.warning)) { - blockingMessages.push(prefixedWarning); - } else { - // Preflight warnings (including timeouts) should not block provisioning. - warnings.push(prefixedWarning); + { + const prefixedWarning = + providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning; + const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); + if (authSource === 'configured_api_key_missing') { + blockingMessages.push(prefixedWarning); + } else if ( + (authSource === 'none' || + authSource === 'codex_runtime' || + authSource === 'gemini_runtime') && + isAuthFailure + ) { + blockingMessages.push(prefixedWarning); + } else if (isBinaryProbeWarning(probeResult.warning)) { + blockingMessages.push(prefixedWarning); + } else { + // Preflight warnings (including timeouts) should not block provisioning. + warnings.push(prefixedWarning); + } } } if (blockingMessages.length > 0) { return { ready: false, + details: details.length > 0 ? details : undefined, message: blockingMessages.length === 1 ? blockingMessages[0] @@ -3591,6 +3726,7 @@ export class TeamProvisioningService { return { ready: true, + details: details.length > 0 ? details : undefined, message: providerIds.length > 1 ? warnings.length > 0 @@ -3603,6 +3739,169 @@ export class TeamProvisioningService { }; } + private async verifySelectedProviderModels({ + claudePath, + cwd, + providerId, + modelIds, + limitContext, + }: { + claudePath: string; + cwd: string; + providerId: TeamProviderId; + modelIds: string[]; + limitContext: boolean; + }): Promise<{ + details: string[]; + warnings: string[]; + blockingMessages: string[]; + }> { + const details: string[] = []; + const warnings: string[] = []; + const blockingMessages: string[] = []; + + if (modelIds.length === 0) { + return { details, warnings, blockingMessages }; + } + + const { env } = await this.buildProvisioningEnv(providerId); + const probeOutcomeByResolvedModelId = new Map< + string, + { kind: 'ready' | 'warning' | 'unavailable'; reason?: string } + >(); + let resolvedDefaultModelId: string | null | undefined; + + const recordOutcome = ( + requestedModelId: string, + outcome: { kind: 'ready' | 'warning' | 'unavailable'; reason?: string } + ): void => { + if (outcome.kind === 'ready') { + details.push(`Selected model ${requestedModelId} verified for launch.`); + return; + } + if (outcome.kind === 'unavailable') { + blockingMessages.push( + `Selected model ${requestedModelId} is unavailable. ${outcome.reason ?? 'Model verification failed'}` + ); + return; + } + warnings.push( + `Selected model ${requestedModelId} could not be verified. ${outcome.reason ?? 'Model verification failed'}` + ); + }; + + for (const modelId of modelIds) { + const label = modelId.trim(); + if (!label) { + continue; + } + + let targetModelId = label; + if (isDefaultProviderModelSelection(label)) { + if (resolvedDefaultModelId === undefined) { + try { + resolvedDefaultModelId = await this.resolveProviderDefaultModel( + claudePath, + cwd, + providerId, + env, + limitContext + ); + } catch { + resolvedDefaultModelId = null; + } + } + if (!resolvedDefaultModelId) { + recordOutcome(label, { + kind: 'warning', + reason: 'Could not resolve the runtime default model', + }); + continue; + } + targetModelId = resolvedDefaultModelId; + } + + const cachedOutcome = probeOutcomeByResolvedModelId.get(targetModelId); + if (cachedOutcome) { + recordOutcome(label, cachedOutcome); + continue; + } + + try { + const result = await this.spawnProbe( + claudePath, + buildProviderModelProbeArgs(targetModelId), + cwd, + env, + getProviderModelProbeTimeoutMs(providerId), + { + resolveOnOutputMatch: ({ stdout, stderr }) => + isProviderModelProbeSuccessOutput(`${stdout}\n${stderr}`), + } + ); + const combinedOutput = buildCombinedLogs(result.stdout, result.stderr).trim(); + if (result.exitCode === 0 && isProviderModelProbeSuccessOutput(combinedOutput)) { + const outcome = { kind: 'ready' as const }; + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + recordOutcome(label, outcome); + continue; + } + + const reason = combinedOutput || `Probe exited with code ${result.exitCode ?? 'unknown'}.`; + const normalizedReason = normalizeProviderModelProbeFailureReason(reason); + if (classifyProviderModelProbeFailure(reason) === 'unavailable') { + const outcome = { kind: 'unavailable' as const, reason: normalizedReason }; + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + recordOutcome(label, outcome); + } else { + const outcome = { kind: 'warning' as const, reason: normalizedReason }; + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + recordOutcome(label, outcome); + } + } catch (error) { + const message = error instanceof Error ? error.message.trim() : String(error).trim(); + const normalizedMessage = normalizeProviderModelProbeFailureReason(message); + if ( + classifyProviderModelProbeFailure(message) === 'unavailable' && + !isTransientModelProbeMessage(message) + ) { + const outcome = { kind: 'unavailable' as const, reason: normalizedMessage }; + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + recordOutcome(label, outcome); + } else { + const outcome = { kind: 'warning' as const, reason: normalizedMessage }; + probeOutcomeByResolvedModelId.set(targetModelId, outcome); + recordOutcome(label, outcome); + } + } + } + + return { details, warnings, blockingMessages }; + } + + private async resolveProviderDefaultModel( + claudePath: string, + cwd: string, + providerId: TeamProviderId, + env: NodeJS.ProcessEnv, + limitContext: boolean + ): Promise { + if (providerId === 'anthropic') { + return getAnthropicDefaultTeamModel(limitContext); + } + + const { stdout } = await execCli(claudePath, ['model', 'list', '--json', '--provider', 'all'], { + cwd, + env, + timeout: 10_000, + }); + const parsed = extractJsonObjectFromCli(stdout); + const defaultModel = parsed.providers?.[providerId]?.defaultModel; + return typeof defaultModel === 'string' && defaultModel.trim().length > 0 + ? defaultModel.trim() + : null; + } + private getFreshCachedProbeResult( cwd: string, providerId: TeamProviderId | undefined @@ -6699,7 +6998,7 @@ export class TeamProvisioningService { const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName); const persisted = await this.launchStateStore.read(teamName); const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, persisted); - if (preferredSnapshot) { + if (preferredSnapshot && preferredSnapshot === bootstrapSnapshot) { return { snapshot: preferredSnapshot, statuses: snapshotToMemberSpawnStatuses(preferredSnapshot), @@ -6784,6 +7083,8 @@ export class TeamProvisioningService { const heartbeatReason = heartbeatMessage ? extractBootstrapFailureReason(heartbeatMessage.text) : null; + const acceptedAtMs = + current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; current.runtimeAlive = runtimeAlive; current.lastRuntimeAliveAt = runtimeAlive ? now : current.lastRuntimeAliveAt; current.sources = { @@ -6806,8 +7107,18 @@ export class TeamProvisioningService { current.hardFailure = false; current.hardFailureReason = undefined; } - const acceptedAtMs = - current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + if (!current.bootstrapConfirmed && !runtimeAlive && !current.hardFailure) { + const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason( + teamName, + expected, + Number.isFinite(acceptedAtMs) ? acceptedAtMs : null + ); + if (transcriptFailureReason) { + current.hardFailure = true; + current.hardFailureReason = transcriptFailureReason; + current.sources.hardFailureSignal = true; + } + } const graceExpired = current.agentToolAccepted === true && Number.isFinite(acceptedAtMs) && @@ -6851,6 +7162,139 @@ export class TeamProvisioningService { }; } + private async findBootstrapTranscriptFailureReason( + teamName: string, + memberName: string, + sinceMs: number | null + ): Promise { + let summaries: Awaited>; + try { + summaries = await this.memberLogsFinder.findMemberLogs(teamName, memberName, sinceMs); + } catch { + return null; + } + + for (const summary of summaries) { + if (!summary.filePath) continue; + const reason = await this.readRecentBootstrapFailureReason( + summary.filePath, + sinceMs, + memberName + ); + if (reason) { + return reason; + } + } + + return this.findBootstrapFailureReasonInProjectRoot(teamName, memberName, sinceMs); + } + + private async readRecentBootstrapFailureReason( + filePath: string, + sinceMs: number | null, + memberName?: string + ): Promise { + let handle: fs.promises.FileHandle | null = null; + const normalizedMemberName = memberName?.trim().toLowerCase() || null; + try { + handle = await fs.promises.open(filePath, 'r'); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size <= 0) { + return null; + } + const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES); + const buffer = Buffer.alloc(stat.size - start); + if (buffer.length === 0) { + return null; + } + await handle.read(buffer, 0, buffer.length, start); + const lines = buffer.toString('utf8').split('\n'); + if (start > 0) { + lines.shift(); + } + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (!line) continue; + let parsed: { timestamp?: unknown } | null = null; + try { + parsed = JSON.parse(line) as { timestamp?: unknown }; + } catch { + continue; + } + const timestampMs = + typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN; + if (sinceMs != null && Number.isFinite(timestampMs) && timestampMs < sinceMs) { + continue; + } + if (normalizedMemberName) { + const parsedAgentName = + typeof (parsed as { agentName?: unknown }).agentName === 'string' + ? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null + : null; + if (parsedAgentName && parsedAgentName !== normalizedMemberName) { + continue; + } + } + const text = extractTranscriptMessageText(parsed); + if (!text) continue; + const reason = extractBootstrapFailureReason(text); + if (reason) { + return reason; + } + } + } catch { + return null; + } finally { + await handle?.close().catch(() => undefined); + } + + return null; + } + + private async findBootstrapFailureReasonInProjectRoot( + teamName: string, + memberName: string, + sinceMs: number | null + ): Promise { + let config: Awaited>; + try { + config = await this.configReader.getConfig(teamName); + } catch { + return null; + } + const projectPath = config?.projectPath?.trim(); + if (!projectPath) { + return null; + } + + const projectDir = path.join(getProjectsBasePath(), extractBaseDir(encodePath(projectPath))); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(projectDir, { withFileTypes: true }); + } catch { + return null; + } + + const jsonlFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) + .sort((left, right) => right.name.localeCompare(left.name)); + for (const entry of jsonlFiles) { + if (config?.leadSessionId && entry.name === `${config.leadSessionId}.jsonl`) { + continue; + } + const reason = await this.readRecentBootstrapFailureReason( + path.join(projectDir, entry.name), + sinceMs, + memberName + ); + if (reason) { + return reason; + } + } + + return null; + } + private captureSendMessages(run: ProvisioningRun, content: Record[]): void { for (const part of content) { if (part.type !== 'tool_use' || typeof part.name !== 'string') continue; @@ -11562,7 +12006,9 @@ export class TeamProvisioningService { } const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim(); - const isPong = new RegExp(`\\b${PREFLIGHT_EXPECTED}\\b`, 'i').test(pongCandidate); + const isPong = new RegExp(`\\b${getProviderModelProbeExpectedOutput()}\\b`, 'i').test( + pongCandidate + ); if (!isPong) { return { warning: diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index b584e5b1..7bdf2e17 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -438,6 +438,9 @@ export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus'; /** Get status for a single provider */ export const CLI_INSTALLER_GET_PROVIDER_STATUS = 'cliInstaller:getProviderStatus'; +/** Trigger on-demand model verification for a single provider */ +export const CLI_INSTALLER_VERIFY_PROVIDER_MODELS = 'cliInstaller:verifyProviderModels'; + /** Start CLI install/update */ export const CLI_INSTALLER_INSTALL = 'cliInstaller:install'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 015f2c8d..7c390a9e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,7 @@ import { CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_PROGRESS, + CLI_INSTALLER_VERIFY_PROVIDER_MODELS, CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, CONTEXT_LIST, @@ -857,13 +858,17 @@ const electronAPI: ElectronAPI = { prepareProvisioning: async ( cwd?: string, providerId?: TeamLaunchRequest['providerId'], - providerIds?: TeamLaunchRequest['providerId'][] + providerIds?: TeamLaunchRequest['providerId'][], + selectedModels?: string[], + limitContext?: boolean ) => { return invokeIpcWithResult( TEAM_PREPARE_PROVISIONING, cwd, providerId, - providerIds + providerIds, + selectedModels, + limitContext ); }, createTeam: async (request: TeamCreateRequest) => { @@ -1413,6 +1418,9 @@ const electronAPI: ElectronAPI = { getProviderStatus: async (providerId: CliProviderId) => { return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId); }, + verifyProviderModels: async (providerId: CliProviderId) => { + return invokeIpcWithResult(CLI_INSTALLER_VERIFY_PROVIDER_MODELS, providerId); + }, install: async (): Promise => { return invokeIpcWithResult(CLI_INSTALLER_INSTALL); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index d3983c79..6d40e202 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -717,7 +717,9 @@ export class HttpAPIClient implements ElectronAPI { prepareProvisioning: async ( _cwd?: string, _providerId?: TeamLaunchRequest['providerId'], - _providerIds?: TeamLaunchRequest['providerId'][] + _providerIds?: TeamLaunchRequest['providerId'][], + _selectedModels?: string[], + _limitContext?: boolean ): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, @@ -1117,6 +1119,7 @@ export class HttpAPIClient implements ElectronAPI { providers: [], }), getProviderStatus: async (): Promise => null, + verifyProviderModels: async (): Promise => null, install: async (): Promise => { console.warn('[HttpAPIClient] CLI installer not available in browser mode'); }, diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 471c10f9..be2ffe20 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api, isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { formatProviderStatusText, getProviderConnectionModeSummary, @@ -32,10 +33,6 @@ import { useStore } from '@renderer/store'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { formatBytes } from '@renderer/utils/formatters'; import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; -import { - getTeamModelBadgeLabel, - getVisibleTeamProviderModels, -} from '@renderer/utils/teamModelCatalog'; import { AlertTriangle, CheckCircle, @@ -266,37 +263,6 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): { }; } -function formatModelBadgeLabel(providerId: CliProviderId, model: string): string { - return getTeamModelBadgeLabel(providerId, model) ?? model; -} - -const ModelBadges = ({ - providerId, - models, -}: { - readonly providerId: CliProviderId; - readonly models: string[]; -}): React.JSX.Element => { - const visibleModels = getVisibleTeamProviderModels(providerId, models); - return ( -
- {visibleModels.map((model) => ( - - {formatModelBadgeLabel(providerId, model)} - - ))} -
- ); -}; - const ProviderDetailSkeleton = (): React.JSX.Element => { return (
@@ -659,7 +625,12 @@ const InstalledBanner = ({
{!showSkeleton && provider.models.length > 0 && (
- +
)} diff --git a/src/renderer/components/runtime/ProviderModelBadges.tsx b/src/renderer/components/runtime/ProviderModelBadges.tsx new file mode 100644 index 00000000..b4e0495f --- /dev/null +++ b/src/renderer/components/runtime/ProviderModelBadges.tsx @@ -0,0 +1,97 @@ +import { + getTeamModelBadgeLabel, + getVisibleTeamProviderModels, +} from '@renderer/utils/teamModelCatalog'; +import { cn } from '@renderer/lib/utils'; + +import type { + CliProviderId, + CliProviderModelAvailability, + CliProviderModelAvailabilityStatus, + CliProviderStatus, +} from '@shared/types'; + +function formatModelBadgeLabel(providerId: CliProviderId, model: string): string { + return getTeamModelBadgeLabel(providerId, model) ?? model; +} + +function getAvailabilityStatus( + model: string, + modelAvailability: CliProviderModelAvailability[] | undefined +): CliProviderModelAvailabilityStatus | null { + return modelAvailability?.find((item) => item.modelId === model)?.status ?? null; +} + +function getAvailabilityReason( + model: string, + modelAvailability: CliProviderModelAvailability[] | undefined +): string | null { + return modelAvailability?.find((item) => item.modelId === model)?.reason ?? null; +} + +function getAvailabilityChip(status: CliProviderModelAvailabilityStatus | null): string | null { + switch (status) { + case 'checking': + return 'Checking'; + case 'unavailable': + return 'Unavailable'; + case 'unknown': + return 'Check failed'; + case 'available': + default: + return null; + } +} + +export function ProviderModelBadges({ + providerId, + models, + modelAvailability, + providerStatus, +}: { + readonly providerId: CliProviderId; + readonly models: string[]; + readonly modelAvailability?: CliProviderModelAvailability[]; + readonly providerStatus?: Pick | null; +}): React.JSX.Element { + const visibleModels = getVisibleTeamProviderModels(providerId, models, providerStatus); + + return ( +
+ {visibleModels.map((model) => { + const availabilityStatus = getAvailabilityStatus(model, modelAvailability); + const availabilityReason = getAvailabilityReason(model, modelAvailability); + const availabilityChip = getAvailabilityChip(availabilityStatus); + + return ( + + {formatModelBadgeLabel(providerId, model)} + {availabilityChip ? ( + + {availabilityChip} + + ) : null} + + ); + })} +
+ ); +} diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index b1bb8d52..2524867b 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { formatProviderStatusText, getProviderConnectionModeSummary, @@ -28,10 +29,6 @@ import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { useStore } from '@renderer/store'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { formatBytes } from '@renderer/utils/formatters'; -import { - getTeamModelBadgeLabel, - getVisibleTeamProviderModels, -} from '@renderer/utils/teamModelCatalog'; import { AlertTriangle, CheckCircle, @@ -49,37 +46,6 @@ import { SettingsSectionHeader } from '../components'; import type { CliProviderId, CliProviderStatus } from '@shared/types'; -function formatModelBadgeLabel(providerId: CliProviderId, model: string): string { - return getTeamModelBadgeLabel(providerId, model) ?? model; -} - -const ModelBadges = ({ - providerId, - models, -}: { - readonly providerId: CliProviderId; - readonly models: string[]; -}): React.JSX.Element => { - const visibleModels = getVisibleTeamProviderModels(providerId, models); - return ( -
- {visibleModels.map((model) => ( - - {formatModelBadgeLabel(providerId, model)} - - ))} -
- ); -}; - const ProviderDetailSkeleton = (): React.JSX.Element => { return (
@@ -600,9 +566,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{!showSkeleton && provider.models.length > 0 && (
-
)} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index d91680ee..0dd81283 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -41,8 +41,12 @@ import { normalizeCreateLaunchProviderForUi, } from '@renderer/utils/geminiUiFreeze'; import { normalizePath } from '@renderer/utils/pathNormalize'; -import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { + getTeamModelSelectionError, + normalizeTeamModelForUi, +} from '@renderer/utils/teamModelAvailability'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; @@ -50,15 +54,21 @@ import { AdvancedCliSection } from './AdvancedCliSection'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; import { - createInitialProviderChecks, failIncompleteProviderChecks, getProvisioningFailureHint, + getPrimaryProvisioningFailureDetail, getProvisioningProviderBackendSummary, type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, updateProviderCheck, } from './ProvisioningProviderStatusList'; +import { getProvisioningModelIssue } from './provisioningModelIssues'; +import { + getProviderPrepareCachedSnapshot, + runProviderPrepareDiagnostics, + type ProviderPrepareDiagnosticsModelResult, +} from './providerPrepareDiagnostics'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { computeEffectiveTeamModel } from './TeamModelSelector'; import { getNextSuggestedTeamName } from './teamNameSets'; @@ -82,7 +92,6 @@ import type { TeamCreateRequest, TeamProviderId, TeamProvisioningMemberInput, - TeamProvisioningPrepareResult, } from '@shared/types'; function getStoredTeamProvider(): TeamProviderId { @@ -115,6 +124,32 @@ function getProviderLabel(providerId: TeamProviderId): string { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } +function buildPrepareModelCacheKey( + cwd: string, + providerId: TeamProviderId, + backendSummary: string | null | undefined +): string { + return `${cwd}::${providerId}::${backendSummary ?? ''}`; +} + +function alignProvisioningChecks( + existingChecks: ProvisioningProviderCheck[], + providerIds: TeamProviderId[] +): ProvisioningProviderCheck[] { + const existingByProviderId = new Map( + existingChecks.map((check) => [check.providerId, check] as const) + ); + return providerIds.map( + (providerId) => + existingByProviderId.get(providerId) ?? { + providerId, + status: 'pending', + backendSummary: null, + details: [], + } + ); +} + export interface TeamCopyData { teamName: string; description?: string; @@ -486,6 +521,25 @@ export const CreateTeamDialog = ({ ); return new Map(entries); }, [cliStatus?.providers]); + const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider); + const prepareChecksRef = useRef([]); + const prepareModelResultsCacheRef = useRef( + new Map>() + ); + + useEffect(() => { + runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider; + }, [runtimeBackendSummaryByProvider]); + + useEffect(() => { + prepareChecksRef.current = prepareChecks; + }, [prepareChecks]); + + useEffect(() => { + if (!open) { + prepareModelResultsCacheRef.current.clear(); + } + }, [open]); useEffect(() => { if (multimodelEnabled) { @@ -534,41 +588,110 @@ export const CreateTeamDialog = ({ let cancelled = false; const requestSeq = ++prepareRequestSeqRef.current; + const initialChecks = alignProvisioningChecks( + prepareChecksRef.current, + selectedMemberProviders + ); setPrepareState('loading'); setPrepareMessage('Checking selected providers...'); setPrepareWarnings([]); - setPrepareChecks(createInitialProviderChecks(selectedMemberProviders)); + setPrepareChecks(initialChecks); // Defer so file list fetch (triggered by project select) can run first const timer = setTimeout(() => { void (async () => { - let checks = createInitialProviderChecks(selectedMemberProviders); + let checks = initialChecks; let anyFailure = false; let anyNotes = false; const collectedWarnings: string[] = []; try { for (const providerId of selectedMemberProviders) { + const selectedModelChecks = (() => { + const next = new Set(); + let hasDefaultSelection = false; + const supportsProviderDefaultCheck = + providerId === 'codex' || + providerId === 'gemini' || + (providerId === 'anthropic' && selectedProviderId === 'anthropic'); + const leadModel = computeEffectiveTeamModel( + selectedModel, + limitContext, + selectedProviderId + ); + if (selectedProviderId === providerId && selectedModel.trim()) { + if (leadModel?.trim()) { + next.add(leadModel.trim()); + } + } else if (selectedProviderId === providerId && supportsProviderDefaultCheck) { + hasDefaultSelection = true; + } + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + const memberProviderId = + normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + if (memberProviderId !== providerId) { + continue; + } + const memberModel = member.model?.trim(); + if (memberModel) { + next.add(memberModel); + } else if (supportsProviderDefaultCheck) { + hasDefaultSelection = true; + } + } + if (supportsProviderDefaultCheck && hasDefaultSelection) { + next.add(DEFAULT_PROVIDER_MODEL_SELECTION); + } + return Array.from(next); + })(); + const backendSummary = + runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; + const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; + const cachedSnapshot = getProviderPrepareCachedSnapshot({ + providerId, + selectedModelIds: selectedModelChecks, + cachedModelResultsById, + }); checks = updateProviderCheck(checks, providerId, { - status: 'checking', - backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, - details: [], + status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking', + backendSummary, + details: cachedSnapshot.details, }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); - setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`); + setPrepareMessage( + selectedModelChecks.length > 0 + ? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...` + : `Checking ${getProviderLabel(providerId)} runtime...` + ); } - const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning( - effectiveCwd, + const prepResult = await runProviderPrepareDiagnostics({ + cwd: effectiveCwd, providerId, - [providerId] - ); - const detailLines = [ - ...(prepResult.warnings ?? []).filter(Boolean), - ...(!prepResult.ready && prepResult.message ? [prepResult.message] : []), - ]; - if (prepResult.warnings?.length) { + selectedModelIds: selectedModelChecks, + prepareProvisioning: api.teams.prepareProvisioning, + limitContext, + cachedModelResultsById, + onModelProgress: ({ details, completedCount, totalCount }) => { + checks = updateProviderCheck(checks, providerId, { + status: 'checking', + backendSummary, + details, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + setPrepareMessage( + `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...` + ); + } + }, + }); + if (prepResult.warnings.length > 0) { anyNotes = true; collectedWarnings.push( ...prepResult.warnings.map( @@ -576,23 +699,29 @@ export const CreateTeamDialog = ({ ) ); } - if (!prepResult.ready) { + if (prepResult.status === 'failed') { anyFailure = true; + } else if (prepResult.status === 'notes') { + anyNotes = true; } + prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById); checks = updateProviderCheck(checks, providerId, { - status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', - backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, - details: detailLines, + status: prepResult.status, + backendSummary, + details: prepResult.details, }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); } } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; + const failureMessage = + getPrimaryProvisioningFailureDetail(checks) ?? + 'Some selected providers need attention.'; setPrepareState(anyFailure ? 'failed' : 'ready'); setPrepareMessage( anyFailure - ? 'Some selected providers need attention.' + ? failureMessage : anyNotes ? 'Selected providers are ready with notes.' : 'Selected providers are ready.' @@ -619,9 +748,11 @@ export const CreateTeamDialog = ({ canCreate, launchTeam, effectiveCwd, + effectiveMemberDrafts, + limitContext, + selectedModel, selectedProviderId, selectedMemberProviders, - runtimeBackendSummaryByProvider, ]); useEffect(() => { @@ -809,6 +940,13 @@ export const CreateTeamDialog = ({ () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId), [selectedModel, limitContext, selectedProviderId] ); + const runtimeProviderStatusById = useMemo( + () => + new Map( + (cliStatus?.providers ?? []).map((provider) => [provider.providerId, provider] as const) + ), + [cliStatus?.providers] + ); const sanitizedTeamName = sanitizeTeamName(teamName.trim()); const teamNameInlineError = validateTeamNameInline(teamName); @@ -854,11 +992,76 @@ export const CreateTeamDialog = ({ () => validateRequest(request, { requireCwd: launchTeam }), [request, launchTeam] ); + const modelValidationError = useMemo(() => { + const leadError = getTeamModelSelectionError( + selectedProviderId, + selectedModel, + runtimeProviderStatusById.get(selectedProviderId) + ); + if (leadError) { + return leadError; + } + + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + const memberError = getTeamModelSelectionError( + providerId, + member.model, + runtimeProviderStatusById.get(providerId) + ); + if (!memberError) { + continue; + } + + const memberName = member.name.trim(); + return memberName ? `${memberName}: ${memberError}` : memberError; + } + + return null; + }, [effectiveMemberDrafts, runtimeProviderStatusById, selectedModel, selectedProviderId]); + const leadModelIssueText = useMemo(() => { + const issue = getProvisioningModelIssue( + prepareChecks, + selectedProviderId, + effectiveModel ?? selectedModel + ); + return issue?.reason ?? issue?.detail ?? null; + }, [effectiveModel, prepareChecks, selectedModel, selectedProviderId]); + const memberModelIssueById = useMemo(() => { + const next: Record = {}; + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + if (syncModelsWithLead && leadModelIssueText) { + next[member.id] = leadModelIssueText; + continue; + } + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + const issue = getProvisioningModelIssue(prepareChecks, providerId, member.model); + const issueText = issue?.reason ?? issue?.detail ?? null; + if (issueText) { + next[member.id] = issueText; + } + } + return next; + }, [ + effectiveMemberDrafts, + leadModelIssueText, + prepareChecks, + selectedProviderId, + syncModelsWithLead, + ]); const hasCreateFormErrors = !!teamNameInlineError || isNameTakenByExistingTeam || isNameProvisioning || - !requestValidation.valid; + !requestValidation.valid || + !!modelValidationError; const internalArgs = useMemo(() => { const args: string[] = []; @@ -897,7 +1100,8 @@ export const CreateTeamDialog = ({ [members, setMembers, setSyncModelsWithLead] ); - const activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null; + const activeError = + localError ?? modelValidationError ?? provisioningErrorsByTeam[request.teamName] ?? null; const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; @@ -928,6 +1132,10 @@ export const CreateTeamDialog = ({ setLocalError(messages.join(' · ') || 'Check form fields'); return; } + if (modelValidationError) { + setLocalError(modelValidationError); + return; + } setFieldErrors({}); setLocalError(null); setIsSubmitting(true); @@ -1040,45 +1248,6 @@ export const CreateTeamDialog = ({ ) : null} - {canCreate && launchTeam && prepareState === 'failed' ? ( -
-
- -
-

- CLI environment is not available — launch is blocked -

-

- {prepareMessage ?? 'Failed to prepare environment'} -

- {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( - - ) : null} - {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} -
- ) : null} -

- {getProvisioningFailureHint(prepareMessage, prepareChecks)} -

-
-
-
- ) : null} - {!canCreate ? (

) : null} + + {canCreate && launchTeam && prepareState === 'failed' ? ( +

+
+ +
+

+ CLI environment is not available - launch is blocked +

+

+ {prepareMessage ?? 'Failed to prepare environment'} +

+

+ Pre-flight check to catch errors before launch +

+
+
+ {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( + + ) : null} + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +

+ {getProvisioningFailureHint(prepareMessage, prepareChecks)} +

+
+ ) : null}
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index dcca38bb..16d8993b 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -43,8 +43,12 @@ import { } from '@renderer/utils/geminiUiFreeze'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; -import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { + getTeamModelSelectionError, + normalizeTeamModelForUi, +} from '@renderer/utils/teamModelAvailability'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, @@ -66,15 +70,21 @@ import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; import { - createInitialProviderChecks, failIncompleteProviderChecks, getProvisioningFailureHint, + getPrimaryProvisioningFailureDetail, getProvisioningProviderBackendSummary, type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, updateProviderCheck, } from './ProvisioningProviderStatusList'; +import { getProvisioningModelIssue } from './provisioningModelIssues'; +import { + getProviderPrepareCachedSnapshot, + runProviderPrepareDiagnostics, + type ProviderPrepareDiagnosticsModelResult, +} from './providerPrepareDiagnostics'; import { computeEffectiveTeamModel, formatTeamModelSummary, @@ -93,10 +103,35 @@ import type { ScheduleLaunchConfig, TeamLaunchRequest, TeamProviderId, - TeamProvisioningPrepareResult, UpdateSchedulePatch, } from '@shared/types'; +function buildPrepareModelCacheKey( + cwd: string, + providerId: TeamProviderId, + backendSummary: string | null | undefined +): string { + return `${cwd}::${providerId}::${backendSummary ?? ''}`; +} + +function alignProvisioningChecks( + existingChecks: ProvisioningProviderCheck[], + providerIds: TeamProviderId[] +): ProvisioningProviderCheck[] { + const existingByProviderId = new Map( + existingChecks.map((check) => [check.providerId, check] as const) + ); + return providerIds.map( + (providerId) => + existingByProviderId.get(providerId) ?? { + providerId, + status: 'pending', + backendSummary: null, + details: [], + } + ); +} + // ============================================================================= // Props — discriminated union // ============================================================================= @@ -341,6 +376,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); return new Map(entries); }, [cliStatus?.providers]); + const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider); + const prepareChecksRef = useRef([]); + const prepareModelResultsCacheRef = useRef( + new Map>() + ); + + useEffect(() => { + runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider; + }, [runtimeBackendSummaryByProvider]); + useEffect(() => { + prepareChecksRef.current = prepareChecks; + }, [prepareChecks]); + useEffect(() => { + if (!open) { + prepareModelResultsCacheRef.current.clear(); + } + }, [open]); + const runtimeProviderStatusById = useMemo( + () => + new Map( + (cliStatus?.providers ?? []).map((provider) => [provider.providerId, provider] as const) + ), + [cliStatus?.providers] + ); useEffect(() => { if (multimodelEnabled) { @@ -629,6 +688,51 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '', [selectedModel, limitContext, selectedProviderId] ); + const selectedModelChecksByProvider = useMemo(() => { + const modelsByProvider = new Map(); + const defaultSelectionByProvider = new Map(); + const addModel = (providerId: TeamProviderId, model: string | undefined): void => { + const trimmed = model?.trim() ?? ''; + if (!trimmed) { + return; + } + const existing = modelsByProvider.get(providerId) ?? []; + if (!existing.includes(trimmed)) { + modelsByProvider.set(providerId, [...existing, trimmed]); + } + }; + const addDefaultSelection = (providerId: TeamProviderId): void => { + if ( + providerId === 'codex' || + providerId === 'gemini' || + (providerId === 'anthropic' && selectedProviderId === 'anthropic') + ) { + defaultSelectionByProvider.set(providerId, true); + } + }; + + if (selectedModel.trim()) { + addModel(selectedProviderId, effectiveLeadRuntimeModel); + } else { + addDefaultSelection(selectedProviderId); + } + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + if (member.model?.trim()) { + addModel(providerId, member.model); + } else { + addDefaultSelection(providerId); + } + } + for (const providerId of defaultSelectionByProvider.keys()) { + addModel(providerId, DEFAULT_PROVIDER_MODEL_SELECTION); + } + + return modelsByProvider; + }, [effectiveLeadRuntimeModel, effectiveMemberDrafts, selectedModel, selectedProviderId]); const runtimeChangeNotes = useMemo(() => { if (!isLaunch) { @@ -811,61 +915,95 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen let cancelled = false; const requestSeq = ++prepareRequestSeqRef.current; + const initialChecks = alignProvisioningChecks( + prepareChecksRef.current, + selectedMemberProviders + ); setPrepareState('loading'); setPrepareMessage('Checking selected providers...'); setPrepareWarnings([]); - setPrepareChecks(createInitialProviderChecks(selectedMemberProviders)); + setPrepareChecks(initialChecks); void (async () => { - let checks = createInitialProviderChecks(selectedMemberProviders); + let checks = initialChecks; let anyFailure = false; let anyNotes = false; const collectedWarnings: string[] = []; try { for (const providerId of selectedMemberProviders) { + const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; + const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; + const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; + const cachedSnapshot = getProviderPrepareCachedSnapshot({ + providerId, + selectedModelIds: selectedModelChecks, + cachedModelResultsById, + }); checks = updateProviderCheck(checks, providerId, { - status: 'checking', - backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, - details: [], + status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking', + backendSummary, + details: cachedSnapshot.details, }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); - setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`); + setPrepareMessage( + selectedModelChecks.length > 0 + ? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...` + : `Checking ${getProviderLabel(providerId)} runtime...` + ); } - const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning( - effectiveCwd, + const prepResult = await runProviderPrepareDiagnostics({ + cwd: effectiveCwd, providerId, - [providerId] - ); - const detailLines = [ - ...(prepResult.warnings ?? []).filter(Boolean), - ...(!prepResult.ready && prepResult.message ? [prepResult.message] : []), - ]; - if (prepResult.warnings?.length) { + selectedModelIds: selectedModelChecks, + prepareProvisioning: api.teams.prepareProvisioning, + limitContext, + cachedModelResultsById, + onModelProgress: ({ details, completedCount, totalCount }) => { + checks = updateProviderCheck(checks, providerId, { + status: 'checking', + backendSummary, + details, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + setPrepareMessage( + `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...` + ); + } + }, + }); + if (prepResult.warnings.length > 0) { anyNotes = true; collectedWarnings.push( ...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`) ); } - if (!prepResult.ready) { + if (prepResult.status === 'failed') { anyFailure = true; + } else if (prepResult.status === 'notes') { + anyNotes = true; } + prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById); checks = updateProviderCheck(checks, providerId, { - status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', - backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, - details: detailLines, + status: prepResult.status, + backendSummary, + details: prepResult.details, }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); } } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; + const failureMessage = + getPrimaryProvisioningFailureDetail(checks) ?? 'Some selected providers need attention.'; setPrepareState(anyFailure ? 'failed' : 'ready'); setPrepareMessage( anyFailure - ? 'Some selected providers need attention.' + ? failureMessage : anyNotes ? 'Selected providers are ready with notes.' : 'Selected providers are ready.' @@ -891,7 +1029,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen effectiveCwd, selectedProviderId, selectedMemberProviders, - runtimeBackendSummaryByProvider, + selectedModelChecksByProvider, ]); // --------------------------------------------------------------------------- @@ -1066,6 +1204,84 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } return errors; }, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]); + const modelValidationError = useMemo(() => { + const leadError = getTeamModelSelectionError( + selectedProviderId, + selectedModel, + runtimeProviderStatusById.get(selectedProviderId) + ); + if (leadError) { + return leadError; + } + + if (!isLaunch) { + return null; + } + + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + const memberError = getTeamModelSelectionError( + providerId, + member.model, + runtimeProviderStatusById.get(providerId) + ); + if (!memberError) { + continue; + } + + const memberName = member.name.trim(); + return memberName ? `${memberName}: ${memberError}` : memberError; + } + + return null; + }, [ + effectiveMemberDrafts, + isLaunch, + runtimeProviderStatusById, + selectedModel, + selectedProviderId, + ]); + const leadModelIssueText = useMemo(() => { + const issue = getProvisioningModelIssue( + prepareChecks, + selectedProviderId, + effectiveLeadRuntimeModel || selectedModel + ); + return issue?.reason ?? issue?.detail ?? null; + }, [effectiveLeadRuntimeModel, prepareChecks, selectedModel, selectedProviderId]); + const memberModelIssueById = useMemo(() => { + const next: Record = {}; + if (!isLaunch) { + return next; + } + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + if (syncModelsWithLead && leadModelIssueText) { + next[member.id] = leadModelIssueText; + continue; + } + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + const issue = getProvisioningModelIssue(prepareChecks, providerId, member.model); + const issueText = issue?.reason ?? issue?.detail ?? null; + if (issueText) { + next[member.id] = issueText; + } + } + return next; + }, [ + effectiveMemberDrafts, + isLaunch, + leadModelIssueText, + prepareChecks, + selectedProviderId, + syncModelsWithLead, + ]); const hasInvalidLaunchMemberNames = useMemo( () => isLaunch && @@ -1087,7 +1303,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // --------------------------------------------------------------------------- const provisioningError = isLaunch ? props.provisioningError : null; - const activeError = localError ?? provisioningError; + const activeError = localError ?? modelValidationError ?? provisioningError; const launchInFlight = useStore((s) => isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false ); @@ -1119,6 +1335,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setLocalError(validationErrors[0]); return; } + if (modelValidationError) { + setLocalError(modelValidationError); + return; + } if (isLaunch && !effectiveCwd) { setLocalError('Select working directory (cwd)'); return; @@ -1228,9 +1448,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ? isSubmitting || launchInFlight || validationErrors.length > 0 || + !!modelValidationError || hasInvalidLaunchMemberNames || hasDuplicateLaunchMemberNames - : isSubmitting || validationErrors.length > 0; + : isSubmitting || validationErrors.length > 0 || !!modelValidationError; // --------------------------------------------------------------------------- // Dynamic labels @@ -1318,63 +1539,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null} - {/* Launch-only: CLI env failed */} - {isLaunch && prepareState === 'failed' ? ( -
-
- -
-

- CLI environment is not available — launch is blocked -

-

- {prepareMessage ?? 'Failed to prepare environment'} -

- {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( - - ) : null} - {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} -
- ) : null} -
-

- {getProvisioningFailureHint(prepareMessage, prepareChecks)} -

- {(prepareMessage ?? '').toLowerCase().includes('spawn ') || - prepareChecks.some((check) => - check.details.some((detail) => detail.toLowerCase().includes('spawn ')) - ) ? ( - - ) : null} -
-
-
-
- ) : null} -
{/* ═══════════════════════════════════════════════════════════════════ Schedule-only: Team selector (standalone mode) @@ -1553,6 +1717,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen onSyncModelsWithTeammatesChange={setSyncModelsWithLead} leadWarningText={leadRuntimeWarningText} memberWarningById={memberRuntimeWarningById} + leadModelIssueText={leadModelIssueText} + memberModelIssueById={memberModelIssueById} softDeleteMembers disableGeminiOption={isGeminiUiFrozen()} /> @@ -1816,7 +1982,64 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null} - {prepareState === 'failed' ?
: null} + {prepareState === 'failed' ? ( +
+
+ +
+

+ CLI environment is not available - launch is blocked +

+

+ {prepareMessage ?? 'Failed to prepare environment'} +

+

+ Pre-flight check to catch errors before launch +

+
+
+ {!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( + + ) : null} + {prepareWarnings.length > 0 && prepareChecks.length === 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +
+

+ {getProvisioningFailureHint(prepareMessage, prepareChecks)} +

+ {(prepareMessage ?? '').toLowerCase().includes('spawn ') || + prepareChecks.some((check) => + check.details.some((detail) => detail.toLowerCase().includes('spawn ')) + ) ? ( + + ) : null} +
+
+ ) : null}
) : null} diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index dcf79f8d..27bb8dfe 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -84,6 +84,21 @@ export function failIncompleteProviderChecks( ); } +type ProvisioningDetailSummary = + | 'CLI binary missing' + | 'Working directory missing' + | 'CLI binary could not be started' + | 'CLI preflight did not complete' + | 'Authentication required' + | 'Runtime provider is not configured' + | 'CLI preflight failed' + | 'Selected model verified' + | 'Selected model unavailable' + | 'Selected model verification timed out' + | 'Selected model check failed' + | 'Ready with notes' + | 'Needs attention'; + function getStatusLabel(status: ProvisioningProviderCheckStatus): string { switch (status) { case 'checking': @@ -100,7 +115,10 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string { } } -function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus): string | null { +function summarizeDetail( + detail: string, + status: ProvisioningProviderCheckStatus +): ProvisioningDetailSummary | null { const lower = detail.toLowerCase(); if (lower.includes('spawn ') && lower.includes(' enoent')) { @@ -132,6 +150,34 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus if (lower.includes('claude cli preflight check failed')) { return 'CLI preflight failed'; } + if (lower.includes('selected model') && lower.includes('verified for launch')) { + return 'Selected model verified'; + } + if (lower.includes('selected model') && lower.includes('is unavailable')) { + return 'Selected model unavailable'; + } + if ( + lower.includes('selected model') && + lower.includes('could not be verified') && + lower.includes('timed out') + ) { + return 'Selected model verification timed out'; + } + if (lower.includes('selected model') && lower.includes('could not be verified')) { + return 'Selected model check failed'; + } + if (lower.includes(' - verified')) { + return 'Selected model verified'; + } + if (lower.includes(' - unavailable -')) { + return 'Selected model unavailable'; + } + if (lower.includes('timed out')) { + return 'Selected model verification timed out'; + } + if (lower.includes(' - check failed -')) { + return 'Selected model check failed'; + } if (status === 'notes') { return 'Ready with notes'; @@ -142,13 +188,173 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus return null; } +function getModelDetailSummary(details: string[]): string | null { + let verifiedCount = 0; + let unavailableCount = 0; + let timedOutCount = 0; + let checkFailedCount = 0; + let checkingCount = 0; + + for (const detail of details) { + const lower = detail.toLowerCase(); + if (lower.includes(' - verified')) { + verifiedCount += 1; + continue; + } + if (lower.includes(' - unavailable -')) { + unavailableCount += 1; + continue; + } + if (lower.includes('timed out')) { + timedOutCount += 1; + continue; + } + if (lower.includes(' - check failed -')) { + checkFailedCount += 1; + continue; + } + if (lower.includes(' - checking...')) { + checkingCount += 1; + } + } + + const parts: string[] = []; + if (unavailableCount > 0) { + parts.push(`${unavailableCount} model${unavailableCount === 1 ? '' : 's'} unavailable`); + } + if (checkFailedCount > 0) { + parts.push(`${checkFailedCount} model${checkFailedCount === 1 ? '' : 's'} check failed`); + } + if (timedOutCount > 0) { + parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`); + } + if (checkingCount > 0) { + parts.push(`${checkingCount} checking`); + } + if (verifiedCount > 0) { + parts.push(`${verifiedCount} verified`); + } + + return parts.length > 0 ? `Selected model checks - ${parts.join(', ')}` : null; +} + function getDisplayStatusText(check: ProvisioningProviderCheck): string { - const summary = check.details.find(Boolean) - ? summarizeDetail(check.details[0], check.status) - : null; + const modelSummary = getModelDetailSummary(check.details); + if (modelSummary) { + return modelSummary; + } + + const summarizedDetails = check.details + .map((detail) => summarizeDetail(detail, check.status)) + .filter((detail): detail is ProvisioningDetailSummary => Boolean(detail)); + + const summary = + check.status === 'failed' + ? (summarizedDetails.find( + (detail) => + detail === 'Selected model unavailable' || + detail === 'Selected model check failed' || + detail === 'Authentication required' || + detail === 'CLI preflight failed' || + detail === 'CLI binary could not be started' + ) ?? + summarizedDetails[0] ?? + null) + : (summarizedDetails[0] ?? null); return summary ?? getStatusLabel(check.status); } +function getDetailTone( + detail: string, + status: ProvisioningProviderCheckStatus +): 'success' | 'failure' | 'checking' | 'neutral' { + const summary = summarizeDetail(detail, status); + if (summary === 'Selected model verified') { + return 'success'; + } + if (summary === 'Selected model verification timed out') { + return 'neutral'; + } + if ( + summary === 'Selected model unavailable' || + summary === 'Selected model check failed' || + summary === 'CLI binary missing' || + summary === 'Working directory missing' || + summary === 'CLI binary could not be started' || + summary === 'CLI preflight did not complete' || + summary === 'Authentication required' || + summary === 'Runtime provider is not configured' || + summary === 'CLI preflight failed' || + summary === 'Needs attention' + ) { + return 'failure'; + } + if (detail.toLowerCase().includes(' - checking...')) { + return 'checking'; + } + return 'neutral'; +} + +function getDetailColorClass(detail: string, status: ProvisioningProviderCheckStatus): string { + switch (getDetailTone(detail, status)) { + case 'success': + return 'text-emerald-400'; + case 'failure': + return 'text-red-300'; + case 'checking': + return 'text-[var(--color-text-secondary)]'; + case 'neutral': + default: + return 'text-[var(--color-text-muted)]'; + } +} + +export function getPrimaryProvisioningFailureDetail( + checks: ProvisioningProviderCheck[] +): string | null { + for (const check of checks) { + if (check.status !== 'failed') { + continue; + } + + const unavailableDetail = check.details.find((detail) => + detail.toLowerCase().includes('selected model') && + detail.toLowerCase().includes('is unavailable') + ? true + : detail.toLowerCase().includes(' - unavailable -') + ); + if (unavailableDetail) { + return unavailableDetail; + } + } + + for (const check of checks) { + if (check.status !== 'failed') { + continue; + } + + const preferredFailure = check.details.find( + (detail) => getDetailTone(detail, check.status) === 'failure' + ); + if (preferredFailure) { + return preferredFailure; + } + + const nonSuccessDetail = check.details.find( + (detail) => getDetailTone(detail, check.status) !== 'success' + ); + if (nonSuccessDetail) { + return nonSuccessDetail; + } + + if (check.details.length > 0) { + return check.details[0]; + } + } + + return null; +} + export function shouldHideProvisioningProviderStatusList( checks: ProvisioningProviderCheck[], message: string | null | undefined @@ -236,7 +442,10 @@ export const ProvisioningProviderStatusList = ({ {visibleDetails.length > 0 ? (
{visibleDetails.map((detail) => ( -

+

{detail}

))} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index c0175dbf..0e593d34 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -11,23 +11,26 @@ import { } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; +import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { GEMINI_UI_DISABLED_BADGE_LABEL, GEMINI_UI_DISABLED_REASON, isGeminiUiFrozen, } from '@renderer/utils/geminiUiFreeze'; +import { + getAvailableTeamProviderModelOptions, + getTeamModelUiDisabledReason, + normalizeTeamModelForUi, + TEAM_MODEL_UI_DISABLED_BADGE_LABEL, +} from '@renderer/utils/teamModelAvailability'; import { doesTeamModelCarryProviderBrand, getProviderScopedTeamModelLabel, getTeamModelLabel as getCatalogTeamModelLabel, - getTeamModelUiDisabledReason, getTeamProviderLabel as getCatalogTeamProviderLabel, - getTeamProviderModelOptions, - normalizeTeamModelForUi, - TEAM_MODEL_UI_DISABLED_BADGE_LABEL, } from '@renderer/utils/teamModelCatalog'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; -import { Info } from 'lucide-react'; +import { AlertTriangle, Info } from 'lucide-react'; export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; @@ -105,9 +108,9 @@ export function computeEffectiveTeamModel( } const base = extractProviderScopedBaseModel(selectedModel, providerId); - if (limitContext) return base; + if (limitContext) return base || getAnthropicDefaultTeamModel(true); if (base === 'haiku') return base; - return base ? `${base}[1m]` : 'opus[1m]'; + return base ? `${base}[1m]` : getAnthropicDefaultTeamModel(limitContext); } export interface TeamModelSelectorProps { @@ -117,6 +120,7 @@ export interface TeamModelSelectorProps { onValueChange: (value: string) => void; id?: string; disableGeminiOption?: boolean; + modelIssueReasonByValue?: Partial>; } export const TeamModelSelector: React.FC = ({ @@ -126,8 +130,10 @@ export const TeamModelSelector: React.FC = ({ onValueChange, id, disableGeminiOption = false, + modelIssueReasonByValue, }) => { const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'agent_teams_orchestrator'; @@ -135,7 +141,7 @@ export const TeamModelSelector: React.FC = ({ disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId; const defaultModelTooltip = useMemo(() => { if (effectiveProviderId === 'anthropic') { - return 'Default model from Claude CLI (/model).\nUses the runtime default for the selected provider.'; + return 'Uses the Claude team default model.\nResolves to Opus 1M, or Opus 200K when Limit context is enabled.'; } return 'Uses the runtime default for the selected provider.'; }, [effectiveProviderId]); @@ -181,13 +187,20 @@ export const TeamModelSelector: React.FC = ({ return statusBadge; }; - const runtimeModels = useMemo( + const runtimeProviderStatus = useMemo( () => - cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId) - ?.models ?? [], + cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId) ?? null, [cliStatus?.providers, effectiveProviderId] ); - const normalizedValue = normalizeTeamModelForUi(effectiveProviderId, value); + const shouldAwaitRuntimeModelList = + effectiveProviderId !== 'anthropic' && + (cliStatus == null || cliStatusLoading) && + runtimeProviderStatus == null; + const normalizedValue = normalizeTeamModelForUi( + effectiveProviderId, + value, + runtimeProviderStatus + ); useEffect(() => { if (normalizedValue !== value) { @@ -196,22 +209,11 @@ export const TeamModelSelector: React.FC = ({ }, [normalizedValue, onValueChange, value]); const modelOptions = useMemo(() => { - const fallback = getTeamProviderModelOptions(effectiveProviderId); - if (effectiveProviderId === 'anthropic' || runtimeModels.length === 0) { - return fallback.map((option) => ({ - ...option, - label: - option.value === '' - ? option.label - : getProviderScopedTeamModelLabel(effectiveProviderId, option.value), - })); + if (shouldAwaitRuntimeModelList) { + return [{ value: '', label: 'Default', badgeLabel: 'Default' }]; } - const dynamicOptions = runtimeModels.map((model) => ({ - value: model, - label: getProviderScopedTeamModelLabel(effectiveProviderId, model), - })); - return [{ value: '', label: 'Default' }, ...dynamicOptions]; - }, [effectiveProviderId, runtimeModels]); + return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus); + }, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]); return (
@@ -292,6 +294,12 @@ export const TeamModelSelector: React.FC = ({ ) : null}
+ {shouldAwaitRuntimeModelList ? ( +

+ Explicit models load from the current runtime. Default remains available while the + list is syncing. +

+ ) : null}
= ({ (() => { const modelDisabledReason = getTeamModelUiDisabledReason( effectiveProviderId, - opt.value + opt.value, + runtimeProviderStatus ); - const modelSelectable = activeProviderSelectable && !modelDisabledReason; + const availabilityStatus = + opt.value === '' ? 'available' : (opt.availabilityStatus ?? 'available'); + const availabilityReason = + opt.value === '' ? null : (opt.availabilityReason ?? null); + const modelIssueReason = + opt.value === '' ? null : (modelIssueReasonByValue?.[opt.value] ?? null); + const hasModelIssue = Boolean(modelIssueReason); + const modelSelectable = + activeProviderSelectable && + !modelDisabledReason && + (opt.value === '' || + availabilityStatus == null || + availabilityStatus === 'available'); + const modelStatusMessage = + modelIssueReason ?? modelDisabledReason ?? availabilityReason ?? null; return (
@@ -130,6 +139,7 @@ export const LeadModelRow = ({ onValueChange={onModelChange} id="lead-model" disableGeminiOption={disableGeminiOption} + modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined} /> void; warningText?: string | null; disableGeminiOption?: boolean; + modelIssueText?: string | null; } export const MemberDraftRow = ({ @@ -87,6 +89,7 @@ export const MemberDraftRow = ({ onRestore, warningText, disableGeminiOption = false, + modelIssueText, }: MemberDraftRowProps): React.JSX.Element => { const { isLight } = useTheme(); const memberColorSet = getTeamColorSet( @@ -175,6 +178,7 @@ export const MemberDraftRow = ({ const modelTooltipText = forceInheritedModelSettings ? 'Provider, model, and effort are inherited from the lead while sync is enabled.' : modelLockReason; + const hasModelIssue = Boolean(modelIssueText); return (
setModelExpanded((prev) => !prev)} @@ -262,13 +270,21 @@ export const MemberDraftRow = ({ providerId={effectiveProviderId} className="size-3.5 shrink-0" /> - {modelButtonLabel} + {modelButtonLabel} + {hasModelIssue ? ( + + ) : null} - {modelTooltipText ? ( + {modelTooltipText || modelIssueText ? ( - {modelTooltipText} + {modelIssueText ?

{modelIssueText}

: null} + {modelTooltipText ? ( +

+ {modelTooltipText} +

+ ) : null}
) : null} @@ -355,6 +371,9 @@ export const MemberDraftRow = ({ }} id={`member-${member.id}-model`} disableGeminiOption={disableGeminiOption} + modelIssueReasonByValue={ + effectiveModel?.trim() ? { [effectiveModel.trim()]: modelIssueText } : undefined + } /> )} -
- -

- If this teammate uses a different provider than the lead, they will be started in a - separate process automatically. -

-
)}
diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 70beadaa..93b95459 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -101,6 +101,7 @@ export interface MembersEditorSectionProps { softDeleteMembers?: boolean; memberWarningById?: Record; disableGeminiOption?: boolean; + memberModelIssueById?: Record; } export const MembersEditorSection = ({ @@ -128,6 +129,7 @@ export const MembersEditorSection = ({ softDeleteMembers = false, memberWarningById, disableGeminiOption = false, + memberModelIssueById, }: MembersEditorSectionProps): React.JSX.Element => { const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonText, setJsonText] = useState(''); @@ -316,6 +318,7 @@ export const MembersEditorSection = ({ modelLockReason={modelLockReason} warningText={memberWarningById?.[member.id] ?? null} disableGeminiOption={disableGeminiOption} + modelIssueText={memberModelIssueById?.[member.id] ?? null} /> ))} {softDeleteMembers && removedMembers.length > 0 ? ( @@ -356,6 +359,7 @@ export const MembersEditorSection = ({ isRemoved warningText={null} disableGeminiOption={disableGeminiOption} + modelIssueText={null} /> ))}
diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index e02a2262..5b6480ba 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -44,6 +44,8 @@ interface TeamRosterEditorSectionProps { leadWarningText?: string | null; memberWarningById?: Record; disableGeminiOption?: boolean; + leadModelIssueText?: string | null; + memberModelIssueById?: Record; } export const TeamRosterEditorSection = ({ @@ -83,6 +85,8 @@ export const TeamRosterEditorSection = ({ leadWarningText, memberWarningById, disableGeminiOption = false, + leadModelIssueText, + memberModelIssueById, }: TeamRosterEditorSectionProps): React.JSX.Element => { return ( {headerTop} @@ -124,6 +129,7 @@ export const TeamRosterEditorSection = ({ onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange} warningText={leadWarningText} disableGeminiOption={disableGeminiOption} + modelIssueText={leadModelIssueText} /> {headerBottom} diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index 3f601835..c62bf068 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -34,7 +34,7 @@ export function useCliInstaller(): { fetchCliStatus: () => Promise; fetchCliProviderStatus: ( providerId: CliProviderId, - options?: { silent?: boolean; epoch?: number } + options?: { silent?: boolean; epoch?: number; verifyModels?: boolean } ) => Promise; invalidateCliStatus: () => Promise; installCli: () => void; diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 89d2a403..b1a56b2e 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -28,8 +28,10 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus { authenticated: false, authMethod: null, verificationState: 'unknown' as const, + modelVerificationState: 'idle' as const, statusMessage: 'Checking...', models: [], + modelAvailability: [], canLoginFromUi: true, capabilities: { teamLaunch: false, @@ -89,14 +91,14 @@ export interface CliInstallerSlice { fetchCliStatus: () => Promise; fetchCliProviderStatus: ( providerId: CliProviderId, - options?: { silent?: boolean; epoch?: number } + options?: { silent?: boolean; epoch?: number; verifyModels?: boolean } ) => Promise; invalidateCliStatus: () => Promise; installCli: () => void; } let cliStatusInFlight: Promise | null = null; -const cliProviderStatusInFlight = new Map>(); +const cliProviderStatusInFlight = new Map>(); let cliStatusEpoch = 0; const cliProviderStatusSeq = new Map(); @@ -257,7 +259,9 @@ export const createCliInstallerSlice: StateCreator { const nextLoading = silent ? state.cliProviderStatusLoading @@ -343,11 +349,11 @@ export const createCliInstallerSlice: StateCreator; + +export type TeamRuntimeModelOption = TeamProviderModelOption & { + availabilityStatus?: CliProviderModelAvailabilityStatus | null; + availabilityReason?: string | null; +}; + +export interface TeamProviderModelVerificationCounts { + checkedCount: number; + totalCount: number; + verifying: boolean; +} + +export function getTeamModelUiDisabledReason( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string | null { + return getRuntimeAwareTeamModelUiDisabledReason(providerId, model, providerStatus); +} + +export function isTeamModelUiDisabled( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): boolean { + return getTeamModelUiDisabledReason(providerId, model, providerStatus) !== null; +} + +function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[] { + return getVisibleTeamProviderModels( + providerId, + getTeamProviderModelOptions(providerId) + .map((option) => option.value) + .filter((value) => value.trim().length > 0) + ); +} + +function getFallbackTeamProviderModelOptions( + providerId: SupportedProviderId +): TeamRuntimeModelOption[] { + return getTeamProviderModelOptions(providerId).map((option) => ({ + ...option, + label: + option.value === '' + ? option.label + : (getProviderScopedTeamModelLabel(providerId, option.value) ?? option.value), + })); +} + +function getRuntimeSelectorModels( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] { + if (!providerStatus) { + return []; + } + + return sortTeamProviderModels(providerId, providerStatus.models); +} + +function getVisibleRuntimeModels( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] { + return getRuntimeSelectorModels(providerId, providerStatus).filter( + (model) => getTeamModelUiDisabledReason(providerId, model, providerStatus) == null + ); +} + +function getModelAvailabilityMap( + providerStatus?: TeamModelRuntimeProviderStatus | null +): Map { + return new Map( + (providerStatus?.modelAvailability ?? []).map((item) => [item.modelId.trim(), item]) + ); +} + +function getRuntimeModelAvailability( + providerId: SupportedProviderId, + model: string, + providerStatus?: TeamModelRuntimeProviderStatus | null +): CliProviderModelAvailabilityStatus | null { + if (providerId === 'anthropic') { + return 'available'; + } + + if (!providerStatus) { + return null; + } + + const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); + if (!visibleModels.includes(model)) { + return null; + } + return 'available'; +} + +function getRuntimeModelAvailabilityReason( + model: string, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string | null { + return getModelAvailabilityMap(providerStatus).get(model)?.reason ?? null; +} + +export function getTeamProviderModelVerificationCounts( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): TeamProviderModelVerificationCounts { + if (providerId === 'anthropic') { + return { + checkedCount: getFallbackTeamProviderModels(providerId).length, + totalCount: getFallbackTeamProviderModels(providerId).length, + verifying: false, + }; + } + + const totalCount = getRuntimeSelectorModels(providerId, providerStatus).length; + + return { + checkedCount: totalCount, + totalCount, + verifying: false, + }; +} + +export function getAvailableTeamProviderModels( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string[] { + if (providerId === 'anthropic') { + return getFallbackTeamProviderModels(providerId); + } + + if (!providerStatus) { + return []; + } + + return getVisibleRuntimeModels(providerId, providerStatus).filter( + (model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available' + ); +} + +export function getAvailableTeamProviderModelOptions( + providerId: SupportedProviderId, + providerStatus?: TeamModelRuntimeProviderStatus | null +): TeamRuntimeModelOption[] { + if (providerId === 'anthropic') { + return getFallbackTeamProviderModelOptions(providerId); + } + + if (!providerStatus) { + return [{ value: '', label: 'Default', badgeLabel: 'Default' }]; + } + + const visibleModels = getRuntimeSelectorModels(providerId, providerStatus); + return [ + { value: '', label: 'Default', badgeLabel: 'Default' }, + ...visibleModels.map((model) => ({ + value: model, + label: getProviderScopedTeamModelLabel(providerId, model) ?? model, + availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus), + availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus), + })), + ]; +} + +export function isTeamModelAvailableForUi( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): boolean { + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return true; + } + + if (getTeamModelUiDisabledReason(providerId, trimmed, providerStatus)) { + return false; + } + + if (providerId === 'anthropic') { + return getFallbackTeamProviderModels(providerId).includes(trimmed); + } + + return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available'; +} + +export function normalizeTeamModelForUi( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string { + const normalized = normalizeCatalogTeamModelForUi(providerId, model); + const trimmed = normalized.trim(); + if (!providerId || !trimmed) { + return normalized; + } + + if (getTeamModelUiDisabledReason(providerId, trimmed, providerStatus)) { + return ''; + } + + if (providerId === 'anthropic') { + return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) ? normalized : ''; + } + + if (!providerStatus) { + return ''; + } + + const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); + if (!visibleModels.includes(trimmed)) { + return ''; + } + + const availability = getRuntimeModelAvailability(providerId, trimmed, providerStatus); + return availability === 'available' ? normalized : ''; +} + +export function getTeamModelSelectionError( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null +): string | null { + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return null; + } + + const disabledReason = getTeamModelUiDisabledReason(providerId, trimmed, providerStatus); + if (disabledReason) { + return `Model "${trimmed}" is disabled. ${disabledReason}`; + } + + if (providerId === 'anthropic') { + return isTeamModelAvailableForUi(providerId, trimmed, providerStatus) + ? null + : `Model "${trimmed}" is not available for the current ${getTeamProviderLabel(providerId) ?? providerId} runtime. Pick one of the listed models or use Default.`; + } + + if (!providerStatus) { + return `Model "${trimmed}" is waiting for ${getTeamProviderLabel(providerId) ?? providerId} runtime verification. Wait for the model list to load or use Default.`; + } + + const visibleModels = getVisibleRuntimeModels(providerId, providerStatus); + if (!visibleModels.includes(trimmed)) { + return `Model "${trimmed}" is not available for the current ${getTeamProviderLabel(providerId) ?? providerId} runtime. Pick one of the listed models or use Default.`; + } + + return null; +} diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index ee7c0614..47248f19 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -1,6 +1,19 @@ -import type { CliProviderId, TeamProviderId } from '@shared/types'; +import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types'; +import { + filterVisibleProviderRuntimeModels, + GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, + GPT_5_2_CODEX_UI_DISABLED_MODEL, + GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, +} from '@shared/utils/providerModelVisibility'; + +export { + GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, + GPT_5_2_CODEX_UI_DISABLED_MODEL, + GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, +} from '@shared/utils/providerModelVisibility'; type SupportedProviderId = CliProviderId | TeamProviderId; +type RuntimeAwareProviderStatus = Pick; export interface TeamProviderModelOption { value: string; @@ -10,10 +23,12 @@ export interface TeamProviderModelOption { } export const TEAM_MODEL_UI_DISABLED_BADGE_LABEL = 'Disabled'; -export const GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL = 'gpt-5.1-codex-mini'; -export const GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL = 'gpt-5.3-codex-spark'; export const GPT_5_1_CODEX_MINI_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model has been less reliable with task and reply tool contracts.'; +export const GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON = + 'Temporarily disabled for team agents when using Codex ChatGPT subscription - this model has been observed returning "Not available with Codex ChatGPT subscription".'; +export const GPT_5_2_CODEX_UI_DISABLED_REASON = + 'Temporarily disabled for team agents - this model has been observed returning "Not available with Codex ChatGPT subscription".'; export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.'; @@ -66,7 +81,12 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record !isTeamModelUiDisabled(providerId, model) - ); + return sortTeamProviderModels( + providerId, + filterVisibleProviderRuntimeModels(providerId, models) + ).filter((model) => !isRuntimeHiddenTeamModel(providerId, model, providerStatus)); } export function getTeamModelUiDisabledReason( @@ -213,6 +262,26 @@ export function getTeamModelUiDisabledReason( return getKnownTeamProviderModelOption(providerId, model)?.uiDisabledReason ?? null; } +export function getRuntimeAwareTeamModelUiDisabledReason( + providerId: SupportedProviderId | undefined, + model: string | undefined, + providerStatus?: RuntimeAwareProviderStatus | null +): string | null { + const staticReason = getTeamModelUiDisabledReason(providerId, model); + if (staticReason) { + return staticReason; + } + + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return null; + } + + return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus) + ? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON + : null; +} + export function isTeamModelUiDisabled( providerId: SupportedProviderId | undefined, model: string | undefined diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 2253c177..c2a1adf2 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -17,6 +17,11 @@ type MemberSpawnStatusCollection = | Map | undefined; +interface FailedSpawnDetail { + name: string; + reason: string | null; +} + const ACTIVE_PROVISIONING_STATES = new Set([ 'validating', 'spawning', @@ -26,6 +31,73 @@ const ACTIVE_PROVISIONING_STATES = new Set([ 'verifying', ]); +function getFailedSpawnDetails( + memberSpawnStatuses: MemberSpawnStatusCollection +): FailedSpawnDetail[] { + if (!memberSpawnStatuses) { + return []; + } + const entries = + memberSpawnStatuses instanceof Map + ? [...memberSpawnStatuses.entries()] + : Object.entries(memberSpawnStatuses); + + return entries + .filter(([, entry]) => entry.launchState === 'failed_to_start' || entry.status === 'error') + .map(([name, entry]) => ({ + name, + reason: + typeof entry.hardFailureReason === 'string' && entry.hardFailureReason.trim().length > 0 + ? entry.hardFailureReason.trim() + : typeof entry.error === 'string' && entry.error.trim().length > 0 + ? entry.error.trim() + : null, + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function truncateFailureReason(reason: string, maxLength = 160): string { + const normalized = reason.replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function buildFailedSpawnPanelMessage( + failedSpawnDetails: readonly FailedSpawnDetail[] +): string | null { + if (failedSpawnDetails.length === 0) { + return null; + } + if (failedSpawnDetails.length === 1) { + const [failed] = failedSpawnDetails; + return failed.reason + ? `${failed.name} failed to start - ${truncateFailureReason(failed.reason, 220)}` + : `${failed.name} failed to start`; + } + const listedFailures = failedSpawnDetails + .slice(0, 2) + .map((failed) => + failed.reason ? `${failed.name} - ${truncateFailureReason(failed.reason, 120)}` : failed.name + ) + .join('; '); + const remainingCount = failedSpawnDetails.length - Math.min(failedSpawnDetails.length, 2); + return `Failed teammates: ${listedFailures}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`; +} + +function buildFailedSpawnCompactDetail( + failedSpawnDetails: readonly FailedSpawnDetail[] +): string | null { + if (failedSpawnDetails.length === 0) { + return null; + } + if (failedSpawnDetails.length === 1) { + return `${failedSpawnDetails[0].name} failed to start`; + } + return `${failedSpawnDetails.length} teammates failed to start`; +} + export interface TeamProvisioningPresentation { progress: TeamProvisioningProgress; isActive: boolean; @@ -99,6 +171,9 @@ export function buildTeamProvisioningPresentation({ memberSpawnStatuses, memberSpawnSnapshot, }); + const failedSpawnDetails = getFailedSpawnDetails(memberSpawnStatuses); + const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails); + const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails); const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } = getLaunchJoinState({ @@ -135,7 +210,7 @@ export function buildTeamProvisioningPresentation({ hasMembersStillJoining, remainingJoinCount, panelTitle: 'Launch failed', - panelMessage: progress.error ?? null, + panelMessage: progress.error ?? failedSpawnPanelMessage ?? null, panelTone: 'error', defaultLiveOutputOpen: true, compactTitle: 'Launch failed', @@ -151,7 +226,8 @@ export function buildTeamProvisioningPresentation({ : `${remainingJoinCount} teammates still joining`; const readyCompactDetail = failedSpawnCount > 0 - ? `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start` + ? (failedSpawnCompactDetail ?? + `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`) : hasMembersStillJoining ? joiningPhrase : expectedTeammateCount === 0 @@ -159,7 +235,7 @@ export function buildTeamProvisioningPresentation({ : `All ${expectedTeammateCount} teammates joined`; const readyDetailMessage = failedSpawnCount > 0 - ? progress.message + ? (failedSpawnPanelMessage ?? progress.message) : expectedTeammateCount === 0 ? 'Team provisioned - lead online' : allTeammatesConfirmedAlive @@ -229,15 +305,19 @@ export function buildTeamProvisioningPresentation({ hasMembersStillJoining, remainingJoinCount, panelTitle: 'Launching team', - panelMessage: progress.message, - panelMessageSeverity: progress.messageSeverity, + panelMessage: + failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? progress.message) : progress.message, + panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity, defaultLiveOutputOpen: true, compactTitle: 'Launching team', compactDetail: - expectedTeammateCount > 0 && progressStepIndex >= 2 - ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` - : progress.message, - compactTone: 'default', + failedSpawnCount > 0 + ? (failedSpawnCompactDetail ?? + `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`) + : expectedTeammateCount > 0 && progressStepIndex >= 2 + ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` + : progress.message, + compactTone: failedSpawnCount > 0 ? 'warning' : 'default', }; } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index afc2aae7..1db3c89b 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -438,7 +438,9 @@ export interface TeamsAPI { prepareProvisioning: ( cwd?: string, providerId?: TeamLaunchRequest['providerId'], - providerIds?: TeamLaunchRequest['providerId'][] + providerIds?: TeamLaunchRequest['providerId'][], + selectedModels?: string[], + limitContext?: boolean ) => Promise; createTeam: (request: TeamCreateRequest) => Promise; getProvisioningStatus: (runId: string) => Promise; diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 7eaf19c0..a68947e0 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -57,6 +57,19 @@ export interface CliExternalRuntimeDiagnostic { detailMessage?: string | null; } +export type CliProviderModelAvailabilityStatus = + | 'checking' + | 'available' + | 'unavailable' + | 'unknown'; + +export interface CliProviderModelAvailability { + modelId: string; + status: CliProviderModelAvailabilityStatus; + reason?: string | null; + checkedAt?: string | null; +} + export interface CliProviderStatus { providerId: CliProviderId; displayName: string; @@ -64,8 +77,10 @@ export interface CliProviderStatus { authenticated: boolean; authMethod: string | null; verificationState: 'verified' | 'unknown' | 'offline' | 'error'; + modelVerificationState?: 'idle' | 'verifying' | 'verified'; statusMessage?: string | null; models: string[]; + modelAvailability?: CliProviderModelAvailability[]; canLoginFromUi: boolean; capabilities: { teamLaunch: boolean; @@ -172,6 +187,8 @@ export interface CliInstallerAPI { getStatus: () => Promise; /** Get current runtime/auth status for a single provider */ getProviderStatus: (providerId: CliProviderId) => Promise; + /** Start on-demand model verification for a single runtime provider */ + verifyProviderModels: (providerId: CliProviderId) => Promise; /** Start install/update flow. Progress sent via onProgress events. */ install: () => Promise; /** Invalidate cached status (forces fresh check on next getStatus) */ diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index dcc8dfdf..1d970668 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -990,6 +990,7 @@ export interface TeamCreateResponse { export interface TeamProvisioningPrepareResult { ready: boolean; message: string; + details?: string[]; warnings?: string[]; } diff --git a/src/shared/utils/anthropicModelDefaults.ts b/src/shared/utils/anthropicModelDefaults.ts new file mode 100644 index 00000000..ce895df7 --- /dev/null +++ b/src/shared/utils/anthropicModelDefaults.ts @@ -0,0 +1,3 @@ +export function getAnthropicDefaultTeamModel(limitContext: boolean): string { + return limitContext ? 'opus' : 'opus[1m]'; +} diff --git a/src/shared/utils/providerModelSelection.ts b/src/shared/utils/providerModelSelection.ts new file mode 100644 index 00000000..bbc987dd --- /dev/null +++ b/src/shared/utils/providerModelSelection.ts @@ -0,0 +1,5 @@ +export const DEFAULT_PROVIDER_MODEL_SELECTION = '__provider_default__'; + +export function isDefaultProviderModelSelection(value: string | undefined): boolean { + return value?.trim() === DEFAULT_PROVIDER_MODEL_SELECTION; +} diff --git a/src/shared/utils/providerModelVisibility.ts b/src/shared/utils/providerModelVisibility.ts new file mode 100644 index 00000000..807ac8b0 --- /dev/null +++ b/src/shared/utils/providerModelVisibility.ts @@ -0,0 +1,47 @@ +import type { CliProviderId, TeamProviderId } from '@shared/types'; + +type SupportedProviderId = CliProviderId | TeamProviderId; + +export const GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL = 'gpt-5.1-codex-mini'; +export const GPT_5_2_CODEX_UI_DISABLED_MODEL = 'gpt-5.2-codex'; +export const GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL = 'gpt-5.3-codex-spark'; + +const UI_DISABLED_MODELS_BY_PROVIDER: Partial> = { + codex: [ + GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, + GPT_5_2_CODEX_UI_DISABLED_MODEL, + GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, + ], +}; + +export function isProviderRuntimeModelUiDisabled( + providerId: SupportedProviderId | undefined, + model: string | undefined +): boolean { + const trimmed = model?.trim(); + if (!providerId || !trimmed) { + return false; + } + + return UI_DISABLED_MODELS_BY_PROVIDER[providerId]?.includes(trimmed) ?? false; +} + +export function filterVisibleProviderRuntimeModels( + providerId: SupportedProviderId, + models: readonly string[] +): string[] { + const seen = new Set(); + const visible: string[] = []; + + for (const model of models) { + const trimmed = model.trim(); + if (!trimmed || seen.has(trimmed) || isProviderRuntimeModelUiDisabled(providerId, trimmed)) { + continue; + } + + seen.add(trimmed); + visible.push(trimmed); + } + + return visible; +} diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index fecd1d15..ffad35a7 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -72,12 +72,21 @@ vi.mock('@main/services/team/cliFlavor', () => ({ })), })); +vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ + buildProviderAwareCliEnv: vi.fn(async () => ({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + })), +})); + import { CliInstallerService, isVersionOlder, normalizeVersion, } from '@main/services/infrastructure/CliInstallerService'; +import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '@main/services/team/cliFlavor'; import { execCli } from '@main/utils/childProcess'; /** @@ -96,6 +105,13 @@ describe('CliInstallerService', () => { vi.clearAllMocks(); realpathMock.mockReset(); realpathMock.mockImplementation(async (value: string) => value); + vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude'); + vi.mocked(getCliFlavorUiOptions).mockReturnValue({ + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + }); service = new CliInstallerService(); }); @@ -176,6 +192,146 @@ describe('CliInstallerService', () => { expect(status.installedVersion).toBe('2.1.101'); expect(status.authLoggedIn).toBe(true); }); + + it('publishes probe-enriched runtime model status snapshots only for explicit verification requests', 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('/usr/local/bin/claude'); + + vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockImplementation( + async (_binaryPath, onUpdate) => { + const providers = [ + { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + modelVerificationState: 'idle', + statusMessage: null, + models: [], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { teamLaunch: true, oneShot: true }, + backend: null, + }, + { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + modelVerificationState: 'idle', + statusMessage: null, + models: ['gpt-5.4', 'gpt-5.2-codex'], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { teamLaunch: true, oneShot: true }, + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + }, + { + providerId: 'gemini', + displayName: 'Gemini', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + modelVerificationState: 'idle', + statusMessage: null, + models: [], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { teamLaunch: false, oneShot: false }, + backend: null, + }, + ]; + onUpdate?.(providers as never); + return providers as never; + } + ); + + vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => { + const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; + if (normalizedArgs === '--version') { + return { stdout: '2.3.4', stderr: '' }; + } + if (normalizedArgs.includes('--model gpt-5.4')) { + return { stdout: 'PONG', stderr: '' }; + } + if (normalizedArgs.includes('--model gpt-5.2-codex')) { + throw new Error( + "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account." + ); + } + throw new Error(`Unexpected execCli call: ${normalizedArgs}`); + }); + + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn(), isDestroyed: () => false }, + }; + service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); + + const status = await service.getStatus(); + expect(status.providers.find((provider) => provider.providerId === 'codex')?.modelAvailability).toEqual([]); + + const verifiedProvider = await service.verifyProviderModels('codex'); + expect(verifiedProvider?.modelAvailability).toEqual( + expect.arrayContaining([ + expect.objectContaining({ modelId: 'gpt-5.4', status: 'checking' }), + expect.objectContaining({ modelId: 'gpt-5.2-codex', status: 'checking' }), + ]) + ); + + await vi.waitFor(() => { + const latestCodexProvider = service + .getLatestStatusSnapshot() + ?.providers.find((provider) => provider.providerId === 'codex'); + + expect(latestCodexProvider?.modelAvailability).toEqual([ + expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }), + expect.objectContaining({ + modelId: 'gpt-5.2-codex', + status: 'unavailable', + }), + ]); + }); + + const statusEvents = mockWindow.webContents.send.mock.calls + .filter((call: unknown[]) => call[0] === 'cliInstaller:progress') + .map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } }) + .filter((event) => event.type === 'status'); + + expect(statusEvents.length).toBeGreaterThan(1); + expect( + statusEvents.some((event) => + event.status?.providers?.some( + (provider) => + typeof provider === 'object' && + provider !== null && + 'providerId' in provider && + 'modelAvailability' in provider && + (provider as { providerId?: string }).providerId === 'codex' && + Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) && + (provider as { modelAvailability: Array<{ status?: string }> }).modelAvailability.some( + (item) => item.status === 'unavailable' + ) + ) + ) + ).toBe(true); + }); }); describe('install mutex', () => { diff --git a/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts new file mode 100644 index 00000000..a498882d --- /dev/null +++ b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const execCliMock = vi.fn(); +const buildProviderAwareCliEnvMock = vi.fn(); + +vi.mock('@main/utils/childProcess', () => ({ + execCli: (...args: Parameters) => execCliMock(...args), +})); + +vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ + buildProviderAwareCliEnv: (...args: Parameters) => + buildProviderAwareCliEnvMock(...args), +})); + +import { + CliProviderModelAvailabilityService, + type ProviderModelAvailabilityContext, +} from '@main/services/runtime/CliProviderModelAvailabilityService'; + +function createContext(models: string[]): ProviderModelAvailabilityContext { + return { + binaryPath: '/usr/local/bin/claude', + installedVersion: '2.3.4', + provider: { + providerId: 'codex', + models, + supported: true, + authenticated: true, + authMethod: 'oauth_token', + selectedBackendId: 'chatgpt', + resolvedBackendId: 'chatgpt', + capabilities: { + teamLaunch: true, + oneShot: true, + }, + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + }, + }; +} + +describe('CliProviderModelAvailabilityService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reuses probe cache for the same provider signature', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockResolvedValue({ stdout: 'PONG', stderr: '' }); + + const service = new CliProviderModelAvailabilityService(); + const context = createContext(['gpt-5.4', 'gpt-5.3-codex']); + + expect(service.getSnapshot(context).modelVerificationState).toBe('verifying'); + expect(service.getSnapshot(context).modelVerificationState).toBe('verifying'); + + await vi.waitFor(() => { + expect(execCliMock).toHaveBeenCalledTimes(2); + }); + + expect(service.getSnapshot(context).modelAvailability).toEqual([ + expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }), + expect.objectContaining({ modelId: 'gpt-5.3-codex', status: 'available' }), + ]); + expect(execCliMock).toHaveBeenCalledTimes(2); + }); + + it('marks unsupported models as unavailable with the runtime reason', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockRejectedValue( + new Error("The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.") + ); + + const onUpdate = vi.fn(); + const service = new CliProviderModelAvailabilityService(onUpdate); + service.getSnapshot(createContext(['gpt-5.2-codex'])); + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith( + 'codex', + expect.any(String), + expect.objectContaining({ + modelAvailability: [ + expect.objectContaining({ + modelId: 'gpt-5.2-codex', + status: 'unavailable', + reason: 'Not available with Codex ChatGPT subscription', + }), + ], + }) + ); + }); + }); + + it('marks timeout-like probe failures as unknown instead of unavailable', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockRejectedValue(new Error('Command timed out after 45000ms')); + + const onUpdate = vi.fn(); + const service = new CliProviderModelAvailabilityService(onUpdate); + service.getSnapshot(createContext(['gpt-5.4'])); + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith( + 'codex', + expect.any(String), + expect.objectContaining({ + modelAvailability: [ + expect.objectContaining({ + modelId: 'gpt-5.4', + status: 'unknown', + reason: 'Model verification timed out', + }), + ], + }) + ); + }); + }); + + it('invalidates the cache when the provider signature changes', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockResolvedValue({ stdout: 'PONG', stderr: '' }); + + const service = new CliProviderModelAvailabilityService(); + service.getSnapshot(createContext(['gpt-5.4'])); + + await vi.waitFor(() => { + expect(execCliMock).toHaveBeenCalledTimes(1); + }); + + service.getSnapshot(createContext(['gpt-5.4', 'gpt-5.2'])); + + await vi.waitFor(() => { + expect(execCliMock).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 95ff14cd..659d5d68 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -673,4 +673,177 @@ describe('TeamProvisioningService', () => { expect(launchArgs).toContain('--resume'); expect(launchArgs).toContain(leadSessionId); }); + + it('marks persisted bootstrap as failed when member transcript shows an unsupported model error', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-bootstrap-unsupported-model'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const acceptedAt = new Date(Date.now() - 5_000).toISOString(); + const errorAt = new Date(Date.now() - 4_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + writeLaunchState(teamName, leadSessionId, { + jack: { + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: acceptedAt, + }, + }); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${leadSessionId}.jsonl`), + `${JSON.stringify({ + timestamp: new Date(Date.now() - 10_000).toISOString(), + teamName, + type: 'user', + message: { role: 'user', content: 'Lead bootstrap context' }, + })}\n`, + 'utf8' + ); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: acceptedAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "jack".`, + }, + }), + JSON.stringify({ + timestamp: errorAt, + teamName, + agentName: 'jack', + type: 'assistant', + isApiErrorMessage: true, + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: `API Error: 400 {"type":"error","error":{"type":"api_error","message":"Codex API error (400): {\\"detail\\":\\"The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.\\"}"}}`, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.jack?.status).toBe('error'); + expect(result.statuses.jack?.launchState).toBe('failed_to_start'); + expect(result.statuses.jack?.error).toContain('gpt-5.2-codex'); + expect(result.statuses.jack?.hardFailureReason).toContain('not supported'); + expect(result.teamLaunchState).toBe('partial_failure'); + }); + + it('marks a live teammate bootstrap as failed when transcript shows model unavailability', async () => { + allowConsoleLogs(); + const teamName = 'zz-live-bootstrap-model-unavailable'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const acceptedAt = new Date(Date.now() - 5_000).toISOString(); + const errorAt = new Date(Date.now() - 4_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: acceptedAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "jack".`, + }, + }), + JSON.stringify({ + timestamp: errorAt, + teamName, + agentName: 'jack', + type: 'assistant', + isApiErrorMessage: true, + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: 'API Error: 400 {"detail":"The requested model is not available for your account."}', + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const run = { + runId: 'run-live-1', + teamName, + startedAt: new Date(Date.now() - 60_000).toISOString(), + request: { + members: [], + }, + expectedMembers: ['jack'], + memberSpawnStatuses: new Map([ + [ + 'jack', + { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + error: undefined, + updatedAt: acceptedAt, + runtimeAlive: false, + livenessSource: undefined, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + firstSpawnAcceptedAt: acceptedAt, + lastHeartbeatAt: undefined, + }, + ], + ]), + provisioningOutputParts: [], + activeToolCalls: new Map(), + isLaunch: false, + } as any; + + (svc as any).runs.set(run.runId, run); + (svc as any).provisioningRunByTeam.set(teamName, run.runId); + + await (svc as any).reconcileBootstrapTranscriptFailures(run); + + expect(run.memberSpawnStatuses.get('jack')).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + }); + expect(run.memberSpawnStatuses.get('jack')?.error).toContain( + 'requested model is not available' + ); + expect(run.provisioningOutputParts.join('\n')).toContain('requested model is not available'); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 208a2385..bdd11285 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ ClaudeBinaryResolver: { resolve: vi.fn() }, @@ -123,6 +124,187 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ]); }); + it('verifies the selected Codex model during prepare and records a success detail', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ + stdout: 'PONG', + stderr: '', + exitCode: 0, + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: ['gpt-5.4'], + }); + + expect(result.ready).toBe(true); + expect(result.details).toContain('Selected model gpt-5.4 verified for launch.'); + expect(spawnProbe).toHaveBeenCalledWith( + '/fake/claude', + expect.arrayContaining(['--model', 'gpt-5.4']), + tempRoot, + expect.any(Object), + 60_000, + expect.any(Object) + ); + }); + + it('verifies the resolved Codex default model during prepare', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + vi.spyOn(svc as any, 'resolveProviderDefaultModel').mockResolvedValue('gpt-5.4-mini'); + const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ + stdout: 'PONG', + stderr: '', + exitCode: 0, + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + }); + + expect(result.ready).toBe(true); + expect(result.details).toContain( + `Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.` + ); + expect(spawnProbe).toHaveBeenCalledWith( + '/fake/claude', + expect.arrayContaining(['--model', 'gpt-5.4-mini']), + tempRoot, + expect.any(Object), + 60_000, + expect.any(Object) + ); + }); + + it('verifies the resolved Anthropic default model during prepare with limitContext', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'oauth_token', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'oauth_token', + geminiRuntimeAuth: null, + }); + const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ + stdout: 'PONG', + stderr: '', + exitCode: 0, + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'anthropic', + modelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + limitContext: true, + }); + + expect(result.ready).toBe(true); + expect(result.details).toContain( + `Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.` + ); + expect(spawnProbe).toHaveBeenCalledWith( + '/fake/claude', + expect.arrayContaining(['--model', 'opus']), + tempRoot, + expect.any(Object), + 60_000, + expect.any(Object) + ); + }); + + it('fails prepare when the selected Codex model is unavailable', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue( + new Error("The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.") + ); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: ['gpt-5.2-codex'], + }); + + expect(result.ready).toBe(false); + expect(result.message).toContain('Selected model gpt-5.2-codex is unavailable.'); + expect(result.message).toContain('Not available with Codex ChatGPT subscription'); + }); + + it('keeps timed out Codex model verification as a warning with a clean generic reason', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + }); + vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue( + new Error( + 'Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence' + ) + ); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelIds: ['gpt-5.3-codex'], + }); + + expect(result.ready).toBe(true); + expect(result.warnings).toContain( + 'Selected model gpt-5.3-codex could not be verified. Model verification timed out' + ); + }); + it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => { const svc = new TeamProvisioningService(); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 98883aa2..96527c46 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -537,4 +537,74 @@ describe('CLI status visibility during completed install state', () => { await Promise.resolve(); }); }); + + it('shows runtime model availability badges on the dashboard', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + modelVerificationState: 'verified', + statusMessage: null, + models: ['gpt-5.4', 'gpt-5.1-codex-max', 'gpt-5.2-codex'], + modelAvailability: [ + { modelId: 'gpt-5.4', status: 'available', checkedAt: '2026-04-16T12:00:00.000Z' }, + { + modelId: 'gpt-5.1-codex-max', + status: 'unavailable', + reason: 'The requested model is not available for your account.', + checkedAt: '2026-04-16T12:00:00.000Z', + }, + { + modelId: 'gpt-5.2-codex', + status: 'unavailable', + reason: 'The requested model is not available for your account.', + checkedAt: '2026-04-16T12:00:00.000Z', + }, + ], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('5.4'); + expect(host.textContent).not.toContain('5.1-codex-max'); + expect(host.textContent).not.toContain('5.2-codex'); + expect(host.textContent).not.toContain('Unavailable'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index f441903a..9762a168 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -6,7 +6,11 @@ import { } from '@renderer/components/team/dialogs/TeamModelSelector'; import { GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, + GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, + GPT_5_2_CODEX_UI_DISABLED_REASON, GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, + getAvailableTeamProviderModels, + getTeamModelSelectionError, getTeamModelUiDisabledReason, normalizeTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; @@ -22,10 +26,13 @@ describe('formatTeamModelSummary', () => { expect(formatTeamModelSummary('codex', 'gpt-5.4', 'medium')).toBe('5.4 · Medium'); }); - it('marks 5.1 Codex Mini as disabled only for Codex team selection', () => { + it('marks the known disabled Codex models only for Codex team selection', () => { expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-mini')).toBe( GPT_5_1_CODEX_MINI_UI_DISABLED_REASON ); + expect(getTeamModelUiDisabledReason('codex', 'gpt-5.2-codex')).toBe( + GPT_5_2_CODEX_UI_DISABLED_REASON + ); expect(getTeamModelUiDisabledReason('codex', 'gpt-5.3-codex-spark')).toBe( GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON ); @@ -33,10 +40,72 @@ describe('formatTeamModelSummary', () => { expect(getTeamModelUiDisabledReason('anthropic', 'gpt-5.1-codex-mini')).toBeNull(); }); + it('disables 5.1 Codex Max only on the Codex ChatGPT subscription path', () => { + const chatgptCodexProviderStatus = { + providerId: 'codex' as const, + models: ['gpt-5.4', 'gpt-5.1-codex-max'], + authMethod: 'oauth_token' as const, + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + modelVerificationState: 'verified' as const, + modelAvailability: [], + authenticated: true, + supported: true, + }; + + expect( + getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-max', chatgptCodexProviderStatus) + ).toBe(GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON); + expect(normalizeTeamModelForUi('codex', 'gpt-5.1-codex-max', chatgptCodexProviderStatus)).toBe( + '' + ); + expect( + getTeamModelSelectionError('codex', 'gpt-5.1-codex-max', chatgptCodexProviderStatus) + ).toContain('Temporarily disabled for team agents when using Codex ChatGPT subscription'); + expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-max')).toBeNull(); + }); + it('normalizes disabled Codex model selections back to default', () => { expect(normalizeTeamModelForUi('codex', 'gpt-5.1-codex-mini')).toBe(''); + expect(normalizeTeamModelForUi('codex', 'gpt-5.2-codex')).toBe(''); expect(normalizeTeamModelForUi('codex', 'gpt-5.3-codex-spark')).toBe(''); - expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini'); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe(''); + }); + + it('uses the runtime-reported Codex model list when provider status is available', () => { + const codexProviderStatus = { + providerId: 'codex' as const, + models: ['gpt-5.4', 'gpt-5.3-codex'], + authMethod: 'oauth_token' as const, + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + modelVerificationState: 'verified' as const, + modelAvailability: [ + { modelId: 'gpt-5.4', status: 'available' as const, checkedAt: null }, + { modelId: 'gpt-5.3-codex', status: 'available' as const, checkedAt: null }, + ], + authenticated: true, + supported: true, + }; + + expect(getAvailableTeamProviderModels('codex', codexProviderStatus)).toEqual([ + 'gpt-5.4', + 'gpt-5.3-codex', + ]); + expect(normalizeTeamModelForUi('codex', 'gpt-5.2-codex', codexProviderStatus)).toBe(''); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4', codexProviderStatus)).toBe('gpt-5.4'); + }); + + it('waits for the runtime model list before validating explicit Codex selections', () => { + expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toContain('waiting for Codex runtime verification'); + expect(getTeamModelSelectionError('codex', '')).toBeNull(); + expect(getTeamModelSelectionError('anthropic', 'opus')).toBeNull(); }); }); @@ -60,6 +129,7 @@ describe('computeEffectiveTeamModel', () => { expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus'); expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus'); expect(computeEffectiveTeamModel('opus[1m][1m]', true, 'anthropic')).toBe('opus'); + expect(computeEffectiveTeamModel('', true, 'anthropic')).toBe('opus'); }); it('returns haiku as-is', () => { diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index fba39cb2..640cb4a6 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -61,27 +61,30 @@ vi.mock('@renderer/components/ui/tabs', () => { }; }); +const storeState = { + cliStatus: null as unknown, + cliStatusLoading: false, + appConfig: { general: { multimodelEnabled: true } }, + fetchCliProviderStatus: vi.fn().mockResolvedValue(undefined), +}; + vi.mock('@renderer/store', () => ({ - useStore: (selector: (state: unknown) => unknown) => - selector({ - cliStatus: null, - appConfig: { general: { multimodelEnabled: true } }, - }), + useStore: (selector: (state: unknown) => unknown) => selector(storeState), })); import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector'; -import { - GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, - GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, -} from '@renderer/utils/teamModelAvailability'; describe('TeamModelSelector disabled Codex models', () => { afterEach(() => { document.body.innerHTML = ''; + storeState.cliStatus = null; + storeState.cliStatusLoading = false; + storeState.fetchCliProviderStatus.mockClear(); }); - it('renders 5.1 Codex Mini as disabled with an explanation tooltip', async () => { + it('shows only Default while Codex runtime models are still loading', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatusLoading = true; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -98,37 +101,10 @@ describe('TeamModelSelector disabled Codex models', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('5.1 Codex Mini'); - expect(host.textContent).toContain('Disabled'); - expect(host.textContent).toContain(GPT_5_1_CODEX_MINI_UI_DISABLED_REASON); - - await act(async () => { - root.unmount(); - await Promise.resolve(); - }); - }); - - it('renders 5.3 Codex Spark as disabled with an explanation tooltip', async () => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); - const host = document.createElement('div'); - document.body.appendChild(host); - const root = createRoot(host); - - await act(async () => { - root.render( - React.createElement(TeamModelSelector, { - providerId: 'codex', - onProviderChange: () => undefined, - value: '', - onValueChange: () => undefined, - }) - ); - await Promise.resolve(); - }); - - expect(host.textContent).toContain('5.3 Codex Spark'); - expect(host.textContent).toContain('Disabled'); - expect(host.textContent).toContain(GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON); + expect(host.textContent).toContain('Default'); + expect(host.textContent).toContain('Explicit models load from the current runtime'); + expect(host.textContent).not.toContain('5.1 Codex Mini'); + expect(host.textContent).not.toContain('5.3 Codex Spark'); await act(async () => { root.unmount(); @@ -190,6 +166,256 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); + it('uses the runtime-reported Codex list and clears stale unsupported selections', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.3-codex'], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: 'gpt-5.2-codex', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + expect(onValueChange).toHaveBeenCalledWith(''); + expect(host.textContent).toContain('5.4'); + expect(host.textContent).toContain('5.3 Codex'); + expect(host.textContent).not.toContain('5.2 Codex'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows 5.2 Codex as a disabled tile when the runtime still reports it', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.2-codex'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + const disabledButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.2 Codex') + ); + + expect(disabledButton).not.toBeNull(); + expect(disabledButton?.getAttribute('aria-disabled')).toBe('true'); + expect(disabledButton?.textContent).toContain('Disabled'); + expect(disabledButton?.getAttribute('title')).toContain( + 'Not available with Codex ChatGPT subscription' + ); + + await act(async () => { + disabledButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onValueChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows 5.1 Codex Max as a disabled tile on the ChatGPT subscription path', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + authMethod: 'oauth_token', + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + models: ['gpt-5.4', 'gpt-5.1-codex-max'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + const disabledButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.1 Codex Max') + ); + + expect(disabledButton).not.toBeNull(); + expect(disabledButton?.getAttribute('aria-disabled')).toBe('true'); + expect(disabledButton?.textContent).toContain('Disabled'); + expect(disabledButton?.getAttribute('title')).toContain( + 'Not available with Codex ChatGPT subscription' + ); + + await act(async () => { + disabledButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onValueChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps runtime model buttons selectable without starting automatic model probes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.4-mini'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + expect(storeState.fetchCliProviderStatus).not.toHaveBeenCalled(); + + const gpt54Button = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.4') + ); + expect(gpt54Button?.getAttribute('aria-disabled')).toBe('false'); + + await act(async () => { + gpt54Button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onValueChange).toHaveBeenCalledWith('gpt-5.4'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('highlights the specific model tile when preflight found a model issue', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'codex', + models: ['gpt-5.4', 'gpt-5.2-codex'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: 'gpt-5.2-codex', + onValueChange: () => undefined, + modelIssueReasonByValue: { + 'gpt-5.2-codex': 'Not available with Codex ChatGPT subscription', + }, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Issue'); + const issueButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('5.2 Codex') + ); + expect(issueButton?.className).toContain('border-red-500/40'); + expect(issueButton?.getAttribute('title')).toBe( + 'Not available with Codex ChatGPT subscription' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows OpenCode as an in-development provider and keeps it non-selectable', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 6d5f69cf..7c69269d 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { + getPrimaryProvisioningFailureDetail, ProvisioningProviderStatusList, createInitialProviderChecks, } from '@renderer/components/team/dialogs/ProvisioningProviderStatusList'; @@ -35,4 +36,96 @@ describe('ProvisioningProviderStatusList', () => { await Promise.resolve(); }); }); + + it('surfaces mixed selected model diagnostics without hiding verified results', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'codex', + status: 'failed', + backendSummary: 'Default adapter', + details: [ + '5.4 Mini - verified', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Codex (Default adapter): Selected model checks - 1 model unavailable, 1 verified' + ); + expect(host.textContent).toContain('5.4 Mini - verified'); + expect(host.textContent).toContain( + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription' + ); + + const detailLines = Array.from(host.querySelectorAll('p')); + expect(detailLines[0]?.className).toContain('text-emerald-400'); + expect(detailLines[1]?.className).toContain('text-red-300'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('picks the first real failure detail instead of a verified line', () => { + expect( + getPrimaryProvisioningFailureDetail([ + { + providerId: 'codex', + status: 'failed', + details: [ + '5.2 - verified', + '5.3 Codex - check failed - Model verification timed out', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ]) + ).toBe('5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription'); + }); + + it('summarizes timed out model verification separately from hard failures', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'codex', + status: 'notes', + backendSummary: 'Default adapter', + details: ['5.3 Codex - check failed - Model verification timed out'], + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'Codex (Default adapter): Selected model checks - 1 model timed out' + ); + expect(host.textContent).toContain('5.3 Codex - check failed - Model verification timed out'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts new file mode 100644 index 00000000..ecd70446 --- /dev/null +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics'; +import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; + +import type { TeamProvisioningPrepareResult } from '@shared/types'; + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +describe('runProviderPrepareDiagnostics', () => { + it('returns a failed provider result immediately when runtime preflight fails', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >().mockResolvedValue({ + ready: false, + message: 'Codex runtime is not authenticated.', + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.4'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual(['Codex runtime is not authenticated.']); + expect(prepareProvisioning).toHaveBeenCalledTimes(1); + }); + + it('emits per-model progress updates and keeps failures scoped to the affected model', async () => { + const deferred54 = createDeferred(); + const deferred52 = createDeferred(); + const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = + []; + + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + if (selectedModels[0] === 'gpt-5.4') { + return deferred54.promise; + } + return deferred52.promise; + }); + + const resultPromise = runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.4', 'gpt-5.2-codex'], + prepareProvisioning, + onModelProgress: (progress) => progressUpdates.push(progress), + }); + + await Promise.resolve(); + expect(progressUpdates[0]).toEqual({ + completedCount: 0, + totalCount: 2, + details: ['5.4 - checking...', '5.2 Codex - checking...'], + }); + + deferred54.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + details: ['Selected model gpt-5.4 verified for launch.'], + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(progressUpdates.at(-1)).toEqual({ + completedCount: 1, + totalCount: 2, + details: ['5.4 - verified', '5.2 Codex - checking...'], + }); + + deferred52.resolve({ + ready: false, + message: + "Selected model gpt-5.2-codex is unavailable. The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + }); + const result = await resultPromise; + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + '5.4 - verified', + '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + ]); + expect(progressUpdates.at(-1)).toEqual({ + completedCount: 2, + totalCount: 2, + details: [ + '5.4 - verified', + '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + ], + }); + }); + + it('normalizes raw Codex API error envelopes into a clean model reason', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: false, + message: + `API Error: 400 {"type":"error","error":{"type":"api_error","message":"Codex API error (400): {\\"detail\\":\\"The 'gpt-5.1-codex-max' model is not supported when using Codex with a ChatGPT account.\\"}"}}`, + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.1-codex-max'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([ + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ]); + }); + + it('normalizes raw timeout probe errors into a provider-agnostic reason', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + warnings: [ + 'Selected model gpt-5.3-codex could not be verified. Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence', + ], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.3-codex'], + prepareProvisioning, + }); + + expect(result.status).toBe('notes'); + expect(result.details).toEqual(['5.3 Codex - check failed - Model verification timed out']); + }); + + it('renders the provider default model as a dedicated Default check line', async () => { + const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = + []; + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + details: [`Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.`], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + prepareProvisioning, + onModelProgress: (progress) => progressUpdates.push(progress), + }); + + expect(progressUpdates[0]).toEqual({ + completedCount: 0, + totalCount: 1, + details: ['Default - checking...'], + }); + expect(result.status).toBe('ready'); + expect(result.details).toEqual(['Default - verified']); + }); + + it('forwards limitContext through model diagnostics for Anthropic default checks', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[], + limitContext?: boolean + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + details: [`Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.`], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'anthropic', + selectedModelIds: [DEFAULT_PROVIDER_MODEL_SELECTION], + limitContext: true, + prepareProvisioning, + }); + + expect(result.details).toEqual(['Default - verified']); + expect(prepareProvisioning).toHaveBeenNthCalledWith( + 1, + '/tmp/project', + 'anthropic', + ['anthropic'], + undefined, + true + ); + expect(prepareProvisioning).toHaveBeenNthCalledWith( + 2, + '/tmp/project', + 'anthropic', + ['anthropic'], + [DEFAULT_PROVIDER_MODEL_SELECTION], + true + ); + }); + + it('reuses cached model results and probes only newly selected models', async () => { + const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> = + []; + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + }); + } + + expect(selectedModels).toEqual(['gpt-5.2-codex']); + return Promise.resolve({ + ready: false, + message: + "Selected model gpt-5.2-codex is unavailable. The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.2', 'gpt-5.4-mini', 'gpt-5.2-codex'], + prepareProvisioning, + cachedModelResultsById: { + 'gpt-5.2': { + status: 'ready', + line: '5.2 - verified', + warningLine: null, + }, + 'gpt-5.4-mini': { + status: 'ready', + line: '5.4 Mini - verified', + warningLine: null, + }, + }, + onModelProgress: (progress) => progressUpdates.push(progress), + }); + + expect(progressUpdates[0]).toEqual({ + completedCount: 2, + totalCount: 3, + details: ['5.2 - verified', '5.4 Mini - verified', '5.2 Codex - checking...'], + }); + expect(result.details).toEqual([ + '5.2 - verified', + '5.4 Mini - verified', + '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription', + ]); + expect(prepareProvisioning).toHaveBeenCalledTimes(2); + expect(prepareProvisioning).toHaveBeenNthCalledWith( + 1, + '/tmp/project', + 'codex', + ['codex'], + undefined, + undefined + ); + expect(prepareProvisioning).toHaveBeenNthCalledWith(2, '/tmp/project', 'codex', ['codex'], [ + 'gpt-5.2-codex', + ], undefined); + }); +}); diff --git a/test/renderer/components/team/dialogs/provisioningModelIssues.test.ts b/test/renderer/components/team/dialogs/provisioningModelIssues.test.ts new file mode 100644 index 00000000..69399be0 --- /dev/null +++ b/test/renderer/components/team/dialogs/provisioningModelIssues.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { getProvisioningModelIssue } from '@renderer/components/team/dialogs/provisioningModelIssues'; + +describe('getProvisioningModelIssue', () => { + it('extracts a formatted Codex model failure with clean reason', () => { + expect( + getProvisioningModelIssue( + [ + { + providerId: 'codex', + status: 'failed', + details: [ + '5.4 Mini - verified', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ], + 'codex', + 'gpt-5.1-codex-max' + ) + ).toEqual({ + providerId: 'codex', + modelId: 'gpt-5.1-codex-max', + kind: 'unavailable', + reason: 'Not available with Codex ChatGPT subscription', + detail: '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + }); + }); + + it('returns null for verified models without their own failure line', () => { + expect( + getProvisioningModelIssue( + [ + { + providerId: 'codex', + status: 'failed', + details: [ + '5.4 Mini - verified', + '5.1 Codex Max - unavailable - Not available with Codex ChatGPT subscription', + ], + }, + ], + 'codex', + 'gpt-5.4-mini' + ) + ).toBeNull(); + }); +}); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 9025be90..32a5cf15 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -6,6 +6,7 @@ vi.mock('@renderer/api', () => ({ cliInstaller: { getStatus: vi.fn(), getProviderStatus: vi.fn(), + verifyProviderModels: vi.fn(), invalidateStatus: vi.fn(), install: vi.fn(), onProgress: vi.fn(() => vi.fn()), diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts new file mode 100644 index 00000000..d9f0a21e --- /dev/null +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { + getAvailableTeamProviderModelOptions, + getAvailableTeamProviderModels, + getTeamModelSelectionError, + normalizeTeamModelForUi, + type TeamModelRuntimeProviderStatus, +} from '@renderer/utils/teamModelAvailability'; + +function createCodexProviderStatus( + models: string[], + overrides: Partial = {} +): TeamModelRuntimeProviderStatus { + return { + providerId: 'codex', + models, + authMethod: 'oauth_token', + backend: { + kind: 'adapter', + label: 'Default adapter', + endpointLabel: 'chatgpt.com/backend-api/codex/responses', + }, + authenticated: true, + supported: true, + modelVerificationState: 'idle', + modelAvailability: [], + ...overrides, + }; +} + +describe('teamModelAvailability', () => { + it('uses runtime-reported Codex models as the source of truth', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([ + 'gpt-5.4', + 'gpt-5.3-codex', + ]); + }); + + it('filters Codex models that are UI-disabled even if runtime reports them', () => { + const providerStatus = createCodexProviderStatus([ + 'gpt-5.4', + 'gpt-5.3-codex-spark', + 'gpt-5.2-codex', + 'gpt-5.1-codex-mini', + 'gpt-5.1-codex-max', + ]); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']); + }); + + it('keeps 5.1 Codex Max available outside the ChatGPT subscription path', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.1-codex-max'], { + authMethod: 'api_key', + backend: { + kind: 'openai', + label: 'OpenAI', + endpointLabel: 'api.openai.com/v1/responses', + }, + }); + + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([ + 'gpt-5.4', + 'gpt-5.1-codex-max', + ]); + }); + + it('builds Codex model options from the runtime list instead of the hardcoded fallback', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(getAvailableTeamProviderModelOptions('codex', providerStatus)).toEqual([ + { value: '', label: 'Default', badgeLabel: 'Default' }, + { value: 'gpt-5.4', label: '5.4', availabilityStatus: 'available', availabilityReason: null }, + { + value: 'gpt-5.3-codex', + label: '5.3 Codex', + availabilityStatus: 'available', + availabilityReason: null, + }, + ]); + }); + + it('clears stale Codex selections when runtime no longer reports that model', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(normalizeTeamModelForUi('codex', 'gpt-5.2-codex', providerStatus)).toBe(''); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4'); + }); + + it('reports an explicit error when a Codex model is unsupported by the current runtime', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4', 'gpt-5.3-codex']); + + expect(getTeamModelSelectionError('codex', 'gpt-5.2-codex', providerStatus)).toContain( + 'Temporarily disabled for team agents' + ); + expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull(); + }); + + it('waits for the runtime model list before validating explicit Codex selections', () => { + expect(getTeamModelSelectionError('codex', 'gpt-5.4')).toContain( + 'waiting for Codex runtime verification' + ); + expect(getTeamModelSelectionError('codex', '')).toBeNull(); + }); + + it('keeps runtime models selectable without per-model verification state', () => { + const providerStatus = createCodexProviderStatus(['gpt-5.4']); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4', providerStatus)).toBe('gpt-5.4'); + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']); + expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull(); + }); + + it('does not require runtime verification for Anthropic curated models', () => { + expect(normalizeTeamModelForUi('anthropic', 'opus')).toBe('opus'); + expect(getTeamModelSelectionError('anthropic', 'opus')).toBeNull(); + }); +}); diff --git a/test/renderer/utils/teamModelCatalog.test.ts b/test/renderer/utils/teamModelCatalog.test.ts index d332928d..25d0f55f 100644 --- a/test/renderer/utils/teamModelCatalog.test.ts +++ b/test/renderer/utils/teamModelCatalog.test.ts @@ -20,7 +20,6 @@ describe('teamModelCatalog', () => { 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2', - 'gpt-5.2-codex', 'gpt-5.1-codex-max', ]); }); diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index f24f8896..723e8e85 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -35,4 +35,128 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.compactTitle).toBe('Team launched'); expect(presentation?.compactDetail).toBe('Lead online'); }); + + it('surfaces the failed teammate reason while launch is still active', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-2', + teamName: 'codex-team', + state: 'assembling', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:05.000Z', + message: 'Spawning member jack...', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'jack', + agentType: 'engineer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + jack: { + status: 'error', + launchState: 'failed_to_start', + error: + "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + hardFailureReason: + "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", + updatedAt: '2026-04-13T10:00:03.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + agentToolAccepted: true, + firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z', + }, + }, + memberSpawnSnapshot: undefined, + }); + + expect(presentation?.panelMessage).toContain('jack failed to start'); + expect(presentation?.panelMessage).toContain('gpt-5.2-codex'); + expect(presentation?.panelMessageSeverity).toBe('warning'); + expect(presentation?.compactDetail).toBe('jack failed to start'); + expect(presentation?.compactTone).toBe('warning'); + }); + + it('surfaces the failed teammate reason after launch completes with errors', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-3', + teamName: 'codex-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed with teammate errors - jack failed to start', + messageSeverity: 'warning', + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'jack', + agentType: 'engineer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + jack: { + status: 'error', + launchState: 'failed_to_start', + error: 'The requested model is not available for your account.', + hardFailureReason: 'The requested model is not available for your account.', + updatedAt: '2026-04-13T10:00:03.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + agentToolAccepted: true, + firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z', + }, + }, + memberSpawnSnapshot: { + expectedMembers: ['jack'], + summary: { + confirmedCount: 0, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }, + }, + }); + + expect(presentation?.successMessage).toBe('Launch finished with errors - 1/1 teammates failed to start'); + expect(presentation?.panelMessage).toContain('requested model is not available'); + expect(presentation?.compactDetail).toBe('jack failed to start'); + }); });