From ce74fe9c421e91fff3e7cc67e16149789666abe3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:47:08 -0700 Subject: [PATCH] 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 --- ...stration-worker-launch-preferences.test.ts | 73 +++++--- .../native-chat-session-option-discovery.ts | 8 +- ...ive-chat-session-option-enrichment.test.ts | 74 ++++++++ .../native-chat-session-option-enrichment.ts | 2 +- .../src/i18n/native-chat-locales.test.ts | 14 +- ...ent-session-option-catalog-claude-codex.ts | 102 +---------- .../agent-session-option-catalog-codex.ts | 172 ++++++++++++++++++ .../agent-session-option-catalog-grok.test.ts | 5 +- .../agent-session-option-catalog.test.ts | 142 +++++++++++++++ src/shared/agent-session-option-catalog.ts | 7 +- src/shared/codex-model-list-probe.test.ts | 59 ++++++ src/shared/codex-model-list-probe.ts | 86 +++++++++ src/shared/commit-message-agent-spec.ts | 69 +++---- ...ative-chat-session-option-commands.test.ts | 6 +- ...ative-chat-session-option-snapshot.test.ts | 6 +- .../native-chat-session-option-state.test.ts | 6 +- 16 files changed, 650 insertions(+), 181 deletions(-) create mode 100644 src/shared/agent-session-option-catalog-codex.ts create mode 100644 src/shared/codex-model-list-probe.test.ts create mode 100644 src/shared/codex-model-list-probe.ts diff --git a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts index fc33c89cdda..c83f6c26484 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts @@ -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' diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts index 680f5d56d71..0457e1d2617 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -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 } : {}) + }) + : [] })) } diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts index b5650cc16bc..edb5f58f8d5 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -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, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts index 1bb49699050..6d6a2542e9b 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts @@ -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) diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts index 51deb11be28..cd545ccf3f9 100644 --- a/src/renderer/src/i18n/native-chat-locales.test.ts +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -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(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))( diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index b7e9d0e6bed..40f9f02decf 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -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')] -} diff --git a/src/shared/agent-session-option-catalog-codex.ts b/src/shared/agent-session-option-catalog-codex.ts new file mode 100644 index 00000000000..1336de617aa --- /dev/null +++ b/src/shared/agent-session-option-catalog-codex.ts @@ -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() + 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 + } +} diff --git a/src/shared/agent-session-option-catalog-grok.test.ts b/src/shared/agent-session-option-catalog-grok.test.ts index 0295cb46ad7..6364d8abe8d 100644 --- a/src/shared/agent-session-option-catalog-grok.test.ts +++ b/src/shared/agent-session-option-catalog-grok.test.ts @@ -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() } }) diff --git a/src/shared/agent-session-option-catalog.test.ts b/src/shared/agent-session-option-catalog.test.ts index e7aae4aaaaa..e3eb2c79ab6 100644 --- a/src/shared/agent-session-option-catalog.test.ts +++ b/src/shared/agent-session-option-catalog.test.ts @@ -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', { diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index b242b6b27c9..e9b536da43c 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -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, diff --git a/src/shared/codex-model-list-probe.test.ts b/src/shared/codex-model-list-probe.test.ts new file mode 100644 index 00000000000..b351cf257a2 --- /dev/null +++ b/src/shared/codex-model-list-probe.test.ts @@ -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() + } + }) +}) diff --git a/src/shared/codex-model-list-probe.ts b/src/shared/codex-model-list-probe.ts new file mode 100644 index 00000000000..721e3583141 --- /dev/null +++ b/src/shared/codex-model-list-probe.ts @@ -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 { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function uniqueEffortLevels(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + const seen = new Set() + 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() + 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 [] + } +} diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index c65850ba08a..573b318cb3b 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -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