fix(codex): support dynamic reasoning efforts (#1012)

* fix(codex): support model-reported reasoning efforts

* fix(web): prevent service worker edge caching

* ci: retrigger stuck Actions run

* fix(codex): accept dynamic reasoning effort values

* fix(web): restore reasoning effort on model switch failure
This commit is contained in:
ejj.cc
2026-07-12 18:38:56 +08:00
committed by GitHub
parent 4c76668a6c
commit d160203bb2
21 changed files with 372 additions and 58 deletions
@@ -471,7 +471,7 @@ export function HappyComposer(props: {
? getCodexComposerReasoningEffortOptions(
modelReasoningEffort,
agentFlavor,
agentFlavor === 'opencode' ? availableModelReasoningEffortOptions : undefined
availableModelReasoningEffortOptions
)
: [],
[agentFlavor, modelReasoningEffort, availableModelReasoningEffortOptions]
@@ -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([])
@@ -14,7 +14,8 @@ const CODEX_REASONING_EFFORT_LABELS: Record<string, string> = {
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[] = [
@@ -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 (
<div className="flex flex-col gap-1.5 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
@@ -26,7 +37,7 @@ export function ReasoningEffortSelector(props: {
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{CODEX_REASONING_EFFORT_OPTIONS.filter((option) => props.agent === 'opencode' ? option.value !== 'xhigh' : option.value !== 'max').map((option) => (
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
+23 -1
View File
@@ -12,6 +12,7 @@ import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggesti
import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions'
import { useRecentPaths } from '@/hooks/useRecentPaths'
import { useTranslation } from '@/lib/use-translation'
import { getCodexModelReasoningEfforts } from '@/lib/codexModelCapabilities'
import {
buildNewSessionCursorPickerState,
isCursorEffortWireAllowed,
@@ -199,6 +200,26 @@ export function NewSession(props: {
}
return options
}, [codexModelsState.models, model])
const codexSupportedReasoningEfforts = useMemo(
() => getCodexModelReasoningEfforts(codexModelsState.models, model),
[codexModelsState.models, model]
)
const codexReasoningEffortOptions = useMemo(
() => codexSupportedReasoningEfforts?.map((value) => ({ value })),
[codexSupportedReasoningEfforts]
)
useEffect(() => {
if (
agent !== 'codex'
|| modelReasoningEffort === 'default'
|| !codexSupportedReasoningEfforts
|| codexSupportedReasoningEfforts.includes(modelReasoningEffort)
) {
return
}
setModelReasoningEffort('default')
}, [agent, codexSupportedReasoningEfforts, modelReasoningEffort])
const cursorModelsState = useCursorModelsForMachine({
api: props.api,
machineId,
@@ -719,7 +740,8 @@ export function NewSession(props: {
<ReasoningEffortSelector
agent={agent}
value={modelReasoningEffort}
isDisabled={isFormDisabled}
availableOptions={agent === 'codex' ? codexReasoningEffortOptions : undefined}
isDisabled={isFormDisabled || (agent === 'codex' && codexModelsState.isLoading)}
onChange={setModelReasoningEffort}
/>
<YoloToggle
+2 -1
View File
@@ -10,7 +10,8 @@ import type { AgentFlavor, ClaudeEffortLevel } from '@hapi/protocol'
export type AgentType = AgentFlavor
export type SessionType = 'simple' | 'worktree'
export type CodexReasoningEffort = 'default' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
// Codex reports supported efforts dynamically; keep this open for new server values.
export type CodexReasoningEffort = string
export type ClaudeEffort = 'auto' | ClaudeEffortLevel
function modelPresetOptions<TModel extends string>(
+38 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
applyModelChangeWithReasoningRollback,
buildGoalStateMessages,
isScratchlistHotkeyBlockedTarget,
isScratchlistToggleHotkey,
@@ -9,6 +10,42 @@ import {
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import type { AttachmentMetadata, DecryptedMessage } from '@/types/api'
describe('applyModelChangeWithReasoningRollback', () => {
it('restores the previous effort when the model switch fails after clearing it', async () => {
const modelError = new Error('model switch failed')
const setModel = vi.fn(async () => { throw modelError })
const setModelReasoningEffort = vi.fn(async () => {})
await expect(applyModelChangeWithReasoningRollback({
model: 'gpt-next',
previousModelReasoningEffort: 'extreme',
shouldClearReasoningEffort: true,
setModel,
setModelReasoningEffort
})).rejects.toBe(modelError)
expect(setModelReasoningEffort.mock.calls).toEqual([[null], ['extreme']])
expect(setModel).toHaveBeenCalledWith('gpt-next')
})
it('keeps the cleared effort when the model switch succeeds', async () => {
const setModel = vi.fn(async () => {})
const setModelReasoningEffort = vi.fn(async () => {})
await applyModelChangeWithReasoningRollback({
model: 'gpt-next',
previousModelReasoningEffort: 'extreme',
shouldClearReasoningEffort: true,
setModel,
setModelReasoningEffort
})
expect(setModelReasoningEffort).toHaveBeenCalledOnce()
expect(setModelReasoningEffort).toHaveBeenCalledWith(null)
expect(setModel).toHaveBeenCalledWith('gpt-next')
})
})
function userMessage(props: {
id: string
createdAt: number
+72 -6
View File
@@ -21,6 +21,10 @@ import { buildConversationOutline } from '@/chat/outline'
import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups'
import { isQueuedForInvocation, mergeMessages } from '@/lib/messages'
import { inactiveSessionCanResume } from '@/lib/sessionResume'
import {
getCodexModelReasoningEfforts,
supportsCodexReasoningEffort
} from '@/lib/codexModelCapabilities'
import { HappyComposer, type ComposerSendError } from '@/components/AssistantChat/HappyComposer'
import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
@@ -64,6 +68,33 @@ import { useVoiceOptional } from '@/lib/voice-context'
import { VoiceBackendSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
type SessionModelSelection = { provider: string; modelId: string } | string | null
export async function applyModelChangeWithReasoningRollback(args: {
model: SessionModelSelection
previousModelReasoningEffort: string | null
shouldClearReasoningEffort: boolean
setModel: (model: SessionModelSelection) => Promise<void>
setModelReasoningEffort: (effort: string | null) => Promise<void>
}): Promise<void> {
let clearedReasoningEffort = false
try {
if (args.shouldClearReasoningEffort) {
await args.setModelReasoningEffort(null)
clearedReasoningEffort = true
}
await args.setModel(args.model)
} catch (error) {
if (clearedReasoningEffort && args.previousModelReasoningEffort) {
await args.setModelReasoningEffort(args.previousModelReasoningEffort).catch((restoreError) => {
console.error('Failed to restore model reasoning effort:', restoreError)
})
}
throw error
}
}
/**
* Returns whether a PendingSchedule should trigger an auto-clear timer.
*
@@ -516,6 +547,16 @@ function SessionChatInner(props: SessionChatProps) {
}
return options
}, [agentFlavor, codexModelsState.models])
const codexSupportedReasoningEfforts = useMemo(
() => agentFlavor === 'codex'
? getCodexModelReasoningEfforts(codexModelsState.models, props.session.model)
: undefined,
[agentFlavor, codexModelsState.models, props.session.model]
)
const codexReasoningEffortOptions = useMemo(
() => codexSupportedReasoningEfforts?.map((value) => ({ value })),
[codexSupportedReasoningEfforts]
)
const opencodeModelsState = useOpencodeModels({
api: props.api,
sessionId: props.session.id,
@@ -909,16 +950,39 @@ function SessionChatInner(props: SessionChatProps) {
}, [setCollaborationMode, props.onRefresh, haptic])
// Model mode change handler
const handleModelChange = useCallback(async (model: { provider: string; modelId: string } | string | null) => {
const handleModelChange = useCallback(async (model: SessionModelSelection) => {
const previousModelReasoningEffort = props.session.modelReasoningEffort
const shouldClearReasoningEffort = agentFlavor === 'codex'
&& Boolean(previousModelReasoningEffort)
&& supportsCodexReasoningEffort(
codexModelsState.models,
model,
previousModelReasoningEffort
) === false
try {
await setModel(model)
await applyModelChangeWithReasoningRollback({
model,
previousModelReasoningEffort,
shouldClearReasoningEffort,
setModel,
setModelReasoningEffort
})
haptic.notification('success')
props.onRefresh()
} catch (e) {
haptic.notification('error')
console.error('Failed to set model:', e)
}
}, [setModel, props.onRefresh, haptic])
}, [
agentFlavor,
codexModelsState.models,
props.session.modelReasoningEffort,
setModelReasoningEffort,
setModel,
props.onRefresh,
haptic
])
const handleCursorBaseModelChange = useCallback(async (baseKey: string | null) => {
if (!cursorPicker) {
@@ -1243,9 +1307,11 @@ function SessionChatInner(props: SessionChatProps) {
piModels={agentFlavor === 'pi' ? (piModelsState.availableModels.length > 0 ? piModelsState.availableModels : piCachedModels) : undefined}
piSelectedModel={agentFlavor === 'pi' ? piSelectedModel : undefined}
availableModelReasoningEffortOptions={
agentFlavor === 'opencode' && opencodeReasoningEffortState.options.length > 0
? opencodeReasoningEffortState.options
: undefined
agentFlavor === 'codex'
? codexReasoningEffortOptions
: agentFlavor === 'opencode' && opencodeReasoningEffortState.options.length > 0
? opencodeReasoningEffortState.options
: undefined
}
active={props.session.active}
allowSendWhenInactive
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import type { CodexModelSummary } from '@/types/api'
import {
getCodexModelReasoningEfforts,
resolveCodexModel,
supportsCodexReasoningEffort
} from './codexModelCapabilities'
const models: CodexModelSummary[] = [
{
id: 'gpt-5.6-sol',
displayName: 'GPT-5.6-Sol',
isDefault: true,
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']
},
{
id: 'gpt-5.6-luna',
displayName: 'GPT-5.6-Luna',
isDefault: false,
supportedReasoningEfforts: ['LOW', 'medium', 'max', 'max', ' ']
}
]
describe('Codex model capabilities', () => {
it('resolves auto and missing model ids to the reported default model', () => {
expect(resolveCodexModel(models, 'auto')?.id).toBe('gpt-5.6-sol')
expect(resolveCodexModel(models, null)?.id).toBe('gpt-5.6-sol')
expect(resolveCodexModel(models, { modelId: 'gpt-5.6-luna' })?.id).toBe('gpt-5.6-luna')
})
it('returns normalized, de-duplicated efforts for the selected model', () => {
expect(getCodexModelReasoningEfforts(models, 'gpt-5.6-luna')).toEqual([
'low',
'medium',
'max'
])
})
it('distinguishes unsupported efforts from unavailable capability data', () => {
expect(supportsCodexReasoningEffort(models, 'gpt-5.6-sol', 'ultra')).toBe(true)
expect(supportsCodexReasoningEffort(models, 'gpt-5.6-luna', 'ultra')).toBe(false)
expect(supportsCodexReasoningEffort(models, 'unknown', 'ultra')).toBeUndefined()
})
})
+44
View File
@@ -0,0 +1,44 @@
import type { CodexModelSummary } from '@/types/api'
type ModelSelection = string | { modelId: string } | null | undefined
function normalizeEfforts(efforts: readonly string[] | undefined): string[] | undefined {
if (!efforts) return undefined
const normalized = [...new Set(
efforts
.map((effort) => effort.trim().toLowerCase())
.filter(Boolean)
)]
return normalized.length > 0 ? normalized : undefined
}
export function resolveCodexModel(
models: readonly CodexModelSummary[],
model: ModelSelection
): CodexModelSummary | null {
const normalizedModelId = (typeof model === 'string' ? model : model?.modelId)?.trim()
if (!normalizedModelId || normalizedModelId === 'auto') {
return models.find((model) => model.isDefault) ?? models[0] ?? null
}
return models.find((model) => model.id === normalizedModelId) ?? null
}
export function getCodexModelReasoningEfforts(
models: readonly CodexModelSummary[],
model: ModelSelection
): string[] | undefined {
return normalizeEfforts(resolveCodexModel(models, model)?.supportedReasoningEfforts)
}
export function supportsCodexReasoningEffort(
models: readonly CodexModelSummary[],
model: ModelSelection,
effort: string | null | undefined
): boolean | undefined {
const supportedEfforts = getCodexModelReasoningEfforts(models, model)
if (!supportedEfforts) return undefined
if (!effort) return true
return supportedEfforts.includes(effort.trim().toLowerCase())
}