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.
This commit is contained in:
777genius 2026-04-16 19:41:23 +03:00
parent 0b97cc0794
commit ac1c99ac1f
48 changed files with 5109 additions and 422 deletions

View file

@ -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<IpcResult<CliInstallationStatus>> {
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<IpcResult<void
}
}
async function handleVerifyProviderModels(
_event: IpcMainInvokeEvent,
providerId: CliProviderId
): Promise<IpcResult<CliProviderStatus | null>> {
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<void> {
cachedStatus = null;
providerStatusInFlight.clear();
ClaudeBinaryResolver.clearCache();
service.invalidateStatusCache();
return { success: true, data: undefined };
}

View file

@ -1442,11 +1442,15 @@ async function handlePrepareProvisioning(
_event: IpcMainInvokeEvent,
cwd: unknown,
providerId: unknown,
providerIds: unknown
providerIds: unknown,
selectedModels: unknown,
limitContext: unknown
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
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,
})
);
}

View file

@ -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<CliProviderId, string | null>();
private electronMetaForDiag(): Record<string, unknown> {
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<CliInstallationStatus> {
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<CliProviderStatus | null> {
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;

View file

@ -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,

View file

@ -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<NodeJS.ProcessEnv>;
}
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<CliProviderModelAvailability, 'status' | 'reason'> {
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<string, ProviderModelAvailabilityCacheEntry>();
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>): 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<Pick<CliProviderModelAvailability, 'status' | 'reason'>> {
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);
}
}
}

View file

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

View file

@ -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<T>(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<void> {
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<void> {
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<TeamProvisioningPrepareResult> {
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<string | null> {
if (providerId === 'anthropic') {
return getAnthropicDefaultTeamModel(limitContext);
}
const { stdout } = await execCli(claudePath, ['model', 'list', '--json', '--provider', 'all'], {
cwd,
env,
timeout: 10_000,
});
const parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(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<string | null> {
let summaries: Awaited<ReturnType<TeamMemberLogsFinder['findMemberLogs']>>;
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<string | null> {
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<string | null> {
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>>;
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<string, unknown>[]): 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:

View file

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

View file

@ -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<TeamProvisioningPrepareResult>(
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<void> => {
return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL);
},

View file

@ -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<TeamProvisioningPrepareResult> => {
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> => null,
verifyProviderModels: async (): Promise<null> => null,
install: async (): Promise<void> => {
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
},

View file

@ -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 (
<div className="flex flex-wrap gap-1.5">
{visibleModels.map((model) => (
<span
key={model}
className="rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
color: 'var(--color-text-secondary)',
}}
>
{formatModelBadgeLabel(providerId, model)}
</span>
))}
</div>
);
};
const ProviderDetailSkeleton = (): React.JSX.Element => {
return (
<div className="mt-1 space-y-2">
@ -659,7 +625,12 @@ const InstalledBanner = ({
</div>
{!showSkeleton && provider.models.length > 0 && (
<div className="col-span-2">
<ModelBadges providerId={provider.providerId} models={provider.models} />
<ProviderModelBadges
providerId={provider.providerId}
models={provider.models}
modelAvailability={provider.modelAvailability}
providerStatus={provider}
/>
</div>
)}
</div>

View file

@ -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<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'> | null;
}): React.JSX.Element {
const visibleModels = getVisibleTeamProviderModels(providerId, models, providerStatus);
return (
<div className="flex flex-wrap gap-1.5">
{visibleModels.map((model) => {
const availabilityStatus = getAvailabilityStatus(model, modelAvailability);
const availabilityReason = getAvailabilityReason(model, modelAvailability);
const availabilityChip = getAvailabilityChip(availabilityStatus);
return (
<span
key={model}
className="inline-flex items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
color: 'var(--color-text-secondary)',
}}
title={availabilityReason ?? availabilityChip ?? undefined}
>
<span>{formatModelBadgeLabel(providerId, model)}</span>
{availabilityChip ? (
<span
className={cn(
'rounded px-1 py-0 text-[9px] font-medium uppercase tracking-[0.06em]',
availabilityStatus === 'checking'
? 'bg-[rgba(59,130,246,0.12)] text-[var(--color-text-secondary)]'
: availabilityStatus === 'unavailable'
? 'bg-[rgba(239,68,68,0.12)] text-[rgb(248,113,113)]'
: 'bg-[rgba(245,158,11,0.12)] text-[rgb(251,191,36)]'
)}
>
{availabilityChip}
</span>
) : null}
</span>
);
})}
</div>
);
}

View file

@ -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 (
<div className="flex flex-wrap gap-1.5">
{visibleModels.map((model) => (
<span
key={model}
className="rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
color: 'var(--color-text-secondary)',
}}
>
{formatModelBadgeLabel(providerId, model)}
</span>
))}
</div>
);
};
const ProviderDetailSkeleton = (): React.JSX.Element => {
return (
<div className="mt-1 space-y-2">
@ -600,9 +566,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
</div>
{!showSkeleton && provider.models.length > 0 && (
<div className="col-span-2">
<ModelBadges
<ProviderModelBadges
providerId={provider.providerId}
models={provider.models}
modelAvailability={provider.modelAvailability}
providerStatus={provider}
/>
</div>
)}

View file

@ -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<TeamProviderId, string | null>(entries);
}, [cliStatus?.providers]);
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
const prepareModelResultsCacheRef = useRef(
new Map<string, Record<string, ProviderPrepareDiagnosticsModelResult>>()
);
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<string>();
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<string, string> = {};
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 = ({
</div>
) : null}
{canCreate && launchTeam && prepareState === 'failed' ? (
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
<div className="min-w-0 space-y-1">
<p className="font-medium text-red-300">
CLI environment is not available launch is blocked
</p>
<p className="text-red-300/80">
{prepareMessage ?? 'Failed to prepare environment'}
</p>
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-1"
suppressDetailsMatching={prepareMessage}
/>
) : null}
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="space-y-0.5">
{prepareWarnings.map((warning) => (
<p
key={warning}
className="text-[11px]"
style={{ color: 'var(--warning-text)' }}
>
{warning}
</p>
))}
</div>
) : null}
<p className="text-[11px] text-[var(--color-text-muted)]">
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
</p>
</div>
</div>
</div>
) : null}
{!canCreate ? (
<p
className="rounded border p-2 text-xs"
@ -1162,6 +1331,8 @@ export const CreateTeamDialog = ({
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
disableGeminiOption={isGeminiUiFrozen()}
leadModelIssueText={leadModelIssueText}
memberModelIssueById={memberModelIssueById}
headerTop={
<div className="flex items-center gap-2">
<Checkbox
@ -1420,6 +1591,48 @@ export const CreateTeamDialog = ({
) : null}
</div>
) : null}
{canCreate && launchTeam && prepareState === 'failed' ? (
<div className="text-xs">
<div className="flex items-start gap-2 text-red-300">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
CLI environment is not available - launch is blocked
</p>
<p className="mt-0.5 text-red-300/80">
{prepareMessage ?? 'Failed to prepare environment'}
</p>
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
Pre-flight check to catch errors before launch
</p>
</div>
</div>
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-2"
suppressDetailsMatching={prepareMessage}
/>
) : null}
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-1 space-y-0.5 pl-6">
{prepareWarnings.map((warning) => (
<p
key={warning}
className="text-[11px]"
style={{ color: 'var(--warning-text)' }}
>
{warning}
</p>
))}
</div>
) : null}
<p className="mt-1 pl-6 text-[11px] text-[var(--color-text-muted)]">
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
</p>
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">

View file

@ -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<TeamProviderId, string | null>(entries);
}, [cliStatus?.providers]);
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
const prepareModelResultsCacheRef = useRef(
new Map<string, Record<string, ProviderPrepareDiagnosticsModelResult>>()
);
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<TeamProviderId, string[]>();
const defaultSelectionByProvider = new Map<TeamProviderId, boolean>();
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<string, string> = {};
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
</div>
) : null}
{/* Launch-only: CLI env failed */}
{isLaunch && prepareState === 'failed' ? (
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
<div className="min-w-0 space-y-1">
<p className="font-medium text-red-300">
CLI environment is not available launch is blocked
</p>
<p className="text-red-300/80">
{prepareMessage ?? 'Failed to prepare environment'}
</p>
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-1"
suppressDetailsMatching={prepareMessage}
/>
) : null}
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="space-y-0.5">
{prepareWarnings.map((warning) => (
<p
key={warning}
className="text-[11px]"
style={{ color: 'var(--warning-text)' }}
>
{warning}
</p>
))}
</div>
) : null}
<div className="flex items-center gap-2 pt-1">
<p className="text-[11px] text-[var(--color-text-muted)]">
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
</p>
{(prepareMessage ?? '').toLowerCase().includes('spawn ') ||
prepareChecks.some((check) =>
check.details.some((detail) => detail.toLowerCase().includes('spawn '))
) ? (
<button
type="button"
className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
onClick={() => {
closeDialog();
openDashboard();
}}
>
Go to Dashboard
</button>
) : null}
</div>
</div>
</div>
</div>
) : null}
<div className="space-y-4">
{/*
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
</div>
) : null}
{prepareState === 'failed' ? <div /> : null}
{prepareState === 'failed' ? (
<div className="text-xs">
<div className="flex items-start gap-2 text-red-300">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
CLI environment is not available - launch is blocked
</p>
<p className="mt-0.5 text-red-300/80">
{prepareMessage ?? 'Failed to prepare environment'}
</p>
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
Pre-flight check to catch errors before launch
</p>
</div>
</div>
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-2"
suppressDetailsMatching={prepareMessage}
/>
) : null}
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-1 space-y-0.5 pl-6">
{prepareWarnings.map((warning) => (
<p
key={warning}
className="text-[11px]"
style={{ color: 'var(--warning-text)' }}
>
{warning}
</p>
))}
</div>
) : null}
<div className="mt-1 flex items-center gap-2 pl-6">
<p className="text-[11px] text-[var(--color-text-muted)]">
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
</p>
{(prepareMessage ?? '').toLowerCase().includes('spawn ') ||
prepareChecks.some((check) =>
check.details.some((detail) => detail.toLowerCase().includes('spawn '))
) ? (
<button
type="button"
className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
onClick={() => {
closeDialog();
openDashboard();
}}
>
Go to Dashboard
</button>
) : null}
</div>
</div>
) : null}
</div>
) : null}

View file

@ -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 ? (
<div className="mt-0.5 space-y-0.5 pl-4">
{visibleDetails.map((detail) => (
<p key={detail} className="text-[10px] text-[var(--color-text-muted)]">
<p
key={detail}
className={`text-[10px] ${getDetailColorClass(detail, check.status)}`}
>
{detail}
</p>
))}

View file

@ -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<Record<string, string | null | undefined>>;
}
export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
@ -126,8 +130,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
}, [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 (
<div className="mb-5">
@ -292,6 +294,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
) : null}
<div className="p-3">
{shouldAwaitRuntimeModelList ? (
<p className="mb-2 text-[11px] text-[var(--color-text-muted)]">
Explicit models load from the current runtime. Default remains available while the
list is syncing.
</p>
) : null}
<div
className="grid gap-1.5 rounded-md bg-[var(--color-surface)]"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
@ -300,9 +308,24 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
(() => {
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 (
<button
@ -310,13 +333,18 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
type="button"
id={opt.value === normalizedValue ? id : undefined}
aria-disabled={!modelSelectable}
title={modelStatusMessage ?? undefined}
className={cn(
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border bg-[var(--color-surface)] px-3 py-2 text-center text-xs font-medium transition-[background-color,border-color,color,box-shadow] duration-150',
normalizedValue === opt.value
? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: modelSelectable
? 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-emphasis)] hover:bg-[color-mix(in_srgb,var(--color-surface-raised)_62%,var(--color-surface)_38%)] hover:text-[var(--color-text-secondary)] hover:shadow-sm'
: 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]',
hasModelIssue && normalizedValue === opt.value
? 'border-red-500/60 bg-red-500/10 text-red-100 shadow-sm'
: hasModelIssue
? 'border-red-500/40 bg-red-500/5 text-red-200 hover:border-red-400/60 hover:bg-red-500/10 hover:text-red-100'
: normalizedValue === opt.value
? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: modelSelectable
? 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-emphasis)] hover:bg-[color-mix(in_srgb,var(--color-surface-raised)_62%,var(--color-surface)_38%)] hover:text-[var(--color-text-secondary)] hover:shadow-sm'
: 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]',
!modelSelectable && 'cursor-not-allowed opacity-45',
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
)}
@ -349,7 +377,29 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</TooltipProvider>
</span>
)}
{modelDisabledReason && (
{hasModelIssue && (
<span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-red-300"
title={modelIssueReason ?? undefined}
>
<AlertTriangle className="size-3 shrink-0" />
<span>Issue</span>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Info className="size-3 shrink-0 opacity-50 transition-opacity hover:opacity-80" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{modelIssueReason}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
{!hasModelIssue && modelDisabledReason && (
<span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
title={modelDisabledReason}

View file

@ -0,0 +1,378 @@
import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types';
export type ProviderPrepareCheckStatus = 'ready' | 'notes' | 'failed';
interface PrepareProvisioningFn {
(
cwd?: string,
providerId?: TeamProviderId,
providerIds?: TeamProviderId[],
selectedModels?: string[],
limitContext?: boolean
): Promise<TeamProvisioningPrepareResult>;
}
interface ProviderPrepareDiagnosticsProgress {
details: string[];
completedCount: number;
totalCount: number;
}
export interface ProviderPrepareDiagnosticsModelResult {
status: 'ready' | 'notes' | 'failed';
line: string;
warningLine?: string | null;
}
export interface ProviderPrepareDiagnosticsCachedSnapshot {
status: ProviderPrepareCheckStatus | 'checking';
details: string[];
completedCount: number;
totalCount: number;
}
export interface ProviderPrepareDiagnosticsResult {
status: ProviderPrepareCheckStatus;
details: string[];
warnings: string[];
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getModelLabel(providerId: TeamProviderId, modelId: string): string {
if (isDefaultProviderModelSelection(modelId)) {
return 'Default';
}
return getProviderScopedTeamModelLabel(providerId, modelId) ?? modelId;
}
export function buildProviderPrepareModelCheckingLine(
providerId: TeamProviderId,
modelId: string
): string {
return `${getModelLabel(providerId, modelId)} - checking...`;
}
function buildModelSuccessLine(providerId: TeamProviderId, modelId: string): string {
return `${getModelLabel(providerId, modelId)} - verified`;
}
export function getProviderPrepareCachedSnapshot({
providerId,
selectedModelIds,
cachedModelResultsById,
}: {
providerId: TeamProviderId;
selectedModelIds: string[];
cachedModelResultsById?: Record<string, ProviderPrepareDiagnosticsModelResult>;
}): ProviderPrepareDiagnosticsCachedSnapshot {
const reusableModelResultsById = cachedModelResultsById ?? {};
const orderedModelIds = Array.from(
new Set(selectedModelIds.map((modelId) => modelId.trim()).filter(Boolean))
);
let completedCount = 0;
let hasFailure = false;
let hasNotes = false;
let hasChecking = false;
const details = orderedModelIds.map((modelId) => {
const cachedResult = reusableModelResultsById[modelId];
if (!cachedResult) {
hasChecking = true;
return buildProviderPrepareModelCheckingLine(providerId, modelId);
}
completedCount += 1;
if (cachedResult.status === 'failed') {
hasFailure = true;
} else if (cachedResult.status === 'notes') {
hasNotes = true;
}
return cachedResult.line;
});
return {
status: hasChecking ? 'checking' : hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready',
details,
completedCount,
totalCount: orderedModelIds.length,
};
}
function stripSelectedModelPrefix(modelId: string, message: string): string {
const trimmed = message.trim();
if (!trimmed) {
return trimmed;
}
const patterns = [
new RegExp(`^Selected model ${escapeRegExp(modelId)} is unavailable\\.\\s*`, 'i'),
new RegExp(`^Selected model ${escapeRegExp(modelId)} could not be verified\\.\\s*`, 'i'),
new RegExp(`^Selected model ${escapeRegExp(modelId)} verified for launch\\.\\s*`, 'i'),
];
for (const pattern of patterns) {
if (pattern.test(trimmed)) {
return trimmed.replace(pattern, '').trim();
}
}
return trimmed;
}
function decodeQuotedJsonString(value: string): string {
try {
return JSON.parse(`"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`) as string;
} catch {
return value;
}
}
function normalizeModelReason(rawReason: string | null | undefined): string | null {
const trimmed = rawReason?.trim() ?? '';
if (!trimmed) {
return null;
}
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 (
trimmed.toLowerCase().includes('timeout running:') ||
trimmed.toLowerCase().includes('timed out') ||
trimmed.toLowerCase().includes('etimedout')
) {
return 'Model verification timed out';
}
const detailMatch = trimmed.match(/"detail":"((?:\\"|[^"])*)"/i);
if (detailMatch?.[1]) {
return normalizeModelReason(detailMatch[1].replace(/\\"/g, '"').trim());
}
const messageMatch = trimmed.match(/"message":"((?:\\"|[^"])*)"/i);
if (messageMatch?.[1]) {
const decodedMessage = messageMatch[1].replace(/\\"/g, '"');
const nestedDetailMatch = decodedMessage.match(/"detail":"([^"]+)"/i);
if (nestedDetailMatch?.[1]) {
return normalizeModelReason(nestedDetailMatch[1].trim());
}
return normalizeModelReason(decodeQuotedJsonString(decodedMessage).trim());
}
return trimmed;
}
function getResultReason(modelId: string, result: TeamProvisioningPrepareResult): string | null {
const candidates = [...(result.details ?? []), ...(result.warnings ?? []), result.message]
.map((entry) => entry?.trim() ?? '')
.filter(Boolean);
for (const candidate of candidates) {
const stripped = stripSelectedModelPrefix(modelId, candidate);
if (stripped) {
return normalizeModelReason(stripped);
}
}
return null;
}
function buildModelFailureLine(
providerId: TeamProviderId,
modelId: string,
kind: 'unavailable' | 'check failed',
reason: string | null
): string {
const label = getModelLabel(providerId, modelId);
return reason ? `${label} - ${kind} - ${reason}` : `${label} - ${kind}`;
}
function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] {
return [...(result.details ?? []), ...(result.warnings ?? [])];
}
export async function runProviderPrepareDiagnostics({
cwd,
providerId,
selectedModelIds,
prepareProvisioning,
limitContext,
onModelProgress,
cachedModelResultsById,
}: {
cwd: string;
providerId: TeamProviderId;
selectedModelIds: string[];
prepareProvisioning: PrepareProvisioningFn;
limitContext?: boolean;
onModelProgress?: (progress: ProviderPrepareDiagnosticsProgress) => void;
cachedModelResultsById?: Record<string, ProviderPrepareDiagnosticsModelResult>;
}): Promise<ProviderPrepareDiagnosticsResult> {
const runtimeResult = await prepareProvisioning(
cwd,
providerId,
[providerId],
undefined,
limitContext
);
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
const runtimeWarnings = [...(runtimeResult.warnings ?? [])];
if (!runtimeResult.ready) {
return {
status: 'failed',
details: [...runtimeDetailLines, ...(runtimeResult.message ? [runtimeResult.message] : [])],
warnings: runtimeWarnings,
modelResultsById: {},
};
}
if (selectedModelIds.length === 0) {
return {
status: runtimeWarnings.length > 0 ? 'notes' : 'ready',
details: runtimeDetailLines,
warnings: runtimeWarnings,
modelResultsById: {},
};
}
const orderedModelIds = Array.from(
new Set(selectedModelIds.map((modelId) => modelId.trim()).filter(Boolean))
);
const reusableModelResultsById = cachedModelResultsById ?? {};
const modelResultsById = new Map<string, ProviderPrepareDiagnosticsModelResult>();
const modelLines = new Map<string, string>();
let completedCount = 0;
let hasFailure = false;
let hasNotes = runtimeWarnings.length > 0;
const modelWarnings: string[] = [];
for (const modelId of orderedModelIds) {
const cachedResult = reusableModelResultsById[modelId];
if (cachedResult) {
modelResultsById.set(modelId, cachedResult);
modelLines.set(modelId, cachedResult.line);
completedCount += 1;
if (cachedResult.status === 'failed') {
hasFailure = true;
} else if (cachedResult.status === 'notes') {
hasNotes = true;
}
if (cachedResult.warningLine) {
modelWarnings.push(cachedResult.warningLine);
}
continue;
}
modelLines.set(modelId, buildProviderPrepareModelCheckingLine(providerId, modelId));
}
const emitProgress = (): void => {
onModelProgress?.({
details: [
...runtimeDetailLines,
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
],
completedCount,
totalCount: orderedModelIds.length,
});
};
emitProgress();
await Promise.all(
orderedModelIds
.filter((modelId) => !modelResultsById.has(modelId))
.map(async (modelId) => {
try {
const modelResult = await prepareProvisioning(
cwd,
providerId,
[providerId],
[modelId],
limitContext
);
if (!modelResult.ready) {
hasFailure = true;
const line = buildModelFailureLine(
providerId,
modelId,
'unavailable',
getResultReason(modelId, modelResult) ?? normalizeModelReason(modelResult.message)
);
modelLines.set(modelId, line);
modelResultsById.set(modelId, {
status: 'failed',
line,
warningLine: null,
});
} else if ((modelResult.warnings?.length ?? 0) > 0) {
hasNotes = true;
const reason = getResultReason(modelId, modelResult);
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason);
modelLines.set(modelId, line);
modelWarnings.push(line);
modelResultsById.set(modelId, {
status: 'notes',
line,
warningLine: line,
});
} else {
const line = buildModelSuccessLine(providerId, modelId);
modelLines.set(modelId, line);
modelResultsById.set(modelId, {
status: 'ready',
line,
warningLine: null,
});
}
} catch (error) {
hasNotes = true;
const reason = normalizeModelReason(
error instanceof Error ? error.message.trim() : String(error).trim()
);
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
modelLines.set(modelId, line);
modelWarnings.push(line);
modelResultsById.set(modelId, {
status: 'notes',
line,
warningLine: line,
});
} finally {
completedCount += 1;
emitProgress();
}
})
);
const dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings]));
const selectedModelResultsById = Object.fromEntries(
orderedModelIds
.map((modelId) => [modelId, modelResultsById.get(modelId)] as const)
.filter((entry): entry is [string, ProviderPrepareDiagnosticsModelResult] =>
Boolean(entry[1])
)
);
return {
status: hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready',
details: [
...runtimeDetailLines,
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
],
warnings: dedupedWarnings,
modelResultsById: selectedModelResultsById,
};
}

View file

@ -0,0 +1,124 @@
import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
import type { ProvisioningProviderCheck } from './ProvisioningProviderStatusList';
import type { TeamProviderId } from '@shared/types';
export interface ProvisioningModelIssue {
providerId: TeamProviderId;
modelId: string;
kind: 'unavailable' | 'check failed';
reason: string | null;
detail: string;
}
function extractReason(detail: string, prefix: string): string | null {
if (!detail.startsWith(prefix)) {
return null;
}
const suffix = detail.slice(prefix.length).trim();
if (!suffix) {
return null;
}
return suffix.startsWith('- ') ? suffix.slice(2).trim() : suffix;
}
function buildIssueFromFormattedDetail(
detail: string,
providerId: TeamProviderId,
modelId: string,
label: string
): ProvisioningModelIssue | null {
const unavailablePrefix = `${label} - unavailable`;
const unavailableReason = extractReason(detail, unavailablePrefix);
if (detail.startsWith(unavailablePrefix)) {
return {
providerId,
modelId,
kind: 'unavailable',
reason: unavailableReason,
detail,
};
}
const checkFailedPrefix = `${label} - check failed`;
const checkFailedReason = extractReason(detail, checkFailedPrefix);
if (detail.startsWith(checkFailedPrefix)) {
return {
providerId,
modelId,
kind: 'check failed',
reason: checkFailedReason,
detail,
};
}
return null;
}
function buildIssueFromLegacyDetail(
detail: string,
providerId: TeamProviderId,
modelId: string
): ProvisioningModelIssue | null {
const unavailablePrefix = `Selected model ${modelId} is unavailable.`;
if (detail.startsWith(unavailablePrefix)) {
const reason = detail.slice(unavailablePrefix.length).trim() || null;
return {
providerId,
modelId,
kind: 'unavailable',
reason,
detail,
};
}
const checkFailedPrefix = `Selected model ${modelId} could not be verified.`;
if (detail.startsWith(checkFailedPrefix)) {
const reason = detail.slice(checkFailedPrefix.length).trim() || null;
return {
providerId,
modelId,
kind: 'check failed',
reason,
detail,
};
}
return null;
}
export function getProvisioningModelIssue(
checks: ProvisioningProviderCheck[],
providerId: TeamProviderId,
modelId: string | null | undefined
): ProvisioningModelIssue | null {
const trimmedModelId = modelId?.trim() ?? '';
if (!trimmedModelId) {
return null;
}
const label = getProviderScopedTeamModelLabel(providerId, trimmedModelId) ?? trimmedModelId;
const providerChecks = checks.filter((check) => check.providerId === providerId);
for (const check of providerChecks) {
for (const detail of check.details) {
const formattedIssue = buildIssueFromFormattedDetail(
detail,
providerId,
trimmedModelId,
label
);
if (formattedIssue) {
return formattedIssue;
}
const legacyIssue = buildIssueFromLegacyDetail(detail, providerId, trimmedModelId);
if (legacyIssue) {
return legacyIssue;
}
}
}
return null;
}

View file

@ -12,8 +12,9 @@ import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
import { Button } from '../../ui/button';
@ -32,6 +33,7 @@ interface LeadModelRowProps {
onSyncModelsWithTeammatesChange: (value: boolean) => void;
warningText?: string | null;
disableGeminiOption?: boolean;
modelIssueText?: string | null;
}
export const LeadModelRow = ({
@ -47,6 +49,7 @@ export const LeadModelRow = ({
onSyncModelsWithTeammatesChange,
warningText,
disableGeminiOption = false,
modelIssueText,
}: LeadModelRowProps): React.JSX.Element => {
const { isLight } = useTheme();
const [modelExpanded, setModelExpanded] = useState(false);
@ -55,6 +58,7 @@ export const LeadModelRow = ({
? getProviderScopedTeamModelLabel(providerId, model.trim())
: 'Default';
const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`;
const hasModelIssue = Boolean(modelIssueText);
return (
<div
@ -99,7 +103,11 @@ export const LeadModelRow = ({
<Button
variant="outline"
size="sm"
className="h-8 w-full justify-start gap-1 overflow-hidden text-left"
className={cn(
'h-8 w-full justify-start gap-1 overflow-hidden text-left',
hasModelIssue &&
'border-red-500/50 bg-red-500/10 text-red-100 hover:border-red-400/60 hover:bg-red-500/15 hover:text-red-50'
)}
aria-label={modelButtonAriaLabel}
onClick={() => setModelExpanded((prev) => !prev)}
>
@ -109,7 +117,8 @@ export const LeadModelRow = ({
<ChevronRight className="size-3.5" />
)}
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
<span className="truncate">{modelButtonLabel}</span>
<span className="min-w-0 flex-1 truncate">{modelButtonLabel}</span>
{hasModelIssue ? <AlertTriangle className="size-3.5 shrink-0 text-red-300" /> : null}
</Button>
</div>
</div>
@ -130,6 +139,7 @@ export const LeadModelRow = ({
onValueChange={onModelChange}
id="lead-model"
disableGeminiOption={disableGeminiOption}
modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined}
/>
<EffortLevelSelector
value={effort ?? ''}

View file

@ -17,8 +17,9 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme';
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { cn } from '@renderer/lib/utils';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { ChevronDown, ChevronRight, Info, RotateCcw, Trash2 } from 'lucide-react';
import { AlertTriangle, ChevronDown, ChevronRight, Info, RotateCcw, Trash2 } from 'lucide-react';
import type { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip';
@ -55,6 +56,7 @@ interface MemberDraftRowProps {
onRestore?: (id: string) => 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 (
<div
@ -248,7 +252,11 @@ export const MemberDraftRow = ({
<Button
variant="outline"
size="sm"
className="h-8 w-full justify-start gap-1 overflow-hidden text-left"
className={cn(
'h-8 w-full justify-start gap-1 overflow-hidden text-left',
hasModelIssue &&
'border-red-500/50 bg-red-500/10 text-red-100 hover:border-red-400/60 hover:bg-red-500/15 hover:text-red-50'
)}
aria-label={modelButtonAriaLabel}
disabled={lockProviderModel || isRemoved}
onClick={() => setModelExpanded((prev) => !prev)}
@ -262,13 +270,21 @@ export const MemberDraftRow = ({
providerId={effectiveProviderId}
className="size-3.5 shrink-0"
/>
<span className="truncate">{modelButtonLabel}</span>
<span className="min-w-0 flex-1 truncate">{modelButtonLabel}</span>
{hasModelIssue ? (
<AlertTriangle className="size-3.5 shrink-0 text-red-300" />
) : null}
</Button>
</span>
</TooltipTrigger>
{modelTooltipText ? (
{modelTooltipText || modelIssueText ? (
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
{modelTooltipText}
{modelIssueText ? <p className="text-red-300">{modelIssueText}</p> : null}
{modelTooltipText ? (
<p className={modelIssueText ? 'mt-1 border-t border-white/10 pt-1' : ''}>
{modelTooltipText}
</p>
) : null}
</TooltipContent>
) : null}
</Tooltip>
@ -355,6 +371,9 @@ export const MemberDraftRow = ({
}}
id={`member-${member.id}-model`}
disableGeminiOption={disableGeminiOption}
modelIssueReasonByValue={
effectiveModel?.trim() ? { [effectiveModel.trim()]: modelIssueText } : undefined
}
/>
<EffortLevelSelector
value={effectiveEffort ?? ''}
@ -370,13 +389,6 @@ export const MemberDraftRow = ({
'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
</p>
)}
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300">
If this teammate uses a different provider than the lead, they will be started in a
separate process automatically.
</p>
</div>
</div>
)}
</div>

View file

@ -101,6 +101,7 @@ export interface MembersEditorSectionProps {
softDeleteMembers?: boolean;
memberWarningById?: Record<string, string | null | undefined>;
disableGeminiOption?: boolean;
memberModelIssueById?: Record<string, string | null | undefined>;
}
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}
/>
))}
</div>

View file

@ -44,6 +44,8 @@ interface TeamRosterEditorSectionProps {
leadWarningText?: string | null;
memberWarningById?: Record<string, string | null | undefined>;
disableGeminiOption?: boolean;
leadModelIssueText?: string | null;
memberModelIssueById?: Record<string, string | null | undefined>;
}
export const TeamRosterEditorSection = ({
@ -83,6 +85,8 @@ export const TeamRosterEditorSection = ({
leadWarningText,
memberWarningById,
disableGeminiOption = false,
leadModelIssueText,
memberModelIssueById,
}: TeamRosterEditorSectionProps): React.JSX.Element => {
return (
<MembersEditorSection
@ -108,6 +112,7 @@ export const TeamRosterEditorSection = ({
modelLockReason={modelLockReason}
softDeleteMembers={softDeleteMembers}
disableGeminiOption={disableGeminiOption}
memberModelIssueById={memberModelIssueById}
headerExtra={
<div className="space-y-3">
{headerTop}
@ -124,6 +129,7 @@ export const TeamRosterEditorSection = ({
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
warningText={leadWarningText}
disableGeminiOption={disableGeminiOption}
modelIssueText={leadModelIssueText}
/>
{headerBottom}
</div>

View file

@ -34,7 +34,7 @@ export function useCliInstaller(): {
fetchCliStatus: () => Promise<void>;
fetchCliProviderStatus: (
providerId: CliProviderId,
options?: { silent?: boolean; epoch?: number }
options?: { silent?: boolean; epoch?: number; verifyModels?: boolean }
) => Promise<void>;
invalidateCliStatus: () => Promise<void>;
installCli: () => void;

View file

@ -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<void>;
fetchCliProviderStatus: (
providerId: CliProviderId,
options?: { silent?: boolean; epoch?: number }
options?: { silent?: boolean; epoch?: number; verifyModels?: boolean }
) => Promise<void>;
invalidateCliStatus: () => Promise<void>;
installCli: () => void;
}
let cliStatusInFlight: Promise<void> | null = null;
const cliProviderStatusInFlight = new Map<CliProviderId, Promise<void>>();
const cliProviderStatusInFlight = new Map<string, Promise<void>>();
let cliStatusEpoch = 0;
const cliProviderStatusSeq = new Map<CliProviderId, number>();
@ -257,7 +259,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
if (get().cliStatus && !get().cliStatus?.installed) {
return;
}
const inFlight = cliProviderStatusInFlight.get(providerId);
const verifyModels = options?.verifyModels === true;
const requestKey = `${providerId}:${verifyModels ? 'verify' : 'status'}`;
const inFlight = cliProviderStatusInFlight.get(requestKey);
if (inFlight) return inFlight;
const requestEpoch = options?.epoch ?? cliStatusEpoch;
@ -277,7 +281,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
}
try {
const providerStatus = await api.cliInstaller.getProviderStatus(providerId);
const providerStatus = verifyModels
? await api.cliInstaller.verifyProviderModels(providerId)
: await api.cliInstaller.getProviderStatus(providerId);
set((state) => {
const nextLoading = silent
? state.cliProviderStatusLoading
@ -343,11 +349,11 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
};
});
} finally {
cliProviderStatusInFlight.delete(providerId);
cliProviderStatusInFlight.delete(requestKey);
}
})();
cliProviderStatusInFlight.set(providerId, request);
cliProviderStatusInFlight.set(requestKey, request);
return request;
},

View file

@ -1,10 +1,302 @@
export {
getTeamModelUiDisabledReason,
import type {
CliProviderId,
CliProviderModelAvailability,
CliProviderModelAvailabilityStatus,
CliProviderStatus,
TeamProviderId,
} from '@shared/types';
import {
getProviderScopedTeamModelLabel,
getRuntimeAwareTeamModelUiDisabledReason,
getTeamProviderLabel,
getTeamProviderModelOptions,
sortTeamProviderModels,
getVisibleTeamProviderModels,
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
GPT_5_2_CODEX_UI_DISABLED_MODEL,
GPT_5_2_CODEX_UI_DISABLED_REASON,
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
type TeamProviderModelOption,
} from './teamModelCatalog';
export {
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
GPT_5_2_CODEX_UI_DISABLED_MODEL,
GPT_5_2_CODEX_UI_DISABLED_REASON,
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
isTeamModelUiDisabled,
normalizeTeamModelForUi,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
} from './teamModelCatalog';
type SupportedProviderId = CliProviderId | TeamProviderId;
export type TeamModelRuntimeProviderStatus = Pick<
CliProviderStatus,
| 'providerId'
| 'models'
| 'modelAvailability'
| 'modelVerificationState'
| 'authMethod'
| 'backend'
| 'authenticated'
| 'supported'
>;
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<string, CliProviderModelAvailability> {
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;
}

View file

@ -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<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'>;
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<SupportedProviderId, readonly TeamProv
uiDisabledReason: GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
},
{ value: 'gpt-5.2', label: 'GPT-5.2', badgeLabel: '5.2' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', badgeLabel: '5.2-codex' },
{
value: 'gpt-5.2-codex',
label: 'GPT-5.2 Codex',
badgeLabel: '5.2-codex',
uiDisabledReason: GPT_5_2_CODEX_UI_DISABLED_REASON,
},
{
value: 'gpt-5.1-codex-mini',
label: 'GPT-5.1 Codex Mini',
@ -197,13 +217,42 @@ export function sortTeamProviderModels(
});
}
export function isCodexChatGptSubscriptionProviderStatus(
providerStatus?: RuntimeAwareProviderStatus | null
): boolean {
if (providerStatus?.providerId !== 'codex') {
return false;
}
const endpointLabel = providerStatus.backend?.endpointLabel?.toLowerCase() ?? '';
return (
providerStatus.authMethod === 'oauth_token' &&
(providerStatus.backend?.kind === 'adapter' ||
endpointLabel.includes('chatgpt.com/backend-api/codex/responses'))
);
}
function isRuntimeHiddenTeamModel(
providerId: SupportedProviderId,
model: string,
providerStatus?: RuntimeAwareProviderStatus | null
): boolean {
return (
providerId === 'codex' &&
model === 'gpt-5.1-codex-max' &&
isCodexChatGptSubscriptionProviderStatus(providerStatus)
);
}
export function getVisibleTeamProviderModels(
providerId: SupportedProviderId,
models: readonly string[]
models: readonly string[],
providerStatus?: RuntimeAwareProviderStatus | null
): string[] {
return sortTeamProviderModels(providerId, models).filter(
(model) => !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

View file

@ -17,6 +17,11 @@ type MemberSpawnStatusCollection =
| Map<string, MemberSpawnStatusEntry>
| 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',
};
}

View file

@ -438,7 +438,9 @@ export interface TeamsAPI {
prepareProvisioning: (
cwd?: string,
providerId?: TeamLaunchRequest['providerId'],
providerIds?: TeamLaunchRequest['providerId'][]
providerIds?: TeamLaunchRequest['providerId'][],
selectedModels?: string[],
limitContext?: boolean
) => Promise<TeamProvisioningPrepareResult>;
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;

View file

@ -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<CliInstallationStatus>;
/** Get current runtime/auth status for a single provider */
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
/** Start on-demand model verification for a single runtime provider */
verifyProviderModels: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
/** Start install/update flow. Progress sent via onProgress events. */
install: () => Promise<void>;
/** Invalidate cached status (forces fresh check on next getStatus) */

View file

@ -990,6 +990,7 @@ export interface TeamCreateResponse {
export interface TeamProvisioningPrepareResult {
ready: boolean;
message: string;
details?: string[];
warnings?: string[];
}

View file

@ -0,0 +1,3 @@
export function getAnthropicDefaultTeamModel(limitContext: boolean): string {
return limitContext ? 'opus' : 'opus[1m]';
}

View file

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

View file

@ -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<Record<SupportedProviderId, readonly string[]>> = {
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<string>();
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;
}

View file

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

View file

@ -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<typeof execCliMock>) => execCliMock(...args),
}));
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
buildProviderAwareCliEnv: (...args: Parameters<typeof buildProviderAwareCliEnvMock>) =>
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);
});
});
});

View file

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

View file

@ -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({

View file

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

View file

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

View file

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

View file

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

View file

@ -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<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<TeamProvisioningPrepareResult>
>().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<TeamProvisioningPrepareResult>();
const deferred52 = createDeferred<TeamProvisioningPrepareResult>();
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<TeamProvisioningPrepareResult>
>((_, __, ___, 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<TeamProvisioningPrepareResult>
>((_, __, ___, 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<TeamProvisioningPrepareResult>
>((_, __, ___, 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<TeamProvisioningPrepareResult>
>((_, __, ___, 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<TeamProvisioningPrepareResult>
>((_, __, ___, 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<TeamProvisioningPrepareResult>
>((_, __, ___, 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);
});
});

View file

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

View file

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

View file

@ -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> = {}
): 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();
});
});

View file

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

View file

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