diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index e6cb42c5..ffebf0e6 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -153,7 +153,9 @@ export type SandboxPolicy = excludeSlashTmp?: boolean; }; -export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; +// The app server reports supported effort identifiers per model. Keep this +// open so newly introduced server values can flow through without a CLI update. +export type ReasoningEffort = string; export type ReasoningSummary = 'auto' | 'none' | 'brief' | 'detailed'; export type CollaborationMode = { diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts index 8b2ab0a2..5fc99c80 100644 --- a/cli/src/codex/runCodex.test.ts +++ b/cli/src/codex/runCodex.test.ts @@ -111,6 +111,7 @@ vi.mock('./utils/codexCliOverrides', () => ({ })) import { runCodex as runCodexImpl } from './runCodex' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' describe('runCodex', () => { beforeEach(() => { @@ -242,4 +243,20 @@ describe('runCodex', () => { replayTranscriptHistoryOnStart: true })) }) + + it('accepts and normalizes model-reported reasoning efforts from session config', async () => { + await runCodexImpl({ workingDirectory: '/tmp/project' }) + + const registration = harness.session.rpcHandlerManager.registerHandler.mock.calls.find( + ([method]) => method === RPC_METHODS.SetSessionConfig + ) + const handler = registration?.[1] as ((payload: unknown) => Promise) | undefined + expect(handler).toBeTypeOf('function') + + await handler?.({ modelReasoningEffort: 'max' }) + await handler?.({ modelReasoningEffort: ' EXTREME ' }) + + expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(2, 'max') + expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(3, 'extreme') + }) }) diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index fe3a0f70..04b8a9df 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -19,11 +19,10 @@ import type { ReasoningEffort } from './appServerTypes'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; import { listSlashCommands } from '@/modules/common/slashCommands'; import { resolveCodexSlashCommand } from './utils/slashCommands'; +import { parseReasoningEffortValue } from './utils/reasoningEffort'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; -const REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']) - export async function runCodex(opts: { startedBy?: 'runner' | 'terminal'; codexArgs?: string[]; @@ -304,16 +303,6 @@ export async function runCodex(opts: { return parsed.data; }; - const resolveModelReasoningEffort = (value: unknown): ReasoningEffort | undefined => { - if (value === null) { - return undefined; - } - if (typeof value !== 'string' || !REASONING_EFFORTS.has(value as ReasoningEffort)) { - throw new Error('Invalid model reasoning effort'); - } - return value as ReasoningEffort; - }; - const resolveModel = (value: unknown): string => { if (typeof value !== 'string') { throw new Error('Invalid model'); @@ -363,7 +352,7 @@ export async function runCodex(opts: { } if (config.modelReasoningEffort !== undefined) { - currentModelReasoningEffort = resolveModelReasoningEffort(config.modelReasoningEffort); + currentModelReasoningEffort = parseReasoningEffortValue(config.modelReasoningEffort); } if (config.collaborationMode !== undefined) { diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index 378cec11..2142e18e 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -123,7 +123,7 @@ describe('appServerConfig', () => { it('passes model reasoning effort via thread config', () => { const params = buildThreadStartParams({ cwd: '/workspace/project', - mode: { permissionMode: 'default', modelReasoningEffort: 'xhigh', collaborationMode: 'default' }, + mode: { permissionMode: 'default', modelReasoningEffort: 'ultra', collaborationMode: 'default' }, mcpServers }); @@ -133,7 +133,7 @@ describe('appServerConfig', () => { args: ['mcp'] }, developer_instructions: codexSystemPrompt, - model_reasoning_effort: 'xhigh' + model_reasoning_effort: 'ultra' }); }); diff --git a/cli/src/codex/utils/reasoningEffort.test.ts b/cli/src/codex/utils/reasoningEffort.test.ts new file mode 100644 index 00000000..d7cf304b --- /dev/null +++ b/cli/src/codex/utils/reasoningEffort.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { parseReasoningEffortValue } from './reasoningEffort'; + +describe('parseReasoningEffortValue', () => { + it('normalizes non-empty model-reported values', () => { + expect(parseReasoningEffortValue(' EXTREME ')).toBe('extreme'); + }); + + it('maps null to the default effort', () => { + expect(parseReasoningEffortValue(null)).toBeUndefined(); + }); + + it('rejects empty and non-string values', () => { + expect(() => parseReasoningEffortValue(' ')).toThrow('Invalid model reasoning effort'); + expect(() => parseReasoningEffortValue(42)).toThrow('Invalid model reasoning effort'); + }); +}); diff --git a/cli/src/codex/utils/reasoningEffort.ts b/cli/src/codex/utils/reasoningEffort.ts new file mode 100644 index 00000000..6e744239 --- /dev/null +++ b/cli/src/codex/utils/reasoningEffort.ts @@ -0,0 +1,16 @@ +import type { ReasoningEffort } from '../appServerTypes'; + +export function parseReasoningEffortValue(value: unknown): ReasoningEffort | undefined { + if (value === null) { + return undefined; + } + if (typeof value !== 'string') { + throw new Error('Invalid model reasoning effort'); + } + + const effort = value.trim().toLowerCase(); + if (!effort) { + throw new Error('Invalid model reasoning effort'); + } + return effort; +} diff --git a/cli/src/codex/utils/slashCommands.test.ts b/cli/src/codex/utils/slashCommands.test.ts index aac27c18..c07fc5e1 100644 --- a/cli/src/codex/utils/slashCommands.test.ts +++ b/cli/src/codex/utils/slashCommands.test.ts @@ -41,6 +41,12 @@ describe('resolveCodexSlashCommand', () => { expect(resolveCodexSlashCommand('/reasoning low', state)).toMatchObject({ updates: { modelReasoningEffort: 'low' } }); + expect(resolveCodexSlashCommand('/reasoning max', state)).toMatchObject({ + updates: { modelReasoningEffort: 'max' } + }); + expect(resolveCodexSlashCommand('/reasoning EXTREME', state)).toMatchObject({ + updates: { modelReasoningEffort: 'extreme' } + }); expect(resolveCodexSlashCommand('/permissions yolo', state)).toMatchObject({ updates: { permissionMode: 'yolo' } }); diff --git a/cli/src/codex/utils/slashCommands.ts b/cli/src/codex/utils/slashCommands.ts index 62c714a7..7c481fe1 100644 --- a/cli/src/codex/utils/slashCommands.ts +++ b/cli/src/codex/utils/slashCommands.ts @@ -3,8 +3,8 @@ import type { CodexPermissionMode } from '@hapi/protocol/types'; import type { ReasoningEffort } from '../appServerTypes'; import type { EnhancedMode } from '../loop'; import type { SlashCommand } from '@/modules/common/slashCommands'; +import { parseReasoningEffortValue } from './reasoningEffort'; -const REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']); export const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4_000; const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([ @@ -186,16 +186,11 @@ export function resolveCodexSlashCommand( updates: { modelReasoningEffort: null } }; } - if (!REASONING_EFFORTS.has(rest as ReasoningEffort)) { - return { - kind: 'handled', - message: `Unknown Codex reasoning effort: ${rest}` - }; - } + const effort = parseReasoningEffortValue(rest); return { kind: 'handled', - message: `Codex reasoning effort set to ${rest}`, - updates: { modelReasoningEffort: rest as ReasoningEffort } + message: `Codex reasoning effort set to ${effort}`, + updates: { modelReasoningEffort: effort } }; } @@ -259,7 +254,7 @@ export function resolveCodexSlashCommand( '- `/compact` — compact current Codex thread context', '- `/status` — show current Codex session config', '- `/model [name|auto]` — show or set model', - '- `/reasoning [low|medium|high|xhigh|default]` — show or set reasoning effort', + '- `/reasoning [level|default]` — show or set reasoning effort', '- `/fast [on|off|status]` — toggle Fast mode (GPT-5.5 / GPT-5.4, ChatGPT login)', '- `/permissions [default|read-only|safe-yolo|yolo]` — show or set permission mode', '', diff --git a/cli/src/commands/codex.test.ts b/cli/src/commands/codex.test.ts index 1e5fba1c..058b2420 100644 --- a/cli/src/commands/codex.test.ts +++ b/cli/src/commands/codex.test.ts @@ -121,6 +121,20 @@ describe('codexCommand', () => { } }) + it('accepts and normalizes a dynamic model reasoning effort', async () => { + await codexCommand.run(createCommandContext([ + '--started-by', + 'runner', + '--model-reasoning-effort', + ' EXTREME ' + ])) + + expect(runCodexMock).toHaveBeenCalledWith({ + startedBy: 'runner', + modelReasoningEffort: 'extreme' + }) + }) + it('prints the upgrade error and exits when the local version check fails', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index 579bf5cb..a57cce5c 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -7,20 +7,7 @@ import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CodexPermissionMode } from '@hapi/protocol/types' import type { ReasoningEffort } from '@/codex/appServerTypes' import { assertCodexLocalSupported } from '@/codex/utils/codexVersion' - -function parseReasoningEffort(value: string): ReasoningEffort { - switch (value) { - case 'none': - case 'minimal': - case 'low': - case 'medium': - case 'high': - case 'xhigh': - return value - default: - throw new Error('Invalid --model-reasoning-effort value') - } -} +import { parseReasoningEffortValue } from '@/codex/utils/reasoningEffort' // Mirror the web /service-tier endpoint's enum so the internal resume spawn // path can never seed/persist an unsupported tier string. @@ -86,7 +73,7 @@ export const codexCommand: CommandDefinition = { if (!effort) { throw new Error('Missing --model-reasoning-effort value') } - options.modelReasoningEffort = parseReasoningEffort(effort) + options.modelReasoningEffort = parseReasoningEffortValue(effort) } else if (arg === '--service-tier') { const tier = commandArgs[++i] if (!tier) { diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index b0cf0592..e3c1a96a 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -191,10 +191,18 @@ function findWebappDistDir(): { distDir: string; indexHtmlPath: string } { } function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response { + const headers: Record = { + 'Content-Type': asset.mimeType + } + + if (asset.path === '/sw.js') { + headers['Cache-Control'] = 'no-store, no-cache, must-revalidate' + headers['CDN-Cache-Control'] = 'no-store' + headers['Cloudflare-CDN-Cache-Control'] = 'no-store' + } + return new Response(Bun.file(asset.sourcePath), { - headers: { - 'Content-Type': asset.mimeType - } + headers }) } diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 70b1f086..d5033cc4 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -471,7 +471,7 @@ export function HappyComposer(props: { ? getCodexComposerReasoningEffortOptions( modelReasoningEffort, agentFlavor, - agentFlavor === 'opencode' ? availableModelReasoningEffortOptions : undefined + availableModelReasoningEffortOptions ) : [], [agentFlavor, modelReasoningEffort, availableModelReasoningEffortOptions] diff --git a/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts b/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts index 1b531f34..901e2c71 100644 --- a/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts +++ b/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts @@ -23,6 +23,39 @@ describe('getCodexComposerReasoningEffortOptions', () => { ]) }) + it('uses arbitrary model-reported efforts for Codex', () => { + expect(getCodexComposerReasoningEffortOptions('extreme', 'codex', [ + { value: 'low' }, + { value: 'medium' }, + { value: 'high' }, + { value: 'xhigh' }, + { value: 'max' }, + { value: 'ultra' }, + { value: 'extreme' } + ])).toEqual([ + { value: null, label: 'Default' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'XHigh' }, + { value: 'max', label: 'Max' }, + { value: 'ultra', label: 'Ultra' }, + { value: 'extreme', label: 'Extreme' } + ]) + }) + + it('keeps an unsupported current Codex effort visible', () => { + expect(getCodexComposerReasoningEffortOptions('ultra', 'codex', [ + { value: 'low' }, + { value: 'max' } + ])).toEqual([ + { value: null, label: 'Default' }, + { value: 'ultra', label: 'Ultra' }, + { value: 'low', label: 'Low' }, + { value: 'max', label: 'Max' } + ]) + }) + it('returns no options for OpenCode until dynamic options are available', () => { expect(getCodexComposerReasoningEffortOptions(null, 'opencode')).toEqual([]) expect(getCodexComposerReasoningEffortOptions(null, 'opencode', [])).toEqual([]) diff --git a/web/src/components/AssistantChat/codexReasoningEffortOptions.ts b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts index b2dfc9d0..692a51ee 100644 --- a/web/src/components/AssistantChat/codexReasoningEffortOptions.ts +++ b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts @@ -14,7 +14,8 @@ const CODEX_REASONING_EFFORT_LABELS: Record = { medium: 'Medium', high: 'High', xhigh: 'XHigh', - max: 'Max' + max: 'Max', + ultra: 'Ultra' } function normalizeCodexComposerReasoningEffort(effort?: string | null): string | null { @@ -31,7 +32,7 @@ function formatCodexReasoningEffortLabel(effort: string): string { ?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}` } -function buildOpencodeComposerReasoningEffortOptions( +function buildDynamicReasoningEffortOptions( currentEffort: string | null, dynamicOptions: ComposerReasoningEffortSourceOption[] ): CodexComposerReasoningEffortOption[] { @@ -66,7 +67,11 @@ export function getCodexComposerReasoningEffortOptions( if (!dynamicOptions || dynamicOptions.length === 0) { return [] } - return buildOpencodeComposerReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions) + return buildDynamicReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions) + } + + if (dynamicOptions && dynamicOptions.length > 0) { + return buildDynamicReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions) } const options: CodexComposerReasoningEffortOption[] = [ diff --git a/web/src/components/NewSession/ReasoningEffortSelector.tsx b/web/src/components/NewSession/ReasoningEffortSelector.tsx index 23d55f14..36f042c8 100644 --- a/web/src/components/NewSession/ReasoningEffortSelector.tsx +++ b/web/src/components/NewSession/ReasoningEffortSelector.tsx @@ -1,10 +1,12 @@ import type { AgentType, CodexReasoningEffort } from './types' import { CODEX_REASONING_EFFORT_OPTIONS } from './types' import { useTranslation } from '@/lib/use-translation' +import { getCodexComposerReasoningEffortOptions } from '@/components/AssistantChat/codexReasoningEffortOptions' export function ReasoningEffortSelector(props: { agent: AgentType value: CodexReasoningEffort + availableOptions?: Array<{ value: string; name?: string }> isDisabled: boolean onChange: (value: CodexReasoningEffort) => void }) { @@ -14,6 +16,15 @@ export function ReasoningEffortSelector(props: { return null } + const options = props.agent === 'codex' && props.availableOptions?.length + ? getCodexComposerReasoningEffortOptions(null, props.agent, props.availableOptions).map((option) => ({ + value: option.value ?? 'default', + label: option.label + })) + : CODEX_REASONING_EFFORT_OPTIONS.filter( + (option) => props.agent === 'opencode' ? option.value !== 'xhigh' : option.value !== 'max' + ) + return (