Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -205,12 +206,29 @@ 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. 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
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 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`:

Expand Down
1 change: 1 addition & 0 deletions config/defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"expandUnchanged": false,
"lastRepositoryPath": "",
"openAIModel": "gpt-5.6-terra",
"openAIReasoningEffort": "",
"opencodeModel": "opencode-default",
"piModel": "pi-default",
"reviewCommentsPrefix": "# Address these Review Comments",
Expand Down
1 change: 1 addition & 0 deletions core/__tests__/App-render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ const createCodiffMock = (overrides: Partial<Window['codiff']> = {}): Window['co
expandUnchanged: false,
lastRepositoryPath: '/repo',
openAIModel: defaultSettings.openAIModel,
openAIReasoningEffort: defaultSettings.openAIReasoningEffort,
opencodeModel: defaultSettings.opencodeModel,
piModel: defaultSettings.piModel,
reviewCommentsPrefix: defaultSettings.reviewCommentsPrefix,
Expand Down
57 changes: 51 additions & 6 deletions core/__tests__/config-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createDefaultConfig>;
};
const {
createDefaultConfig: createElectronDefaultConfig,
readConfig,
watchConfig,
writeConfig,
} = require('../../electron/config.cjs') as {
createDefaultConfig: typeof createDefaultConfig;
readConfig: () => ReturnType<typeof createDefaultConfig>;
watchConfig: (onChange: (config: ReturnType<typeof createDefaultConfig>) => void) => () => void;
writeConfig: (config: ReturnType<typeof createDefaultConfig>) => void;
};

const readElectronConfig = (raw: unknown) => {
using home = createTemporaryDirectorySync('codiff-config-home.');
Expand Down Expand Up @@ -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<typeof createDefaultConfig> | 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({
Expand Down
6 changes: 6 additions & 0 deletions core/config/codiff-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions core/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type CodiffSettings = {
expandUnchanged: boolean;
lastRepositoryPath: string;
openAIModel: string;
openAIReasoningEffort: string;
opencodeModel: string;
piModel: string;
reviewCommentsPrefix: string;
Expand Down
1 change: 1 addition & 0 deletions core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,7 @@ export type CodiffPreferences = {
expandUnchanged: boolean;
lastRepositoryPath: string;
openAIModel: string;
openAIReasoningEffort: string;
opencodeModel: string;
piModel: string;
reviewCommentsPrefix: string;
Expand Down
14 changes: 14 additions & 0 deletions electron/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const {
getAgentMenuModels: (
agent: ReturnType<typeof getAgent>,
selectedModel: string,
models?: ReadonlyArray<{ id: string; label: string }>,
) => ReadonlyArray<{ id: string; label: string }>;
listAgents: () => ReadonlyArray<{ id: string }>;
normalizeAgentBackend: (value: unknown) => string;
Expand Down Expand Up @@ -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');
});
180 changes: 180 additions & 0 deletions electron/__tests__/codex-models.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createCommandTransport>['transport'];
signal?: AbortSignal;
timeoutMs?: number;
}) => Promise<
ReadonlyArray<{ id: string; label: string; reasoningEfforts: ReadonlyArray<string> }>
>;
};

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<string | number | undefined> = [];
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<string | number | undefined> = [];
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']);
});
Loading