From 88d5ffba44bc613cdcdcb4a241a95250e6944cb6 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 8 Jul 2026 22:21:57 +0800 Subject: [PATCH] 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. --- src-tauri/src/commands/config.rs | 17 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/services/provider/live.rs | 99 ++++++++++ src-tauri/src/services/provider/mod.rs | 1 + .../forms/CodexCommonConfigModal.tsx | 6 +- .../providers/forms/CodexConfigEditor.tsx | 4 +- .../forms/hooks/useCodexCommonConfig.ts | 183 ++++++++++++------ src/lib/api/config.ts | 23 +++ src/utils/providerConfigUtils.ts | 39 +--- tests/hooks/useCommonConfigSave.test.tsx | 105 +++++++++- 10 files changed, 381 insertions(+), 97 deletions(-) diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index dea8befde..db5b85015 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -275,6 +275,23 @@ pub async fn get_common_config_snippet( .map_err(|e| e.to_string()) } +/// 对前端编辑器里的 config.toml 文本做通用配置片段的合并/剥离。 +/// 放后端是为了走 toml_edit(保注释、保键序);前端 smol-toml 的 +/// 整文档重序列化会破坏用户手写格式。 +#[tauri::command] +pub async fn update_toml_common_config_snippet( + config_toml: String, + snippet_toml: String, + enabled: bool, +) -> Result { + crate::services::provider::update_toml_common_config_snippet( + &config_toml, + &snippet_toml, + enabled, + ) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn set_common_config_snippet( app_type: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38ac49e5c..870d0b771 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1215,6 +1215,7 @@ pub fn run() { commands::set_claude_common_config_snippet, commands::get_common_config_snippet, commands::set_common_config_snippet, + commands::update_toml_common_config_snippet, commands::extract_common_config_snippet, commands::read_live_provider_settings, commands::get_settings, diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index b16024c5e..dde293bd8 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -306,6 +306,40 @@ fn remove_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) { } } +/// 前端表单勾选/取消"使用通用配置"时,对编辑器里的 config.toml 文本做 +/// 结构化合并/剥离。必须在后端用 toml_edit 做:前端 smol-toml 只能 +/// parse → merge → 整文档重序列化,注释全丢、键序重排,还会生成多余的 +/// 空父表头(如 `[model_providers]`)。 +pub fn update_toml_common_config_snippet( + config_toml: &str, + snippet_toml: &str, + enabled: bool, +) -> Result { + let trimmed = snippet_toml.trim(); + if trimmed.is_empty() { + return Ok(config_toml.to_string()); + } + + let mut target_doc = if config_toml.trim().is_empty() { + DocumentMut::new() + } else { + config_toml + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))? + }; + let source_doc = trimmed + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex common config snippet: {e}")))?; + + if enabled { + merge_toml_table_like(target_doc.as_table_mut(), source_doc.as_table()); + } else { + remove_toml_table_like(target_doc.as_table_mut(), source_doc.as_table()); + } + + Ok(target_doc.to_string()) +} + fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: &str) -> bool { let trimmed = snippet.trim(); if trimmed.is_empty() { @@ -1678,6 +1712,71 @@ mod tests { use super::*; use serde_json::json; + /// C5 回归锁:前端表单的合并/剥离必须走 toml_edit 文档模型。 + /// smol-toml 的 parse→merge→stringify 整文档重序列化会丢注释、 + /// 按字母序重排键、并为 dotted 表生成多余的空父表头。 + #[test] + fn update_toml_common_config_snippet_preserves_comments_and_key_order() { + // 刻意非字母序的键序 + 注释,模拟用户手写格式 + let config = r#"# my precious comment +model = "gpt-5.5" +model_provider = "aprov" +disable_response_storage = true + +[model_providers.aprov] +# provider comment +name = "A Prov" +base_url = "https://a.example/v1" +"#; + let snippet = "[tui]\nnotifications = true\n"; + + let merged = update_toml_common_config_snippet(config, snippet, true).unwrap(); + assert!(merged.contains("# my precious comment")); + assert!(merged.contains("# provider comment")); + let model_pos = merged.find("model = ").unwrap(); + let provider_pos = merged.find("model_provider = ").unwrap(); + let disable_pos = merged.find("disable_response_storage").unwrap(); + assert!( + model_pos < provider_pos && provider_pos < disable_pos, + "merge must not reorder user keys, got: {merged}" + ); + assert!(merged.contains("[tui]")); + assert!(merged.contains("notifications = true")); + assert!( + !merged.contains("[model_providers]\n"), + "merge must not synthesize an empty parent table header, got: {merged}" + ); + + let removed = update_toml_common_config_snippet(&merged, snippet, false).unwrap(); + assert!(!removed.contains("[tui]"), "snippet keys must be stripped"); + assert!(removed.contains("# my precious comment")); + assert!(removed.contains("disable_response_storage = true")); + } + + /// 合并时标量=片段覆盖供应商值(与 Claude 侧 deepMerge 一致); + /// 剥离按值匹配:用户改过的值不删(与 strip 路径的 + /// toml_value_is_subset 语义一致)。 + #[test] + fn update_toml_common_config_snippet_scalar_override_and_value_matched_removal() { + let snippet = "[tui]\nnotifications = true\n"; + + let merged = + update_toml_common_config_snippet("[tui]\nnotifications = false\n", snippet, true) + .unwrap(); + assert!( + merged.contains("notifications = true"), + "snippet scalar should override provider value, got: {merged}" + ); + + let removed = + update_toml_common_config_snippet("[tui]\nnotifications = false\n", snippet, false) + .unwrap(); + assert!( + removed.contains("notifications = false"), + "user-modified value must survive removal, got: {removed}" + ); + } + #[test] fn claude_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() { let settings = json!({ diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 8a0370a22..8bbec5e33 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -25,6 +25,7 @@ pub use live::{ import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live, import_opencode_providers_from_live, read_live_settings, should_import_default_config_on_startup, sync_current_to_live, + update_toml_common_config_snippet, }; // Internal re-exports (pub(crate)) diff --git a/src/components/providers/forms/CodexCommonConfigModal.tsx b/src/components/providers/forms/CodexCommonConfigModal.tsx index 0c0207d91..e1ccc6b0b 100644 --- a/src/components/providers/forms/CodexCommonConfigModal.tsx +++ b/src/components/providers/forms/CodexCommonConfigModal.tsx @@ -9,7 +9,7 @@ interface CodexCommonConfigModalProps { isOpen: boolean; onClose: () => void; value: string; - onSave: (value: string) => boolean; + onSave: (value: string) => boolean | Promise; error?: string; onExtract?: () => void; isExtracting?: boolean; @@ -58,8 +58,8 @@ export const CodexCommonConfigModal: React.FC = ({ onClose(); }; - const handleSave = () => { - if (onSave(draftValue)) { + const handleSave = async () => { + if (await onSave(draftValue)) { onClose(); } }; diff --git a/src/components/providers/forms/CodexConfigEditor.tsx b/src/components/providers/forms/CodexConfigEditor.tsx index af458aaa1..86b88b7bf 100644 --- a/src/components/providers/forms/CodexConfigEditor.tsx +++ b/src/components/providers/forms/CodexConfigEditor.tsx @@ -22,11 +22,11 @@ interface CodexConfigEditorProps { useCommonConfig: boolean; - onCommonConfigToggle: (checked: boolean) => void; + onCommonConfigToggle: (checked: boolean) => void | Promise; commonConfigSnippet: string; - onCommonConfigSnippetChange: (value: string) => boolean; + onCommonConfigSnippetChange: (value: string) => boolean | Promise; onCommonConfigErrorClear: () => void; diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts index 86c360b46..8fa9a1965 100644 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -1,13 +1,31 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; import { parse as parseToml } from "smol-toml"; -import { - updateTomlCommonConfigSnippet, - hasTomlCommonConfigSnippet, -} from "@/utils/providerConfigUtils"; +import { hasTomlCommonConfigSnippet } from "@/utils/providerConfigUtils"; import { configApi } from "@/lib/api"; import { normalizeTomlText } from "@/utils/textNormalization"; +/** + * 合并/剥离通用配置片段(走后端 toml_edit,保注释、保键序)。 + * 失败时返回原文本 + error,调用方据此决定是否回退 UI 状态。 + */ +const applyTomlSnippet = async ( + configToml: string, + snippetToml: string, + enabled: boolean, +): Promise<{ updatedConfig: string; error?: string }> => { + try { + const updatedConfig = await configApi.updateTomlCommonConfigSnippet( + configToml, + snippetToml, + enabled, + ); + return { updatedConfig }; + } catch (e) { + return { updatedConfig: configToml, error: String(e) }; + } +}; + const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet"; const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config # Add your common TOML configuration here`; @@ -48,6 +66,26 @@ export function useCodexCommonConfig({ const hasInitializedNewMode = useRef(false); // 用于跟踪编辑模式是否已初始化显式开关/预览 const hasInitializedEditMode = useRef(false); + // 后端 TOML 合并是异步的:连续操作(快速点开关、连点保存)可能乱序 + // 返回。每个写 config 的异步操作在发起时领取递增序号,结果落地前 + // 校验自己仍是最新一次;过期结果直接丢弃,保证最后一次操作胜出。 + const tomlOpSeqRef = useRef(0); + // 镜像最新的 codexConfig:用户在编辑器手动改动走父组件 onChange, + // 不经过本 hook(序号不变)。异步结果落地前还要校验发起时的 config + // 基线未被外部改写,否则会覆盖用户在请求在飞期间的手动编辑。 + const latestCodexConfigRef = useRef(codexConfig); + useEffect(() => { + latestCodexConfigRef.current = codexConfig; + }, [codexConfig]); + + // 结果落地前的过期判定:hook 内有更新的操作(序号变了),或发起时的 + // config 基线已被外部改写(用户手动编辑为准)。任一成立即丢弃结果。 + const isTomlOpStale = useCallback( + (seq: number, baseConfig: string) => + seq !== tomlOpSeqRef.current || + baseConfig !== latestCodexConfigRef.current, + [], + ); // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 useEffect(() => { @@ -168,25 +206,34 @@ export function useCodexCommonConfig({ // 如果应该启用通用配置但配置中还没有,则自动添加 if (hasCommon && !inferredHasCommon && parsedSnippet.hasContent) { - const { updatedConfig, error } = updateTomlCommonConfigSnippet( - codexConfig, - commonConfigSnippet, - true, - ); - if (error) { - setCommonConfigError(error); - setUseCommonConfig(false); - return; - } + let cancelled = false; + const seq = ++tomlOpSeqRef.current; + (async () => { + const { updatedConfig, error } = await applyTomlSnippet( + codexConfig, + commonConfigSnippet, + true, + ); + if (cancelled || isTomlOpStale(seq, codexConfig)) { + return; + } + if (error) { + setCommonConfigError(error); + setUseCommonConfig(false); + return; + } - setCommonConfigError(""); - setUseCommonConfig(true); - isUpdatingFromCommonConfig.current = true; - onConfigChange(updatedConfig); - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - return; + setCommonConfigError(""); + setUseCommonConfig(true); + isUpdatingFromCommonConfig.current = true; + onConfigChange(updatedConfig); + setTimeout(() => { + isUpdatingFromCommonConfig.current = false; + }, 0); + })(); + return () => { + cancelled = true; + }; } setCommonConfigError(""); @@ -197,6 +244,7 @@ export function useCodexCommonConfig({ initialData, initialEnabled, isLoading, + isTomlOpStale, onConfigChange, parseCommonConfigSnippet, ]); @@ -221,28 +269,39 @@ export function useCodexCommonConfig({ return; } - const { updatedConfig, error } = updateTomlCommonConfigSnippet( - codexConfig, - commonConfigSnippet, - true, - ); - if (error) { - setCommonConfigError(error); - setUseCommonConfig(false); - return; - } + let cancelled = false; + const seq = ++tomlOpSeqRef.current; + (async () => { + const { updatedConfig, error } = await applyTomlSnippet( + codexConfig, + commonConfigSnippet, + true, + ); + if (cancelled || isTomlOpStale(seq, codexConfig)) { + return; + } + if (error) { + setCommonConfigError(error); + setUseCommonConfig(false); + return; + } - setCommonConfigError(""); - setUseCommonConfig(true); - isUpdatingFromCommonConfig.current = true; - onConfigChange(updatedConfig); - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); + setCommonConfigError(""); + setUseCommonConfig(true); + isUpdatingFromCommonConfig.current = true; + onConfigChange(updatedConfig); + setTimeout(() => { + isUpdatingFromCommonConfig.current = false; + }, 0); + })(); + return () => { + cancelled = true; + }; }, [ initialData, commonConfigSnippet, isLoading, + isTomlOpStale, codexConfig, onConfigChange, parseCommonConfigSnippet, @@ -250,7 +309,10 @@ export function useCodexCommonConfig({ // 处理通用配置开关 const handleCommonConfigToggle = useCallback( - (checked: boolean) => { + async (checked: boolean) => { + // 在同步校验之前领号:即使本次走同步早退分支,也要让更早发出、 + // 仍在飞的异步结果作废,避免它晚到后把开关翻回去。 + const seq = ++tomlOpSeqRef.current; const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet); if (parsedSnippet.error) { setCommonConfigError(parsedSnippet.error); @@ -267,12 +329,14 @@ export function useCodexCommonConfig({ return; } - const { updatedConfig, error: snippetError } = - updateTomlCommonConfigSnippet( - codexConfig, - commonConfigSnippet, - checked, - ); + const { updatedConfig, error: snippetError } = await applyTomlSnippet( + codexConfig, + commonConfigSnippet, + checked, + ); + if (isTomlOpStale(seq, codexConfig)) { + return; + } if (snippetError) { setCommonConfigError(snippetError); @@ -293,6 +357,7 @@ export function useCodexCommonConfig({ [ codexConfig, commonConfigSnippet, + isTomlOpStale, onConfigChange, parseCommonConfigSnippet, t, @@ -301,7 +366,10 @@ export function useCodexCommonConfig({ // 处理通用配置片段变化 const handleCommonConfigSnippetChange = useCallback( - (value: string): boolean => { + async (value: string): Promise => { + // 与 handleCommonConfigToggle 同一套序号:连续保存或保存与开关 + // 交错时,只允许最后一次操作的结果落地。 + const seq = ++tomlOpSeqRef.current; const previousSnippet = commonConfigSnippet; if (!value.trim()) { @@ -312,11 +380,14 @@ export function useCodexCommonConfig({ let updatedConfig = codexConfig; if (!previousParsed.error && previousParsed.hasContent) { - const removeResult = updateTomlCommonConfigSnippet( + const removeResult = await applyTomlSnippet( codexConfig, previousSnippet, false, ); + if (isTomlOpStale(seq, codexConfig)) { + return false; + } if (removeResult.error) { setCommonConfigError(removeResult.error); return false; @@ -352,11 +423,14 @@ export function useCodexCommonConfig({ const previousParsed = parseCommonConfigSnippet(previousSnippet); if (!previousParsed.error && previousParsed.hasContent) { - const removeResult = updateTomlCommonConfigSnippet( + const removeResult = await applyTomlSnippet( codexConfig, previousSnippet, false, ); + if (isTomlOpStale(seq, codexConfig)) { + return false; + } if (removeResult.error) { setCommonConfigError(removeResult.error); return false; @@ -364,11 +438,11 @@ export function useCodexCommonConfig({ nextConfig = removeResult.updatedConfig; } - const addResult = updateTomlCommonConfigSnippet( - nextConfig, - value, - true, - ); + const addResult = await applyTomlSnippet(nextConfig, value, true); + // nextConfig 派生自发起时的 codexConfig,基线校验仍对 codexConfig 做 + if (isTomlOpStale(seq, codexConfig)) { + return false; + } if (addResult.error) { setCommonConfigError(addResult.error); @@ -400,6 +474,7 @@ export function useCodexCommonConfig({ [ commonConfigSnippet, codexConfig, + isTomlOpStale, onConfigChange, parseCommonConfigSnippet, t, diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts index 1fbbb30cf..900c46ae2 100644 --- a/src/lib/api/config.ts +++ b/src/lib/api/config.ts @@ -48,6 +48,29 @@ export async function setCommonConfigSnippet( return invoke("set_common_config_snippet", { appType, snippet }); } +/** + * 对编辑器里的 config.toml 文本做通用配置片段的合并/剥离 + * + * 合并/剥离在后端用 toml_edit 完成(保注释、保键序);前端 smol-toml + * 的整文档重序列化会破坏用户手写格式,禁止在前端做这类结构化改写。 + * + * @param configToml - 编辑器当前的 config.toml 文本 + * @param snippetToml - 通用配置片段(TOML 字符串) + * @param enabled - true 合并片段,false 按值匹配剥离片段 + * @returns 更新后的 config.toml 文本 + */ +export async function updateTomlCommonConfigSnippet( + configToml: string, + snippetToml: string, + enabled: boolean, +): Promise { + return invoke("update_toml_common_config_snippet", { + configToml, + snippetToml, + enabled, + }); +} + /** * 提取通用配置片段 * diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 16a73a6fe..6ae912822 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -3,7 +3,7 @@ import type { TemplateValueConfig } from "../config/claudeProviderPresets"; import { deepClone } from "@/utils/deepClone"; import { normalizeTomlText } from "@/utils/textNormalization"; -import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import { parse as parseToml } from "smol-toml"; const isPlainObject = (value: unknown): value is Record => { return Object.prototype.toString.call(value) === "[object Object]"; @@ -343,40 +343,9 @@ export const setApiKeyInConfig = ( // ========== TOML Config Utilities ========== -export interface UpdateTomlCommonConfigResult { - updatedConfig: string; - error?: string; -} - -// Write/remove common config snippet to/from TOML config (structural merge) -export const updateTomlCommonConfigSnippet = ( - tomlString: string, - snippetString: string, - enabled: boolean, -): UpdateTomlCommonConfigResult => { - if (!snippetString.trim()) { - return { updatedConfig: tomlString }; - } - - try { - const config = parseToml(normalizeTomlText(tomlString || "")); - const snippet = parseToml(normalizeTomlText(snippetString)); - - if (enabled) { - const merged = deepMerge( - deepClone(config) as Record, - deepClone(snippet) as Record, - ); - return { updatedConfig: stringifyToml(merged) }; - } else { - const result = deepClone(config) as Record; - deepRemove(result, snippet as Record); - return { updatedConfig: stringifyToml(result) }; - } - } catch (e) { - return { updatedConfig: tomlString, error: String(e) }; - } -}; +// TOML 片段的合并/剥离必须走后端命令(configApi.updateTomlCommonConfigSnippet, +// toml_edit 保注释保键序)。禁止在前端用 smol-toml parse→merge→stringify +// 整文档重序列化:注释全丢、键序重排、还会生成多余的空父表头。 // Check if TOML config already contains the common config snippet (structural subset check) export const hasTomlCommonConfigSnippet = ( diff --git a/tests/hooks/useCommonConfigSave.test.tsx b/tests/hooks/useCommonConfigSave.test.tsx index cafe379a3..d8930811e 100644 --- a/tests/hooks/useCommonConfigSave.test.tsx +++ b/tests/hooks/useCommonConfigSave.test.tsx @@ -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((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((resolve) => { + resolveMerge = resolve; + }), + ); + + let togglePending: Promise = 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(() =>