diff --git a/src/features/codex-model-catalog/core/domain/__tests__/normalizeCodexAppServerModel.test.ts b/src/features/codex-model-catalog/core/domain/__tests__/normalizeCodexAppServerModel.test.ts index ed7f325c..95d159f0 100644 --- a/src/features/codex-model-catalog/core/domain/__tests__/normalizeCodexAppServerModel.test.ts +++ b/src/features/codex-model-catalog/core/domain/__tests__/normalizeCodexAppServerModel.test.ts @@ -69,6 +69,29 @@ describe('normalizeCodexAppServerModels', () => { expect(result.models[0]?.defaultReasoningEffort).toBe('medium'); }); + it('preserves Codex Fast support from app-server speed-tier metadata', () => { + const result = normalizeCodexAppServerModels([ + { + id: 'gpt-5.5', + additionalSpeedTiers: [{ serviceTier: 'fast' }], + }, + { + id: 'gpt-5.5-mini', + additionalSpeedTiers: ['flex'], + }, + { + id: 'gpt-5.6', + supportsFastMode: true, + }, + ]); + + expect(result.models.find((model) => model.id === 'gpt-5.5')?.supportsFastMode).toBe(true); + expect(result.models.find((model) => model.id === 'gpt-5.5-mini')?.supportsFastMode).toBe( + false + ); + expect(result.models.find((model) => model.id === 'gpt-5.6')?.supportsFastMode).toBe(true); + }); + it('uses model as the launch value and de-duplicates duplicate launch models', () => { const result = normalizeCodexAppServerModels([ { diff --git a/src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts b/src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts index ab7e50b3..83c02b2a 100644 --- a/src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts +++ b/src/features/codex-model-catalog/core/domain/normalizeCodexAppServerModel.ts @@ -9,6 +9,10 @@ export interface CodexAppServerModelLike { hidden?: boolean; supportedReasoningEfforts?: unknown[]; defaultReasoningEffort?: unknown; + additionalSpeedTiers?: unknown; + serviceTiers?: unknown; + supportedServiceTiers?: unknown; + supportsFastMode?: unknown; inputModalities?: unknown; supportsPersonality?: boolean; isDefault?: boolean; @@ -89,6 +93,55 @@ function normalizeModalities(value: unknown): string[] { return modalities.length > 0 ? modalities : ['text', 'image']; } +function normalizeSpeedTier(value: unknown): string | null { + if (typeof value === 'string') { + return value.trim().toLowerCase() || null; + } + + if (!value || typeof value !== 'object') { + return null; + } + + const candidate = value as { + id?: unknown; + name?: unknown; + serviceTier?: unknown; + service_tier?: unknown; + speedTier?: unknown; + speed_tier?: unknown; + tier?: unknown; + }; + return ( + normalizeSpeedTier(candidate.serviceTier) ?? + normalizeSpeedTier(candidate.service_tier) ?? + normalizeSpeedTier(candidate.speedTier) ?? + normalizeSpeedTier(candidate.speed_tier) ?? + normalizeSpeedTier(candidate.tier) ?? + normalizeSpeedTier(candidate.id) ?? + normalizeSpeedTier(candidate.name) + ); +} + +function hasFastSpeedTier(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some((item) => normalizeSpeedTier(item) === 'fast'); + } + + return normalizeSpeedTier(value) === 'fast'; +} + +function normalizeSupportsFastMode(model: CodexAppServerModelLike): boolean { + if (model.supportsFastMode === true) { + return true; + } + + return ( + hasFastSpeedTier(model.additionalSpeedTiers) || + hasFastSpeedTier(model.serviceTiers) || + hasFastSpeedTier(model.supportedServiceTiers) + ); +} + function asBadgeLabel(modelId: string): string { return modelId.replace(/^gpt-/, ''); } @@ -142,6 +195,7 @@ export function normalizeCodexAppServerModels( ), inputModalities: normalizeModalities(model.inputModalities), supportsPersonality: model.supportsPersonality === true, + supportsFastMode: normalizeSupportsFastMode(model), isDefault: model.isDefault === true, upgrade: Boolean(model.upgrade), source: 'app-server', diff --git a/src/features/codex-runtime-profile/core/domain/resolveCodexRuntimeProfile.ts b/src/features/codex-runtime-profile/core/domain/resolveCodexRuntimeProfile.ts new file mode 100644 index 00000000..2fb674a5 --- /dev/null +++ b/src/features/codex-runtime-profile/core/domain/resolveCodexRuntimeProfile.ts @@ -0,0 +1,312 @@ +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; +import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; + +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; +import type { + CliProviderModelCatalog, + CliProviderModelCatalogItem, + CliProviderStatus, + TeamFastMode, + TeamProviderBackendId, +} from '@shared/types'; + +export const CODEX_FAST_MODEL_ID = 'gpt-5.4'; +export const CODEX_FAST_SPEED_MULTIPLIER = 1.5; +export const CODEX_FAST_CREDIT_COST_MULTIPLIER = 2; + +export type CodexFastCapabilitySource = 'model-catalog' | 'static-fallback' | 'unavailable'; + +type CodexProviderStatusSource = Partial< + Pick< + CliProviderStatus, + | 'providerId' + | 'authenticated' + | 'authMethod' + | 'selectedBackendId' + | 'resolvedBackendId' + | 'backend' + | 'connection' + | 'modelCatalog' + | 'runtimeCapabilities' + | 'models' + > +> & { + providerId?: CliProviderStatus['providerId']; +}; + +export interface CodexRuntimeProfileSource { + providerStatus?: CodexProviderStatusSource | null; + accountSnapshot?: Pick< + CodexAccountSnapshotDto, + 'effectiveAuthMode' | 'launchAllowed' | 'launchIssueMessage' | 'launchReadinessState' + > | null; + providerBackendId?: TeamProviderBackendId | string | null; +} + +export interface CodexRuntimeSelection { + resolvedLaunchModel: string | null; + catalogModel: CliProviderModelCatalogItem | null; + displayName: string | null; + catalogSource: CliProviderModelCatalog['source'] | 'runtime' | 'unavailable'; + catalogStatus: CliProviderModelCatalog['status'] | 'unavailable'; + catalogFetchedAt: string | null; + providerBackendId: TeamProviderBackendId | null; + effectiveAuthMode: 'chatgpt' | 'api_key' | null; + launchAllowed: boolean; + launchReadinessState: string | null; + launchIssueMessage: string | null; +} + +export interface CodexFastModeResolution { + selectedFastMode: TeamFastMode; + requestedFastMode: boolean; + resolvedFastMode: boolean; + showFastModeControl: boolean; + selectable: boolean; + disabledReason: string | null; + capabilitySource: CodexFastCapabilitySource; + creditCostMultiplier: 2; + speedMultiplier: 1.5; +} + +export interface CodexRuntimeReconciliation { + nextFastMode: TeamFastMode; + fastModeResetReason: string | null; +} + +function getCodexCatalog( + providerStatus: CodexProviderStatusSource | null | undefined +): CliProviderModelCatalog | null { + return providerStatus?.modelCatalog?.providerId === 'codex' ? providerStatus.modelCatalog : null; +} + +function normalizeSelectedModel(model: string | null | undefined): string | null { + const trimmed = model?.trim(); + if (!trimmed || isDefaultProviderModelSelection(trimmed)) { + return null; + } + return trimmed; +} + +function getDefaultCatalogModel( + catalog: CliProviderModelCatalog +): CliProviderModelCatalogItem | null { + return ( + catalog.models.find((model) => model.id === catalog.defaultModelId) ?? + catalog.models.find((model) => model.launchModel === catalog.defaultLaunchModel) ?? + catalog.models.find((model) => model.isDefault) ?? + null + ); +} + +function findCatalogModel( + catalog: CliProviderModelCatalog | null, + selectedModel: string | null +): CliProviderModelCatalogItem | null { + if (!catalog) { + return null; + } + + if (!selectedModel) { + return getDefaultCatalogModel(catalog); + } + + return ( + catalog.models.find( + (model) => model.launchModel === selectedModel || model.id === selectedModel + ) ?? null + ); +} + +function resolveBackendId(source: CodexRuntimeProfileSource): TeamProviderBackendId | null { + const status = source.providerStatus; + return ( + migrateProviderBackendId('codex', source.providerBackendId) ?? + migrateProviderBackendId('codex', status?.resolvedBackendId) ?? + migrateProviderBackendId('codex', status?.selectedBackendId) ?? + migrateProviderBackendId('codex', status?.backend?.kind) ?? + 'codex-native' + ); +} + +function resolveEffectiveAuthMode( + source: CodexRuntimeProfileSource +): CodexRuntimeSelection['effectiveAuthMode'] { + return ( + source.accountSnapshot?.effectiveAuthMode ?? + source.providerStatus?.connection?.codex?.effectiveAuthMode ?? + (source.providerStatus?.authMethod === 'chatgpt' + ? 'chatgpt' + : source.providerStatus?.authMethod === 'api_key' + ? 'api_key' + : null) + ); +} + +function resolveLaunchAllowed(source: CodexRuntimeProfileSource): { + launchAllowed: boolean; + launchReadinessState: string | null; + launchIssueMessage: string | null; +} { + const account = source.accountSnapshot; + const connection = source.providerStatus?.connection?.codex; + const launchAllowed = + account?.launchAllowed ?? + connection?.launchAllowed ?? + source.providerStatus?.authenticated ?? + false; + return { + launchAllowed, + launchReadinessState: account?.launchReadinessState ?? connection?.launchReadinessState ?? null, + launchIssueMessage: account?.launchIssueMessage ?? connection?.launchIssueMessage ?? null, + }; +} + +function isCatalogUsableForFast(selection: CodexRuntimeSelection): boolean { + return selection.catalogStatus === 'ready' || selection.catalogStatus === 'stale'; +} + +function isCodexProfileStillLoading(selection: CodexRuntimeSelection): boolean { + return ( + selection.catalogStatus === 'unavailable' && + selection.effectiveAuthMode === null && + selection.launchReadinessState === null + ); +} + +function resolveCodexFastCapability(selection: CodexRuntimeSelection): { + supported: boolean; + source: CodexFastCapabilitySource; +} { + const resolvedModel = selection.catalogModel?.launchModel ?? selection.resolvedLaunchModel; + if (selection.catalogModel?.supportsFastMode === true) { + return { supported: true, source: 'model-catalog' }; + } + + if (resolvedModel === CODEX_FAST_MODEL_ID) { + return { supported: true, source: 'static-fallback' }; + } + + return { supported: false, source: 'unavailable' }; +} + +export function resolveCodexRuntimeSelection(params: { + source: CodexRuntimeProfileSource; + selectedModel?: string | null; +}): CodexRuntimeSelection { + const providerStatus = + params.source.providerStatus?.providerId === 'codex' ? params.source.providerStatus : null; + const source = { ...params.source, providerStatus }; + const catalog = getCodexCatalog(providerStatus); + const explicitModel = normalizeSelectedModel(params.selectedModel); + const catalogModel = findCatalogModel(catalog, explicitModel); + const resolvedLaunchModel = + catalogModel?.launchModel?.trim() || + explicitModel || + catalog?.defaultLaunchModel?.trim() || + catalog?.defaultModelId?.trim() || + null; + const launch = resolveLaunchAllowed(source); + + return { + resolvedLaunchModel, + catalogModel, + displayName: catalogModel?.displayName?.trim() || null, + catalogSource: catalog?.source ?? 'unavailable', + catalogStatus: catalog?.status ?? 'unavailable', + catalogFetchedAt: catalog?.fetchedAt ?? null, + providerBackendId: resolveBackendId(source), + effectiveAuthMode: resolveEffectiveAuthMode(source), + launchAllowed: launch.launchAllowed, + launchReadinessState: launch.launchReadinessState, + launchIssueMessage: launch.launchIssueMessage, + }; +} + +export function resolveCodexFastMode(params: { + selection: CodexRuntimeSelection; + selectedFastMode?: TeamFastMode | null; +}): CodexFastModeResolution { + const selectedFastMode = params.selectedFastMode ?? 'inherit'; + const requestedFastMode = selectedFastMode === 'on'; + const selection = params.selection; + const catalogUsable = isCatalogUsableForFast(selection); + const fastCapability = resolveCodexFastCapability(selection); + + const selectable = + selection.providerBackendId === 'codex-native' && + selection.effectiveAuthMode === 'chatgpt' && + selection.launchAllowed && + catalogUsable && + Boolean(selection.catalogModel) && + fastCapability.supported; + + let disabledReason: string | null = null; + if (selection.providerBackendId !== 'codex-native') { + disabledReason = 'Codex Fast mode requires the native Codex runtime.'; + } else if (isCodexProfileStillLoading(selection)) { + disabledReason = 'Codex runtime capability data is still loading.'; + } else if (selection.effectiveAuthMode === 'api_key') { + disabledReason = + 'Codex Fast mode is available only with a ChatGPT account. API key mode uses standard API pricing.'; + } else if (selection.effectiveAuthMode !== 'chatgpt') { + disabledReason = 'Connect a ChatGPT account to use Codex Fast mode.'; + } else if (!selection.launchAllowed) { + disabledReason = + selection.launchIssueMessage ?? + 'Codex Fast mode requires a launch-ready ChatGPT account session.'; + } else if (!catalogUsable || !selection.catalogModel) { + disabledReason = 'Codex Fast mode is disabled until the runtime model catalog is available.'; + } else if (!fastCapability.supported) { + disabledReason = selection.displayName + ? `Codex Fast mode is not available for ${selection.displayName}.` + : 'Codex Fast mode is not available for the selected model.'; + } + + return { + selectedFastMode, + requestedFastMode, + resolvedFastMode: requestedFastMode && selectable, + showFastModeControl: true, + selectable, + disabledReason, + capabilitySource: fastCapability.source, + creditCostMultiplier: CODEX_FAST_CREDIT_COST_MULTIPLIER, + speedMultiplier: CODEX_FAST_SPEED_MULTIPLIER, + }; +} + +export function reconcileCodexRuntimeSelections(params: { + selection: CodexRuntimeSelection; + selectedFastMode?: TeamFastMode | null; +}): CodexRuntimeReconciliation { + if (isCodexProfileStillLoading(params.selection)) { + return { + nextFastMode: params.selectedFastMode ?? 'inherit', + fastModeResetReason: null, + }; + } + + const fastResolution = resolveCodexFastMode({ + selection: params.selection, + selectedFastMode: params.selectedFastMode, + }); + const nextFastMode = + fastResolution.selectedFastMode === 'on' && !fastResolution.selectable + ? 'inherit' + : fastResolution.selectedFastMode; + return { + nextFastMode, + fastModeResetReason: + fastResolution.selectedFastMode === 'on' && nextFastMode !== 'on' + ? (fastResolution.disabledReason ?? + 'Codex Fast mode is not available for the selected model or account. Reset to Default.') + : null, + }; +} + +export function buildCodexFastModeArgs(resolvedFastMode: boolean | null | undefined): string[] { + return resolvedFastMode === true + ? ['-c', 'service_tier="fast"', '-c', 'features.fast_mode=true'] + : []; +} diff --git a/src/features/codex-runtime-profile/main/index.ts b/src/features/codex-runtime-profile/main/index.ts new file mode 100644 index 00000000..2cb5d22b --- /dev/null +++ b/src/features/codex-runtime-profile/main/index.ts @@ -0,0 +1,17 @@ +export { + buildCodexFastModeArgs, + CODEX_FAST_CREDIT_COST_MULTIPLIER, + CODEX_FAST_MODEL_ID, + CODEX_FAST_SPEED_MULTIPLIER, + reconcileCodexRuntimeSelections, + resolveCodexFastMode, + resolveCodexRuntimeSelection, +} from '../core/domain/resolveCodexRuntimeProfile'; + +export type { + CodexFastCapabilitySource, + CodexFastModeResolution, + CodexRuntimeProfileSource, + CodexRuntimeReconciliation, + CodexRuntimeSelection, +} from '../core/domain/resolveCodexRuntimeProfile'; diff --git a/src/features/codex-runtime-profile/renderer/index.ts b/src/features/codex-runtime-profile/renderer/index.ts new file mode 100644 index 00000000..2cb5d22b --- /dev/null +++ b/src/features/codex-runtime-profile/renderer/index.ts @@ -0,0 +1,17 @@ +export { + buildCodexFastModeArgs, + CODEX_FAST_CREDIT_COST_MULTIPLIER, + CODEX_FAST_MODEL_ID, + CODEX_FAST_SPEED_MULTIPLIER, + reconcileCodexRuntimeSelections, + resolveCodexFastMode, + resolveCodexRuntimeSelection, +} from '../core/domain/resolveCodexRuntimeProfile'; + +export type { + CodexFastCapabilitySource, + CodexFastModeResolution, + CodexRuntimeProfileSource, + CodexRuntimeReconciliation, + CodexRuntimeSelection, +} from '../core/domain/resolveCodexRuntimeProfile'; diff --git a/src/main/services/infrastructure/codexAppServer/protocol.ts b/src/main/services/infrastructure/codexAppServer/protocol.ts index 7ca4b714..e6bb3739 100644 --- a/src/main/services/infrastructure/codexAppServer/protocol.ts +++ b/src/main/services/infrastructure/codexAppServer/protocol.ts @@ -132,6 +132,10 @@ export interface CodexAppServerModel { hidden?: boolean; supportedReasoningEfforts?: (string | CodexAppServerReasoningEffortOption)[]; defaultReasoningEffort?: string | null; + additionalSpeedTiers?: unknown[] | null; + serviceTiers?: unknown[] | null; + supportedServiceTiers?: unknown[] | null; + supportsFastMode?: boolean | null; inputModalities?: string[] | null; supportsPersonality?: boolean; isDefault?: boolean; diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index d2692183..2d04bb54 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -8,9 +8,11 @@ * (thinking blocks, tool cards, markdown) via CliLogsRichView. */ +import { buildCodexFastModeArgs } from '@features/codex-runtime-profile/main'; import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; @@ -24,6 +26,52 @@ const STDOUT_MAX_BYTES = 512 * 1024; // 512KB — stream-json is verbose (JSON w const STDERR_MAX_BYTES = 16 * 1024; // 16KB const SUMMARY_MAX_CHARS = 500; +function buildAnthropicFastModeArgs(config: ScheduleLaunchConfig): string[] { + if (config.providerId !== 'anthropic' || typeof config.resolvedFastMode !== 'boolean') { + return []; + } + + const settings = config.resolvedFastMode + ? { + fastMode: true, + fastModePerSessionOptIn: false, + } + : { + fastMode: false, + }; + + return ['--settings', JSON.stringify(settings)]; +} + +function buildProviderFastModeArgs(config: ScheduleLaunchConfig): string[] { + if (config.providerId === 'anthropic') { + return buildAnthropicFastModeArgs(config); + } + if (config.providerId === 'codex') { + return buildCodexFastModeArgs(config.resolvedFastMode); + } + return []; +} + +function validateFastModeLaunchConfig(config: ScheduleLaunchConfig): void { + if ( + config.providerId === 'codex' && + config.fastMode === 'on' && + config.resolvedFastMode !== true + ) { + throw new Error( + 'Codex Fast mode was requested for this schedule, but the saved launch profile is not Fast-eligible. Reopen the schedule and save it again with a supported ChatGPT account configuration.' + ); + } + if (config.providerId !== 'codex' || config.resolvedFastMode !== true) { + return; + } + const backendId = migrateProviderBackendId('codex', config.providerBackendId); + if (backendId !== 'codex-native') { + throw new Error('Codex Fast mode requires the native Codex runtime.'); + } +} + /** * Extracts a human-readable summary from stream-json stdout. * Finds the last assistant message's text content blocks. @@ -97,6 +145,8 @@ export class ScheduledTaskExecutor { const shellEnv = await resolveInteractiveShellEnv(); + validateFastModeLaunchConfig(request.config); + const args = this.buildArgs(request); logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`); @@ -108,6 +158,7 @@ export class ScheduledTaskExecutor { const { env, connectionIssues, providerArgs } = await buildProviderAwareCliEnv({ binaryPath, providerId, + providerBackendId: request.config.providerBackendId, shellEnv, env: { ...shellEnv, @@ -191,6 +242,12 @@ export class ScheduledTaskExecutor { args.push('--model', config.model); } + if (config.effort) { + args.push('--effort', config.effort); + } + + args.push(...buildProviderFastModeArgs(config)); + if (config.skipPermissions !== false) { args.push('--dangerously-skip-permissions'); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 733ba278..0322c65a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6,6 +6,11 @@ import { resolveAnthropicFastMode, resolveAnthropicRuntimeSelection, } from '@features/anthropic-runtime-profile/main'; +import { + buildCodexFastModeArgs, + resolveCodexFastMode, + resolveCodexRuntimeSelection, +} from '@features/codex-runtime-profile/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; @@ -210,6 +215,7 @@ interface OpenCodeRuntimeControlAck { import type { CliProviderModelCatalog, + CliProviderStatus, ActiveToolCall, CliProviderRuntimeCapabilities, CrossTeamSendResult, @@ -430,13 +436,7 @@ interface ProviderModelListCommandResponse { } interface RuntimeStatusCommandResponse { - providers?: Record< - string, - { - modelCatalog?: CliProviderModelCatalog | null; - runtimeCapabilities?: CliProviderRuntimeCapabilities | null; - } - >; + providers?: Record>; } interface RuntimeProviderLaunchFacts { @@ -444,6 +444,9 @@ interface RuntimeProviderLaunchFacts { modelIds: Set; modelCatalog: CliProviderModelCatalog | null; runtimeCapabilities: CliProviderRuntimeCapabilities | null; + providerStatus?: + | (Partial & { providerId?: CliProviderStatus['providerId'] }) + | null; } function extractJsonObjectFromCli(raw: string): T { @@ -544,6 +547,20 @@ function resolveAnthropicSelectionFromFacts(params: { }); } +function resolveCodexSelectionFromFacts(params: { + selectedModel?: string; + providerBackendId?: TeamCreateRequest['providerBackendId']; + facts: Pick; +}) { + return resolveCodexRuntimeSelection({ + source: { + providerStatus: params.facts.providerStatus, + providerBackendId: params.providerBackendId, + }, + selectedModel: params.selectedModel, + }); +} + function buildAnthropicSettingsArgs( providerId: TeamProviderId, launchIdentity?: ProviderModelLaunchIdentity | null @@ -564,6 +581,19 @@ function buildAnthropicSettingsArgs( return ['--settings', JSON.stringify(settings)]; } +function buildProviderFastModeArgs( + providerId: TeamProviderId, + launchIdentity?: ProviderModelLaunchIdentity | null +): string[] { + if (providerId === 'anthropic') { + return buildAnthropicSettingsArgs(providerId, launchIdentity); + } + if (providerId === 'codex') { + return buildCodexFastModeArgs(launchIdentity?.resolvedFastMode); + } + return []; +} + function isProbeTimeoutMessage(message: string): boolean { const lower = message.toLowerCase(); return ( @@ -719,7 +749,9 @@ function buildRuntimeLaunchWarning( const fastLabel = providerId === 'anthropic' ? `, fast ${request.fastMode ?? (getAnthropicFastModeDefault() ? 'inherit:on' : 'inherit:off')}` - : ''; + : providerId === 'codex' + ? `, fast ${request.fastMode ?? 'inherit:off'}` + : ''; const backend = migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || getConfiguredRuntimeBackend(providerId); @@ -3244,6 +3276,7 @@ export class TeamProvisioningService { let runtimeCapabilities: CliProviderRuntimeCapabilities | null = null; let modelCatalog: CliProviderModelCatalog | null = null; + let providerStatus: RuntimeProviderLaunchFacts['providerStatus'] = null; if ( runtimeStatusResult.status === 'fulfilled' && runtimeStatusResult.value && @@ -3253,7 +3286,13 @@ export class TeamProvisioningService { const parsed = extractJsonObjectFromCli( runtimeStatusResult.value.stdout ); - const providerStatus = parsed.providers?.[params.providerId]; + const parsedProviderStatus = parsed.providers?.[params.providerId] ?? null; + providerStatus = parsedProviderStatus + ? { + ...parsedProviderStatus, + providerId: parsedProviderStatus.providerId ?? params.providerId, + } + : null; runtimeCapabilities = providerStatus?.runtimeCapabilities ?? null; modelCatalog = providerStatus?.modelCatalog?.providerId === params.providerId @@ -3295,6 +3334,7 @@ export class TeamProvisioningService { modelIds, modelCatalog, runtimeCapabilities, + providerStatus, }; } @@ -3346,6 +3386,38 @@ export class TeamProvisioningService { }; } + if (providerId === 'codex') { + const selection = resolveCodexSelectionFromFacts({ + selectedModel: params.request.model, + providerBackendId: params.request.providerBackendId, + facts: params.facts, + }); + const fastResolution = resolveCodexFastMode({ + selection, + selectedFastMode: params.request.fastMode, + }); + const resolvedCodexModel = selection.resolvedLaunchModel ?? resolvedLaunchModel; + + return { + providerId, + providerBackendId: + migrateProviderBackendId(providerId, params.request.providerBackendId) ?? + selection.providerBackendId, + selectedModel: explicitModel ?? null, + selectedModelKind: explicitModel ? 'explicit' : 'default', + resolvedLaunchModel: resolvedCodexModel, + catalogId: + selection.catalogModel?.id?.trim() || selection.resolvedLaunchModel || resolvedCodexModel, + catalogSource: selection.catalogSource, + catalogFetchedAt: selection.catalogFetchedAt, + selectedEffort: params.request.effort ?? null, + resolvedEffort: params.request.effort ?? null, + selectedFastMode: params.request.fastMode ?? 'inherit', + resolvedFastMode: fastResolution.resolvedFastMode, + fastResolutionReason: fastResolution.disabledReason, + }; + } + const resolvedEffort = params.request.effort ?? null; return { @@ -3433,6 +3505,23 @@ export class TeamProvisioningService { ); } + const codexSelection = resolveCodexSelectionFromFacts({ + selectedModel: params.model, + facts: params.facts, + }); + const codexFastResolution = resolveCodexFastMode({ + selection: codexSelection, + selectedFastMode: params.fastMode, + }); + if ((params.fastMode ?? 'inherit') === 'on' && !codexFastResolution.selectable) { + throw new Error( + `${params.actorLabel} enables Codex Fast mode, but ${ + codexFastResolution.disabledReason ?? + 'it is unavailable for the selected runtime, model, or auth mode.' + }` + ); + } + if (!explicitModel || params.facts.modelIds.has(explicitModel)) { return; } @@ -7548,7 +7637,7 @@ export class TeamProvisioningService { launchIdentity ); const resolvedProviderId = resolveTeamProviderId(request.providerId); - const anthropicSettingsArgs = buildAnthropicSettingsArgs(resolvedProviderId, launchIdentity); + const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity); const spawnArgs = [ '--input-format', 'stream-json', @@ -7573,7 +7662,7 @@ export class TeamProvisioningService { : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), ...(launchModelArg ? ['--model', launchModelArg] : []), ...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []), - ...anthropicSettingsArgs, + ...providerFastModeArgs, ...(request.worktree ? ['--worktree', request.worktree] : []), ...parseCliArgs(request.extraCliArgs), ...providerArgs, @@ -8532,14 +8621,14 @@ export class TeamProvisioningService { launchIdentity ); const resolvedProviderId = resolveTeamProviderId(request.providerId); - const anthropicSettingsArgs = buildAnthropicSettingsArgs(resolvedProviderId, launchIdentity); + const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity); if (launchModelArg) { launchArgs.push('--model', launchModelArg); } if (launchIdentity.resolvedEffort) { launchArgs.push('--effort', launchIdentity.resolvedEffort); } - launchArgs.push(...anthropicSettingsArgs); + launchArgs.push(...providerFastModeArgs); if (request.worktree) { launchArgs.push('--worktree', request.worktree); } diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index d48363fb..ae4b612c 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -12,6 +12,13 @@ import { normalizeCodexResetTimestamp, useCodexAccountSnapshot, } from '@features/codex-account/renderer'; +import { + CODEX_FAST_CREDIT_COST_MULTIPLIER, + CODEX_FAST_MODEL_ID, + CODEX_FAST_SPEED_MULTIPLIER, + resolveCodexFastMode, + resolveCodexRuntimeSelection, +} from '@features/codex-runtime-profile/renderer'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { Button } from '@renderer/components/ui/button'; import { @@ -746,6 +753,32 @@ export const ProviderRuntimeSettingsDialog = ({ selectedProvider ?? null, configuredAuthMode ); + const codexFastCapability = useMemo(() => { + if (selectedProvider?.providerId !== 'codex') { + return null; + } + const fastProbeModel = + selectedProvider.modelCatalog?.models.find((model) => model.supportsFastMode === true) + ?.launchModel ?? CODEX_FAST_MODEL_ID; + const selection = resolveCodexRuntimeSelection({ + source: { + providerStatus: selectedProvider, + accountSnapshot: codexAccount.snapshot, + }, + selectedModel: fastProbeModel, + }); + return resolveCodexFastMode({ + selection, + selectedFastMode: 'on', + }); + }, [codexAccount.snapshot, selectedProvider]); + const codexFastCapabilityHint = + selectedProvider?.providerId === 'codex' && codexFastCapability + ? codexFastCapability.selectable + ? `Fast mode can be enabled per team or schedule for Fast-capable Codex models with your ChatGPT account. It is about ${CODEX_FAST_SPEED_MULTIPLIER}x faster and costs ${CODEX_FAST_CREDIT_COST_MULTIPLIER}x credits.` + : (codexFastCapability.disabledReason ?? + 'Codex Fast mode is currently unavailable for this account or runtime.') + : null; const hasSubscriptionSession = selectedProvider?.providerId === 'anthropic' ? selectedProvider.authMethod === 'oauth_token' || selectedProvider.authMethod === 'claude.ai' @@ -1413,6 +1446,25 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null} + {codexFastCapabilityHint ? ( +
+ {codexFastCapabilityHint} +
+ ) : null} + {codexConnection?.rateLimits ? (
void; + model?: string; + providerBackendId?: TeamProviderBackendId | string | null; + id?: string; +} + +export const CodexFastModeSelector: React.FC = ({ + value, + onValueChange, + model, + providerBackendId, + id, +}) => { + const { providerStatus } = useEffectiveCliProviderStatus('codex'); + const selection = useMemo( + () => + resolveCodexRuntimeSelection({ + source: { + providerStatus, + providerBackendId, + }, + selectedModel: model, + }), + [model, providerBackendId, providerStatus] + ); + const resolution = useMemo( + () => + resolveCodexFastMode({ + selection, + selectedFastMode: value, + }), + [selection, value] + ); + + if (!resolution.showFastModeControl) { + return null; + } + + const helperText = + value === 'inherit' + ? resolution.selectable + ? `Default is Off. Enable Fast for about ${CODEX_FAST_SPEED_MULTIPLIER}x speed at ${CODEX_FAST_CREDIT_COST_MULTIPLIER}x credits.` + : (resolution.disabledReason ?? + 'Available for Fast-capable Codex models with a ChatGPT account.') + : (resolution.disabledReason ?? + 'Available for Fast-capable Codex models with a ChatGPT account. API key mode uses standard API pricing.'); + + return ( +
+ +
+ +
+ {[ + { value: 'inherit' as const, label: 'Default (Off)', disabled: false }, + { value: 'on' as const, label: 'Fast', disabled: !resolution.selectable }, + { value: 'off' as const, label: 'Off', disabled: false }, + ].map((option) => ( + + ))} +
+
+

{helperText}

+
+ ); +}; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 101762a7..e71b8d20 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -9,6 +9,12 @@ import { mergeCodexCliStatusWithSnapshot, useCodexAccountSnapshot, } from '@features/codex-account/renderer'; +import { + buildCodexFastModeArgs, + reconcileCodexRuntimeSelections, + resolveCodexFastMode, + resolveCodexRuntimeSelection, +} from '@features/codex-runtime-profile/renderer'; import { api } from '@renderer/api'; import { buildMemberDraftColorMap, @@ -1073,28 +1079,75 @@ export const CreateTeamDialog = ({ selectedProviderId, ] ); + const codexRuntimeSelection = useMemo( + () => + selectedProviderId === 'codex' + ? resolveCodexRuntimeSelection({ + source: { + providerStatus: runtimeProviderStatusById.get('codex'), + providerBackendId: resolveUiOwnedProviderBackendId( + 'codex', + runtimeProviderStatusById.get('codex') + ), + }, + selectedModel, + }) + : null, + [runtimeProviderStatusById, selectedModel, selectedProviderId] + ); + const codexFastModeResolution = useMemo( + () => + selectedProviderId === 'codex' && codexRuntimeSelection + ? resolveCodexFastMode({ + selection: codexRuntimeSelection, + selectedFastMode, + }) + : null, + [codexRuntimeSelection, selectedFastMode, selectedProviderId] + ); useEffect(() => { - if (selectedProviderId !== 'anthropic') { + if (selectedProviderId !== 'anthropic' && selectedProviderId !== 'codex') { setAnthropicRuntimeNotice(null); return; } - const reconciliation = reconcileAnthropicRuntimeSelections({ - selection: - anthropicRuntimeSelection ?? - resolveAnthropicRuntimeSelection({ - source: { - modelCatalog: null, - runtimeCapabilities: null, - }, - selectedModel, - limitContext, - }), - selectedEffort, - selectedFastMode, - providerFastModeDefault: anthropicProviderFastModeDefault, - }); + const reconciliation = + selectedProviderId === 'anthropic' + ? reconcileAnthropicRuntimeSelections({ + selection: + anthropicRuntimeSelection ?? + resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: null, + runtimeCapabilities: null, + }, + selectedModel, + limitContext, + }), + selectedEffort, + selectedFastMode, + providerFastModeDefault: anthropicProviderFastModeDefault, + }) + : { + nextEffort: selectedEffort, + effortResetReason: null, + ...reconcileCodexRuntimeSelections({ + selection: + codexRuntimeSelection ?? + resolveCodexRuntimeSelection({ + source: { + providerStatus: runtimeProviderStatusById.get('codex'), + providerBackendId: resolveUiOwnedProviderBackendId( + 'codex', + runtimeProviderStatusById.get('codex') + ), + }, + selectedModel, + }), + selectedFastMode, + }), + }; const notices: string[] = []; if (reconciliation.nextEffort !== selectedEffort) { @@ -1115,7 +1168,9 @@ export const CreateTeamDialog = ({ }, [ anthropicProviderFastModeDefault, anthropicRuntimeSelection, + codexRuntimeSelection, limitContext, + runtimeProviderStatusById, selectedEffort, selectedFastMode, selectedModel, @@ -1144,7 +1199,10 @@ export const CreateTeamDialog = ({ ) ?? undefined, model: effectiveModel, effort: (selectedEffort as EffortLevel) || undefined, - fastMode: selectedProviderId === 'anthropic' ? selectedFastMode : undefined, + fastMode: + selectedProviderId === 'anthropic' || selectedProviderId === 'codex' + ? selectedFastMode + : undefined, limitContext, skipPermissions, worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, @@ -1262,11 +1320,14 @@ export const CreateTeamDialog = ({ ? { fastMode: true, fastModePerSessionOptIn: false } : { fastMode: false }; args.push('--settings', JSON.stringify(fastSettings)); + } else if (selectedProviderId === 'codex') { + args.push(...buildCodexFastModeArgs(codexFastModeResolution?.resolvedFastMode)); } return args; }, [ anthropicFastModeResolution?.resolvedFastMode, anthropicRuntimeSelection?.defaultEffort, + codexFastModeResolution?.resolvedFastMode, effectiveModel, selectedEffort, selectedProviderId, diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 3edceb7c..210aa8a4 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -9,6 +9,12 @@ import { mergeCodexCliStatusWithSnapshot, useCodexAccountSnapshot, } from '@features/codex-account/renderer'; +import { + buildCodexFastModeArgs, + reconcileCodexRuntimeSelections, + resolveCodexFastMode, + resolveCodexRuntimeSelection, +} from '@features/codex-runtime-profile/renderer'; import { api } from '@renderer/api'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { @@ -81,6 +87,8 @@ import { useShallow } from 'zustand/react/shallow'; import { CronScheduleInput } from '../schedule/CronScheduleInput'; import { AdvancedCliSection } from './AdvancedCliSection'; +import { AnthropicFastModeSelector } from './AnthropicFastModeSelector'; +import { CodexFastModeSelector } from './CodexFastModeSelector'; import { EffortLevelSelector } from './EffortLevelSelector'; import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { @@ -410,6 +418,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [warmUpMinutes, setWarmUpMinutes] = useState(15); const [maxTurns, setMaxTurns] = useState(50); const [maxBudgetUsd, setMaxBudgetUsd] = useState(''); + const [scheduleHydrationKey, setScheduleHydrationKey] = useState(null); const effectiveMemberDrafts = useMemo( () => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts), [membersDrafts, syncModelsWithLead] @@ -661,7 +670,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); - setSelectedFastModeRaw(getStoredTeamFastMode()); + setSelectedFastModeRaw(schedule.launchConfig.fastMode ?? getStoredTeamFastMode()); + setSavedLaunchProviderBackendId(schedule.launchConfig.providerBackendId ?? null); + setScheduleHydrationKey(`${schedule.id}:${schedule.updatedAt ?? ''}`); } else { // Create mode — reset to defaults setSchedLabel(''); @@ -679,6 +690,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setSelectedModelRaw(getStoredTeamModel(storedProviderId)); setSelectedEffortRaw('medium'); setSelectedFastModeRaw(getStoredTeamFastMode()); + setSavedLaunchProviderBackendId(null); + setScheduleHydrationKey(null); } setLocalError(null); @@ -783,6 +796,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return previousProviderId !== selectedProviderId; }, [isLaunchMode, previousProviderId, selectedProviderId]); + const effectiveAnthropicRuntimeLimitContext = isSchedule ? false : limitContext; + const effectiveLeadRuntimeModel = useMemo( () => computeEffectiveTeamModel( @@ -802,10 +817,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities, }, selectedModel, - limitContext, + limitContext: effectiveAnthropicRuntimeLimitContext, }) : null, - [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] + [ + effectiveAnthropicRuntimeLimitContext, + runtimeProviderStatusById, + selectedModel, + selectedProviderId, + ] ); const anthropicFastModeResolution = useMemo( () => @@ -823,28 +843,97 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen selectedProviderId, ] ); + const codexRuntimeSelection = useMemo( + () => + selectedProviderId === 'codex' + ? resolveCodexRuntimeSelection({ + source: { + providerStatus: runtimeProviderStatusById.get('codex'), + providerBackendId: + resolveUiOwnedProviderBackendId('codex', runtimeProviderStatusById.get('codex')) ?? + migrateProviderBackendId( + 'codex', + previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId + ) ?? + undefined, + }, + selectedModel, + }) + : null, + [ + previousLaunchParams?.providerBackendId, + runtimeProviderStatusById, + savedLaunchProviderBackendId, + selectedModel, + selectedProviderId, + ] + ); + const codexFastModeResolution = useMemo( + () => + selectedProviderId === 'codex' && codexRuntimeSelection + ? resolveCodexFastMode({ + selection: codexRuntimeSelection, + selectedFastMode, + }) + : null, + [codexRuntimeSelection, selectedFastMode, selectedProviderId] + ); useEffect(() => { - if (selectedProviderId !== 'anthropic') { + if (isSchedule && schedule) { + const nextHydrationKey = `${schedule.id}:${schedule.updatedAt ?? ''}`; + if (scheduleHydrationKey !== nextHydrationKey) { + return; + } + } + + if (selectedProviderId !== 'anthropic' && selectedProviderId !== 'codex') { setAnthropicRuntimeNotice(null); return; } - const reconciliation = reconcileAnthropicRuntimeSelections({ - selection: - anthropicRuntimeSelection ?? - resolveAnthropicRuntimeSelection({ - source: { - modelCatalog: null, - runtimeCapabilities: null, - }, - selectedModel, - limitContext, - }), - selectedEffort, - selectedFastMode, - providerFastModeDefault: anthropicProviderFastModeDefault, - }); + const reconciliation = + selectedProviderId === 'anthropic' + ? reconcileAnthropicRuntimeSelections({ + selection: + anthropicRuntimeSelection ?? + resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: null, + runtimeCapabilities: null, + }, + selectedModel, + limitContext: effectiveAnthropicRuntimeLimitContext, + }), + selectedEffort, + selectedFastMode, + providerFastModeDefault: anthropicProviderFastModeDefault, + }) + : { + nextEffort: selectedEffort, + effortResetReason: null, + ...reconcileCodexRuntimeSelections({ + selection: + codexRuntimeSelection ?? + resolveCodexRuntimeSelection({ + source: { + providerStatus: runtimeProviderStatusById.get('codex'), + providerBackendId: + resolveUiOwnedProviderBackendId( + 'codex', + runtimeProviderStatusById.get('codex') + ) ?? + migrateProviderBackendId( + 'codex', + previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId + ) ?? + undefined, + }, + selectedModel, + }), + selectedFastMode, + }), + }; const notices: string[] = []; if (reconciliation.nextEffort !== selectedEffort) { @@ -865,11 +954,18 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }, [ anthropicProviderFastModeDefault, anthropicRuntimeSelection, - limitContext, + codexRuntimeSelection, + effectiveAnthropicRuntimeLimitContext, + previousLaunchParams?.providerBackendId, + runtimeProviderStatusById, + savedLaunchProviderBackendId, selectedEffort, selectedFastMode, selectedModel, selectedProviderId, + schedule, + scheduleHydrationKey, + isSchedule, ]); const selectedModelChecksByProvider = useMemo(() => { @@ -1377,12 +1473,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ? { fastMode: true, fastModePerSessionOptIn: false } : { fastMode: false }; args.push('--settings', JSON.stringify(fastSettings)); + } else if (selectedProviderId === 'codex') { + args.push(...buildCodexFastModeArgs(codexFastModeResolution?.resolvedFastMode)); } if (!clearContext) args.push('--resume', ''); return args; }, [ anthropicFastModeResolution?.resolvedFastMode, anthropicRuntimeSelection?.defaultEffort, + codexFastModeResolution?.resolvedFastMode, isLaunchMode, skipPermissions, selectedModel, @@ -1628,7 +1727,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen runtimeProviderStatusById.get(selectedProviderId) ), effort: (selectedEffort as EffortLevel) || undefined, - fastMode: selectedProviderId === 'anthropic' ? selectedFastMode : undefined, + fastMode: + selectedProviderId === 'anthropic' || selectedProviderId === 'codex' + ? selectedFastMode + : undefined, limitContext, clearContext: clearContext || undefined, skipPermissions, @@ -1648,12 +1750,46 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } else { // Schedule mode: create or update const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined; + const scheduleProviderBackendId = + resolveUiOwnedProviderBackendId( + selectedProviderId, + runtimeProviderStatusById.get(selectedProviderId) + ) ?? + migrateProviderBackendId( + selectedProviderId, + previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId + ) ?? + undefined; + const scheduleModel = computeEffectiveTeamModel( + selectedModel, + false, + selectedProviderId, + runtimeProviderStatusById.get(selectedProviderId) + ); + const explicitScheduleEffort = selectedEffort + ? (selectedEffort as EffortLevel) + : undefined; + const scheduleEffort = + selectedProviderId === 'anthropic' + ? (explicitScheduleEffort ?? anthropicRuntimeSelection?.defaultEffort ?? undefined) + : explicitScheduleEffort; const launchConfig: ScheduleLaunchConfig = { cwd: effectiveCwd, prompt: promptDraft.value.trim(), providerId: selectedProviderId, - model: selectedModel || undefined, - effort: (selectedEffort as EffortLevel) || undefined, + providerBackendId: scheduleProviderBackendId, + model: scheduleModel, + effort: scheduleEffort, + fastMode: + selectedProviderId === 'anthropic' || selectedProviderId === 'codex' + ? selectedFastMode + : undefined, + resolvedFastMode: + selectedProviderId === 'anthropic' + ? (anthropicFastModeResolution?.resolvedFastMode ?? false) + : selectedProviderId === 'codex' + ? (codexFastModeResolution?.resolvedFastMode ?? false) + : undefined, skipPermissions, }; @@ -2186,6 +2322,49 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen model={selectedModel} limitContext={false} /> + {selectedProviderId === 'anthropic' ? ( +
+ + {anthropicRuntimeNotice ? ( +
+ {anthropicRuntimeNotice} +
+ ) : null} +
+ ) : null} + {selectedProviderId === 'codex' ? ( +
+ + {anthropicRuntimeNotice ? ( +
+ {anthropicRuntimeNotice} +
+ ) : null} +
+ ) : null} ) : null} + {providerId === 'codex' && onFastModeChange ? ( + + ) : null} {providerId === 'anthropic' ? ( ): CliProviderModelCatalog { + return { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:01:00.000Z', + defaultModelId: 'gpt-5.4', + defaultLaunchModel: 'gpt-5.4', + models: [ + { + id: 'gpt-5.4', + launchModel: 'gpt-5.4', + displayName: 'GPT-5.4', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'medium', + inputModalities: ['text'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + }, + { + id: 'gpt-5.4-mini', + launchModel: 'gpt-5.4-mini', + displayName: 'GPT-5.4 Mini', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + inputModalities: ['text'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'app-server', + }, + { + id: 'gpt-5.5', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'medium', + inputModalities: ['text'], + supportsPersonality: false, + supportsFastMode: true, + isDefault: false, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + ...overrides, + }; +} + +function makeProviderStatus(overrides?: Partial): Partial { + return { + providerId: 'codex', + authenticated: true, + authMethod: 'chatgpt', + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + backend: { + kind: 'codex-native', + label: 'Codex', + }, + models: ['gpt-5.4', 'gpt-5.4-mini'], + modelCatalog: makeCodexCatalog(), + connection: { + supportsOAuth: true, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'chatgpt', + apiKeyConfigured: false, + apiKeySource: null, + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + requiresOpenaiAuth: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + }, + }, + ...overrides, + }; +} + +describe('resolveCodexRuntimeProfile', () => { + it('allows explicit Fast for GPT-5.4 with ChatGPT auth on codex-native', () => { + const selection = resolveCodexRuntimeSelection({ + source: { providerStatus: makeProviderStatus() }, + selectedModel: 'gpt-5.4', + }); + const fast = resolveCodexFastMode({ selection, selectedFastMode: 'on' }); + + expect(fast).toMatchObject({ + selectedFastMode: 'on', + requestedFastMode: true, + resolvedFastMode: true, + selectable: true, + disabledReason: null, + capabilitySource: 'static-fallback', + creditCostMultiplier: 2, + speedMultiplier: 1.5, + }); + }); + + it('allows explicit Fast for future catalog-declared Fast-capable models without changing static policy', () => { + const selection = resolveCodexRuntimeSelection({ + source: { providerStatus: makeProviderStatus() }, + selectedModel: 'gpt-5.5', + }); + const fast = resolveCodexFastMode({ selection, selectedFastMode: 'on' }); + + expect(fast).toMatchObject({ + selectedFastMode: 'on', + requestedFastMode: true, + resolvedFastMode: true, + selectable: true, + disabledReason: null, + capabilitySource: 'model-catalog', + }); + }); + + it('keeps inherit safely off even when GPT-5.4 is eligible', () => { + const selection = resolveCodexRuntimeSelection({ + source: { providerStatus: makeProviderStatus() }, + selectedModel: 'gpt-5.4', + }); + const fast = resolveCodexFastMode({ selection, selectedFastMode: 'inherit' }); + + expect(fast.selectable).toBe(true); + expect(fast.requestedFastMode).toBe(false); + expect(fast.resolvedFastMode).toBe(false); + }); + + it('disables Fast for API key mode with an API pricing reason', () => { + const providerStatus = makeProviderStatus({ + authMethod: 'api_key', + connection: { + ...makeProviderStatus().connection!, + codex: { + ...makeProviderStatus().connection!.codex!, + effectiveAuthMode: 'api_key', + launchReadinessState: 'ready_api_key', + }, + }, + }); + const selection = resolveCodexRuntimeSelection({ + source: { providerStatus }, + selectedModel: 'gpt-5.4', + }); + const fast = resolveCodexFastMode({ selection, selectedFastMode: 'on' }); + + expect(fast.selectable).toBe(false); + expect(fast.resolvedFastMode).toBe(false); + expect(fast.disabledReason).toContain('API key mode uses standard API pricing'); + }); + + it('disables Fast for models that do not expose Fast support', () => { + const selection = resolveCodexRuntimeSelection({ + source: { providerStatus: makeProviderStatus() }, + selectedModel: 'gpt-5.4-mini', + }); + const fast = resolveCodexFastMode({ selection, selectedFastMode: 'on' }); + + expect(fast.selectable).toBe(false); + expect(fast.capabilitySource).toBe('unavailable'); + expect(fast.disabledReason).toContain('not available for GPT-5.4 Mini'); + }); + + it('disables Fast when catalog truth is degraded or missing', () => { + const degraded = resolveCodexRuntimeSelection({ + source: { + providerStatus: makeProviderStatus({ + modelCatalog: makeCodexCatalog({ status: 'degraded' }), + }), + }, + selectedModel: 'gpt-5.4', + }); + const missing = resolveCodexRuntimeSelection({ + source: { + providerStatus: makeProviderStatus({ + modelCatalog: null, + }), + }, + selectedModel: 'gpt-5.4', + }); + + expect(resolveCodexFastMode({ selection: degraded, selectedFastMode: 'on' }).selectable).toBe( + false + ); + expect(resolveCodexFastMode({ selection: missing, selectedFastMode: 'on' }).selectable).toBe( + false + ); + }); + + it('builds official per-run Codex fast config overrides only for resolved Fast', () => { + expect(buildCodexFastModeArgs(true)).toEqual([ + '-c', + 'service_tier="fast"', + '-c', + 'features.fast_mode=true', + ]); + expect(buildCodexFastModeArgs(false)).toEqual([]); + expect(buildCodexFastModeArgs(null)).toEqual([]); + }); +}); diff --git a/test/main/services/schedule/ScheduledTaskExecutor.test.ts b/test/main/services/schedule/ScheduledTaskExecutor.test.ts index c6068134..d2ebed47 100644 --- a/test/main/services/schedule/ScheduledTaskExecutor.test.ts +++ b/test/main/services/schedule/ScheduledTaskExecutor.test.ts @@ -171,6 +171,35 @@ describe('ScheduledTaskExecutor', () => { await resultPromise; }); + it('passes provider backend identity into provider-aware env resolution', async () => { + const proc = createMockProcess(); + mockSpawnCli.mockReturnValue(proc); + + const executor = new ScheduledTaskExecutor(); + const resultPromise = executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'Run the tests', + providerId: 'codex', + providerBackendId: 'codex-native', + }, + }) + ); + + await flushAsync(); + + expect(buildProviderAwareCliEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'codex', + providerBackendId: 'codex-native', + }) + ); + + proc.emit('close', 0); + await resultPromise; + }); + it('rejects on process error event', async () => { const proc = createMockProcess(); mockSpawnCli.mockReturnValue(proc); @@ -248,7 +277,10 @@ describe('ScheduledTaskExecutor', () => { await flushAsync(); const longText = 'X'.repeat(1000); - const streamLine = JSON.stringify({ type: 'assistant', content: [{ type: 'text', text: longText }] }); + const streamLine = JSON.stringify({ + type: 'assistant', + content: [{ type: 'text', text: longText }], + }); proc.stdout.emit('data', Buffer.from(streamLine + '\n')); proc.emit('close', 0); @@ -266,10 +298,14 @@ describe('ScheduledTaskExecutor', () => { await flushAsync(); - const lines = [ - JSON.stringify({ type: 'assistant', content: [{ type: 'text', text: 'First message' }] }), - JSON.stringify({ type: 'assistant', content: [{ type: 'text', text: 'All tests passed.' }] }), - ].join('\n') + '\n'; + const lines = + [ + JSON.stringify({ type: 'assistant', content: [{ type: 'text', text: 'First message' }] }), + JSON.stringify({ + type: 'assistant', + content: [{ type: 'text', text: 'All tests passed.' }], + }), + ].join('\n') + '\n'; proc.stdout.emit('data', Buffer.from(lines)); proc.emit('close', 0); @@ -341,13 +377,15 @@ describe('ScheduledTaskExecutor', () => { mockSpawnCli.mockReturnValue(proc); const executor = new ScheduledTaskExecutor(); - void executor.execute(makeRequest({ - config: { - cwd: '/tmp/project', - prompt: 'do it', - model: 'claude-sonnet-4-5-20250514', - }, - })); + void executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'do it', + model: 'claude-sonnet-4-5-20250514', + }, + }) + ); await flushAsync(); const args = mockSpawnCli.mock.calls[0][1] as string[]; @@ -357,18 +395,204 @@ describe('ScheduledTaskExecutor', () => { proc.emit('close', 0); }); + it('includes --effort when specified', async () => { + const proc = createMockProcess(); + mockSpawnCli.mockReturnValue(proc); + + const executor = new ScheduledTaskExecutor(); + void executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'do it', + providerId: 'anthropic', + effort: 'max', + }, + }) + ); + await flushAsync(); + + const args = mockSpawnCli.mock.calls[0][1] as string[]; + expect(args).toContain('--effort'); + expect(args).toContain('max'); + + proc.emit('close', 0); + }); + + it('includes resolved Anthropic fast mode settings when specified', async () => { + const proc = createMockProcess(); + mockSpawnCli.mockReturnValue(proc); + + const executor = new ScheduledTaskExecutor(); + void executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'do it', + providerId: 'anthropic', + fastMode: 'on', + resolvedFastMode: true, + }, + }) + ); + await flushAsync(); + + const args = mockSpawnCli.mock.calls[0][1] as string[]; + expect(args).toEqual( + expect.arrayContaining([ + '--settings', + JSON.stringify({ fastMode: true, fastModePerSessionOptIn: false }), + ]) + ); + + proc.emit('close', 0); + }); + + it('includes resolved Anthropic fast-off settings without re-reading global defaults', async () => { + const proc = createMockProcess(); + mockSpawnCli.mockReturnValue(proc); + + const executor = new ScheduledTaskExecutor(); + void executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'do it', + providerId: 'anthropic', + fastMode: 'inherit', + resolvedFastMode: false, + }, + }) + ); + await flushAsync(); + + const args = mockSpawnCli.mock.calls[0][1] as string[]; + expect(args).toEqual( + expect.arrayContaining(['--settings', JSON.stringify({ fastMode: false })]) + ); + + proc.emit('close', 0); + }); + + it('includes Codex native fast config only when resolved fast mode is true', async () => { + const proc = createMockProcess(); + mockSpawnCli.mockReturnValue(proc); + + const executor = new ScheduledTaskExecutor(); + void executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'do it', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + fastMode: 'on', + resolvedFastMode: true, + }, + }) + ); + await flushAsync(); + + const args = mockSpawnCli.mock.calls[0][1] as string[]; + expect(args).toEqual( + expect.arrayContaining(['-c', 'service_tier="fast"', '-c', 'features.fast_mode=true']) + ); + + proc.emit('close', 0); + }); + + it('does not include Codex fast config when resolved fast mode is false', async () => { + const proc = createMockProcess(); + mockSpawnCli.mockReturnValue(proc); + + const executor = new ScheduledTaskExecutor(); + void executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'do it', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + fastMode: 'inherit', + resolvedFastMode: false, + }, + }) + ); + await flushAsync(); + + const args = mockSpawnCli.mock.calls[0][1] as string[]; + expect(args).not.toContain('service_tier="fast"'); + expect(args).not.toContain('features.fast_mode=true'); + + proc.emit('close', 0); + }); + + it('rejects explicit Codex schedule Fast before spawn when saved eligibility is false', async () => { + const executor = new ScheduledTaskExecutor(); + + await expect( + executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'do it', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + fastMode: 'on', + resolvedFastMode: false, + }, + }) + ) + ).rejects.toThrow('Codex Fast mode was requested'); + + expect(mockSpawnCli).not.toHaveBeenCalled(); + }); + + it('does not hard-code Codex Fast schedules to GPT-5.4 when saved eligibility is true', async () => { + const proc = createMockProcess(); + mockSpawnCli.mockReturnValue(proc); + + const executor = new ScheduledTaskExecutor(); + void executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'do it', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + fastMode: 'on', + resolvedFastMode: true, + }, + }) + ); + await flushAsync(); + + const args = mockSpawnCli.mock.calls[0][1] as string[]; + expect(args).toEqual( + expect.arrayContaining(['-c', 'service_tier="fast"', '-c', 'features.fast_mode=true']) + ); + + proc.emit('close', 0); + }); + it('excludes --dangerously-skip-permissions when skipPermissions is false', async () => { const proc = createMockProcess(); mockSpawnCli.mockReturnValue(proc); const executor = new ScheduledTaskExecutor(); - void executor.execute(makeRequest({ - config: { - cwd: '/tmp/project', - prompt: 'do it', - skipPermissions: false, - }, - })); + void executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'do it', + skipPermissions: false, + }, + }) + ); await flushAsync(); const args = mockSpawnCli.mock.calls[0][1] as string[]; @@ -382,14 +606,16 @@ describe('ScheduledTaskExecutor', () => { mockSpawnCli.mockReturnValue(proc); const executor = new ScheduledTaskExecutor(); - void executor.execute(makeRequest({ - config: { - cwd: '/tmp/project', - prompt: 'do it', - allowedTools: ['Read', 'Write'], - disallowedTools: ['Bash'], - }, - })); + void executor.execute( + makeRequest({ + config: { + cwd: '/tmp/project', + prompt: 'do it', + allowedTools: ['Read', 'Write'], + disallowedTools: ['Bash'], + }, + }) + ); await flushAsync(); const args = mockSpawnCli.mock.calls[0][1] as string[]; @@ -476,9 +702,11 @@ describe('ScheduledTaskExecutor', () => { mockResolveShellEnv.mockResolvedValue({ MY_VAR: 'test' }); const executor = new ScheduledTaskExecutor(); - void executor.execute(makeRequest({ - config: { cwd: '/home/user/project', prompt: 'test' }, - })); + void executor.execute( + makeRequest({ + config: { cwd: '/home/user/project', prompt: 'test' }, + }) + ); await flushAsync(); const opts = mockSpawnCli.mock.calls[0][2]; @@ -520,8 +748,7 @@ describe('ScheduledTaskExecutor', () => { buildProviderAwareCliEnvMock.mockResolvedValue({ env: { SHELL: '/bin/zsh' }, connectionIssues: { - anthropic: - 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.', + anthropic: 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.', }, }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 4f1a40af..31856854 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -312,8 +312,7 @@ function createMemberSpawnRun(params?: { memberSpawnStatuses, memberSpawnToolUseIds: new Map(), pendingMemberRestarts: new Map(), - memberSpawnLeadInboxCursorByMember: - params?.memberSpawnLeadInboxCursorByMember ?? new Map(), + memberSpawnLeadInboxCursorByMember: params?.memberSpawnLeadInboxCursorByMember ?? new Map(), provisioningOutputParts: [], activeToolCalls: new Map(), isLaunch: false, @@ -814,12 +813,10 @@ describe('TeamProvisioningService', () => { run.cancelRequested = false; const sendMessageToRun = vi.fn(async () => {}); - const getConfig = vi - .fn() - .mockResolvedValue({ - name: 'Edited Team', - members: [{ name: 'team-lead', agentType: 'team-lead' }], - }); + const getConfig = vi.fn().mockResolvedValue({ + name: 'Edited Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }); const getMembers = vi .fn() .mockResolvedValueOnce([ @@ -894,12 +891,10 @@ describe('TeamProvisioningService', () => { run.cancelRequested = false; const sendMessageToRun = vi.fn(async () => {}); - const getConfig = vi - .fn() - .mockResolvedValue({ - name: 'Edited Team', - members: [{ name: 'team-lead', agentType: 'team-lead' }], - }); + const getConfig = vi.fn().mockResolvedValue({ + name: 'Edited Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }); const getMembers = vi .fn() .mockResolvedValueOnce([ @@ -1349,8 +1344,8 @@ describe('TeamProvisioningService', () => { (svc as any).aliveRunByTeam.set('tmux-team', run.runId); (svc as any).runs.set(run.runId, run); - vi.mocked(listTmuxPanePidsForCurrentPlatform).mockImplementation(async () => - new Map([['%2', 999]]) + vi.mocked(listTmuxPanePidsForCurrentPlatform).mockImplementation( + async () => new Map([['%2', 999]]) ); const restartPromise = expect(svc.restartMember('tmux-team', 'forge')).rejects.toThrow( @@ -1410,8 +1405,8 @@ describe('TeamProvisioningService', () => { vi.mocked(killTmuxPaneForCurrentPlatformSync).mockImplementation(() => { throw new Error('pane kill failed'); }); - vi.mocked(listTmuxPanePidsForCurrentPlatform).mockImplementation(async () => - new Map([['%2', 999]]) + vi.mocked(listTmuxPanePidsForCurrentPlatform).mockImplementation( + async () => new Map([['%2', 999]]) ); const restartPromise = expect(svc.restartMember('tmux-team', 'forge')).rejects.toThrow( @@ -1636,8 +1631,8 @@ describe('TeamProvisioningService', () => { backendType: 'process', }, ]); - (svc as any).findLiveProcessPidByAgentId = vi.fn(() => - new Map([['forge@process-team', process.pid]]) + (svc as any).findLiveProcessPidByAgentId = vi.fn( + () => new Map([['forge@process-team', process.pid]]) ); (svc as any).liveTeamAgentRuntimeMetadataCache.set('process-team', { expiresAtMs: Date.now() + 60_000, @@ -1700,8 +1695,8 @@ describe('TeamProvisioningService', () => { ]), }; (svc as any).readPersistedRuntimeMembers = vi.fn(() => []); - (svc as any).findLiveProcessPidByAgentId = vi.fn(() => - new Map([['forge@process-team', process.pid]]) + (svc as any).findLiveProcessPidByAgentId = vi.fn( + () => new Map([['forge@process-team', process.pid]]) ); (svc as any).aliveRunByTeam.set('process-team', run.runId); (svc as any).runs.set(run.runId, run); @@ -1929,6 +1924,77 @@ describe('TeamProvisioningService', () => { expect(teamMetaStore.deleteMeta).toHaveBeenCalledWith('cleanup-team'); }); + it('passes official Codex Fast config overrides when launch identity resolves Fast', async () => { + allowConsoleLogs(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockImplementation(() => { + throw new Error('spawn EINVAL'); + }); + + const mcpConfigBuilder = { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-create.json'), + removeConfigFile: vi.fn(async () => {}), + }; + const membersMetaStore = { + writeMembers: vi.fn(async () => {}), + }; + const teamMetaStore = { + writeMeta: vi.fn(async () => {}), + deleteMeta: vi.fn(async () => {}), + }; + + const svc = new TeamProvisioningService( + undefined, + undefined, + membersMetaStore as any, + undefined, + mcpConfigBuilder as any, + teamMetaStore as any + ); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { CODEX_API_KEY: 'test' }, + authSource: 'codex_runtime', + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async () => false); + (svc as any).resolveAndValidateLaunchIdentity = vi.fn(async () => ({ + providerId: 'codex', + providerBackendId: 'codex-native', + selectedModel: 'gpt-5.4', + selectedModelKind: 'explicit', + resolvedLaunchModel: 'gpt-5.4', + catalogId: 'gpt-5.4', + catalogSource: 'app-server', + catalogFetchedAt: '2026-04-21T00:00:00.000Z', + selectedEffort: 'xhigh', + resolvedEffort: 'xhigh', + selectedFastMode: 'on', + resolvedFastMode: true, + fastResolutionReason: null, + })); + + await expect( + svc.createTeam( + { + teamName: 'codex-fast-team', + cwd: tempClaudeRoot, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'xhigh', + fastMode: 'on', + members: [{ name: 'alice' }], + }, + () => {} + ) + ).rejects.toThrow('spawn EINVAL'); + + const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; + expect(launchArgs).toEqual( + expect.arrayContaining(['-c', 'service_tier="fast"', '-c', 'features.fast_mode=true']) + ); + }); + it('removes generated MCP config when launchTeam spawn fails synchronously', async () => { allowConsoleLogs(); const teamName = 'launch-cleanup-team'; @@ -3279,16 +3345,17 @@ describe('TeamProvisioningService', () => { it('clears stale failed_to_start state when live runtime metadata proves the teammate is alive', async () => { const svc = new TeamProvisioningService(); - (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => - new Map([ - [ - 'bob', - { - alive: true, - model: 'gpt-5.2', - }, - ], - ]) + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: true, + model: 'gpt-5.2', + }, + ], + ]) ); const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', { @@ -3315,16 +3382,17 @@ describe('TeamProvisioningService', () => { it('does not clear an explicit restart failure just because the old runtime is still alive', async () => { const svc = new TeamProvisioningService(); - (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => - new Map([ - [ - 'bob', - { - alive: true, - model: 'gpt-5.3-codex', - }, - ], - ]) + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: true, + model: 'gpt-5.3-codex', + }, + ], + ]) ); const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', { @@ -3382,7 +3450,12 @@ describe('TeamProvisioningService', () => { name: 'Beacon Desk', members: [ { name: 'team-lead', agentType: 'team-lead' }, - { name: 'bob', agentType: 'general-purpose', providerId: 'codex', model: 'gpt-5.3-codex' }, + { + name: 'bob', + agentType: 'general-purpose', + providerId: 'codex', + model: 'gpt-5.3-codex', + }, ], })), }; @@ -3571,5 +3644,4 @@ describe('TeamProvisioningService', () => { agentToolAccepted: true, }); }); - }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index f4299df7..bd60b5d5 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -572,7 +572,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => { 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.") + new Error( + "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account." + ) ); const result = await svc.prepareForProvisioning(tempRoot, { @@ -701,8 +703,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { SHELL: '/bin/zsh', }, connectionIssues: { - anthropic: - 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.', + anthropic: 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.', }, }); @@ -715,9 +716,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => { it('does not treat assistant-text 401 noise as an auth failure', () => { const svc = new TeamProvisioningService(); - expect((svc as any).isAuthFailureWarning('assistant mentioned 401 unauthorized', 'assistant')).toBe( - false - ); + expect( + (svc as any).isAuthFailureWarning('assistant mentioned 401 unauthorized', 'assistant') + ).toBe(false); expect((svc as any).isAuthFailureWarning('invalid api key', 'stderr')).toBe(true); }); @@ -772,7 +773,11 @@ describe('TeamProvisioningService prepare/auth behavior', () => { await (svc as any).handleProvisioningTurnComplete(run); - expect(handleAuthFailureInOutput).not.toHaveBeenCalledWith(run, expect.any(String), 'pre-complete'); + expect(handleAuthFailureInOutput).not.toHaveBeenCalledWith( + run, + expect.any(String), + 'pre-complete' + ); expect(run.onProgress).toHaveBeenCalledWith( expect.objectContaining({ runId: 'run-1', @@ -829,7 +834,11 @@ describe('TeamProvisioningService prepare/auth behavior', () => { await (svc as any).handleProvisioningTurnComplete(run); - expect(handleAuthFailureInOutput).toHaveBeenCalledWith(run, '[ERROR] invalid api key', 'pre-complete'); + expect(handleAuthFailureInOutput).toHaveBeenCalledWith( + run, + '[ERROR] invalid api key', + 'pre-complete' + ); expect(run.onProgress).not.toHaveBeenCalledWith( expect.objectContaining({ runId: 'run-2', @@ -973,6 +982,187 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }); }); + it('builds Codex launch identity with explicit Fast only for eligible GPT-5.4 ChatGPT launches', () => { + const svc = new TeamProvisioningService(); + const launchIdentity = (svc as any).buildProviderModelLaunchIdentity({ + request: { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'xhigh', + fastMode: 'on', + }, + facts: { + defaultModel: 'gpt-5.4', + modelIds: new Set(['gpt-5.4']), + modelCatalog: { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:01:00.000Z', + defaultModelId: 'gpt-5.4', + defaultLaunchModel: 'gpt-5.4', + models: [ + { + id: 'gpt-5.4', + launchModel: 'gpt-5.4', + displayName: 'GPT-5.4', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'medium', + inputModalities: ['text'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + runtimeCapabilities: { + modelCatalog: { dynamic: true, source: 'app-server' }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high', 'xhigh'], + configPassthrough: true, + }, + }, + providerStatus: { + providerId: 'codex', + authenticated: true, + authMethod: 'chatgpt', + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + modelCatalog: { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:01:00.000Z', + defaultModelId: 'gpt-5.4', + defaultLaunchModel: 'gpt-5.4', + models: [ + { + id: 'gpt-5.4', + launchModel: 'gpt-5.4', + displayName: 'GPT-5.4', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'medium', + inputModalities: ['text'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + connection: { + codex: { + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + }, + }, + }, + }, + }); + + expect(launchIdentity).toMatchObject({ + providerId: 'codex', + providerBackendId: 'codex-native', + selectedModel: 'gpt-5.4', + resolvedLaunchModel: 'gpt-5.4', + selectedEffort: 'xhigh', + resolvedEffort: 'xhigh', + selectedFastMode: 'on', + resolvedFastMode: true, + fastResolutionReason: null, + }); + }); + + it('rejects explicit Codex Fast before launch when auth or model eligibility is invalid', () => { + const svc = new TeamProvisioningService(); + const facts = { + defaultModel: 'gpt-5.4-mini', + modelIds: new Set(['gpt-5.4-mini']), + modelCatalog: { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:01:00.000Z', + defaultModelId: 'gpt-5.4-mini', + defaultLaunchModel: 'gpt-5.4-mini', + models: [ + { + id: 'gpt-5.4-mini', + launchModel: 'gpt-5.4-mini', + displayName: 'GPT-5.4 Mini', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + inputModalities: ['text'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + runtimeCapabilities: { + modelCatalog: { dynamic: true, source: 'app-server' }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'], + configPassthrough: true, + }, + }, + providerStatus: { + providerId: 'codex', + authenticated: true, + authMethod: 'api_key', + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + modelCatalog: null, + connection: { + codex: { + effectiveAuthMode: 'api_key', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_api_key', + }, + }, + }, + }; + + expect(() => + (svc as any).validateRuntimeLaunchSelection({ + actorLabel: 'Team lead', + providerId: 'codex', + model: 'gpt-5.4-mini', + fastMode: 'on', + facts, + }) + ).toThrow('enables Codex Fast mode'); + }); + it('rejects Anthropic max and fast when the exact resolved launch model does not support them', () => { const svc = new TeamProvisioningService(); const facts = { @@ -1107,21 +1297,17 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ); }); - it( - 'validates the generated agent-teams MCP server directly over stdio', - async () => { - const svc = new TeamProvisioningService(); - const configPath = writeMcpConfig(tempRoot, { - 'agent-teams': getRealAgentTeamsMcpLaunchSpec(), - }); - vi.mocked(spawnCli).mockImplementation(spawnRealCli); + it('validates the generated agent-teams MCP server directly over stdio', async () => { + const svc = new TeamProvisioningService(); + const configPath = writeMcpConfig(tempRoot, { + 'agent-teams': getRealAgentTeamsMcpLaunchSpec(), + }); + vi.mocked(spawnCli).mockImplementation(spawnRealCli); - await expect( - (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) - ).resolves.toBeUndefined(); - }, - 45_000 - ); + await expect( + (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) + ).resolves.toBeUndefined(); + }, 45_000); it('fails validation when the generated MCP config has no agent-teams entry', async () => { const svc = new TeamProvisioningService(); diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index 651a2c27..03d98f97 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -26,6 +26,9 @@ const storeState = { vi.mock('@renderer/api', () => ({ isElectronMode: () => true, api: { + getCodexAccountSnapshot: vi.fn(async () => null), + refreshCodexAccountSnapshot: vi.fn(async () => null), + onCodexAccountSnapshotChanged: vi.fn(() => () => {}), getProjects: vi.fn(async () => [ { id: 'project-1', @@ -226,11 +229,14 @@ vi.mock('@renderer/hooks/useChipDraftPersistence', () => ({ })); vi.mock('@renderer/hooks/useDraftPersistence', () => ({ - useDraftPersistence: () => ({ - value: '', - setValue: vi.fn(), - isSaved: false, - }), + useDraftPersistence: () => { + const [value, setValue] = React.useState(''); + return { + value, + setValue, + isSaved: false, + }; + }, })); vi.mock('@renderer/hooks/useFileListCacheWarmer', () => ({ @@ -290,14 +296,62 @@ vi.mock('@renderer/components/team/dialogs/ProvisioningProviderStatusList', () = })); vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({ - TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'), + TeamModelSelector: ({ value }: { value: string }) => + React.createElement('div', { 'data-testid': 'team-model-selector' }, `model:${value}`), computeEffectiveTeamModel: (model: string) => model || undefined, formatTeamModelSummary: (providerId: string, model: string, effort?: string) => [providerId, model, effort].filter(Boolean).join(' '), })); vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({ - EffortLevelSelector: () => React.createElement('div', null, 'effort-selector'), + EffortLevelSelector: ({ value }: { value: string }) => + React.createElement('div', { 'data-testid': 'effort-selector' }, `effort:${value}`), +})); + +vi.mock('@renderer/components/team/dialogs/AnthropicFastModeSelector', () => ({ + AnthropicFastModeSelector: ({ + value, + onValueChange, + }: { + value: string; + onValueChange: (value: 'inherit' | 'on' | 'off') => void; + }) => + React.createElement( + 'div', + { 'data-testid': 'fast-mode-selector' }, + React.createElement('span', null, `fast:${value}`), + React.createElement( + 'button', + { + type: 'button', + onClick: () => onValueChange('on'), + }, + 'set fast on' + ) + ), +})); + +vi.mock('@renderer/components/team/dialogs/CodexFastModeSelector', () => ({ + CodexFastModeSelector: ({ + value, + onValueChange, + }: { + value: string; + onValueChange: (value: 'inherit' | 'on' | 'off') => void; + }) => + React.createElement( + 'div', + { 'data-testid': 'codex-fast-mode-selector' }, + React.createElement('span', null, `codex-fast:${value}`), + React.createElement( + 'button', + { + type: 'button', + onClick: () => onValueChange('on'), + }, + 'set codex fast on' + ) + ), })); import { api } from '@renderer/api'; @@ -314,6 +368,8 @@ describe('LaunchTeamDialog', () => { document.body.innerHTML = ''; localStorage.clear(); vi.clearAllMocks(); + storeState.cliStatus = { providers: [] }; + storeState.launchParamsByTeam = {}; }); it('renders relaunch-specific title, warning and submit label', async () => { @@ -427,4 +483,324 @@ describe('LaunchTeamDialog', () => { await flush(); }); }); + + it('prefills and saves Anthropic schedule runtime contract including max effort and fast mode', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'anthropic', + status: 'ready', + modelCatalog: { + schemaVersion: 1, + providerId: 'anthropic', + source: 'anthropic-models-api', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + defaultLaunchModel: 'claude-opus-4-6', + models: [ + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'high', + supportsFastMode: true, + source: 'anthropic-models-api', + }, + ], + }, + runtimeCapabilities: { + fastMode: { + supported: true, + available: true, + reason: null, + source: 'runtime', + }, + }, + }, + ], + } as any; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'schedule', + open: true, + teamName: 'team-alpha', + onClose: vi.fn(), + schedule: { + id: 'schedule-1', + teamName: 'team-alpha', + label: 'Nightly', + cronExpression: '0 9 * * 1-5', + timezone: 'UTC', + status: 'active', + warmUpMinutes: 15, + maxConsecutiveFailures: 3, + consecutiveFailures: 0, + maxTurns: 50, + createdAt: '2026-04-21T00:00:00.000Z', + updatedAt: '2026-04-21T00:00:00.000Z', + launchConfig: { + cwd: '/tmp/project', + prompt: 'Run the scheduled check', + providerId: 'anthropic', + model: 'claude-opus-4-6', + effort: 'max', + fastMode: 'on', + resolvedFastMode: true, + skipPermissions: true, + }, + } as any, + }) + ); + await flush(); + }); + + expect(host.textContent).toContain('model:claude-opus-4-6'); + expect(host.textContent).toContain('effort:max'); + expect(host.textContent).toContain('fast:on'); + + const submitButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Save Changes' + ); + expect(submitButton).toBeTruthy(); + + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flush(); + }); + + expect(updateSchedule).toHaveBeenCalledTimes(1); + expect(updateSchedule.mock.calls[0]?.[1]).toMatchObject({ + launchConfig: { + cwd: '/tmp/project', + prompt: 'Run the scheduled check', + providerId: 'anthropic', + model: 'claude-opus-4-6', + effort: 'max', + fastMode: 'on', + resolvedFastMode: true, + skipPermissions: true, + }, + }); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); + + it('preserves Codex schedule backend lane and effort in edit saves', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'codex', + status: 'ready', + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + }, + ], + } as any; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'schedule', + open: true, + teamName: 'team-alpha', + onClose: vi.fn(), + schedule: { + id: 'schedule-2', + teamName: 'team-alpha', + label: 'Codex job', + cronExpression: '0 10 * * 1-5', + timezone: 'UTC', + status: 'active', + warmUpMinutes: 15, + maxConsecutiveFailures: 3, + consecutiveFailures: 0, + maxTurns: 50, + createdAt: '2026-04-21T00:00:00.000Z', + updatedAt: '2026-04-21T00:00:00.000Z', + launchConfig: { + cwd: '/tmp/project', + prompt: 'Run Codex scheduled check', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'xhigh', + skipPermissions: true, + }, + } as any, + }) + ); + await flush(); + }); + + const submitButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Save Changes' + ); + expect(submitButton).toBeTruthy(); + + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flush(); + }); + + expect(updateSchedule).toHaveBeenCalledTimes(1); + expect(updateSchedule.mock.calls[0]?.[1]).toMatchObject({ + launchConfig: { + cwd: '/tmp/project', + prompt: 'Run Codex scheduled check', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'xhigh', + fastMode: 'inherit', + resolvedFastMode: false, + skipPermissions: true, + }, + }); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); + + it('saves Codex schedule Fast mode when GPT-5.4 ChatGPT eligibility is available', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [ + { + providerId: 'codex', + status: 'ready', + authenticated: true, + authMethod: 'chatgpt', + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + modelCatalog: { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + defaultModelId: 'gpt-5.4', + defaultLaunchModel: 'gpt-5.4', + models: [ + { + id: 'gpt-5.4', + launchModel: 'gpt-5.4', + displayName: 'GPT-5.4', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'medium', + source: 'app-server', + }, + ], + }, + connection: { + codex: { + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + }, + }, + }, + ], + } as any; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'schedule', + open: true, + teamName: 'team-alpha', + onClose: vi.fn(), + schedule: { + id: 'schedule-3', + teamName: 'team-alpha', + label: 'Codex fast job', + cronExpression: '0 10 * * 1-5', + timezone: 'UTC', + status: 'active', + warmUpMinutes: 15, + maxConsecutiveFailures: 3, + consecutiveFailures: 0, + maxTurns: 50, + createdAt: '2026-04-21T00:00:00.000Z', + updatedAt: '2026-04-21T00:00:00.000Z', + launchConfig: { + cwd: '/tmp/project', + prompt: 'Run Codex scheduled check', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'xhigh', + fastMode: 'inherit', + resolvedFastMode: false, + skipPermissions: true, + }, + } as any, + }) + ); + await flush(); + }); + + const fastButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'set codex fast on' + ); + expect(fastButton).toBeTruthy(); + await act(async () => { + fastButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flush(); + }); + + const submitButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Save Changes' + ); + expect(submitButton).toBeTruthy(); + + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flush(); + }); + + expect(updateSchedule).toHaveBeenCalledTimes(1); + expect(updateSchedule.mock.calls[0]?.[1]).toMatchObject({ + launchConfig: { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'xhigh', + fastMode: 'on', + resolvedFastMode: true, + }, + }); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); });