From 78333e8829ab04f6127019f069e70c08066ba27b Mon Sep 17 00:00:00 2001 From: Pascal Andy Date: Tue, 6 Oct 2026 13:59:50 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat:=20codex:=20expose=20the?= =?UTF-8?q?=20installed=20model=20catalog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Purpose: let users select current Codex models and supported reasoning efforts - Impact: preserve custom IDs, forward explicit effort, and separate effort-specific cache entries by GPT-6.1-Sol via Codex --- README.md | 26 ++- config/defaults.json | 1 + core/__tests__/App-render.test.tsx | 1 + core/__tests__/config-defaults.test.ts | 57 +++++- core/config/codiff-config.schema.json | 6 + core/config/types.ts | 1 + core/types.ts | 1 + electron/__tests__/agent.test.ts | 14 ++ electron/__tests__/codex-models.test.ts | 180 ++++++++++++++++ electron/__tests__/codex.test.ts | 113 ++++++++-- .../__tests__/narrative-walkthrough.test.ts | 20 ++ electron/agent.cjs | 11 +- electron/codex-models.cjs | 193 ++++++++++++++++++ electron/codex.cjs | 45 ++-- electron/config.cjs | 4 + electron/main.cjs | 75 ++++++- electron/narrative-walkthrough.cjs | 13 +- 17 files changed, 700 insertions(+), 61 deletions(-) create mode 100644 electron/__tests__/codex-models.test.ts create mode 100644 electron/codex-models.cjs diff --git a/README.md b/README.md index eb0804fe..2e0635c4 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ counts; when it is `false`, Codiff hides those changes from the working-tree rev "editorCommand": "", "lastRepositoryPath": "", "openAIModel": "gpt-5.6-terra", + "openAIReasoningEffort": "", "opencodeModel": "opencode-default", "sidebarPosition": "left", "showWhitespace": false, @@ -205,12 +206,25 @@ application menu: `settings.opencodeModel`. - `pi` — the Pi CLI, using its configured default model. -Codex walkthroughs default to GPT-5.6 Terra with low reasoning. The Model menu also offers GPT-5.6 -Sol and Luna with medium reasoning. GPT-6 Astra, Sol, and Luna can be set by model ID in -`settings.openAIModel`; they use low reasoning by default. If a selected GPT-6 or GPT-5.6 model is -unavailable, Codiff retries with Terra when applicable and then GPT-5.5, persisting the first model -that succeeds. Walkthroughs with at least 100 reviewable hunks use GPT-5.5 with low reasoning when -Terra is the configured default. +The Codex `Model` and `Reasoning Effort` menus load the installed CLI's model catalog in the +background. They offer its visible models and supported reasoning efforts, including new models +without a Codiff update. If discovery fails or an older CLI does not support it, the predefined +model choices remain available. Custom model IDs in `settings.openAIModel` are also accepted. + +Set `settings.openAIReasoningEffort` to an effort supported by your selected model, for example +`"high"` with `settings.openAIModel` set to `"gpt-6.1-sol"`. Selecting a different model in the menu +resets an explicit effort that the new model does not advertise. Leave the effort empty to keep +Codiff's existing defaults for its predefined models and inherit Codex settings for other models. +An unsupported explicit effort reports an error rather than triggering a model fallback. +An explicit effort also applies to a fallback model; clear it to use that model's default. +Walkthrough caches distinguish explicit reasoning efforts, so a new generation request with a +different effort does not reuse the previous effort's result. + +Codex walkthroughs still default to GPT-5.6 Terra with low reasoning. GPT-5.6 Sol and Luna use +medium reasoning unless you override it. If a selected model is unavailable, Codiff retries with +Terra when applicable and then GPT-5.5, persisting the first model that succeeds unless you changed +the selection during the run. Walkthroughs with at least 100 reviewable hunks use GPT-5.5 with low +reasoning when Terra is the configured default and no explicit effort overrides it. Install the backend you want and verify it is available before using `codiff -w`: diff --git a/config/defaults.json b/config/defaults.json index fd1ae4c7..4af3b337 100644 --- a/config/defaults.json +++ b/config/defaults.json @@ -11,6 +11,7 @@ "expandUnchanged": false, "lastRepositoryPath": "", "openAIModel": "gpt-5.6-terra", + "openAIReasoningEffort": "", "opencodeModel": "opencode-default", "piModel": "pi-default", "reviewCommentsPrefix": "# Address these Review Comments", diff --git a/core/__tests__/App-render.test.tsx b/core/__tests__/App-render.test.tsx index 747e0a4d..c25f2ca7 100644 --- a/core/__tests__/App-render.test.tsx +++ b/core/__tests__/App-render.test.tsx @@ -184,6 +184,7 @@ const createCodiffMock = (overrides: Partial = {}): Window['co expandUnchanged: false, lastRepositoryPath: '/repo', openAIModel: defaultSettings.openAIModel, + openAIReasoningEffort: defaultSettings.openAIReasoningEffort, opencodeModel: defaultSettings.opencodeModel, piModel: defaultSettings.piModel, reviewCommentsPrefix: defaultSettings.reviewCommentsPrefix, diff --git a/core/__tests__/config-defaults.test.ts b/core/__tests__/config-defaults.test.ts index cff379d6..2d7a741e 100644 --- a/core/__tests__/config-defaults.test.ts +++ b/core/__tests__/config-defaults.test.ts @@ -2,18 +2,24 @@ import { copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { expect, test } from 'vite-plus/test'; +import { expect, test, vi } from 'vite-plus/test'; import packageJson from '../../package.json' with { type: 'json' }; import schema from '../config/codiff-config.schema.json' with { type: 'json' }; import { createDefaultConfig } from '../config/defaults.ts'; import { createTemporaryDirectorySync, createTemporaryEnvironment } from './helpers/resources.ts'; const require = createRequire(import.meta.url); -const { createDefaultConfig: createElectronDefaultConfig, readConfig } = - require('../../electron/config.cjs') as { - createDefaultConfig: typeof createDefaultConfig; - readConfig: () => ReturnType; - }; +const { + createDefaultConfig: createElectronDefaultConfig, + readConfig, + watchConfig, + writeConfig, +} = require('../../electron/config.cjs') as { + createDefaultConfig: typeof createDefaultConfig; + readConfig: () => ReturnType; + watchConfig: (onChange: (config: ReturnType) => void) => () => void; + writeConfig: (config: ReturnType) => void; +}; const readElectronConfig = (raw: unknown) => { using home = createTemporaryDirectorySync('codiff-config-home.'); @@ -88,6 +94,45 @@ test('electron config normalizes sidebar position', () => { ).toBe('left'); }); +test('old configurations retain automatic reasoning and custom values survive save and reload', async () => { + expect(readElectronConfig({}).settings.openAIReasoningEffort).toBe(''); + expect( + readElectronConfig({ settings: { openAIReasoningEffort: 42 } }).settings.openAIReasoningEffort, + ).toBe(''); + using home = createTemporaryDirectorySync('codiff-model-config.'); + using _environment = createTemporaryEnvironment({ HOME: home.path }); + const configDirectory = join(home.path, '.codiff'); + mkdirSync(configDirectory); + writeFileSync( + join(configDirectory, 'codiff.jsonc'), + JSON.stringify({ settings: { openAIModel: 'gpt-6.1-sol', openAIReasoningEffort: ' high ' } }), + ); + const config = readConfig(); + expect(config.settings).toMatchObject({ + openAIModel: 'gpt-6.1-sol', + openAIReasoningEffort: 'high', + }); + let reloaded: ReturnType | undefined; + const stop = watchConfig((next) => { + reloaded = next; + }); + try { + writeConfig({ ...config, settings: { ...config.settings, openAIReasoningEffort: 'ultra' } }); + expect(readConfig().settings).toMatchObject({ + openAIModel: 'gpt-6.1-sol', + openAIReasoningEffort: 'ultra', + }); + await vi.waitFor(() => + expect(reloaded?.settings).toMatchObject({ + openAIModel: 'gpt-6.1-sol', + openAIReasoningEffort: 'ultra', + }), + ); + } finally { + stop(); + } +}); + test('electron config keeps custom walkthrough prompt text only when it is a string', () => { expect( readElectronConfig({ diff --git a/core/config/codiff-config.schema.json b/core/config/codiff-config.schema.json index 4819e612..010bfc77 100644 --- a/core/config/codiff-config.schema.json +++ b/core/config/codiff-config.schema.json @@ -73,6 +73,12 @@ "default": "gpt-5.6-terra", "description": "The OpenAI model to use for review assistance and walkthroughs." }, + "openAIReasoningEffort": { + "type": "string", + "default": "", + "examples": ["high", "xhigh", "max", "ultra"], + "description": "Codex reasoning effort for review assistance and walkthroughs. Supported values depend on the selected model and installed Codex CLI. Leave empty to preserve Codiff's existing defaults for its predefined models and inherit Codex settings for other models." + }, "opencodeModel": { "type": "string", "default": "opencode-default", diff --git a/core/config/types.ts b/core/config/types.ts index 21888e06..514b3992 100644 --- a/core/config/types.ts +++ b/core/config/types.ts @@ -14,6 +14,7 @@ export type CodiffSettings = { expandUnchanged: boolean; lastRepositoryPath: string; openAIModel: string; + openAIReasoningEffort: string; opencodeModel: string; piModel: string; reviewCommentsPrefix: string; diff --git a/core/types.ts b/core/types.ts index c074f270..de80b0d5 100644 --- a/core/types.ts +++ b/core/types.ts @@ -802,6 +802,7 @@ export type CodiffPreferences = { expandUnchanged: boolean; lastRepositoryPath: string; openAIModel: string; + openAIReasoningEffort: string; opencodeModel: string; piModel: string; reviewCommentsPrefix: string; diff --git a/electron/__tests__/agent.test.ts b/electron/__tests__/agent.test.ts index 416ca109..7044551a 100644 --- a/electron/__tests__/agent.test.ts +++ b/electron/__tests__/agent.test.ts @@ -26,6 +26,7 @@ const { getAgentMenuModels: ( agent: ReturnType, selectedModel: string, + models?: ReadonlyArray<{ id: string; label: string }>, ) => ReadonlyArray<{ id: string; label: string }>; listAgents: () => ReadonlyArray<{ id: string }>; normalizeAgentBackend: (value: unknown) => string; @@ -111,6 +112,19 @@ test('shows a custom configured model in the agent model menu', () => { ]); }); +test('uses the runtime Codex catalog and preserves a selection missing from it', () => { + const agent = getAgent('codex'); + const catalog = [ + { id: 'gpt-6.1-sol', label: 'GPT-6.1 Sol' }, + { id: 'gpt-6-astra', label: 'GPT-6 Astra' }, + ]; + expect(getAgentMenuModels(agent, 'gpt-6.1-sol', catalog)).toEqual(catalog); + expect(getAgentMenuModels(agent, 'custom-codex-model', catalog)).toEqual([ + ...catalog, + { id: 'custom-codex-model', label: 'Custom: custom-codex-model' }, + ]); +}); + test('falls back to the default backend for unknown ids', () => { expect(getAgent('unknown').id).toBe('codex'); }); diff --git a/electron/__tests__/codex-models.test.ts b/electron/__tests__/codex-models.test.ts new file mode 100644 index 00000000..308ff90d --- /dev/null +++ b/electron/__tests__/codex-models.test.ts @@ -0,0 +1,180 @@ +import { createRequire } from 'node:module'; +import { createInterface } from 'node:readline'; +import { beforeEach, expect, test, vi } from 'vite-plus/test'; +import { createCommandTransport, type FakeCommandProcess } from './helpers/command-transport.ts'; + +const require = createRequire(import.meta.url); +const { readCodexModels } = require('../codex-models.cjs') as { + readCodexModels: (options?: { + commandTransport?: ReturnType['transport']; + signal?: AbortSignal; + timeoutMs?: number; + }) => Promise< + ReadonlyArray<{ id: string; label: string; reasoningEfforts: ReadonlyArray }> + >; +}; + +type Request = { + id?: number; + method: string; + params?: { cursor?: string; includeHidden?: boolean }; +}; + +const createCatalogTransport = ( + onRequest: (request: Request, process: FakeCommandProcess) => void, +) => + createCommandTransport((process) => { + const lines = createInterface({ input: process.stdin }); + lines.on('line', (line) => onRequest(JSON.parse(line), process)); + }); + +beforeEach(() => { + const shell = process.env.SHELL; + delete process.env.SHELL; + return () => { + if (shell === undefined) delete process.env.SHELL; + else process.env.SHELL = shell; + }; +}); + +test('discovers every visible page with executable IDs and model-specific efforts', async () => { + const requests: Request[] = []; + const { calls, transport } = createCatalogTransport((request, process) => { + requests.push(request); + if (request.method === 'initialize') { + process.stdout(`${JSON.stringify({ id: request.id, result: {} })}\n`); + } else if (request.method === 'model/list') { + const result = request.params?.cursor + ? { + data: [ + { + model: 'gpt-6-luna', + displayName: 'GPT-6 Luna', + supportedReasoningEfforts: [ + { reasoningEffort: 'low' }, + { reasoningEffort: 'high' }, + ], + }, + ], + nextCursor: null, + } + : { + data: [ + { + id: 'picker-sol', + model: 'gpt-6.1-sol', + displayName: 'GPT-6.1 Sol', + supportedReasoningEfforts: [ + { reasoningEffort: 'high' }, + { reasoningEffort: 'ultra' }, + { reasoningEffort: 'high' }, + null, + ], + }, + { model: 'hidden-model', hidden: true }, + { model: '' }, + null, + ], + nextCursor: 'page-2', + }; + process.stdout(`${JSON.stringify({ id: request.id, result })}\n`); + } + }); + await expect(readCodexModels({ commandTransport: transport })).resolves.toEqual([ + { id: 'gpt-6.1-sol', label: 'GPT-6.1 Sol', reasoningEfforts: ['high', 'ultra'] }, + { id: 'gpt-6-luna', label: 'GPT-6 Luna', reasoningEfforts: ['low', 'high'] }, + ]); + expect(requests.map((request) => request.method)).toEqual([ + 'initialize', + 'initialized', + 'model/list', + 'model/list', + ]); + expect( + requests.filter((request) => request.method === 'model/list').map((request) => request.params), + ).toEqual([ + { limit: 100, includeHidden: false }, + { limit: 100, includeHidden: false, cursor: 'page-2' }, + ]); + expect(calls[0].process.killed).toBe(true); +}); + +test.each([ + { result: { data: [], nextCursor: 'loop' }, error: 'repeated' }, + { result: { data: 'invalid', nextCursor: null }, error: 'invalid model catalog' }, +])('rejects a broken catalog and terminates discovery: $error', async ({ result, error }) => { + const { calls, transport } = createCatalogTransport((request, process) => { + if (request.id != null) + process.stdout( + `${JSON.stringify({ id: request.id, result: request.method === 'initialize' ? {} : result })}\n`, + ); + }); + await expect(readCodexModels({ commandTransport: transport })).rejects.toThrow(error); + expect(calls[0].process.killed).toBe(true); +}); + +test('rejects an unsupported model/list method so callers can retain manual choices', async () => { + const { calls, transport } = createCatalogTransport((request, process) => { + if (request.method === 'initialize') + process.stdout(`${JSON.stringify({ id: request.id, result: {} })}\n`); + if (request.method === 'model/list') + process.stdout( + `${JSON.stringify({ id: request.id, error: { code: -32601, message: 'Method not found' } })}\n`, + ); + }); + await expect(readCodexModels({ commandTransport: transport })).rejects.toThrow( + 'Method not found', + ); + expect(calls[0].process.killed).toBe(true); +}); + +test('returns an empty catalog without inventing available models', async () => { + const { transport } = createCatalogTransport((request, process) => { + if (request.id != null) + process.stdout( + `${JSON.stringify({ id: request.id, result: request.method === 'initialize' ? {} : { data: [], nextCursor: null } })}\n`, + ); + }); + await expect(readCodexModels({ commandTransport: transport })).resolves.toEqual([]); +}); + +test('escalates termination when the CLI does not close after SIGTERM', async () => { + const signals: Array = []; + const { transport } = createCommandTransport((process) => { + const kill = process.process.kill.bind(process.process); + process.process.kill = (signal) => { + signals.push(signal); + return kill(signal); + }; + }); + await expect(readCodexModels({ commandTransport: transport, timeoutMs: 10 })).rejects.toThrow( + 'timed out', + ); + await vi.waitFor(() => expect(signals).toEqual(['SIGTERM', 'SIGKILL'])); +}); + +test('times out an unresponsive CLI and kills its process', async () => { + const { calls, transport } = createCommandTransport(() => {}); + await expect(readCodexModels({ commandTransport: transport, timeoutMs: 10 })).rejects.toThrow( + 'timed out', + ); + expect(calls[0].process.killed).toBe(true); +}); + +test('cancels pending discovery when the application exits', async () => { + const controller = new AbortController(); + const signals: Array = []; + const { calls, transport } = createCommandTransport((process) => { + const kill = process.process.kill.bind(process.process); + process.process.kill = (signal) => { + signals.push(signal); + return kill(signal); + }; + process.stdin.on('data', () => controller.abort()); + }); + await expect( + readCodexModels({ commandTransport: transport, signal: controller.signal }), + ).rejects.toThrow('cancelled'); + expect(calls[0].process.killed).toBe(true); + expect(signals).toEqual(['SIGTERM', 'SIGKILL']); +}); diff --git a/electron/__tests__/codex.test.ts b/electron/__tests__/codex.test.ts index 713b645c..dbcbeee7 100644 --- a/electron/__tests__/codex.test.ts +++ b/electron/__tests__/codex.test.ts @@ -46,7 +46,7 @@ const { }) => void; onModelFallback?: (fallbackModel: string, originalModel: string) => void; onProgress?: (phase: string) => void; - reasoningEffort?: 'low' | 'medium' | 'high'; + reasoningEffort?: string; timeoutMs?: number; }, ) => Promise; @@ -87,18 +87,11 @@ beforeEach(() => { }; }); -test('normalizes OpenAI model preferences to known models', () => { - expect(normalizeOpenAIModel('gpt-6-astra')).toBe('gpt-6-astra'); - expect(normalizeOpenAIModel('gpt-6-sol')).toBe('gpt-6-sol'); - expect(normalizeOpenAIModel('gpt-6-luna')).toBe('gpt-6-luna'); - expect(normalizeOpenAIModel('gpt-5.6-sol')).toBe('gpt-5.6-sol'); - expect(normalizeOpenAIModel('gpt-5.6-terra')).toBe('gpt-5.6-terra'); - expect(normalizeOpenAIModel('gpt-5.6-luna')).toBe('gpt-5.6-luna'); - expect(normalizeOpenAIModel('gpt-5.5')).toBe('gpt-5.5'); - expect(normalizeOpenAIModel('gpt-5.3-codex-spark')).toBe(DEFAULT_OPENAI_MODEL); - expect(normalizeOpenAIModel('gpt-5.4-mini')).toBe(DEFAULT_OPENAI_MODEL); - expect(normalizeOpenAIModel('gpt-5.3-codex')).toBe(DEFAULT_OPENAI_MODEL); - expect(normalizeOpenAIModel('gpt-4o')).toBe(DEFAULT_OPENAI_MODEL); +test('uses the default model only for empty or invalid preferences', () => { + for (const value of [undefined, null, 42, '', ' ']) { + expect(normalizeOpenAIModel(value)).toBe(DEFAULT_OPENAI_MODEL); + } + expect(normalizeOpenAIModel(' gpt-6.1-sol ')).toBe('gpt-6.1-sol'); }); test('rejects invalid explicit Codex CLI overrides', async () => { @@ -169,6 +162,82 @@ test('runs Codex walkthroughs as fresh ephemeral repository-scoped calls', async expect(calls[0].args).not.toContain('resume'); }); +test.each(['high', 'ultra'])( + 'passes a custom Codex model with explicit %s effort to exec', + async (effort) => { + const { calls, transport } = createCommandTransport((commandProcess) => { + commandProcess.stdin.on('finish', () => void completeCodexExec(commandProcess)); + }); + await expect( + runCodex('/repo', 'prompt', {}, undefined, undefined, { + commandTransport: transport, + model: 'gpt-6.1-sol', + reasoningEffort: effort, + }), + ).resolves.toBe('{"version":1}'); + expect(getArgumentValue(calls[0].args, '-m')).toBe('gpt-6.1-sol'); + expect(getArgumentValue(calls[0].args, '-c')).toBe(`model_reasoning_effort="${effort}"`); + }, +); + +test('inherits Codex effort for custom models when no override is configured', async () => { + const { calls, transport } = createCommandTransport((commandProcess) => { + commandProcess.stdin.on('finish', () => void completeCodexExec(commandProcess)); + }); + await runCodex('/repo', 'prompt', {}, undefined, undefined, { + commandTransport: transport, + model: 'future-codex-model', + }); + expect(getArgumentValue(calls[0].args, '-m')).toBe('future-codex-model'); + expect(calls[0].args).not.toContain('-c'); +}); + +test('keeps an explicit effort override when an unavailable model falls back', async () => { + const attempts: string[] = []; + const { transport } = createCommandTransport((process) => { + process.stdin.on('finish', () => { + const model = getArgumentValue(process.args, '-m'); + attempts.push(`${model}|${getArgumentValue(process.args, '-c')}`); + if (model === 'gpt-6.1-sol') { + process.stderr('You do not have access to model gpt-6.1-sol.'); + process.close(1); + } else void completeCodexExec(process); + }); + }); + await expect( + runCodex('/repo', 'prompt', {}, undefined, undefined, { + commandTransport: transport, + model: 'gpt-6.1-sol', + reasoningEffort: 'high', + }), + ).resolves.toBe('{"version":1}'); + expect(attempts).toEqual([ + 'gpt-6.1-sol|model_reasoning_effort="high"', + 'gpt-5.6-terra|model_reasoning_effort="high"', + ]); +}); + +test.each([ + 'The model does not support the requested reasoning effort.', + 'model_reasoning_effort is not supported.', + 'Unsupported reasoning effort: HTTP 404.', +])('reports unsupported effort without retrying another model: %s', async (message) => { + const { calls, transport } = createCommandTransport((commandProcess) => { + commandProcess.stdin.on('finish', () => { + commandProcess.stderr(message); + commandProcess.close(1); + }); + }); + await expect( + runCodex('/repo', 'prompt', {}, undefined, undefined, { + commandTransport: transport, + model: 'gpt-6.1-sol', + reasoningEffort: 'unsupported-effort', + }), + ).rejects.toThrow(message); + expect(calls).toHaveLength(1); +}); + test('retries unavailable GPT-5.6 models with model-specific reasoning', async () => { const attempts: Array = []; const { transport } = createCommandTransport((commandProcess) => { @@ -204,14 +273,18 @@ test('retries unavailable GPT-5.6 models with model-specific reasoning', async ( expect(fallbacks).toEqual([['gpt-5.5', 'gpt-5.6-sol']]); }); -test('retries unavailable GPT-6 models with Terra before GPT-5.5', async () => { +test.each([ + 'You do not have access to model gpt-6-sol.', + 'HTTP 403 Forbidden', + 'HTTP 404 Not Found', +])('retries unavailable GPT-6 models with Terra before GPT-5.5: %s', async (message) => { const attempts: Array = []; const { transport } = createCommandTransport((commandProcess) => { commandProcess.stdin.on('finish', () => { const model = getArgumentValue(commandProcess.args, '-m') || ''; attempts.push(model); if (model === 'gpt-6-sol') { - commandProcess.stderr(`You do not have access to model ${model}.`); + commandProcess.stderr(message); commandProcess.close(1); } else { void completeCodexExec(commandProcess); @@ -304,6 +377,8 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { await expect( runCodex(directory.path, 'prompt', {}, 'walkthrough.json', 'Timed out.', { + model: 'gpt-6.1-sol', + reasoningEffort: 'high', onProgress: (phase) => phases.push(phase), onMetrics: (value) => metrics.push(value), }), @@ -338,18 +413,24 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { expect(records.filter((record) => record.arg).map((record) => record.arg)).toContain( 'app-server', ); + expect(records.filter((record) => record.arg).map((record) => record.arg)).toContain( + 'model_reasoning_effort="high"', + ); const threadStart = records.find((record) => record.message?.method === 'thread/start').message; expect(threadStart.params).toMatchObject({ approvalPolicy: 'never', cwd: directory.path, ephemeral: true, + model: 'gpt-6.1-sol', + config: { model_reasoning_effort: 'high' }, sandbox: 'read-only', }); const turnStart = records.find((record) => record.message?.method === 'turn/start').message; expect(turnStart.params).toMatchObject({ approvalPolicy: 'never', cwd: directory.path, - effort: 'low', + effort: 'high', + model: 'gpt-6.1-sol', outputSchema: {}, sandboxPolicy: { networkAccess: false, diff --git a/electron/__tests__/narrative-walkthrough.test.ts b/electron/__tests__/narrative-walkthrough.test.ts index 76a6154f..89988a23 100644 --- a/electron/__tests__/narrative-walkthrough.test.ts +++ b/electron/__tests__/narrative-walkthrough.test.ts @@ -24,6 +24,7 @@ const { model: unknown, context?: unknown, customPrompt?: string, + reasoningEffort?: string, ) => string; narrativeWalkthroughSchema: { properties: Record; @@ -644,6 +645,25 @@ test('builds cache keys from semantic generation inputs', () => { summary: 'Prior discussion', }), ).not.toBe(key); + const codex = { ...agent, id: 'codex', label: 'Codex' }; + const automatic = getNarrativeWalkthroughCacheKey(state, codex, 'gpt-6.1-sol', null); + const low = getNarrativeWalkthroughCacheKey(state, codex, 'gpt-6.1-sol', null, undefined, 'low'); + const high = getNarrativeWalkthroughCacheKey( + state, + codex, + 'gpt-6.1-sol', + null, + undefined, + 'high', + ); + expect(high).not.toBe(low); + expect(high).not.toBe(automatic); + expect(getNarrativeWalkthroughCacheKey(state, codex, 'gpt-6.1-sol', null, undefined, '')).toBe( + automatic, + ); + expect( + getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', null, undefined, 'high'), + ).toBe(key); }); test('omits blank custom walkthrough prompt guidance', () => { diff --git a/electron/agent.cjs b/electron/agent.cjs index 29ff8efb..792db6a6 100644 --- a/electron/agent.cjs +++ b/electron/agent.cjs @@ -27,7 +27,7 @@ const { readPiSessionContext } = require('./pi-session-context.cjs'); * onModelFallback?: (fallbackModel: string, originalModel: string) => Promise | void; * onPartialText?: (delta: string) => void; * onProgress?: (phase: import('../core/types.ts').WalkthroughProgressPhase) => void; - * reasoningEffort?: 'low' | 'medium' | 'high'; + * reasoningEffort?: string; * timeoutMs?: number; * }} AgentOptions * @typedef {{ @@ -182,12 +182,13 @@ const listAgents = () => AGENT_BACKENDS.map((id) => AGENT_FACTORIES[id]()); /** * @param {Agent} agent * @param {string} selectedModel + * @param {Agent['models']} [models] * @returns {ReadonlyArray<{id: string; label: string}>} */ -const getAgentMenuModels = (agent, selectedModel) => - agent.models.some((model) => model.id === selectedModel) - ? agent.models - : [...agent.models, { id: selectedModel, label: `Custom: ${selectedModel}` }]; +const getAgentMenuModels = (agent, selectedModel, models = agent.models) => + models.some((model) => model.id === selectedModel) + ? models + : [...models, { id: selectedModel, label: `Custom: ${selectedModel}` }]; /** * Select the first installed backend without launching a CLI process. diff --git a/electron/codex-models.cjs b/electron/codex-models.cjs new file mode 100644 index 00000000..f9e409b5 --- /dev/null +++ b/electron/codex-models.cjs @@ -0,0 +1,193 @@ +// @ts-check + +const { createInterface } = require('node:readline'); +const { resolveAgentCommandTransport } = require('./agent-command.cjs'); +const { getCodexCommand } = require('./codex.cjs'); +const { getCommandEnvironment } = require('./login-shell-environment.cjs'); + +/** + * @typedef {{ + * id: string; + * label: string; + * reasoningEfforts: ReadonlyArray; + * }} CodexModel + */ + +/** @param {unknown} value @returns {value is Record} */ +const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Read the installed CLI's picker catalog without starting an inference turn. + * @param {{ + * commandTransport?: import('./agent-command.cjs').AgentCommandTransport; + * signal?: AbortSignal; + * timeoutMs?: number; + * }} [options] + * @returns {Promise>} + */ +const readCodexModels = async (options = {}) => { + const environment = await getCommandEnvironment(); + if (options.signal?.aborted) { + throw new Error('Codex model discovery was cancelled.'); + } + const transport = resolveAgentCommandTransport(options.commandTransport, getCodexCommand); + const child = transport.spawn(transport.command, ['app-server', '--stdio'], { + env: environment, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const lines = createInterface({ input: child.stdout }); + + return new Promise((resolve, reject) => { + let finished = false; + let requestId = 0; + let stderr = ''; + /** @type {{id: number; resolve: (value: unknown) => void; reject: (error: Error) => void} | undefined} */ + let pending; + const timer = setTimeout( + () => finish(new Error('Codex model discovery timed out.')), + options.timeoutMs ?? 8_000, + ); + + /** @param {Error | null} error @param {ReadonlyArray} [models] */ + const finish = (error, models = []) => { + if (finished) return; + finished = true; + clearTimeout(timer); + options.signal?.removeEventListener('abort', onAbort); + lines.close(); + child.stdin.end(); + child.kill('SIGTERM'); + if (options.signal?.aborted) { + child.kill('SIGKILL'); + } else { + const killTimer = setTimeout(() => child.kill('SIGKILL'), 250); + killTimer.unref(); + child.once('close', () => clearTimeout(killTimer)); + } + pending?.reject(error || new Error('Codex model discovery finished.')); + pending = undefined; + if (error) reject(error); + else resolve(models); + }; + const onAbort = () => finish(new Error('Codex model discovery was cancelled.')); + options.signal?.addEventListener('abort', onAbort, { once: true }); + + /** @param {unknown} message */ + const send = (message) => { + if (!finished) child.stdin.write(`${JSON.stringify(message)}\n`); + }; + /** @param {string} method @param {unknown} params @returns {Promise} */ + const request = (method, params) => + new Promise((resolveRequest, rejectRequest) => { + if (finished) { + rejectRequest(new Error('Codex model discovery finished.')); + return; + } + requestId += 1; + pending = { id: requestId, resolve: resolveRequest, reject: rejectRequest }; + send({ id: requestId, method, params }); + }); + + lines.on('line', (line) => { + if (finished) return; + try { + /** @type {unknown} */ + const message = JSON.parse(line); + if (!isRecord(message)) return; + if (typeof message.method === 'string' && message.id != null) { + send({ + id: message.id, + error: { + code: -32601, + message: 'Model discovery does not handle interactive requests.', + }, + }); + return; + } + if (typeof message.id !== 'number') return; + const entry = pending; + if (!entry || entry.id !== message.id) return; + pending = undefined; + if (message.error) { + entry.reject( + new Error( + isRecord(message.error) && typeof message.error.message === 'string' + ? message.error.message + : 'Codex model discovery failed.', + ), + ); + } else entry.resolve(message.result); + } catch { + finish(new Error('Codex returned an invalid model catalog response.')); + } + }); + lines.on('error', (error) => finish(error)); + child.stderr.on('data', (chunk) => { + stderr = (stderr + chunk.toString()).slice(-4_096); + }); + child.stdin.on('error', (error) => finish(error)); + child.on('error', (error) => finish(error)); + child.on('close', () => + finish(new Error(stderr.trim() || 'Codex exited before returning its model catalog.')), + ); + + void (async () => { + await request('initialize', { + clientInfo: { name: 'codiff', title: 'Codiff', version: '1' }, + capabilities: { experimentalApi: true, requestAttestation: false }, + }); + send({ method: 'initialized' }); + /** @type {Map} */ + const models = new Map(); + const cursors = new Set(); + let cursor; + do { + const page = await request('model/list', { + limit: 100, + includeHidden: false, + ...(cursor ? { cursor } : {}), + }); + if (!isRecord(page) || !Array.isArray(page.data)) { + throw new Error('Codex returned an invalid model catalog.'); + } + for (const item of page.data) { + if (!isRecord(item) || item.hidden === true) continue; + const id = + typeof item.model === 'string' + ? item.model.trim() + : typeof item.id === 'string' + ? item.id.trim() + : ''; + if (!id) continue; + const efforts = Array.isArray(item.supportedReasoningEfforts) + ? item.supportedReasoningEfforts.flatMap((option) => + isRecord(option) && + typeof option.reasoningEffort === 'string' && + option.reasoningEffort.trim() + ? [option.reasoningEffort.trim()] + : [], + ) + : []; + models.set(id, { + id, + label: + typeof item.displayName === 'string' && item.displayName.trim() + ? item.displayName.trim() + : id, + reasoningEfforts: [...new Set(efforts)], + }); + } + if (page.nextCursor != null && typeof page.nextCursor !== 'string') { + throw new Error('Codex returned an invalid model catalog cursor.'); + } + cursor = page.nextCursor || undefined; + if (cursor && cursors.has(cursor)) + throw new Error('Codex repeated a model catalog cursor.'); + if (cursor) cursors.add(cursor); + } while (cursor); + finish(null, [...models.values()]); + })().catch((error) => finish(error instanceof Error ? error : new Error(String(error)))); + }); +}; + +module.exports = { readCodexModels }; diff --git a/electron/codex.cjs b/electron/codex.cjs index d3507e52..2f949f33 100644 --- a/electron/codex.cjs +++ b/electron/codex.cjs @@ -9,7 +9,6 @@ const { cleanText, findExecutableOnPath, isExecutableFile, - normalizeEnum, oneLine, parseJSONMessage, truncate, @@ -42,7 +41,7 @@ const CODEX_NOT_FOUND_MESSAGE = * }) => void; * onModelFallback?: (fallbackModel: string, originalModel: string) => Promise | void; * onProgress?: (phase: import('../core/types.ts').WalkthroughProgressPhase) => void; - * reasoningEffort?: 'low' | 'medium' | 'high'; + * reasoningEffort?: string; * timeoutMs?: number; * }} CodexOptions */ @@ -71,16 +70,14 @@ const OPENAI_MODELS = Object.freeze([ label: 'Compatibility: GPT-5.5', }, ]); -const OPENAI_MODEL_IDS = new Set([ - ...OPENAI_MODELS.map((model) => model.id), - 'gpt-6-astra', - 'gpt-6-sol', - 'gpt-6-luna', -]); -const CODEX_REASONING_EFFORTS = new Set(['low', 'medium', 'high']); const OPENAI_MODEL_REASONING_EFFORTS = new Map([ + [DEFAULT_OPENAI_MODEL, CODEX_REASONING_EFFORT], + [FALLBACK_OPENAI_MODEL, CODEX_REASONING_EFFORT], ['gpt-5.6-sol', 'medium'], ['gpt-5.6-luna', 'medium'], + ['gpt-6-astra', CODEX_REASONING_EFFORT], + ['gpt-6-sol', CODEX_REASONING_EFFORT], + ['gpt-6-luna', CODEX_REASONING_EFFORT], ]); /** @param {string} [detail] */ @@ -225,23 +222,19 @@ const getCodexStructuredErrorMessage = (value) => { /** @param {unknown} value @returns {string} */ const normalizeOpenAIModel = (value) => - normalizeEnum(value, OPENAI_MODEL_IDS, DEFAULT_OPENAI_MODEL); + typeof value === 'string' && value.trim() ? value.trim() : DEFAULT_OPENAI_MODEL; /** @param {unknown} model @param {unknown} [reasoningEffort] */ const getOpenAIModelReasoningEffort = (model, reasoningEffort) => - normalizeEnum( - reasoningEffort, - CODEX_REASONING_EFFORTS, - OPENAI_MODEL_REASONING_EFFORTS.get(normalizeOpenAIModel(model)) || CODEX_REASONING_EFFORT, - ); + typeof reasoningEffort === 'string' && reasoningEffort.trim() + ? reasoningEffort.trim() + : OPENAI_MODEL_REASONING_EFFORTS.get(normalizeOpenAIModel(model)); /** @param {unknown} model @param {unknown} [fallbackModel] */ const getOpenAIModelFallbacks = (model, fallbackModel = FALLBACK_OPENAI_MODEL) => { const normalizedModel = normalizeOpenAIModel(model); const candidates = [ - ...(['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-luna'].includes( - normalizedModel, - ) + ...(normalizedModel !== DEFAULT_OPENAI_MODEL && normalizedModel !== FALLBACK_OPENAI_MODEL ? [DEFAULT_OPENAI_MODEL] : []), normalizeOpenAIModel(fallbackModel), @@ -251,7 +244,8 @@ const getOpenAIModelFallbacks = (model, fallbackModel = FALLBACK_OPENAI_MODEL) = /** @param {string} value */ const isOpenAIModelAvailabilityError = (value) => - /\b(?:model_not_found|unknown model|invalid model|model is not available|not available for|not supported|does not have access|do not have access|don't have access|access to model|403|404)\b/i.test( + !/\b(?:model_reasoning_effort|reasoning[_\s-]*effort|effort)\b/i.test(value) && + /\b(?:model_not_found|unknown model|invalid model|model is not available|not available for|model\b.*not supported|does not have access|do not have access|don't have access|access to model|403|404)\b/i.test( value, ); @@ -410,8 +404,9 @@ const runCodex = async ( 'exec', '-m', codexModel, - '-c', - `model_reasoning_effort="${reasoningEffort}"`, + ...(reasoningEffort + ? ['-c', `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`] + : []), '--cd', repoRoot, '--sandbox', @@ -515,7 +510,13 @@ const runCodex = async ( ); const child = commandTransport.spawn( commandTransport.command, - ['app-server', '--stdio', '-c', `model_reasoning_effort="${reasoningEffort}"`], + [ + 'app-server', + '--stdio', + ...(reasoningEffort + ? ['-c', `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`] + : []), + ], { cwd: repoRoot, env: environment, diff --git a/electron/config.cjs b/electron/config.cjs index 3defebf5..cd8461e0 100644 --- a/electron/config.cjs +++ b/electron/config.cjs @@ -309,6 +309,10 @@ const mergeConfig = (raw) => { typeof rawSettings.openAIModel === 'string' ? rawSettings.openAIModel : defaults.settings.openAIModel, + openAIReasoningEffort: + typeof rawSettings.openAIReasoningEffort === 'string' + ? rawSettings.openAIReasoningEffort.trim() + : defaults.settings.openAIReasoningEffort, opencodeModel: typeof rawSettings.opencodeModel === 'string' ? rawSettings.opencodeModel diff --git a/electron/main.cjs b/electron/main.cjs index f81c2503..17a0d325 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -29,6 +29,7 @@ const { } = require('./git-state.cjs'); const { attachExternalLinkHandling } = require('./external-links.cjs'); const { normalizeOpenAIModel } = require('./codex.cjs'); +const { readCodexModels } = require('./codex-models.cjs'); const { normalizeClaudeModel } = require('./claude.cjs'); const { normalizeOpenCodeModel, renderOpenCodeCommand } = require('./opencode.cjs'); const { createWalkthroughCommit } = require('./walkthrough-commit.cjs'); @@ -155,6 +156,11 @@ const openWindows = new Set(); const pendingCommentsClipboardController = createPendingCommentsClipboardController({ clipboard }); /** @type {CodiffConfig} */ let config = createDefaultConfig(); +/** @type {ReadonlyArray | undefined} */ +let codexModels; +/** @type {Promise | undefined} */ +let codexModelDiscovery; +const codexModelDiscoveryAbort = new AbortController(); /** * @type {Map>} @@ -401,6 +407,7 @@ const selectAgentBackend = (backend) => { } updateConfig({ settings: { ...config.settings, agentBackend } }); + if (agentBackend === 'codex') void loadCodexModels(); }; /** @param {import('./agent.cjs').Agent} agent @param {string} model */ @@ -410,16 +417,32 @@ const selectAgentModel = (agent, model) => { return; } - updateConfig({ settings: { ...config.settings, [agent.modelSettingKey]: normalized } }); + const modelInfo = + agent.id === 'codex' ? codexModels?.find((item) => item.id === normalized) : undefined; + const effort = config.settings.openAIReasoningEffort; + updateConfig({ + settings: { + ...config.settings, + [agent.modelSettingKey]: normalized, + ...(modelInfo && effort && !modelInfo.reasoningEfforts.includes(effort) + ? { openAIReasoningEffort: '' } + : {}), + }, + }); }; /** @param {import('./agent.cjs').Agent} agent */ const getAgentOptions = (agent) => ({ fallbackModel: agent.fallbackModel, model: config.settings[agent.modelSettingKey], - /** @param {string} fallbackModel */ - onModelFallback: async (fallbackModel) => { - updateConfig({ settings: { ...config.settings, [agent.modelSettingKey]: fallbackModel } }); + ...(agent.id === 'codex' + ? { reasoningEffort: config.settings.openAIReasoningEffort || undefined } + : {}), + /** @param {string} fallbackModel @param {string} originalModel */ + onModelFallback: async (fallbackModel, originalModel) => { + if (config.settings[agent.modelSettingKey] === originalModel) { + updateConfig({ settings: { ...config.settings, [agent.modelSettingKey]: fallbackModel } }); + } }, }); @@ -554,7 +577,11 @@ const buildAgentSubmenu = () => const buildModelSubmenu = () => { const agent = getActiveAgent(); const selectedModel = config.settings[agent.modelSettingKey]; - return getAgentMenuModels(agent, selectedModel).map((model) => ({ + return getAgentMenuModels( + agent, + selectedModel, + agent.id === 'codex' ? codexModels : undefined, + ).map((model) => ({ checked: selectedModel === model.id, click: () => selectAgentModel(agent, model.id), label: model.label, @@ -562,6 +589,32 @@ const buildModelSubmenu = () => { })); }; +const loadCodexModels = () => { + codexModelDiscovery ??= readCodexModels({ signal: codexModelDiscoveryAbort.signal }) + .then((models) => { + if (models.length && !codexModelDiscoveryAbort.signal.aborted) { + codexModels = models; + Menu.setApplicationMenu(buildApplicationMenu()); + } + }) + .catch(() => {}); + return codexModelDiscovery; +}; + +/** @returns {Array} */ +const buildReasoningEffortSubmenu = () => { + const selected = config.settings.openAIReasoningEffort; + const model = codexModels?.find((item) => item.id === config.settings.openAIModel); + const advertised = model ? model.reasoningEfforts : ['low', 'medium', 'high']; + const efforts = [...new Set(['', ...advertised, ...(selected ? [selected] : [])])]; + return efforts.map((effort) => ({ + checked: selected === effort, + click: () => updateConfig({ settings: { ...config.settings, openAIReasoningEffort: effort } }), + label: !effort ? 'Default' : advertised.includes(effort) ? effort : `Custom: ${effort}`, + type: 'radio', + })); +}; + const getInstallSkillMenuItem = () => buildInstallSkillMenuItem( (skill, browserWindow) => void skillInstallers.get(skill.id)?.install(browserWindow), @@ -619,6 +672,9 @@ const buildApplicationMenu = () => label: 'Model', submenu: buildModelSubmenu(), }, + ...(getActiveAgent().id === 'codex' + ? [{ label: 'Reasoning Effort', submenu: buildReasoningEffortSubmenu() }] + : []), { type: 'separator' }, { click: () => { @@ -659,6 +715,9 @@ const buildApplicationMenu = () => label: 'Model', submenu: buildModelSubmenu(), }, + ...(getActiveAgent().id === 'codex' + ? [{ label: 'Reasoning Effort', submenu: buildReasoningEffortSubmenu() }] + : []), { type: 'separator' }, { click: () => { @@ -1392,9 +1451,13 @@ if (squirrelStartup || !lock) { nativeTheme.themeSource = config.settings.theme; sendConfigChanged(); Menu.setApplicationMenu(buildApplicationMenu()); + if (getActiveAgent().id === 'codex') void loadCodexModels(); }); + if (getActiveAgent().id === 'codex') void loadCodexModels(); }); + app.on('will-quit', () => codexModelDiscoveryAbort.abort()); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { const launchOptions = getLaunchOptions(); @@ -1695,6 +1758,7 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) walkthroughModel, walkthroughContext, walkthroughPrompt, + agentOptions.reasoningEffort, ); if (!options?.force) { const cachedWalkthrough = readStoredWalkthrough(cacheKey); @@ -1740,6 +1804,7 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) generatedModel, walkthroughContext, walkthroughPrompt, + agentOptions.reasoningEffort, ); try { const cacheableWalkthrough = { ...result.walkthrough }; diff --git a/electron/narrative-walkthrough.cjs b/electron/narrative-walkthrough.cjs index 6c56aaa8..909d5e21 100644 --- a/electron/narrative-walkthrough.cjs +++ b/electron/narrative-walkthrough.cjs @@ -864,8 +864,16 @@ const buildNarrativeWalkthroughPrompt = ( * @param {unknown} model * @param {WalkthroughContext | null | undefined} context * @param {unknown} customPrompt + * @param {string} [reasoningEffort] */ -const getNarrativeWalkthroughCacheKey = (state, agent, model, context, customPrompt) => { +const getNarrativeWalkthroughCacheKey = ( + state, + agent, + model, + context, + customPrompt, + reasoningEffort, +) => { const prompt = buildNarrativeWalkthroughPrompt(state, context, agent.label, customPrompt); return createHash('sha256') .update( @@ -883,6 +891,9 @@ const getNarrativeWalkthroughCacheKey = (state, agent, model, context, customPro })), })), model: agent.normalizeModel(model), + ...(agent.id === 'codex' && reasoningEffort?.trim() + ? { reasoningEffort: reasoningEffort.trim() } + : {}), prompt, responseSchema: narrativeWalkthroughResponseSchema, version: WALKTHROUGH_CACHE_KEY_VERSION, From 3f996ff46c4ded6020a482a727f81630e8c27387 Mon Sep 17 00:00:00 2001 From: Pascal Andy Date: Tue, 6 Oct 2026 14:08:04 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9A=91=20fix:=20codex:=20preserve=20e?= =?UTF-8?q?xplicit=20effort=20for=20large=20walkthroughs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Purpose: prevent automatic large-diff routing from sending explicit effort to an incompatible model - Impact: retain selected models, report fallback causes, and allow later discovery retries by GPT-6.1-Sol via Codex --- README.md | 10 ++++--- electron/__tests__/codex.test.ts | 26 +++++++++++++++++++ .../__tests__/narrative-walkthrough.test.ts | 12 +++++++-- electron/codex.cjs | 11 +++++++- electron/main.cjs | 11 ++++++-- electron/narrative-walkthrough.cjs | 6 +++-- 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2e0635c4..245204e0 100644 --- a/README.md +++ b/README.md @@ -208,8 +208,11 @@ application menu: The Codex `Model` and `Reasoning Effort` menus load the installed CLI's model catalog in the background. They offer its visible models and supported reasoning efforts, including new models -without a Codiff update. If discovery fails or an older CLI does not support it, the predefined -model choices remain available. Custom model IDs in `settings.openAIModel` are also accepted. +without a Codiff update. Legacy models omitted from a successful catalog, including GPT-5.5, +remain configurable by ID; a selected model missing from the catalog appears as a custom choice. +If discovery fails or an older CLI does not support it, the predefined model choices remain +available, and selecting Codex or changing configuration retries discovery. Custom model IDs in +`settings.openAIModel` are also accepted. Set `settings.openAIReasoningEffort` to an effort supported by your selected model, for example `"high"` with `settings.openAIModel` set to `"gpt-6.1-sol"`. Selecting a different model in the menu @@ -224,7 +227,8 @@ Codex walkthroughs still default to GPT-5.6 Terra with low reasoning. GPT-5.6 So medium reasoning unless you override it. If a selected model is unavailable, Codiff retries with Terra when applicable and then GPT-5.5, persisting the first model that succeeds unless you changed the selection during the run. Walkthroughs with at least 100 reviewable hunks use GPT-5.5 with low -reasoning when Terra is the configured default and no explicit effort overrides it. +reasoning when Terra is the configured default and no explicit effort is configured. An explicit +effort keeps the selected model for these large walkthroughs. Install the backend you want and verify it is available before using `codiff -w`: diff --git a/electron/__tests__/codex.test.ts b/electron/__tests__/codex.test.ts index dbcbeee7..48c53193 100644 --- a/electron/__tests__/codex.test.ts +++ b/electron/__tests__/codex.test.ts @@ -238,6 +238,32 @@ test.each([ expect(calls).toHaveLength(1); }); +test('reports the unavailable selected model and a fallback effort error together', async () => { + const attempts: string[] = []; + const { transport } = createCommandTransport((process) => { + process.stdin.on('finish', () => { + const model = getArgumentValue(process.args, '-m') || ''; + attempts.push(model); + process.stderr( + model === 'gpt-5.6-terra' + ? 'You do not have access to model gpt-5.6-terra.' + : 'Model gpt-5.5 does not support reasoning effort ultra.', + ); + process.close(1); + }); + }); + await expect( + runCodex('/repo', 'prompt', {}, undefined, undefined, { + commandTransport: transport, + model: 'gpt-5.6-terra', + reasoningEffort: 'ultra', + }), + ).rejects.toThrow( + 'Codex model gpt-5.6-terra was unavailable: You do not have access to model gpt-5.6-terra. Fallback model gpt-5.5 failed: Model gpt-5.5 does not support reasoning effort ultra.', + ); + expect(attempts).toEqual(['gpt-5.6-terra', 'gpt-5.5']); +}); + test('retries unavailable GPT-5.6 models with model-specific reasoning', async () => { const attempts: Array = []; const { transport } = createCommandTransport((commandProcess) => { diff --git a/electron/__tests__/narrative-walkthrough.test.ts b/electron/__tests__/narrative-walkthrough.test.ts index 89988a23..3c3cafa9 100644 --- a/electron/__tests__/narrative-walkthrough.test.ts +++ b/electron/__tests__/narrative-walkthrough.test.ts @@ -49,7 +49,12 @@ const { customPrompt?: string, previousWalkthrough?: unknown, ) => Promise; - resolveNarrativeWalkthroughModel: (state: any, agent: any, model: unknown) => string; + resolveNarrativeWalkthroughModel: ( + state: any, + agent: any, + model: unknown, + reasoningEffort?: string, + ) => string; }; const addedPatch = (count: number) => @@ -442,7 +447,7 @@ test.each([ }, ); -test('uses GPT-5.5 for large walkthroughs only when Codex is on the default model', () => { +test('uses GPT-5.5 for large walkthroughs only with automatic effort on the default model', () => { const createState = (hunkCount: number) => ({ branch: 'main', files: [ @@ -478,6 +483,9 @@ test('uses GPT-5.5 for large walkthroughs only when Codex is on the default mode expect(resolveNarrativeWalkthroughModel(createState(100), codexAgent, 'gpt-5.6-terra')).toBe( 'gpt-5.5', ); + expect( + resolveNarrativeWalkthroughModel(createState(100), codexAgent, 'gpt-5.6-terra', 'max'), + ).toBe('gpt-5.6-terra'); expect(resolveNarrativeWalkthroughModel(createState(100), codexAgent, 'gpt-5.6-sol')).toBe( 'gpt-5.6-sol', ); diff --git a/electron/codex.cjs b/electron/codex.cjs index 2f949f33..adaa78c7 100644 --- a/electron/codex.cjs +++ b/electron/codex.cjs @@ -802,6 +802,7 @@ const runCodex = async ( }; const candidates = [model, ...fallbackModels]; + let availabilityError; for (const [index, candidate] of candidates.entries()) { try { const response = await invokeCodex(candidate); @@ -811,9 +812,17 @@ const runCodex = async ( return response; } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (index === candidates.length - 1 || !isOpenAIModelAvailabilityError(message)) { + const unavailable = isOpenAIModelAvailabilityError(message); + if (index === candidates.length - 1 || !unavailable) { + if (availabilityError && options.reasoningEffort?.trim() && !unavailable) { + throw new Error( + `Codex model ${model} was unavailable: ${availabilityError} Fallback model ${candidate} failed: ${message}`, + { cause: error }, + ); + } throw error; } + availabilityError ??= message; } } diff --git a/electron/main.cjs b/electron/main.cjs index 17a0d325..f3a60782 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -597,7 +597,9 @@ const loadCodexModels = () => { Menu.setApplicationMenu(buildApplicationMenu()); } }) - .catch(() => {}); + .catch(() => { + codexModelDiscovery = undefined; + }); return codexModelDiscovery; }; @@ -1750,7 +1752,12 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) await agent.readSessionContext(launchOptions?.[agent.sessionLaunchOptionKey]), ); const agentOptions = getAgentOptions(agent); - const walkthroughModel = resolveNarrativeWalkthroughModel(state, agent, agentOptions.model); + const walkthroughModel = resolveNarrativeWalkthroughModel( + state, + agent, + agentOptions.model, + agentOptions.reasoningEffort, + ); const walkthroughPrompt = config.settings.walkthroughPrompt; const cacheKey = getNarrativeWalkthroughCacheKey( state, diff --git a/electron/narrative-walkthrough.cjs b/electron/narrative-walkthrough.cjs index 909d5e21..dec140b7 100644 --- a/electron/narrative-walkthrough.cjs +++ b/electron/narrative-walkthrough.cjs @@ -739,16 +739,18 @@ const getWalkthroughSize = (state) => ({ /** * Use the compatibility model for large default-Codex walkthroughs. Explicit - * model selections and non-Codex backends keep their configured model. + * model or effort selections and non-Codex backends keep their configured model. * * @param {RepositoryState} state * @param {Agent} agent * @param {unknown} model + * @param {string} [reasoningEffort] */ -const resolveNarrativeWalkthroughModel = (state, agent, model) => { +const resolveNarrativeWalkthroughModel = (state, agent, model, reasoningEffort) => { const normalizedModel = agent.normalizeModel(model); return agent.id === 'codex' && normalizedModel === agent.defaultModel && + !reasoningEffort?.trim() && getWalkthroughSize(state).hunkCount >= LARGE_WALKTHROUGH_HUNK_THRESHOLD ? agent.fallbackModel : normalizedModel;