From f71b7506bb294aebdfb7360091f00d29b97a466b Mon Sep 17 00:00:00 2001 From: Paul Calcraft Date: Sat, 4 Apr 2026 08:29:59 +0100 Subject: [PATCH] Add support for codex fast mode --- cli/src/api/apiMachine.ts | 3 +- cli/src/codex/appServerTypes.ts | 4 ++ cli/src/codex/codexRemoteLauncher.ts | 14 ++++-- cli/src/codex/loop.ts | 3 +- cli/src/codex/runCodex.ts | 10 +++- cli/src/codex/utils/appServerConfig.test.ts | 26 ++++++++++ cli/src/codex/utils/appServerConfig.ts | 4 ++ cli/src/commands/codex.ts | 20 +++++++- cli/src/modules/common/rpcTypes.ts | 1 + cli/src/runner/run.ts | 3 ++ hub/src/sync/rpcGateway.ts | 3 +- hub/src/sync/syncEngine.ts | 2 + hub/src/web/routes/machines.ts | 2 + web/src/api/client.ts | 3 +- .../components/NewSession/FastModeToggle.tsx | 47 +++++++++++++++++++ web/src/components/NewSession/index.tsx | 14 +++++- web/src/components/NewSession/types.test.ts | 20 +++++++- web/src/components/NewSession/types.ts | 7 ++- web/src/hooks/mutations/useSpawnSession.ts | 2 + web/src/lib/locales/en.ts | 3 ++ web/src/lib/locales/zh-CN.ts | 3 ++ 21 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 web/src/components/NewSession/FastModeToggle.tsx diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 42a25f835..3237a0ce9 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -102,7 +102,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { - const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, token, sessionType, worktreeName } = params || {} + const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, serviceTier, yolo, token, sessionType, worktreeName } = params || {} if (!directory) { throw new Error('Directory is required') @@ -118,6 +118,7 @@ export class ApiMachineClient { model, effort, modelReasoningEffort, + serviceTier, yolo, token, sessionType, diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index fdb7fcf6b..171a083c7 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -22,6 +22,7 @@ export interface InitializeResponse { export interface ThreadStartParams { model?: string; modelProvider?: string; + serviceTier?: ServiceTier; cwd?: string; approvalPolicy?: ApprovalPolicy; sandbox?: SandboxMode; @@ -49,6 +50,7 @@ export interface ThreadResumeParams { path?: string; model?: string; modelProvider?: string; + serviceTier?: ServiceTier; cwd?: string; approvalPolicy?: ApprovalPolicy; sandbox?: SandboxMode; @@ -103,6 +105,7 @@ export type SandboxPolicy = export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; export type ReasoningSummary = 'auto' | 'none' | 'brief' | 'detailed'; +export type ServiceTier = 'fast' | 'flex'; export type CollaborationMode = { mode: 'plan' | 'default'; @@ -120,6 +123,7 @@ export interface TurnStartParams { approvalPolicy?: ApprovalPolicy; sandboxPolicy?: SandboxPolicy; model?: string; + serviceTier?: ServiceTier; effort?: ReasoningEffort; summary?: ReasoningSummary; personality?: string; diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index be648d65d..e0207670c 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -673,7 +673,12 @@ class CodexRemoteLauncher extends RemoteLauncherBase { allowAnonymousTerminalEvent = true; } } catch (error) { - logger.warn('Error in codex session:', error); + const errorMessage = error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : JSON.stringify(error); + logger.warn(`[Codex] Error in codex session: ${errorMessage}`); const isAbortError = error instanceof Error && error.name === 'AbortError'; turnInFlight = false; allowAnonymousTerminalEvent = false; @@ -683,8 +688,11 @@ class CodexRemoteLauncher extends RemoteLauncherBase { messageBuffer.addMessage('Aborted by user', 'status'); session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); } else { - messageBuffer.addMessage('Process exited unexpectedly', 'status'); - session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + const sessionErrorMessage = errorMessage + ? `Codex error: ${errorMessage}` + : 'Process exited unexpectedly'; + messageBuffer.addMessage(sessionErrorMessage, 'status'); + session.sendSessionEvent({ type: 'message', message: sessionErrorMessage }); this.currentTurnId = null; this.currentThreadId = null; hasThread = false; diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 013fd07ce..c75f0359f 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -6,7 +6,7 @@ import { codexLocalLauncher } from './codexLocalLauncher'; import { codexRemoteLauncher } from './codexRemoteLauncher'; import { ApiClient, ApiSessionClient } from '@/lib'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; -import type { ReasoningEffort } from './appServerTypes'; +import type { ReasoningEffort, ServiceTier } from './appServerTypes'; import type { CodexCollaborationMode, CodexPermissionMode } from '@hapi/protocol/types'; export type PermissionMode = CodexPermissionMode; @@ -16,6 +16,7 @@ export interface EnhancedMode { model?: string; collaborationMode: CodexCollaborationMode; modelReasoningEffort?: ReasoningEffort; + serviceTier?: ServiceTier; } interface LoopOptions { diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index d94286a14..3ee96548d 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -12,7 +12,7 @@ import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; -import type { ReasoningEffort } from './appServerTypes'; +import type { ReasoningEffort, ServiceTier } from './appServerTypes'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; @@ -23,6 +23,7 @@ export async function runCodex(opts: { resumeSessionId?: string; model?: string; modelReasoningEffort?: ReasoningEffort; + serviceTier?: ServiceTier; }): Promise { const workingDirectory = getInvokedCwd(); const startedBy = opts.startedBy ?? 'terminal'; @@ -48,6 +49,7 @@ export async function runCodex(opts: { permissionMode: mode.permissionMode, model: mode.model, modelReasoningEffort: mode.modelReasoningEffort, + serviceTier: mode.serviceTier, collaborationMode: mode.collaborationMode })); @@ -57,6 +59,7 @@ export async function runCodex(opts: { let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; let currentModel = opts.model; const currentModelReasoningEffort = opts.modelReasoningEffort; + const currentServiceTier = opts.serviceTier; let currentCollaborationMode: EnhancedMode['collaborationMode'] = 'default'; const lifecycle = createRunnerLifecycle({ @@ -83,7 +86,8 @@ export async function runCodex(opts: { logger.debug( `[Codex] Synced session config for keepalive: ` + `permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}, ` + - `modelReasoningEffort=${currentModelReasoningEffort ?? 'default'}, collaborationMode=${currentCollaborationMode}` + `modelReasoningEffort=${currentModelReasoningEffort ?? 'default'}, ` + + `serviceTier=${currentServiceTier ?? 'default'}, collaborationMode=${currentCollaborationMode}` ); }; @@ -105,6 +109,7 @@ export async function runCodex(opts: { logger.debug( `[Codex] User message received with permission mode: ${currentPermissionMode}, ` + `model: ${currentModel ?? 'auto'}, modelReasoningEffort: ${currentModelReasoningEffort ?? 'default'}, ` + + `serviceTier: ${currentServiceTier ?? 'default'}, ` + `collaborationMode: ${currentCollaborationMode}` ); @@ -112,6 +117,7 @@ export async function runCodex(opts: { permissionMode: messagePermissionMode ?? 'default', model: currentModel, modelReasoningEffort: currentModelReasoningEffort, + serviceTier: currentServiceTier, collaborationMode: currentCollaborationMode }; const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index 0951b4133..f9414183a 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -97,6 +97,16 @@ describe('appServerConfig', () => { }); }); + it('passes service tier via thread params', () => { + const params = buildThreadStartParams({ + cwd: '/workspace/project', + mode: { permissionMode: 'default', serviceTier: 'fast', collaborationMode: 'default' }, + mcpServers + }); + + expect(params.serviceTier).toBe('fast'); + }); + it('builds turn params with mode defaults', () => { const params = buildTurnStartParams({ threadId: 'thread-1', @@ -126,6 +136,22 @@ describe('appServerConfig', () => { expect(params.model).toBeUndefined(); }); + it('passes service tier via turn params', () => { + const params = buildTurnStartParams({ + threadId: 'thread-1', + message: 'hello', + cwd: '/workspace/project', + mode: { + permissionMode: 'default', + model: 'o3', + serviceTier: 'fast', + collaborationMode: 'default' + } + }); + + expect(params.serviceTier).toBe('fast'); + }); + it('puts collaboration mode in turn params with model settings', () => { const params = buildTurnStartParams({ threadId: 'thread-1', diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 12565909f..8825706e3 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -95,6 +95,7 @@ export function buildThreadStartParams(args: { sandbox: resolvedSandbox, baseInstructions, developerInstructions: resolvedDeveloperInstructions, + ...(args.mode.serviceTier ? { serviceTier: args.mode.serviceTier } : {}), ...(Object.keys(configWithInstructions).length > 0 ? { config: configWithInstructions } : {}) }; @@ -159,6 +160,9 @@ export function buildTurnStartParams(args: { } else if (model) { params.model = model; } + if (args.mode?.serviceTier) { + params.serviceTier = args.mode.serviceTier; + } return params; } diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index f52730630..89631c155 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -4,7 +4,7 @@ import { initializeToken } from '@/ui/tokenInit' import { maybeAutoStartServer } from '@/utils/autoStartServer' import type { CommandDefinition } from './types' import type { CodexPermissionMode } from '@hapi/protocol/types' -import type { ReasoningEffort } from '@/codex/appServerTypes' +import type { ReasoningEffort, ServiceTier } from '@/codex/appServerTypes' function parseReasoningEffort(value: string): ReasoningEffort { switch (value) { @@ -20,6 +20,17 @@ function parseReasoningEffort(value: string): ReasoningEffort { } } +function parseServiceTier(value: string): ServiceTier { + switch (value) { + case 'fast': + return 'fast' + case 'flex': + return 'flex' + default: + throw new Error('Invalid --service-tier value') + } +} + export const codexCommand: CommandDefinition = { name: 'codex', requiresRuntimeAssets: true, @@ -34,6 +45,7 @@ export const codexCommand: CommandDefinition = { resumeSessionId?: string model?: string modelReasoningEffort?: ReasoningEffort + serviceTier?: ServiceTier } = {} const unknownArgs: string[] = [] @@ -66,6 +78,12 @@ export const codexCommand: CommandDefinition = { throw new Error('Missing --model-reasoning-effort value') } options.modelReasoningEffort = parseReasoningEffort(effort) + } else if (arg === '--service-tier') { + const serviceTier = commandArgs[++i] + if (!serviceTier) { + throw new Error('Missing --service-tier value') + } + options.serviceTier = parseServiceTier(serviceTier) } else { unknownArgs.push(arg) } diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 3b1755903..41c15d773 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -8,6 +8,7 @@ export interface SpawnSessionOptions { model?: string effort?: string modelReasoningEffort?: string + serviceTier?: string yolo?: boolean token?: string sessionType?: 'simple' | 'worktree' diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index c4e907a1b..d2fda6668 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -369,6 +369,9 @@ export async function startRunner(): Promise { if (options.modelReasoningEffort && agent === 'codex') { args.push('--model-reasoning-effort', options.modelReasoningEffort); } + if (options.serviceTier && agent === 'codex') { + args.push('--service-tier', options.serviceTier); + } if (yolo) { args.push('--yolo'); } diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index d59ff3b6d..8dbfe9ef4 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -111,6 +111,7 @@ export class RpcGateway { agent: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' = 'claude', model?: string, modelReasoningEffort?: string, + serviceTier?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, @@ -121,7 +122,7 @@ export class RpcGateway { const result = await this.machineRpc( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort } + { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, serviceTier, yolo, sessionType, worktreeName, resumeSessionId, effort } ) if (result && typeof result === 'object') { const obj = result as Record diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 6b5be2f1c..ce2ecec6c 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -321,6 +321,7 @@ export class SyncEngine { agent: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' = 'claude', model?: string, modelReasoningEffort?: string, + serviceTier?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, @@ -333,6 +334,7 @@ export class SyncEngine { agent, model, modelReasoningEffort, + serviceTier, yolo, sessionType, worktreeName, diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 9c9237976..d6542858b 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -10,6 +10,7 @@ const spawnBodySchema = z.object({ model: z.string().optional(), effort: z.string().optional(), modelReasoningEffort: z.string().optional(), + serviceTier: z.string().optional(), yolo: z.boolean().optional(), sessionType: z.enum(['simple', 'worktree']).optional(), worktreeName: z.string().optional() @@ -57,6 +58,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho parsed.data.agent, parsed.data.model, parsed.data.modelReasoningEffort, + parsed.data.serviceTier, parsed.data.yolo, parsed.data.sessionType, parsed.data.worktreeName, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 163eb206d..ff914f12f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -389,6 +389,7 @@ export class ApiClient { agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode', model?: string, modelReasoningEffort?: string, + serviceTier?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, @@ -396,7 +397,7 @@ export class ApiClient { ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', - body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort }) + body: JSON.stringify({ directory, agent, model, modelReasoningEffort, serviceTier, yolo, sessionType, worktreeName, effort }) }) } diff --git a/web/src/components/NewSession/FastModeToggle.tsx b/web/src/components/NewSession/FastModeToggle.tsx new file mode 100644 index 000000000..531e2d384 --- /dev/null +++ b/web/src/components/NewSession/FastModeToggle.tsx @@ -0,0 +1,47 @@ +import type { AgentType, CodexServiceTier } from './types' +import { CODEX_FAST_SERVICE_TIER } from './types' +import { useTranslation } from '@/lib/use-translation' + +export function FastModeToggle(props: { + agent: AgentType + serviceTier: CodexServiceTier + isDisabled: boolean + onToggle: (value: CodexServiceTier) => void +}) { + const { t } = useTranslation() + + if (props.agent !== 'codex') { + return null + } + + const isEnabled = props.serviceTier === CODEX_FAST_SERVICE_TIER + + return ( +
+ +
+
+ + {t('newSession.fastMode.title')} + + + {t('newSession.fastMode.desc')} + +
+ +
+
+ ) +} diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index e0d1dd1c1..4a639de36 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -9,10 +9,11 @@ import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggesti import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions' import { useRecentPaths } from '@/hooks/useRecentPaths' import { useTranslation } from '@/lib/use-translation' -import type { AgentType, ClaudeEffort, CodexReasoningEffort, SessionType } from './types' +import type { AgentType, ClaudeEffort, CodexReasoningEffort, CodexServiceTier, SessionType } from './types' import { ActionButtons } from './ActionButtons' import { AgentSelector } from './AgentSelector' import { DirectorySection } from './DirectorySection' +import { FastModeToggle } from './FastModeToggle' import { MachineSelector } from './MachineSelector' import { ModelSelector } from './ModelSelector' import { ClaudeEffortSelector } from './ClaudeEffortSelector' @@ -49,6 +50,7 @@ export function NewSession(props: { const [model, setModel] = useState('auto') const [effort, setEffort] = useState('auto') const [modelReasoningEffort, setModelReasoningEffort] = useState('default') + const [serviceTier, setServiceTier] = useState('default') const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode) const [sessionType, setSessionType] = useState('simple') const [worktreeName, setWorktreeName] = useState('') @@ -251,6 +253,9 @@ export function NewSession(props: { const resolvedModelReasoningEffort = agent === 'codex' && modelReasoningEffort !== 'default' ? modelReasoningEffort : undefined + const resolvedServiceTier = agent === 'codex' && serviceTier !== 'default' + ? serviceTier + : undefined const result = await spawnSession({ machineId, directory: trimmedDirectory, @@ -258,6 +263,7 @@ export function NewSession(props: { model: resolvedModel, effort: resolvedEffort, modelReasoningEffort: resolvedModelReasoningEffort, + serviceTier: resolvedServiceTier, yolo: yoloMode, sessionType, worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined @@ -341,6 +347,12 @@ export function NewSession(props: { isDisabled={isFormDisabled} onChange={setModelReasoningEffort} /> + { it('includes 1m model options in the expected order', () => { @@ -30,3 +30,21 @@ describe('Claude effort options', () => { ]) }) }) + +describe('Codex reasoning effort options', () => { + it('exposes the full supported effort range in expected order', () => { + expect(CODEX_REASONING_EFFORT_OPTIONS).toEqual([ + { value: 'default', label: 'Default' }, + { value: 'none', label: 'None' }, + { value: 'minimal', label: 'Minimal' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'XHigh' }, + ]) + }) + + it('maps fast mode to the fast service tier', () => { + expect(CODEX_FAST_SERVICE_TIER).toBe('fast') + }) +}) diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index 332cf2e02..17e697490 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -2,7 +2,8 @@ import { GEMINI_MODEL_PRESETS, GEMINI_MODEL_LABELS } from '@hapi/protocol' export type AgentType = 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' export type SessionType = 'simple' | 'worktree' -export type CodexReasoningEffort = 'default' | 'low' | 'medium' | 'high' | 'xhigh' +export type CodexReasoningEffort = 'default' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' +export type CodexServiceTier = 'default' | 'fast' export type ClaudeEffort = 'auto' | 'medium' | 'high' | 'max' export const MODEL_OPTIONS: Record = { @@ -31,8 +32,12 @@ export const MODEL_OPTIONS: Record