fix(codex): move common-config TOML merge off smol-toml to backend toml_edit

updateTomlCommonConfigSnippet re-serialized the whole document through
smol-toml (parse -> deepMerge -> stringify): comments dropped, keys
reordered, and empty parent table headers synthesized -- the
long-standing "config.toml keeps getting reordered" symptom (audit C5,
introduced in 083e48bf).

Replace it with a backend command backed by the same
merge_toml_table_like / remove_toml_table_like used when writing live
configs, so the form preview and the live write share one merge
semantic and user formatting survives edit-time merges. The frontend
sync helper is deleted outright to keep the pattern from coming back.

Making the form operations async exposes them to interleaving, so a
result is discarded unless it is still current when it lands:

- a per-hook sequence number (last operation wins) covers rapid
  toggle/save races where an earlier merge resolves after a later
  removal and would flip the switch back;
- a config-baseline check covers the user hand-editing the TOML while
  a merge is in flight -- the stale result must not clobber their edit.
  The checkbox state self-heals via the existing inference effect.

Regression tests pin both by resolving a suspended merge after a newer
operation / an external edit, plus backend tests locking comment and
key-order preservation, scalar override, and value-matched removal.
This commit is contained in:
Jason
2026-07-08 22:21:57 +08:00
parent 94fc1cc064
commit 88d5ffba44
10 changed files with 381 additions and 97 deletions
+102 -3
View File
@@ -6,6 +6,7 @@ import { useGeminiCommonConfig } from "@/components/providers/forms/hooks/useGem
const getCommonConfigSnippetMock = vi.fn();
const setCommonConfigSnippetMock = vi.fn();
const extractCommonConfigSnippetMock = vi.fn();
const updateTomlCommonConfigSnippetMock = vi.fn();
vi.mock("@/lib/api", () => ({
configApi: {
@@ -15,6 +16,8 @@ vi.mock("@/lib/api", () => ({
setCommonConfigSnippetMock(...args),
extractCommonConfigSnippet: (...args: unknown[]) =>
extractCommonConfigSnippetMock(...args),
updateTomlCommonConfigSnippet: (...args: unknown[]) =>
updateTomlCommonConfigSnippetMock(...args),
},
}));
@@ -23,6 +26,9 @@ describe("common config snippet saving", () => {
getCommonConfigSnippetMock.mockResolvedValue("");
setCommonConfigSnippetMock.mockResolvedValue(undefined);
extractCommonConfigSnippetMock.mockResolvedValue("");
updateTomlCommonConfigSnippetMock.mockImplementation(
async (configToml: string) => configToml,
);
});
it("does not persist an invalid Codex common config snippet", async () => {
@@ -36,9 +42,9 @@ describe("common config snippet saving", () => {
await waitFor(() => expect(result.current.isLoading).toBe(false));
let saved = false;
act(() => {
saved = result.current.handleCommonConfigSnippetChange(
let saved = true;
await act(async () => {
saved = await result.current.handleCommonConfigSnippetChange(
"base_url = https://bad.example/v1",
);
});
@@ -49,6 +55,99 @@ describe("common config snippet saving", () => {
expect(result.current.commonConfigError).toContain("invalid value");
});
it("discards stale toggle results when a newer toggle finishes first", async () => {
getCommonConfigSnippetMock.mockResolvedValue(
"[tui]\nnotifications = true\n",
);
const onConfigChange = vi.fn();
const { result } = renderHook(() =>
useCodexCommonConfig({
codexConfig: 'model = "gpt-5"',
onConfigChange,
initialData: { settingsConfig: { config: 'model = "gpt-5"' } },
initialEnabled: false,
}),
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
await waitFor(() => expect(result.current.useCommonConfig).toBe(false));
// 第一次调用(勾选 on 的 merge)挂起,第二次(取消勾选的剥离)立即返回:
// 模拟后端乱序完成
let resolveMerge: ((value: string) => void) | undefined;
updateTomlCommonConfigSnippetMock
.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
resolveMerge = resolve;
}),
)
.mockImplementationOnce(async (configToml: string) => configToml);
await act(async () => {
const mergePending = result.current.handleCommonConfigToggle(true);
const removeDone = result.current.handleCommonConfigToggle(false);
await removeDone;
// on 的合并结果此时才姗姗来迟——必须被序号守卫丢弃
resolveMerge?.('model = "gpt-5"\n\n[tui]\nnotifications = true\n');
await mergePending;
});
// 用户最后一次操作是 off:过期的 on 结果不得翻转开关或改写配置
expect(result.current.useCommonConfig).toBe(false);
const lastConfig = onConfigChange.mock.calls.at(-1)?.[0] as string;
expect(lastConfig).not.toContain("[tui]");
});
it("discards async merge results when the user edited the config while in flight", async () => {
getCommonConfigSnippetMock.mockResolvedValue(
"[tui]\nnotifications = true\n",
);
const initialData = { settingsConfig: { config: 'model = "gpt-5"' } };
const onConfigChange = vi.fn();
const { result, rerender } = renderHook(
({ config }: { config: string }) =>
useCodexCommonConfig({
codexConfig: config,
onConfigChange,
initialData,
initialEnabled: false,
}),
{ initialProps: { config: 'model = "gpt-5"' } },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
await waitFor(() => expect(result.current.useCommonConfig).toBe(false));
let resolveMerge: ((value: string) => void) | undefined;
updateTomlCommonConfigSnippetMock.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
resolveMerge = resolve;
}),
);
let togglePending: Promise<void> = Promise.resolve();
act(() => {
togglePending = result.current.handleCommonConfigToggle(true);
});
// merge 在飞期间,用户在编辑器里手动改了 config(不经过 hook
// 序号不变,只有 codexConfig prop 变化)
rerender({ config: 'model = "gpt-6-user-edit"' });
await act(async () => {
resolveMerge?.('model = "gpt-5"\n\n[tui]\nnotifications = true\n');
await togglePending;
});
// 基于陈旧基线的合并结果必须被丢弃,不得覆盖用户的手动编辑
expect(onConfigChange).not.toHaveBeenCalled();
expect(result.current.useCommonConfig).toBe(false);
});
it("does not persist an invalid Gemini common config snippet", async () => {
const onEnvChange = vi.fn();
const { result } = renderHook(() =>