fix(codex): discover effort menus from the host CLI catalog

Worker-start and native chat were still bound to a frozen seed, so
unadvertised `minimal` stayed on every menu and a new Codex tier would
silently stay unreachable. Probe `codex debug models` the same way
commit-message generation already does, use each model's
supported_reasoning_levels as the picker, and keep the seed only as a
fallback when the probe fails.

Co-authored-by: gilje <rp0927@gmail.com>
This commit is contained in:
Brennan Benson
2026-08-20 17:47:08 -07:00
co-authored by gilje
parent 012e9f410c
commit ce74fe9c42
16 changed files with 650 additions and 181 deletions
@@ -36,48 +36,53 @@ describe('orchestration worker launch preferences', () => {
it.each([
{
model: 'gpt-5.6-sol',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
rejected: ['future-effort']
accepted: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
rejected: ['minimal', 'future-effort']
},
{
model: 'gpt-5.6-terra',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
rejected: ['future-effort']
accepted: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
rejected: ['minimal', 'future-effort']
},
{
model: 'gpt-5.6-luna',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
rejected: ['ultra', 'future-effort']
accepted: ['low', 'medium', 'high', 'xhigh', 'max'],
rejected: ['ultra', 'minimal', 'future-effort']
},
{
model: 'gpt-5.5',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
},
{
model: 'gpt-5.2-codex',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
accepted: ['low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'minimal', 'future-effort']
},
{
model: 'gpt-5.4',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
accepted: ['low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'minimal', 'future-effort']
},
{
model: 'gpt-5.4-mini',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
accepted: ['low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'minimal', 'future-effort']
},
{
model: 'gpt-5.3-codex-spark',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
accepted: ['low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'minimal', 'future-effort']
},
{
model: 'gpt-5.2-codex',
accepted: ['low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'minimal', 'future-effort']
},
{
model: 'future-codex-model',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
accepted: ['low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'minimal', 'future-effort']
},
{
model: 'luna',
accepted: ['low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'minimal', 'future-effort']
}
])('enforces the Codex effort ceiling for $model', ({ model, accepted, rejected }) => {
const catalog = getAgentSessionOptionCatalog('codex')!
@@ -104,6 +109,32 @@ describe('orchestration worker launch preferences', () => {
}
})
it('passes a supported Codex max/ultra request through instead of capping it', () => {
expect(
resolveWorkerLaunchPreferences({
agent: 'codex',
model: 'gpt-5.6-luna',
effort: 'max'
}).preferences
).toEqual({ model: 'gpt-5.6-luna', effort: 'max' })
expect(
resolveWorkerLaunchPreferences({
agent: 'codex',
model: 'gpt-5.6-sol',
effort: 'ultra'
}).preferences
).toEqual({ model: 'gpt-5.6-sol', effort: 'ultra' })
})
it('rejects an unsupported Codex effort instead of silently substituting a lower tier', () => {
expect(() =>
resolveWorkerLaunchPreferences({ agent: 'codex', model: 'gpt-5.6-luna', effort: 'ultra' })
).toThrow('does not support effort ultra')
expect(() =>
resolveWorkerLaunchPreferences({ agent: 'codex', model: 'gpt-5.5', effort: 'max' })
).toThrow('does not support effort max')
})
it('rejects effort without a model', () => {
expect(() => resolveWorkerLaunchPreferences({ agent: 'codex', effort: 'high' })).toThrow(
'--effort requires --model'
@@ -1,6 +1,7 @@
import type { AgentType } from '../../../../shared/agent-status-types'
import {
createClaudeCatalogOptions,
createCodexCatalogOptions,
getAgentSessionOptionCatalog,
type CatalogModel
} from '../../../../shared/agent-session-option-catalog'
@@ -96,6 +97,11 @@ export async function discoverNativeChatCatalogModels(
effortLevelIds: model.thinkingLevels?.map(({ id }) => id) ?? [],
supportsFastMode: model.supportsFastMode
})
: []
: agent === 'codex'
? createCodexCatalogOptions({
effortLevelIds: model.thinkingLevels?.map(({ id }) => id) ?? [],
...(model.defaultThinkingLevel ? { defaultEffort: model.defaultThinkingLevel } : {})
})
: []
}))
}
@@ -302,6 +302,80 @@ describe('native chat session option enrichment', () => {
).toEqual({ model: 'retired' })
})
it('uses discovered Codex models and their per-model reasoning levels', async () => {
mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({
success: true,
catalogOrigin: 'probe',
models: [
{
id: 'gpt-5.6-sol',
label: 'GPT-5.6 Sol',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'high', label: 'High' },
{ id: 'max', label: 'Max' },
{ id: 'ultra', label: 'Ultra' }
],
defaultThinkingLevel: 'high'
},
{ id: 'account-only-model', label: 'Account only model' }
]
})
const discovered = await discoverNativeChatCatalogModels('codex', {
settings: {},
worktreeId: 'repo::/worktree',
worktreePath: '/worktree'
})
expect(discovered?.map(({ id }) => id)).toEqual(['gpt-5.6-sol', 'account-only-model'])
expect(discovered?.[0].options[0].kind).toMatchObject({
type: 'select',
choices: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' },
{ value: 'max', label: 'Max' },
{ value: 'ultra', label: 'Ultra' }
],
defaultValue: 'high'
})
expect(discovered?.[1].options).toEqual([])
const listener = vi.fn()
subscribeNativeChatEnrichedModels('codex', 'local', listener)
ensureNativeChatModelEnrichment({
agent: 'codex',
hostKey: 'local',
discover: async () => discovered
})
await vi.waitFor(() => expect(listener).toHaveBeenCalledOnce())
const enriched = readNativeChatEnrichedModels('codex', 'local')!
expect(enriched.map(({ id }) => id)).toEqual(['gpt-5.6-sol', 'account-only-model'])
expect(enriched[0].options[0].kind).toMatchObject({
choices: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' },
{ value: 'max', label: 'Max' },
{ value: 'ultra', label: 'Ultra' }
]
})
})
it('does not advertise the Codex spec fallback when probing is unavailable', async () => {
mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({
success: true,
catalogOrigin: 'spec',
models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }]
})
await expect(
discoverNativeChatCatalogModels('codex', {
settings: {},
worktreeId: 'repo::/worktree',
worktreePath: '/worktree'
})
).resolves.toBeNull()
})
it('does not advertise the Claude spec fallback when probing is unavailable', async () => {
mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({
success: true,
@@ -102,7 +102,7 @@ export function ensureNativeChatModelEnrichment(args: {
return
}
entry.models =
args.agent === 'claude'
args.agent === 'claude' || args.agent === 'codex'
? [...discovered]
: catalog.discoveredModelsAreAuthoritative
? mergeDiscoveredAuthoritativeModels(catalog.models, discovered)
@@ -4,7 +4,7 @@ import es from './locales/es.json'
import ja from './locales/ja.json'
import ko from './locales/ko.json'
import zh from './locales/zh.json'
import { CODEX_SESSION_OPTION_CATALOG } from '../../../shared/agent-session-option-catalog-claude-codex'
import { CODEX_SESSION_OPTION_CATALOG } from '../../../shared/agent-session-option-catalog-codex'
const localizedCatalogs = { es, ja, ko, zh }
const englishSetting = en.auto.components.settings.ExperimentalPane.nativeChat
@@ -25,7 +25,17 @@ const codexEffortValues = new Set(
describe('native chat locale copy', () => {
it('covers every Codex effort choice', () => {
expect([...codexEffortValues].sort()).toEqual([...localizedEffortValues].sort())
const localized = new Set<string>(localizedEffortValues)
expect([...codexEffortValues].every((value) => localized.has(value))).toBe(true)
expect(localizedEffortValues).toEqual([
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
'ultra'
])
})
it.each(Object.entries(localizedCatalogs))(
@@ -3,7 +3,7 @@ import type {
CatalogModel,
CatalogOption
} from './agent-session-option-catalog-types'
import { agentArgOptionTokens, removeAgentArgOption } from './agent-session-option-agent-args'
import { removeAgentArgOption } from './agent-session-option-agent-args'
import {
CLAUDE_MODEL_LIST_ARGS,
CLAUDE_MODEL_LIST_STDIN,
@@ -11,49 +11,6 @@ import {
} from './claude-model-list-probe'
import { hasFlag } from './agent-cli-flag-detection'
function hasCodexEffortOverride(tokens: readonly string[]): boolean {
if (hasFlag(tokens, ['--reasoning-effort'])) {
return true
}
const optionTokens = agentArgOptionTokens(tokens)
return optionTokens.some((token, index) => {
const previous = optionTokens[index - 1]
return (
(token.startsWith('model_reasoning_effort=') &&
(previous === '-c' || previous === '--config')) ||
token.startsWith('-cmodel_reasoning_effort=') ||
token.startsWith('-c=model_reasoning_effort=') ||
token.startsWith('--config=model_reasoning_effort=')
)
})
}
function removeCodexEffortOverride(tokens: readonly string[]): string[] {
const withoutFlag = removeAgentArgOption(tokens, ['--reasoning-effort'])
const result: string[] = []
for (let index = 0; index < withoutFlag.length; index += 1) {
const token = withoutFlag[index]
if (token === '--') {
result.push(...withoutFlag.slice(index))
break
}
const next = withoutFlag[index + 1]
if ((token === '-c' || token === '--config') && next?.startsWith('model_reasoning_effort=')) {
index += 1
continue
}
if (
token.startsWith('-cmodel_reasoning_effort=') ||
token.startsWith('-c=model_reasoning_effort=') ||
token.startsWith('--config=model_reasoning_effort=')
) {
continue
}
result.push(token)
}
return result
}
const STANDARD_EFFORT_CHOICES = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
@@ -179,60 +136,3 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
parse: parseClaudeCatalogModels
}
}
const CODEX_EFFORT_CHOICES = [
{ value: 'minimal', label: 'Minimal' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Extra high' },
{ value: 'max', label: 'Max' },
{ value: 'ultra', label: 'Ultra' }
]
// Why: Codex can clamp higher values, so expose only each model's advertised levels.
function codexEffort(ceiling: 'xhigh' | 'max' | 'ultra'): CatalogOption {
const ceilingIndex = CODEX_EFFORT_CHOICES.findIndex((choice) => choice.value === ceiling)
return {
id: 'effort',
label: 'Reasoning effort',
category: 'thought_level',
kind: {
type: 'select',
choices: CODEX_EFFORT_CHOICES.slice(0, ceilingIndex + 1),
defaultValue: 'medium'
},
apply: {
launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`],
agentArgsOverride: hasCodexEffortOverride,
removeAgentArgs: removeCodexEffortOverride,
midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' }
}
}
}
export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
supportsWorkerLaunchPreferences: true,
// Why: Codex model access depends on auth. Keep this seed short and allow
// unknown persisted ids to pass through instead of claiming a complete list.
models: [
{ id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', options: [codexEffort('ultra')] },
{ id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra', options: [codexEffort('ultra')] },
{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', options: [codexEffort('max')] },
{ id: 'gpt-5.5', label: 'GPT-5.5', options: [codexEffort('xhigh')] },
{
id: 'gpt-5.2-codex',
label: 'GPT-5.2 Codex',
options: [codexEffort('xhigh')]
}
],
modelApply: {
launchArgs: (value) => ['-m', String(value)],
agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']),
removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']),
// Codex classifies multi-character writes as pasted prose; type the bare
// command and let its own picker apply the account-supported model.
midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' }
},
unknownModelOptions: [codexEffort('xhigh')]
}
@@ -0,0 +1,172 @@
import { hasFlag } from './agent-cli-flag-detection'
import { agentArgOptionTokens, removeAgentArgOption } from './agent-session-option-agent-args'
import type {
AgentSessionOptionCatalog,
CatalogModel,
CatalogOption
} from './agent-session-option-catalog-types'
import { CODEX_MODEL_LIST_COMMAND, parseCodexModelList } from './codex-model-list-probe'
import { labelFromModelId } from './model-id-label'
function hasCodexEffortOverride(tokens: readonly string[]): boolean {
if (hasFlag(tokens, ['--reasoning-effort'])) {
return true
}
const optionTokens = agentArgOptionTokens(tokens)
return optionTokens.some((token, index) => {
const previous = optionTokens[index - 1]
return (
(token.startsWith('model_reasoning_effort=') &&
(previous === '-c' || previous === '--config')) ||
token.startsWith('-cmodel_reasoning_effort=') ||
token.startsWith('-c=model_reasoning_effort=') ||
token.startsWith('--config=model_reasoning_effort=')
)
})
}
function removeCodexEffortOverride(tokens: readonly string[]): string[] {
const withoutFlag = removeAgentArgOption(tokens, ['--reasoning-effort'])
const result: string[] = []
for (let index = 0; index < withoutFlag.length; index += 1) {
const token = withoutFlag[index]
if (token === '--') {
result.push(...withoutFlag.slice(index))
break
}
const next = withoutFlag[index + 1]
if ((token === '-c' || token === '--config') && next?.startsWith('model_reasoning_effort=')) {
index += 1
continue
}
if (
token.startsWith('-cmodel_reasoning_effort=') ||
token.startsWith('-c=model_reasoning_effort=') ||
token.startsWith('--config=model_reasoning_effort=')
) {
continue
}
result.push(token)
}
return result
}
const CODEX_EFFORT_CHOICES = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Extra high' },
{ value: 'max', label: 'Max' },
{ value: 'ultra', label: 'Ultra' }
]
function labelCodexEffort(value: string): string {
return (
CODEX_EFFORT_CHOICES.find((choice) => choice.value === value)?.label ??
(value === 'minimal' ? 'Minimal' : labelFromModelId(value))
)
}
function codexEffortWithChoices(
choices: readonly { value: string; label: string }[],
defaultValue: string
): CatalogOption {
return {
id: 'effort',
label: 'Reasoning effort',
category: 'thought_level',
kind: {
type: 'select',
choices: [...choices],
defaultValue
},
apply: {
launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`],
agentArgsOverride: hasCodexEffortOverride,
removeAgentArgs: removeCodexEffortOverride,
midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' }
}
}
}
// Why: Codex silently clamps a higher requested tier, so expose only advertised levels.
function codexEffort(ceiling: 'xhigh' | 'max' | 'ultra'): CatalogOption {
const ceilingIndex = CODEX_EFFORT_CHOICES.findIndex((choice) => choice.value === ceiling)
return codexEffortWithChoices(CODEX_EFFORT_CHOICES.slice(0, ceilingIndex + 1), 'medium')
}
export function createCodexCatalogOptions(args: {
effortLevelIds: readonly string[]
defaultEffort?: string
}): CatalogOption[] {
const seen = new Set<string>()
const choices = args.effortLevelIds.flatMap((id) => {
if (!id || seen.has(id)) {
return []
}
seen.add(id)
return [{ value: id, label: labelCodexEffort(id) }]
})
if (choices.length === 0) {
return []
}
const choiceIds = choices.map(({ value }) => value)
const requestedDefault = args.defaultEffort
const defaultEffort =
requestedDefault && choiceIds.includes(requestedDefault)
? requestedDefault
: choiceIds.includes('medium')
? 'medium'
: choices[0].value
return [codexEffortWithChoices(choices, defaultEffort)]
}
function parseCodexCatalogModels(stdout: string): CatalogModel[] {
return parseCodexModelList(stdout).flatMap((model) => {
// Hidden rows are API-only (auto-review, aliases). Picking one is a confusing launch.
if (model.visibility === 'hide') {
return []
}
return [
{
id: model.id,
label: model.label,
options: createCodexCatalogOptions({
effortLevelIds: model.effortLevels,
...(model.defaultEffort ? { defaultEffort: model.defaultEffort } : {})
})
}
]
})
}
export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
supportsWorkerLaunchPreferences: true,
// Why: Codex model access depends on auth. Seed the advertised picker; a successful
// `codex debug models` probe replaces membership and per-model effort menus.
models: [
{ id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', options: [codexEffort('ultra')] },
{ id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra', options: [codexEffort('ultra')] },
{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', options: [codexEffort('max')] },
{ id: 'gpt-5.5', label: 'GPT-5.5', options: [codexEffort('xhigh')] },
{ id: 'gpt-5.4', label: 'GPT-5.4', options: [codexEffort('xhigh')] },
{ id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', options: [codexEffort('xhigh')] },
{ id: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark', options: [codexEffort('xhigh')] }
],
modelApply: {
launchArgs: (value) => ['-m', String(value)],
agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']),
removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']),
// Codex classifies multi-character writes as pasted prose; type the bare
// command and let its own picker apply the account-supported model.
midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' }
},
unknownModelOptions: [codexEffort('xhigh')],
// Why: a seeded id the CLI has retired is a fatal `-m`, so a successful probe
// must drop it. Option menus come from the probe's supported_reasoning_levels.
discoveredModelsAreAuthoritative: true,
listModels: {
command: CODEX_MODEL_LIST_COMMAND,
parse: parseCodexCatalogModels
}
}
@@ -70,9 +70,10 @@ describe('grok session option catalog', () => {
expect(effortValues(unknown[0])).toEqual(['low', 'medium', 'high', 'xhigh'])
})
it('treats a successful discovery as authoritative, unlike the other agents', () => {
it('treats a successful discovery as authoritative, unlike additive catalogs', () => {
expect(GROK_SESSION_OPTION_CATALOG.discoveredModelsAreAuthoritative).toBe(true)
for (const agent of ['claude', 'codex', 'gemini', 'cursor'] as const) {
expect(getAgentSessionOptionCatalog('codex')?.discoveredModelsAreAuthoritative).toBe(true)
for (const agent of ['claude', 'gemini', 'cursor'] as const) {
expect(getAgentSessionOptionCatalog(agent)?.discoveredModelsAreAuthoritative).toBeUndefined()
}
})
@@ -99,6 +99,133 @@ describe('agent session option catalog', () => {
expect(parsed[2].options).toEqual([])
})
it('seeds Codex effort menus from advertised ceilings, not a shared cap', () => {
const catalog = getAgentSessionOptionCatalog('codex')!
const effortValues = (modelId: string): string[] => {
const option = catalog.models
.find((model) => model.id === modelId)
?.options.find((candidate) => candidate.id === 'effort')
return option?.kind.type === 'select' ? option.kind.choices.map(({ value }) => value) : []
}
expect(catalog.models.map(({ id }) => id)).toEqual([
'gpt-5.6-sol',
'gpt-5.6-terra',
'gpt-5.6-luna',
'gpt-5.5',
'gpt-5.4',
'gpt-5.4-mini',
'gpt-5.3-codex-spark'
])
expect(effortValues('gpt-5.6-sol')).toEqual(['low', 'medium', 'high', 'xhigh', 'max', 'ultra'])
expect(effortValues('gpt-5.6-luna')).toEqual(['low', 'medium', 'high', 'xhigh', 'max'])
expect(effortValues('gpt-5.5')).toEqual(['low', 'medium', 'high', 'xhigh'])
expect(catalog.models.flatMap((model) => effortValues(model.id))).not.toContain('minimal')
expect(catalog.models.some((model) => model.id === 'gpt-5.2-codex')).toBe(false)
expect(catalog.models.some((model) => model.id === 'luna')).toBe(false)
})
it('parses Codex discovery into model-specific reasoning options', () => {
const catalog = getAgentSessionOptionCatalog('codex')!
expect(catalog.listModels?.command).toBe('codex debug models')
expect(catalog.discoveredModelsAreAuthoritative).toBe(true)
const parsed = catalog.listModels!.parse(
JSON.stringify({
models: [
{
slug: 'gpt-5.6-sol',
display_name: 'GPT-5.6-Sol',
default_reasoning_level: 'low',
visibility: 'list',
supported_reasoning_levels: [
{ effort: 'low' },
{ effort: 'high' },
{ effort: 'max' },
{ effort: 'ultra' }
]
},
{
slug: 'gpt-5.6-luna',
display_name: 'GPT-5.6-Luna',
default_reasoning_level: 'medium',
visibility: 'list',
supported_reasoning_levels: [{ effort: 'low' }, { effort: 'max' }]
},
{
slug: 'gpt-5.5',
display_name: 'GPT-5.5',
visibility: 'list',
supported_reasoning_levels: [
{ effort: 'low' },
{ effort: 'medium' },
{ effort: 'high' },
{ effort: 'xhigh' }
]
},
{
slug: 'codex-auto-review',
display_name: 'Codex Auto Review',
visibility: 'hide',
supported_reasoning_levels: [{ effort: 'max' }]
}
]
})
)
expect(parsed.map(({ id }) => id)).toEqual(['gpt-5.6-sol', 'gpt-5.6-luna', 'gpt-5.5'])
expect(
parsed[0].options[0].kind.type === 'select'
? parsed[0].options[0].kind.choices.map(({ value }) => value)
: []
).toEqual(['low', 'high', 'max', 'ultra'])
expect(parsed[0].options[0].kind).toMatchObject({ defaultValue: 'low' })
expect(
parsed[1].options[0].kind.type === 'select'
? parsed[1].options[0].kind.choices.map(({ value }) => value)
: []
).toEqual(['low', 'max'])
expect(
parsed[2].options[0].kind.type === 'select'
? parsed[2].options[0].kind.choices.map(({ value }) => value)
: []
).toEqual(['low', 'medium', 'high', 'xhigh'])
})
it('keeps the Codex seed when model discovery output is malformed', () => {
const parse = getAgentSessionOptionCatalog('codex')!.listModels!.parse
expect(parse('')).toEqual([])
expect(parse('{"models":false}')).toEqual([])
expect(parse('garbage')).toEqual([])
})
it('deduplicates Codex models and reasoning levels from discovery', () => {
const parse = getAgentSessionOptionCatalog('codex')!.listModels!.parse
const parsed = parse(
JSON.stringify({
models: [
{
slug: 'gpt-account-frontier',
display_name: 'GPT Account Frontier',
visibility: 'list',
supported_reasoning_levels: [{ effort: 'low' }, { effort: 'low' }, { effort: 'high' }]
},
{ slug: 'gpt-account-frontier', display_name: 'Duplicate row', visibility: 'list' }
]
})
)
expect(parsed).toHaveLength(1)
expect(parsed[0].label).toBe('GPT Account Frontier')
expect(parsed[0].options[0].kind).toMatchObject({
choices: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
],
defaultValue: 'low'
})
})
it('keeps the Claude seed when list_models output is unsupported or malformed', () => {
const parse = getAgentSessionOptionCatalog('claude')!.listModels!.parse
const unsupported =
@@ -142,6 +269,21 @@ describe('agent session option catalog', () => {
})
})
it('passes a requested Codex effort through launch args instead of capping it', () => {
expect(
resolveAgentSessionOptionLaunch('codex', { model: 'gpt-5.6-luna', effort: 'max' })
).toEqual({
args: ['-m', 'gpt-5.6-luna', '-c', 'model_reasoning_effort=max'],
appliedValues: { model: 'gpt-5.6-luna', effort: 'max' }
})
expect(
resolveAgentSessionOptionLaunch('codex', { model: 'gpt-5.6-sol', effort: 'ultra' })
).toEqual({
args: ['-m', 'gpt-5.6-sol', '-c', 'model_reasoning_effort=ultra'],
appliedValues: { model: 'gpt-5.6-sol', effort: 'ultra' }
})
})
it('passes unknown model and option values through launch mappings', () => {
expect(
resolveAgentSessionOptionLaunch('claude', {
+5 -2
View File
@@ -1,9 +1,12 @@
import type { AgentType } from './agent-status-types'
import {
CLAUDE_SESSION_OPTION_CATALOG,
CODEX_SESSION_OPTION_CATALOG,
createClaudeCatalogOptions
} from './agent-session-option-catalog-claude-codex'
import {
CODEX_SESSION_OPTION_CATALOG,
createCodexCatalogOptions
} from './agent-session-option-catalog-codex'
import {
CURSOR_SESSION_OPTION_CATALOG,
GEMINI_SESSION_OPTION_CATALOG
@@ -26,7 +29,7 @@ export type {
CatalogOption,
CatalogOptionApply
} from './agent-session-option-catalog-types'
export { createClaudeCatalogOptions }
export { createClaudeCatalogOptions, createCodexCatalogOptions }
const CATALOGS: AgentSessionOptionCatalogMap = {
claude: CLAUDE_SESSION_OPTION_CATALOG,
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'
import { CODEX_MODEL_LIST_MAX_JSON_CHARACTERS, parseCodexModelList } from './codex-model-list-probe'
describe('Codex model list probe', () => {
it('parses advertised reasoning levels and keeps hidden rows for callers to filter', () => {
expect(
parseCodexModelList(
JSON.stringify({
models: [
{
slug: 'gpt-5.6-luna',
display_name: 'GPT-5.6-Luna',
default_reasoning_level: 'medium',
visibility: 'list',
supported_reasoning_levels: [{ effort: 'low' }, { effort: 'max' }]
},
{
slug: 'codex-auto-review',
display_name: 'Codex Auto Review',
visibility: 'hide',
supported_reasoning_levels: [{ effort: 'max' }]
}
]
})
)
).toEqual([
{
id: 'gpt-5.6-luna',
label: 'GPT-5.6-Luna',
effortLevels: ['low', 'max'],
defaultEffort: 'medium',
visibility: 'list'
},
{
id: 'codex-auto-review',
label: 'Codex Auto Review',
effortLevels: ['max'],
defaultEffort: null,
visibility: 'hide'
}
])
})
it('returns no models for malformed or empty output so callers keep their seed', () => {
expect(parseCodexModelList('')).toEqual([])
expect(parseCodexModelList('garbage')).toEqual([])
expect(parseCodexModelList('{"models":false}')).toEqual([])
})
it('rejects oversized output before JSON.parse', () => {
const parseSpy = vi.spyOn(JSON, 'parse')
try {
expect(parseCodexModelList(' '.repeat(CODEX_MODEL_LIST_MAX_JSON_CHARACTERS + 1))).toEqual([])
expect(parseSpy).not.toHaveBeenCalled()
} finally {
parseSpy.mockRestore()
}
})
})
+86
View File
@@ -0,0 +1,86 @@
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
export const CODEX_MODEL_LIST_ARGS = ['debug', 'models'] as const
export const CODEX_MODEL_LIST_COMMAND = `codex ${CODEX_MODEL_LIST_ARGS.join(' ')}`
export const CODEX_MODEL_LIST_MAX_JSON_CHARACTERS = 4 * 1024 * 1024
export const CODEX_MODEL_LIST_JSON_STRUCTURE_LIMITS = {
structuralTokens: 64 * 1024,
nestingDepth: 16
} as const
export type CodexModelListModel = {
id: string
label: string
effortLevels: string[]
defaultEffort: string | null
/** Absent on older CLI dumps; treat missing as listed. */
visibility: string | null
}
function nonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
return value.trim() || null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function uniqueEffortLevels(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
const seen = new Set<string>()
const levels: string[] = []
for (const entry of value) {
if (!isRecord(entry)) {
continue
}
const effort = nonEmptyString(entry.effort)
if (effort && !seen.has(effort)) {
seen.add(effort)
levels.push(effort)
}
}
return levels
}
export function parseCodexModelList(stdout: string): CodexModelListModel[] {
try {
if (stdout.length > CODEX_MODEL_LIST_MAX_JSON_CHARACTERS) {
return []
}
assertJsonTextStructureWithinLimits(stdout, CODEX_MODEL_LIST_JSON_STRUCTURE_LIMITS)
const parsed: unknown = JSON.parse(stdout)
if (!isRecord(parsed) || !Array.isArray(parsed.models)) {
return []
}
const seen = new Set<string>()
const result: CodexModelListModel[] = []
for (const value of parsed.models) {
if (!isRecord(value)) {
continue
}
const id = nonEmptyString(value.slug)
const label = nonEmptyString(value.display_name)
if (!id || !label || seen.has(id)) {
continue
}
seen.add(id)
result.push({
id,
label,
effortLevels: uniqueEffortLevels(value.supported_reasoning_levels),
defaultEffort: nonEmptyString(value.default_reasoning_level),
visibility: nonEmptyString(value.visibility)
})
}
return result
} catch {
return []
}
}
+30 -39
View File
@@ -1,11 +1,15 @@
import type { TuiAgent } from './tui-agent'
import { isTuiAgentEnabled } from './tui-agent-selection'
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
import {
CLAUDE_MODEL_LIST_ARGS,
CLAUDE_MODEL_LIST_STDIN,
parseClaudeModelList
} from './claude-model-list-probe'
import {
CODEX_MODEL_LIST_ARGS,
CODEX_MODEL_LIST_JSON_STRUCTURE_LIMITS,
parseCodexModelList
} from './codex-model-list-probe'
import { labelFromModelId } from './model-id-label'
/* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */
@@ -81,10 +85,7 @@ export type CommitMessageAgentCapability = {
defaultModelId: string
}
export const COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS = {
structuralTokens: 64 * 1024,
nestingDepth: 16
} as const
export const COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS = CODEX_MODEL_LIST_JSON_STRUCTURE_LIMITS
const BASIC_THINKING_LEVELS: ThinkingLevel[] = [
{ id: 'low', label: 'Low' },
@@ -172,39 +173,29 @@ export function parseClaudeModels(stdout: string): CommitMessageModel[] {
}
export function parseCodexModels(stdout: string): CommitMessageModel[] {
try {
assertJsonTextStructureWithinLimits(stdout, COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS)
const parsed = JSON.parse(stdout) as {
models?: {
slug?: string
display_name?: string
supported_reasoning_levels?: { effort?: string }[]
default_reasoning_level?: string
}[]
}
return uniqueModels(
(parsed.models ?? [])
.filter((model) => model.slug && model.display_name)
.map((model) => ({
id: model.slug!,
label: model.display_name!,
...(model.supported_reasoning_levels?.length
? {
thinkingLevels: model.supported_reasoning_levels
.map((level) => level.effort)
.filter((effort): effort is string => Boolean(effort))
.map((effort) => ({
id: effort,
label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort)
})),
defaultThinkingLevel: model.default_reasoning_level ?? 'low'
}
: {})
}))
)
} catch {
return []
}
return uniqueModels(
parseCodexModelList(stdout).map((model) => {
const defaultFromProbe =
model.defaultEffort && model.effortLevels.includes(model.defaultEffort)
? model.defaultEffort
: undefined
return {
id: model.id,
label: model.label,
...(model.effortLevels.length > 0
? {
thinkingLevels: model.effortLevels.map((effort) => ({
id: effort,
label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort)
})),
defaultThinkingLevel:
defaultFromProbe ??
(model.effortLevels.includes('low') ? 'low' : model.effortLevels[0])
}
: {})
}
})
)
}
export function parseLineModels(stdout: string): CommitMessageModel[] {
@@ -401,7 +392,7 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
modelSource: 'dynamic',
modelDiscovery: {
binary: 'codex',
args: ['debug', 'models'],
args: [...CODEX_MODEL_LIST_ARGS],
parse: parseCodexModels
},
// Why: ordered to match the official `codex` model picker — descending
@@ -1,8 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import {
CLAUDE_SESSION_OPTION_CATALOG,
CODEX_SESSION_OPTION_CATALOG
} from './agent-session-option-catalog-claude-codex'
import { CLAUDE_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex'
import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-codex'
import { GROK_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-grok'
import {
buildNativeChatSessionOptionCommand,
@@ -1,10 +1,8 @@
import { describe, expect, it } from 'vitest'
import type { SessionOptionDescriptor } from './native-chat-session-options'
import { mergeDiscoveredAuthoritativeModels } from './agent-session-option-catalog'
import {
CLAUDE_SESSION_OPTION_CATALOG,
CODEX_SESSION_OPTION_CATALOG
} from './agent-session-option-catalog-claude-codex'
import { CLAUDE_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex'
import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-codex'
import { GROK_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-grok'
import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch'
import {
@@ -1,8 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
CLAUDE_SESSION_OPTION_CATALOG,
CODEX_SESSION_OPTION_CATALOG
} from './agent-session-option-catalog-claude-codex'
import { CLAUDE_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex'
import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-codex'
import {
applyNativeChatReportedSessionOptions,
createNativeChatSessionOptionRecord,