Merge remote-tracking branch 'origin/dev' into kai/review/pr-1634

This commit is contained in:
Tam Nhu Tran committed 2026-07-13 09:16:46 -04:00
commit b52e42eba4
13 files changed
+625 -32

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "8.7.0-dev.6",
"version": "8.7.0-dev.7",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
@@ -247,6 +247,9 @@ describe('Model Catalog', () => {
const { MODEL_CATALOG } = modelCatalog;
const ids = MODEL_CATALOG.codex.models.map((m) => m.id);
assert.deepStrictEqual(ids, [
'gpt-5.6-sol',
'gpt-5.6-terra',
'gpt-5.6-luna',
'gpt-5.5',
'gpt-5.4',
'gpt-5.4-mini',
@@ -29,6 +29,9 @@ describe('codex plan compatibility', () => {
});
it('does not rewrite cross-plan or already-safe Codex models', () => {
expect(getFreePlanFallbackCodexModel('gpt-5.6-sol')).toBeNull();
expect(getFreePlanFallbackCodexModel('gpt-5.6-terra-high')).toBeNull();
expect(getFreePlanFallbackCodexModel('gpt-5.6-luna-fast')).toBeNull();
expect(getFreePlanFallbackCodexModel('gpt-5.4')).toBeNull();
expect(getFreePlanFallbackCodexModel('gpt-5.4-mini')).toBeNull();
expect(getFreePlanFallbackCodexModel('gpt-5.2')).toBeNull();
@@ -94,6 +97,9 @@ describe('codex plan compatibility', () => {
});
it('tracks Codex thinking caps for current safe defaults, paid models, and legacy aliases', () => {
expect(getModelMaxLevel('codex', 'gpt-5.6-sol')).toBe('xhigh');
expect(getModelMaxLevel('codex', 'gpt-5.6-terra')).toBe('xhigh');
expect(getModelMaxLevel('codex', 'gpt-5.6-luna')).toBe('xhigh');
expect(getModelMaxLevel('codex', 'gpt-5.5')).toBe('xhigh');
expect(getModelMaxLevel('codex', 'gpt-5.4')).toBe('xhigh');
expect(getModelMaxLevel('codex', 'gpt-5.4-mini')).toBe('high');
+36
View File
@@ -190,6 +190,42 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
displayName: 'Copilot Codex',
defaultModel: 'gpt-5.4',
models: [
{
id: 'gpt-5.6-sol',
name: 'GPT-5.6 Sol',
description: 'Latest frontier agentic coding model.',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh'],
maxLevel: 'xhigh',
dynamicAllowed: false,
},
codexServiceTiers: ['fast'],
},
{
id: 'gpt-5.6-terra',
name: 'GPT-5.6 Terra',
description: 'Balanced agentic coding model for everyday work.',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh'],
maxLevel: 'xhigh',
dynamicAllowed: false,
},
codexServiceTiers: ['fast'],
},
{
id: 'gpt-5.6-luna',
name: 'GPT-5.6 Luna',
description: 'Fast and affordable agentic coding model.',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh'],
maxLevel: 'xhigh',
dynamicAllowed: false,
},
codexServiceTiers: ['fast'],
},
{
id: 'gpt-5.5',
name: 'GPT-5.5',
+127
View File
@@ -26,6 +26,75 @@ export function parseTomlObject(rawText: string): Record<string, unknown> {
return parsed;
}
export interface TomlNewlineRepairProposal {
repairedText: string;
insertionOffset: number;
}
const CODEX_MODEL_MIGRATION_TABLE = /^[ \t]*\[notice\.model_migrations\][ \t]*(?:#.*)?$/;
const QUOTED_MIGRATION_JOIN =
/^[ \t]*(?:"(?:\\.|[^"\\])*"|'[^']*')[ \t]*=[ \t]*(?:"(?:\\.|[^"\\])*"|'[^']*')(?=\[)/;
const TOML_TABLE_HEADER_SUFFIX = /^(?:\[\[[^\]\r\n]+\]\]|\[[^\]\r\n]+\])[ \t]*(?:#.*)?$/;
type TomlMultilineDelimiter = '"""' | "'''";
function isEscapedAt(text: string, offset: number): boolean {
let backslashCount = 0;
for (let index = offset - 1; index >= 0 && text[index] === '\\'; index -= 1) {
backslashCount += 1;
}
return backslashCount % 2 === 1;
}
function updateTomlMultilineDelimiter(
line: string,
initialDelimiter: TomlMultilineDelimiter | null
): TomlMultilineDelimiter | null {
let delimiter = initialDelimiter;
let cursor = 0;
while (cursor < line.length) {
if (delimiter) {
const closingOffset = line.indexOf(delimiter, cursor);
if (closingOffset === -1) return delimiter;
if (delimiter === '"""' && isEscapedAt(line, closingOffset)) {
cursor = closingOffset + 1;
continue;
}
cursor = closingOffset + delimiter.length;
delimiter = null;
continue;
}
const char = line[cursor];
if (char === '#') return null;
if (line.startsWith('"""', cursor) || line.startsWith("'''", cursor)) {
delimiter = line.slice(cursor, cursor + 3) as TomlMultilineDelimiter;
cursor += 3;
continue;
}
if (char === '"') {
cursor += 1;
while (cursor < line.length) {
if (line[cursor] === '"' && !isEscapedAt(line, cursor)) {
cursor += 1;
break;
}
cursor += 1;
}
continue;
}
if (char === "'") {
const closingOffset = line.indexOf("'", cursor + 1);
cursor = closingOffset === -1 ? line.length : closingOffset + 1;
continue;
}
cursor += 1;
}
return delimiter;
}
export function safeParseTomlObject(rawText: string): SafeTomlObjectParseResult {
try {
return {
@@ -39,3 +108,61 @@ export function safeParseTomlObject(rawText: string): SafeTomlObjectParseResult
};
}
}
export function proposeJoinedCodexModelMigrationRepair(
rawText: string
): TomlNewlineRepairProposal | null {
if (!safeParseTomlObject(rawText).parseError) return null;
const candidates: Array<{ insertionOffset: number; lineEnding: string }> = [];
let cursor = 0;
let inModelMigrationTable = false;
let previousLineEnding = '\n';
let multilineDelimiter: TomlMultilineDelimiter | null = null;
while (cursor < rawText.length) {
const newlineOffset = rawText.indexOf('\n', cursor);
const rawLineEnd = newlineOffset === -1 ? rawText.length : newlineOffset;
const usesCrLf = newlineOffset !== -1 && rawText[rawLineEnd - 1] === '\r';
const contentEnd = usesCrLf ? rawLineEnd - 1 : rawLineEnd;
const lineEnding = newlineOffset === -1 ? '' : usesCrLf ? '\r\n' : '\n';
const rawLine = rawText.slice(cursor, contentEnd);
const line = cursor === 0 && rawLine.charCodeAt(0) === 0xfeff ? rawLine.slice(1) : rawLine;
if (!multilineDelimiter) {
if (CODEX_MODEL_MIGRATION_TABLE.test(line)) {
inModelMigrationTable = true;
} else if (/^[ \t]*\[\[?/.test(line)) {
inModelMigrationTable = false;
} else if (inModelMigrationTable) {
const joinedMapping = line.match(QUOTED_MIGRATION_JOIN);
if (joinedMapping) {
const suffix = line.slice(joinedMapping[0].length);
if (TOML_TABLE_HEADER_SUFFIX.test(suffix)) {
candidates.push({
insertionOffset: cursor + joinedMapping[0].length,
lineEnding: lineEnding || previousLineEnding,
});
}
}
}
}
multilineDelimiter = updateTomlMultilineDelimiter(line, multilineDelimiter);
if (lineEnding) previousLineEnding = lineEnding;
if (newlineOffset === -1) break;
cursor = newlineOffset + 1;
}
if (candidates.length !== 1) return null;
const [{ insertionOffset, lineEnding }] = candidates;
const repairedText = `${rawText.slice(0, insertionOffset)}${lineEnding}${rawText.slice(insertionOffset)}`;
try {
parseTomlObject(repairedText);
return { repairedText, insertionOffset };
} catch {
return null;
}
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'bun:test';
import {
parseTomlObject,
proposeJoinedCodexModelMigrationRepair,
} from '../../../src/shared/toml-object';
describe('joined Codex model migration TOML recovery', () => {
it('proposes the exact one-newline repair for a joined following table header', () => {
const rawText = `model = "gpt-5.4"
[notice.model_migrations]
"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier]
description = "Keep this exact text"
`;
const proposal = proposeJoinedCodexModelMigrationRepair(rawText);
const insertionOffset = rawText.indexOf('[agents.code_simplifier]');
expect(proposal).toEqual({
repairedText: `${rawText.slice(0, insertionOffset)}\n${rawText.slice(insertionOffset)}`,
insertionOffset,
});
expect(() => parseTomlObject(proposal?.repairedText ?? '')).not.toThrow();
});
it('preserves a UTF-8 BOM, CRLF endings, comments, spacing, and quoting', () => {
const rawText =
"\uFEFF# exact comment\r\n[notice.model_migrations]\r\n 'gpt-5.3-codex' = 'gpt-5.4'[[agents]]\r\nname = \"one\"\r\n";
const proposal = proposeJoinedCodexModelMigrationRepair(rawText);
const insertionOffset = rawText.indexOf('[[agents]]');
expect(proposal?.repairedText).toBe(
`${rawText.slice(0, insertionOffset)}\r\n${rawText.slice(insertionOffset)}`
);
expect(proposal?.insertionOffset).toBe(insertionOffset);
});
it('does not propose a repair for valid TOML', () => {
const rawText = `[notice.model_migrations]
"gpt-5.3-codex" = "gpt-5.4"
[agents.code_simplifier]
description = "valid"
`;
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
});
it('does not generically repair a joined boundary outside the migration table', () => {
const rawText = `model = "gpt-5.4"[agents.code_simplifier]
description = "invalid"
`;
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
});
it('ignores a migration table lookalike inside a multiline TOML string', () => {
const rawText = `description = """
[notice.model_migrations]
"""
"old" = "new"[agents.outside]
`;
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
});
it('does not propose a repair for unrelated invalid TOML', () => {
expect(proposeJoinedCodexModelMigrationRepair('model = "gpt-5.4"\n[features\n')).toBeNull();
});
it('requires a quoted string-to-string migration mapping', () => {
const rawText = `[notice.model_migrations]
gpt-5 = 54[agents.code_simplifier]
description = "invalid"
`;
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
});
it('does not propose a repair when the following text is not a valid table header', () => {
const rawText = `[notice.model_migrations]
"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier
`;
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
});
it('does not propose a repair when another syntax error would remain', () => {
const rawText = `[notice.model_migrations]
"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier]
description = "valid"
[features
`;
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
});
it('does not propose an ambiguous repair when multiple joined boundaries match', () => {
const rawText = `[notice.model_migrations]
"gpt-5.2" = "gpt-5.3"[agents.first]
name = "first"
[notice.model_migrations]
"gpt-5.3" = "gpt-5.4"[agents.second]
name = "second"
`;
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
});
it('does not treat a joined-looking string inside a multiline value as a repair candidate', () => {
const rawText = `[notice.model_migrations]
note = """
"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier]
"""
[features
`;
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
});
});
@@ -43,6 +43,8 @@ export interface ModelEntry {
};
/** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */
codexMaxEffort?: CodexEffort;
/** Exact Codex reasoning-effort suffixes supported by this model in the dashboard UI. */
codexEfforts?: CodexEffort[];
/** Additional Codex service-tier suffixes supported by this model in the dashboard UI. */
codexServiceTiers?: CodexServiceTier[];
}
@@ -347,12 +349,13 @@ function getPreferredOptionValue(
function getModelOptionValues(
codexMaxEffort: CodexEffort | undefined,
codexEfforts: readonly CodexEffort[] | undefined,
codexServiceTiers: readonly CodexServiceTier[] | undefined,
optionValue: string,
isCodexProvider: boolean
): string[] {
return isCodexProvider
? getCodexEffortVariants(optionValue, codexMaxEffort, codexServiceTiers)
? getCodexEffortVariants(optionValue, codexMaxEffort, codexServiceTiers, codexEfforts)
: [optionValue];
}
@@ -395,6 +398,7 @@ export function FlexibleModelSelector({
resolvedCatalogModels.flatMap((model) =>
getModelOptionValues(
model.codexMaxEffort,
model.codexEfforts,
model.codexServiceTiers,
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
isCodexProvider
@@ -431,6 +435,7 @@ export function FlexibleModelSelector({
const routingHint = routingHints.get(model.id.toLowerCase());
const optionValues = getModelOptionValues(
model.codexMaxEffort,
model.codexEfforts,
model.codexServiceTiers,
getPreferredOptionValue(model.id, routingHint),
isCodexProvider
@@ -485,6 +490,7 @@ export function FlexibleModelSelector({
.flatMap((model) => {
const routingHint = routingHints.get(model.id.toLowerCase());
const optionValues = getModelOptionValues(
undefined,
undefined,
undefined,
getPreferredOptionValue(model.id, routingHint),
+4 -3
View File
@@ -69,7 +69,8 @@ export function applyCodexEffortSuffix(
export function getCodexEffortVariants(
modelId: string,
maxEffort: CodexEffort | undefined,
serviceTiers: readonly CodexServiceTier[] = []
serviceTiers: readonly CodexServiceTier[] = [],
efforts?: readonly CodexEffort[]
): string[] {
if (!maxEffort) {
const explicitEffort = parseCodexEffort(modelId);
@@ -91,9 +92,9 @@ export function getCodexEffortVariants(
appendVariantsForEffort(undefined);
for (const effort of CODEX_EFFORTS_IN_ORDER) {
for (const effort of efforts ?? CODEX_EFFORTS_IN_ORDER) {
appendVariantsForEffort(effort);
if (effort === maxEffort) {
if (!efforts && effort === maxEffort) {
break;
}
}
+42
View File
@@ -259,6 +259,48 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
displayName: 'Codex',
defaultModel: 'gpt-5.4',
models: [
{
id: 'gpt-5.6-sol',
name: 'GPT-5.6 Sol',
description: 'Latest frontier agentic coding model.',
codexMaxEffort: 'xhigh',
codexEfforts: ['low', 'medium', 'high', 'xhigh'],
codexServiceTiers: ['fast'],
presetMapping: {
default: 'gpt-5.6-sol',
opus: 'gpt-5.6-sol',
sonnet: 'gpt-5.6-sol',
haiku: 'gpt-5.4-mini',
},
},
{
id: 'gpt-5.6-terra',
name: 'GPT-5.6 Terra',
description: 'Balanced agentic coding model for everyday work.',
codexMaxEffort: 'xhigh',
codexEfforts: ['low', 'medium', 'high', 'xhigh'],
codexServiceTiers: ['fast'],
presetMapping: {
default: 'gpt-5.6-terra',
opus: 'gpt-5.6-terra',
sonnet: 'gpt-5.6-terra',
haiku: 'gpt-5.4-mini',
},
},
{
id: 'gpt-5.6-luna',
name: 'GPT-5.6 Luna',
description: 'Fast and affordable agentic coding model.',
codexMaxEffort: 'xhigh',
codexEfforts: ['low', 'medium', 'high', 'xhigh'],
codexServiceTiers: ['fast'],
presetMapping: {
default: 'gpt-5.6-luna',
opus: 'gpt-5.6-luna',
sonnet: 'gpt-5.6-luna',
haiku: 'gpt-5.4-mini',
},
},
{
id: 'gpt-5.5',
name: 'GPT-5.5',
+70 -26
View File
@@ -10,6 +10,7 @@ import { useCodex } from '@/hooks/use-codex';
import { isApiConflictError } from '@/lib/api-client';
import { CodexOverviewTab } from '@/components/compatible-cli/codex-overview-tab';
import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
getKnownCodexFeatures,
@@ -20,7 +21,13 @@ import {
readCodexProjectTrust,
readCodexTopLevelSettings,
} from '@/lib/codex-config';
import { safeParseTomlObject } from '@shared/toml-object';
import { proposeJoinedCodexModelMigrationRepair, safeParseTomlObject } from '@shared/toml-object';
interface RawConfigDraft {
text: string;
baseText: string;
expectedMtime?: number;
}
export function CodexPage() {
const { t } = useTranslation();
@@ -40,10 +47,14 @@ export function CodexPage() {
isPatchingConfig,
} = useCodex();
const [rawDraftText, setRawDraftText] = useState<string | null>(null);
const [rawDraft, setRawDraft] = useState<RawConfigDraft | null>(null);
const rawBaseText = rawConfig?.rawText ?? '';
const rawEditorText = rawDraftText ?? rawBaseText;
const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText;
const rawEditorText = rawDraft?.text ?? rawBaseText;
const rawConfigDirty = rawDraft !== null;
const rawBaseRepairProposal = useMemo(() => {
if (rawConfigDirty || !rawConfig?.parseError || rawConfig.readError) return null;
return proposeJoinedCodexModelMigrationRepair(rawBaseText);
}, [rawBaseText, rawConfig?.parseError, rawConfig?.readError, rawConfigDirty]);
const rawEditorParsed = safeParseTomlObject(rawEditorText);
const rawEditorValidation = rawEditorParsed.parseError
? { valid: false as const, error: rawEditorParsed.parseError }
@@ -84,11 +95,20 @@ export function CodexPage() {
const featureState = useMemo(() => readCodexFeatureState(controlsConfig), [controlsConfig]);
const setRawEditorDraftText = (nextText: string) => {
if (nextText === rawBaseText) {
setRawDraftText(null);
return;
}
setRawDraftText(nextText);
setRawDraft((currentDraft) => {
const baseText = currentDraft?.baseText ?? rawBaseText;
if (nextText === baseText) return null;
return {
text: nextText,
baseText,
expectedMtime: currentDraft
? currentDraft.expectedMtime
: rawConfig?.exists
? rawConfig.mtime
: undefined,
};
});
};
const refreshAll = async () => {
@@ -103,24 +123,24 @@ export function CodexPage() {
return;
}
setRawDraftText(null);
setRawDraft(null);
} catch (error) {
toast.error((error as Error).message || t('toasts.codexRefreshError'));
}
};
const handleSaveRawConfig = async () => {
if (!rawEditorValidation.valid) {
if (!rawDraft || !rawEditorValidation.valid) {
toast.error(t('toasts.codexFixToml'));
return;
}
try {
await saveRawConfigAsync({
rawText: rawEditorText,
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
rawText: rawDraft.text,
expectedMtime: rawDraft.expectedMtime,
});
setRawDraftText(null);
setRawDraft(null);
toast.success(t('toasts.codexSaved'));
await refetchDiagnostics();
} catch (error) {
@@ -141,7 +161,7 @@ export function CodexPage() {
...patch,
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
});
setRawDraftText(null);
setRawDraft(null);
toast.success(successMessage);
} catch (error) {
if (isApiConflictError(error)) {
@@ -259,7 +279,7 @@ export function CodexPage() {
}}
onSave={handleSaveRawConfig}
onRefresh={refreshAll}
onDiscard={() => setRawDraftText(null)}
onDiscard={() => setRawDraft(null)}
language="toml"
exactText
/* TODO i18n: missing key for "Loading config.toml..." */
@@ -267,16 +287,40 @@ export function CodexPage() {
/* TODO i18n: missing key for "TOML warning" */
parseWarningLabel="TOML warning"
ownershipNotice={
<div className="rounded-md border border-amber-200 bg-amber-50/60 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-300">
{/* TODO i18n: missing keys for ownership notice paragraphs */}
<p className="font-medium">This file is upstream-owned by Codex CLI.</p>
<p>
CCS does not keep <code>~/.codex/config.toml</code> in sync for you.
</p>
<p>
CCS-backed Codex launches may apply transient <code>-c</code> overrides and
<code> CCS_CODEX_API_KEY</code>; those effective values may not appear here.
</p>
<div className="space-y-3">
{rawBaseRepairProposal ? (
<div
role="alert"
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-300"
>
<p className="font-medium">Safe repair available</p>
<p className="mt-1">
Codex joined a model migration to the next table header. Previewing inserts
exactly one newline in this unsaved editor draft. Review it, then select Save
to write <code>config.toml</code>.
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => setRawEditorDraftText(rawBaseRepairProposal.repairedText)}
>
Preview one-newline repair
</Button>
</div>
) : null}
<div className="rounded-md border border-amber-200 bg-amber-50/60 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-300">
{/* TODO i18n: missing keys for ownership notice paragraphs */}
<p className="font-medium">This file is upstream-owned by Codex CLI.</p>
<p>
CCS does not keep <code>~/.codex/config.toml</code> in sync for you.
</p>
<p>
CCS-backed Codex launches may apply transient <code>-c</code> overrides and
<code> CCS_CODEX_API_KEY</code>; those effective values may not appear here.
</p>
</div>
</div>
}
/>
@@ -191,6 +191,10 @@ describe('FlexibleModelSelector', () => {
expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument();
expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument();
expect(screen.getByText('gpt-5.4-high-fast')).toBeInTheDocument();
expect(screen.getByText('gpt-5.6-sol-low')).toBeInTheDocument();
expect(screen.getByText('gpt-5.6-terra-xhigh')).toBeInTheDocument();
expect(screen.getByText('gpt-5.6-luna-fast')).toBeInTheDocument();
expect(screen.queryByText('gpt-5.6-sol-minimal')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('gpt-5.3-codex-high'));
expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high');
@@ -4,6 +4,9 @@ import { MODEL_CATALOGS } from '@/lib/model-catalogs';
describe('codex model catalog defaults', () => {
it('mirrors the current Codex runtime catalog and free-safe defaults', () => {
const codexCatalog = MODEL_CATALOGS.codex;
const codex56Sol = codexCatalog.models.find((model) => model.id === 'gpt-5.6-sol');
const codex56Terra = codexCatalog.models.find((model) => model.id === 'gpt-5.6-terra');
const codex56Luna = codexCatalog.models.find((model) => model.id === 'gpt-5.6-luna');
const codex55 = codexCatalog.models.find((model) => model.id === 'gpt-5.5');
const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex');
const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2');
@@ -11,6 +14,12 @@ describe('codex model catalog defaults', () => {
const codexMini = codexCatalog.models.find((model) => model.id === 'gpt-5.4-mini');
expect(codexCatalog.defaultModel).toBe('gpt-5.4');
for (const model of [codex56Sol, codex56Terra, codex56Luna]) {
expect(model?.tier).toBeUndefined();
expect(model?.codexEfforts).toEqual(['low', 'medium', 'high', 'xhigh']);
expect(model?.codexServiceTiers).toEqual(['fast']);
expect(model?.presetMapping?.haiku).toBe('gpt-5.4-mini');
}
expect(codex55?.tier).toBe('paid');
expect(codex55?.presetMapping?.haiku).toBe('gpt-5.4-mini');
expect(codex55?.codexMaxEffort).toBe('xhigh');
+193 -1
View File
@@ -40,7 +40,11 @@ vi.mock('@/components/shared/code-editor', () => ({
}));
vi.mock('@/components/compatible-cli/codex-control-center-tab', () => ({
CodexControlCenterTab: () => <div>Control Center</div>,
CodexControlCenterTab: ({ disabled }: { disabled?: boolean }) => (
<div data-testid="codex-control-center" data-disabled={String(Boolean(disabled))}>
Control Center
</div>
),
}));
vi.mock('@/components/compatible-cli/codex-docs-tab', () => ({
@@ -53,6 +57,15 @@ vi.mock('@/components/compatible-cli/codex-overview-tab', () => ({
import { CodexPage } from '@/pages/codex';
const joinedMigrationRawText = `[notice.model_migrations]
"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier]
description = "Keep exact text"
`;
const repairedMigrationRawText = joinedMigrationRawText.replace(
'"gpt-5.4"[agents.code_simplifier]',
'"gpt-5.4"\n[agents.code_simplifier]'
);
const diagnostics = {
binary: {
installed: true,
@@ -228,5 +241,184 @@ describe('CodexPage', () => {
expect(screen.getByText(/Read-only: Refusing symlink file for safety\./)).toBeInTheDocument();
expect(screen.getByLabelText('codex raw editor')).toHaveAttribute('readonly');
expect(
screen.queryByRole('button', { name: 'Preview one-newline repair' })
).not.toBeInTheDocument();
});
it('previews the one-newline repair as an unsaved draft before explicitly saving it', async () => {
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 101 });
mocks.useCodex.mockReturnValue(
buildUseCodexResult({
rawConfig: {
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 100,
rawText: joinedMigrationRawText,
config: null,
parseError: 'Invalid TOML',
readError: null,
},
})
);
render(<CodexPage />);
await userEvent.click(screen.getByRole('tab', { name: 'Control Center' }));
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' }));
expect(screen.getByLabelText('codex raw editor')).toHaveValue(repairedMigrationRawText);
expect(screen.getByText('Unsaved')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
expect(mocks.saveRawConfigAsync).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({
rawText: repairedMigrationRawText,
expectedMtime: 100,
})
);
});
it('keeps the repair draft bound to its original mtime across a fetched snapshot update', async () => {
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 201 });
let rawConfig = {
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 100,
rawText: joinedMigrationRawText,
config: null,
parseError: 'Invalid TOML',
readError: null,
};
mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig }));
const view = render(<CodexPage />);
await userEvent.click(screen.getByRole('tab', { name: 'Control Center' }));
await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' }));
rawConfig = {
...rawConfig,
mtime: 200,
rawText: 'model = "gpt-5.4-mini"\n',
config: { model: 'gpt-5.4-mini' },
parseError: null,
};
view.rerender(<CodexPage />);
expect(screen.getByLabelText('codex raw editor')).toHaveValue(repairedMigrationRawText);
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({
rawText: repairedMigrationRawText,
expectedMtime: 100,
})
);
});
it('keeps manual raw edits bound to the snapshot mtime captured on first edit', async () => {
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 201 });
let rawConfig = {
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 100,
rawText: 'model = "gpt-5.4"\n',
config: { model: 'gpt-5.4' },
parseError: null,
readError: null,
};
mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig }));
const view = render(<CodexPage />);
const editor = screen.getByLabelText('codex raw editor');
await userEvent.clear(editor);
await userEvent.type(editor, 'model = "gpt-5.4-mini"');
rawConfig = {
...rawConfig,
mtime: 200,
rawText: 'model = "gpt-5.4-high"\n',
config: { model: 'gpt-5.4-high' },
};
view.rerender(<CodexPage />);
expect(screen.getByLabelText('codex raw editor')).toHaveValue('model = "gpt-5.4-mini"');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({
rawText: 'model = "gpt-5.4-mini"',
expectedMtime: 100,
})
);
});
it('does not offer the targeted repair for generic invalid TOML', () => {
mocks.useCodex.mockReturnValue(
buildUseCodexResult({
rawConfig: {
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 100,
rawText: 'model = "gpt-5.4"\n[features\n',
config: null,
parseError: 'Invalid TOML',
readError: null,
},
})
);
render(<CodexPage />);
expect(
screen.queryByRole('button', { name: 'Preview one-newline repair' })
).not.toBeInTheDocument();
});
it('re-enables structured controls only after the saved valid snapshot is loaded', async () => {
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 101 });
let rawConfig = {
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 100,
rawText: joinedMigrationRawText,
config: null,
parseError: 'Invalid TOML',
readError: null,
};
mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig }));
const view = render(<CodexPage />);
await userEvent.click(screen.getByRole('tab', { name: 'Control Center' }));
await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' }));
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(mocks.saveRawConfigAsync).toHaveBeenCalledTimes(1));
rawConfig = {
...rawConfig,
mtime: 101,
rawText: repairedMigrationRawText,
config: {
notice: { model_migrations: { 'gpt-5.3-codex': 'gpt-5.4' } },
agents: { code_simplifier: { description: 'Keep exact text' } },
},
parseError: null,
};
view.rerender(<CodexPage />);
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'false');
});
});