feat(runtime): add provider fast mode support
This commit is contained in:
parent
28b64ec467
commit
796c529439
19 changed files with 2235 additions and 159 deletions
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
: [];
|
||||
}
|
||||
17
src/features/codex-runtime-profile/main/index.ts
Normal file
17
src/features/codex-runtime-profile/main/index.ts
Normal 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';
|
||||
17
src/features/codex-runtime-profile/renderer/index.ts
Normal file
17
src/features/codex-runtime-profile/renderer/index.ts
Normal 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';
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
101
src/renderer/components/team/dialogs/CodexFastModeSelector.tsx
Normal file
101
src/renderer/components/team/dialogs/CodexFastModeSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue