feat(runtime): add provider fast mode support

This commit is contained in:
777genius 2026-04-21 22:22:47 +03:00
parent 28b64ec467
commit 796c529439
19 changed files with 2235 additions and 159 deletions

View file

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

View file

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

View file

@ -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']
: [];
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, Partial<CliProviderStatus>>;
}
interface RuntimeProviderLaunchFacts {
@ -444,6 +444,9 @@ interface RuntimeProviderLaunchFacts {
modelIds: Set<string>;
modelCatalog: CliProviderModelCatalog | null;
runtimeCapabilities: CliProviderRuntimeCapabilities | null;
providerStatus?:
| (Partial<CliProviderStatus> & { providerId?: CliProviderStatus['providerId'] })
| null;
}
function extractJsonObjectFromCli<T>(raw: string): T {
@ -544,6 +547,20 @@ function resolveAnthropicSelectionFromFacts(params: {
});
}
function resolveCodexSelectionFromFacts(params: {
selectedModel?: string;
providerBackendId?: TeamCreateRequest['providerBackendId'];
facts: Pick<RuntimeProviderLaunchFacts, 'providerStatus'>;
}) {
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<RuntimeStatusCommandResponse>(
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);
}

View file

@ -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 = ({
</div>
) : null}
{codexFastCapabilityHint ? (
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
borderColor: codexFastCapability?.selectable
? 'rgba(34, 197, 94, 0.28)'
: 'var(--color-border-subtle)',
color: codexFastCapability?.selectable
? '#86efac'
: 'var(--color-text-secondary)',
backgroundColor: codexFastCapability?.selectable
? 'rgba(34, 197, 94, 0.08)'
: 'transparent',
}}
>
{codexFastCapabilityHint}
</div>
) : null}
{codexConnection?.rateLimits ? (
<div className="space-y-2">
<div

View file

@ -0,0 +1,101 @@
import React, { useMemo } from 'react';
import {
CODEX_FAST_CREDIT_COST_MULTIPLIER,
CODEX_FAST_SPEED_MULTIPLIER,
resolveCodexFastMode,
resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/renderer';
import { Label } from '@renderer/components/ui/label';
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
import { cn } from '@renderer/lib/utils';
import { Zap } from 'lucide-react';
import type { TeamFastMode, TeamProviderBackendId } from '@shared/types';
export interface CodexFastModeSelectorProps {
value: TeamFastMode;
onValueChange: (value: TeamFastMode) => void;
model?: string;
providerBackendId?: TeamProviderBackendId | string | null;
id?: string;
}
export const CodexFastModeSelector: React.FC<CodexFastModeSelectorProps> = ({
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 (
<div className="mb-3">
<Label htmlFor={id} className="label-optional mb-1.5 block">
Fast mode (2x credits)
</Label>
<div className="flex items-center gap-2">
<Zap size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<div className="inline-flex flex-wrap rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{[
{ 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) => (
<button
key={option.value}
type="button"
id={option.value === value ? id : undefined}
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
value === option.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]',
option.disabled &&
'cursor-not-allowed opacity-50 hover:text-[var(--color-text-muted)]'
)}
disabled={option.disabled}
onClick={() => onValueChange(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">{helperText}</p>
</div>
);
};

View file

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

View file

@ -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<string | null>(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', '<previous>');
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' ? (
<div className="mt-2">
<AnthropicFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
model={selectedModel}
limitContext={false}
id="dialog-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 mt-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
{anthropicRuntimeNotice}
</div>
) : null}
</div>
) : null}
{selectedProviderId === 'codex' ? (
<div className="mt-2">
<CodexFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
model={selectedModel}
providerBackendId={
resolveUiOwnedProviderBackendId(
'codex',
runtimeProviderStatusById.get('codex')
) ??
migrateProviderBackendId(
'codex',
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
) ??
undefined
}
id="dialog-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 mt-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
{anthropicRuntimeNotice}
</div>
) : null}
</div>
) : null}
<SkipPermissionsCheckbox
id="dialog-skip-permissions"
checked={skipPermissions}

View file

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { AnthropicFastModeSelector } from '@renderer/components/team/dialogs/AnthropicFastModeSelector';
import { CodexFastModeSelector } from '@renderer/components/team/dialogs/CodexFastModeSelector';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
@ -178,6 +179,14 @@ export const LeadModelRow = ({
id="lead-fast-mode"
/>
) : null}
{providerId === 'codex' && onFastModeChange ? (
<CodexFastModeSelector
value={fastMode}
onValueChange={onFastModeChange}
model={model}
id="lead-fast-mode"
/>
) : null}
{providerId === 'anthropic' ? (
<LimitContextCheckbox
id="lead-limit-context"

View file

@ -5,7 +5,7 @@
* Repository Pattern abstraction allows swapping storage backend (JSON sql.js/Drizzle).
*/
import type { EffortLevel, TeamProviderId } from './team';
import type { EffortLevel, TeamFastMode, TeamProviderBackendId, TeamProviderId } from './team';
// =============================================================================
// Schedule Status Types
@ -50,8 +50,11 @@ export interface ScheduleLaunchConfig {
cwd: string;
prompt: string;
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
resolvedFastMode?: boolean;
skipPermissions?: boolean;
allowedTools?: string[];
disallowedTools?: string[];

View file

@ -0,0 +1,237 @@
import { describe, expect, it } from 'vitest';
import {
buildCodexFastModeArgs,
resolveCodexFastMode,
resolveCodexRuntimeSelection,
} from '../../../src/features/codex-runtime-profile/core/domain/resolveCodexRuntimeProfile';
import type { CliProviderModelCatalog, CliProviderStatus } from '../../../src/shared/types';
function makeCodexCatalog(overrides?: Partial<CliProviderModelCatalog>): 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<CliProviderStatus>): Partial<CliProviderStatus> {
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([]);
});
});

View file

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

View file

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

View file

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

View file

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