From 3f03158f5ebe457d947628711de2cedbdfa947fc Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 5 Sep 2026 14:27:23 +0800 Subject: [PATCH] fix(codex): align fallback metadata and remove stale models automatically - Align fallback defaults and instruction structure with upstream Codex while preserving Fast availability for all models - Stop forcing fallback reasoning levels or inferring original-image support - Sync managed catalogs on model refresh, configuration changes, and every 30 seconds - Remove unavailable models, replace missing defaults, and handle empty-list recovery - Preserve existing configuration on request failures or invalid responses - Serialize refreshes, update UI model lists, and preserve backups across restarts - Add regression coverage for fallback compatibility, stale-model cleanup, and configuration recovery --- .../resources/codex_models/model-catalog.json | 9 +- src-tauri/src/agents/commands.rs | 94 +++++- src-tauri/src/agents/state.rs | 90 +++++ src-tauri/src/codex_catalog.rs | 250 +++++++++++++- src-tauri/src/configuration_watcher.rs | 22 +- src-tauri/src/main.rs | 2 + src-tauri/src/tests/agent_configuration.rs | 6 +- src-tauri/src/tests/agent_state.rs | 309 ++++++++++++++++++ src/pages/AgentsPage.tsx | 3 +- 9 files changed, 760 insertions(+), 25 deletions(-) diff --git a/src-tauri/resources/codex_models/model-catalog.json b/src-tauri/resources/codex_models/model-catalog.json index 516d087..a897693 100644 --- a/src-tauri/resources/codex_models/model-catalog.json +++ b/src-tauri/resources/codex_models/model-catalog.json @@ -18,9 +18,16 @@ "experimental_supported_tools": [], "tool_mode": null, "multi_agent_version": null, + "multi_agent_reasoning_effort": null, "use_responses_lite": false, "include_skills_usage_instructions": false, + "include_plugin_usage_instructions": false, + "include_apps_usage_instructions": false, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, "auto_review_model_override": null, + "model_specialty": null, "context_window": 272000, "max_context_window": 272000, "auto_compact_token_limit": null, @@ -34,7 +41,7 @@ "description": null, "default_reasoning_level": null, "supported_reasoning_levels": [], - "shell_type": "default", + "shell_type": "unified_exec", "visibility": "none", "supported_in_api": true, "availability_nux": null, diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 9166778..8d8b145 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -1,6 +1,7 @@ use super::*; const AGENT_STATUS_DETECTION_CONCURRENCY: usize = 4; +static CODEX_CATALOG_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); #[derive(Clone, Copy)] enum AgentStatusDetectionTarget { @@ -132,6 +133,7 @@ pub(crate) async fn refresh_agent_config_statuses( #[tauri::command] pub(crate) async fn get_agent_models( + app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, client: String, ) -> Result, String> { @@ -140,8 +142,19 @@ pub(crate) async fn get_agent_models( return fetch_agent_models(config.port, effective_agent_api_key(&config)).await; } let client = AgentClient::parse(&client)?; + let _sync_guard = if client == AgentClient::Codex { + Some(CODEX_CATALOG_SYNC_LOCK.lock().await) + } else { + None + }; let config = gui_config_state.snapshot()?; - Ok(fetch_prepared_agent_models(client, &config).await?.models) + let prepared = fetch_prepared_agent_models(client, &config).await?; + if client == AgentClient::Codex { + if let Err(error) = sync_prepared_codex_model_catalog(&app, &config, &prepared) { + eprintln!("自动刷新已应用的 Codex 模型目录失败: {error}"); + } + } + Ok(prepared.models) } pub(crate) async fn resolve_pi_default_model( @@ -764,6 +777,83 @@ pub(crate) async fn fetch_prepared_agent_models( } } +fn sync_prepared_codex_model_catalog( + app: &tauri::AppHandle, + config: &GuiConfigFile, + prepared: &PreparedAgentModels, +) -> Result { + let Some(catalog) = prepared.codex_catalog.as_deref() else { + return Ok(false); + }; + let home = app + .path() + .home_dir() + .map_err(|error| format!("无法获取用户目录: {error}"))?; + let changed = sync_codex_model_catalog_if_configured( + &home, + config.port, + effective_agent_api_key(config), + &prepared.models, + catalog, + )?; + if changed { + app.state::().clear()?; + let _ = app.emit( + CONFIG_FILES_CHANGED_EVENT, + ConfigFilesChangedPayload { + paths: agent_managed_paths(AgentClient::Codex, &home) + .iter() + .map(|path| path_to_string(path)) + .collect(), + errors: Vec::new(), + }, + ); + } + Ok(changed) +} + +pub(crate) async fn refresh_applied_codex_model_catalog( + app: &tauri::AppHandle, + config: &GuiConfigFile, +) -> Result { + let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await; + let runtime_models = + fetch_codex_runtime_models(config.port, effective_agent_api_key(config)).await?; + let prepared = prepare_codex_agent_models(&runtime_models)?; + sync_prepared_codex_model_catalog(app, config, &prepared) +} + +pub(crate) fn start_codex_model_catalog_sync(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + let Ok(home) = app.path().home_dir() else { + return; + }; + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let Ok(config) = app.state::().snapshot() else { + continue; + }; + let paths = agent_config_paths(AgentClient::Codex, &home); + if !matches!( + inspect_agent_managed_config( + AgentClient::Codex, + &paths, + config.port, + effective_agent_api_key(&config), + ), + Ok((true, _, _)) + ) { + continue; + } + if let Err(error) = refresh_applied_codex_model_catalog(&app, &config).await { + eprintln!("后台同步 Codex 模型目录失败,保留现有配置: {error}"); + } + } + }); +} + pub(crate) fn agent_uses_cpa_runtime_context_windows(client: AgentClient) -> bool { matches!( client, @@ -843,7 +933,7 @@ pub(crate) fn prepare_codex_agent_models( Err(error) if error.contains("CPA 当前没有可写入 Codex 的模型") => { Ok(PreparedAgentModels { models: Vec::new(), - codex_catalog: None, + codex_catalog: Some("{\n \"models\": []\n}\n".to_string()), }) } Err(error) => Err(error), diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index 24dab78..34adfdf 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -1517,6 +1517,96 @@ pub(crate) fn commit_agent_configuration( } } +pub(crate) fn sync_codex_model_catalog_if_configured( + home: &Path, + port: u16, + api_key: &str, + models: &[AgentModelOption], + catalog: &str, +) -> Result { + let client = AgentClient::Codex; + let paths = agent_config_paths(client, home); + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏".to_string())?; + let (configured, current_model, _) = + inspect_agent_managed_config(client, &paths, port, api_key)?; + if !configured { + return Ok(false); + } + let next_model = models + .iter() + .find(|model| { + current_model + .as_deref() + .is_some_and(|current| model.name.eq_ignore_ascii_case(current)) + }) + .or_else(|| models.first()) + .map(|model| model.name.as_str()); + if let Some(model) = next_model { + validate_codex_catalog(catalog, model)?; + } else { + let root: serde_json::Value = serde_json::from_str(catalog) + .map_err(|error| format!("Codex 模型目录格式无效: {error}"))?; + if !root + .get("models") + .and_then(serde_json::Value::as_array) + .is_some_and(Vec::is_empty) + { + return Err("空模型列表必须对应空的 Codex 模型目录".to_string()); + } + } + + let catalog_path = codex_model_catalog_path(home); + let mut updates = Vec::new(); + if read_agent_bytes(&catalog_path)?.as_deref() != Some(catalog.as_bytes()) { + updates.push(AgentFileUpdate { + path: catalog_path, + after: catalog.to_string(), + }); + } + if current_model.as_deref() != next_model { + let content = fs::read_to_string(&paths[0]) + .map_err(|error| format!("读取 Codex 配置失败: {error}"))?; + let mut document = content + .parse::() + .map_err(|error| format!("Codex config.toml 格式无效: {error}"))?; + if let Some(model) = next_model { + set_codex_table_item(document.as_table_mut(), "model", toml_edit::value(model)); + } else { + document.remove("model"); + } + updates.push(AgentFileUpdate { + path: paths[0].clone(), + after: document.to_string(), + }); + } + if updates.is_empty() { + return Ok(false); + } + + if load_agent_applied_state(client, home)?.is_none() { + for path in [paths[0].clone(), codex_model_catalog_path(home)] { + if !updates.iter().any(|update| update.path == path) { + let after = fs::read_to_string(&path) + .map_err(|error| format!("读取 Codex 配置以创建备份失败: {error}"))?; + updates.push(AgentFileUpdate { path, after }); + } + } + } + updates.sort_by_key(|update| update.path != paths[0]); + + let result = commit_agent_configuration( + client, + home, + next_model.unwrap_or_default(), + &updates, + "updated", + None, + )?; + Ok(!result.changed_files.is_empty()) +} + #[cfg(test)] pub(crate) fn apply_agent_configuration( client: AgentClient, diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index 83f8944..0313017 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -151,6 +151,9 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result) { + if matches!( + string_value(model, "shell_type").as_str(), + "default" | "local" | "shell_command" + ) { + model.insert( + "shell_type".to_string(), + Value::String("unified_exec".to_string()), + ); + } + for field in [ + "include_skills_usage_instructions", + "include_plugin_usage_instructions", + "include_apps_usage_instructions", + "node_repl_auto_review_required", + "node_repl_disabled", + "supports_image_detail_original", + ] { + model.entry(field.to_string()).or_insert(Value::Bool(false)); + } + for field in [ + "guardian", + "auto_review_model_override", + "model_specialty", + "tool_mode", + "multi_agent_version", + "multi_agent_reasoning_effort", + ] { + model.entry(field.to_string()).or_insert(Value::Null); + } + let base_instructions = model + .get("base_instructions") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let messages = model + .entry("model_messages".to_string()) + .or_insert(Value::Null); + if !messages.is_object() { + *messages = Value::Object(Map::new()); + } + if let Some(messages) = messages.as_object_mut() { + if !messages + .get("instructions_template") + .is_some_and(Value::is_string) + { + messages.insert( + "instructions_template".to_string(), + Value::String(base_instructions), + ); + } + for field in [ + "persistent_instructions", + "tools", + "instructions_variables", + "approvals", + "collaboration_modes", + "auto_review", + "permissions", + "multi_agent", + "token_budget", + "confirmation_policies", + "guardian_v2", + ] { + messages.entry(field.to_string()).or_insert(Value::Null); + } + } +} + fn apply_runtime_metadata(model: &mut Map, runtime: &CodexRuntimeModel) { model.insert("slug".to_string(), Value::String(runtime.slug.clone())); model.insert( @@ -467,13 +540,8 @@ fn apply_runtime_metadata(model: &mut Map, runtime: &CodexRuntime "input_modalities".to_string(), Value::Array(modalities.iter().cloned().map(Value::String).collect()), ); - model.insert( - "supports_image_detail_original".to_string(), - Value::Bool(modalities.iter().any(|value| value == "image")), - ); } - open_all_reasoning_levels(model, runtime); if runtime.hidden { model.insert("visibility".to_string(), Value::String("hide".to_string())); } @@ -652,12 +720,8 @@ mod tests { "context_window": 128000, "max_context_window": 128000, "input_modalities": ["text"], - "default_reasoning_level": "medium", - "supported_reasoning_levels": [ - {"effort":"low","description":"Low"}, - {"effort":"medium","description":"Medium"}, - {"effort":"high","description":"High"} - ], + "default_reasoning_level": null, + "supported_reasoning_levels": [], "shell_type": "shell_command", "supported_in_api": true, "default_reasoning_summary": "none", @@ -750,7 +814,7 @@ mod tests { ); assert_eq!(sources.fallback["default_reasoning_level"], Value::Null); assert!(reasoning_efforts(&sources.fallback).is_empty()); - assert_eq!(sources.fallback["shell_type"], "default"); + assert_eq!(sources.fallback["shell_type"], "unified_exec"); assert_eq!(sources.fallback["visibility"], "none"); assert_eq!(sources.fallback["default_reasoning_summary"], "auto"); assert_eq!(sources.fallback["supports_parallel_tool_calls"], false); @@ -816,6 +880,24 @@ mod tests { assert_eq!(models[1].slug, "Model-B"); } + #[test] + fn runtime_parser_distinguishes_empty_lists_from_invalid_model_entries() { + for payload in [ + serde_json::json!({"models": []}), + serde_json::json!({"data": []}), + serde_json::json!([]), + ] { + assert!(parse_runtime_models(&payload).unwrap().is_empty()); + } + for payload in [ + serde_json::json!({"error": "unavailable"}), + serde_json::json!({"models": null}), + serde_json::json!({"models": [null, {}, {"id": " "}]}), + ] { + assert!(parse_runtime_models(&payload).is_err()); + } + } + #[test] fn runtime_parser_exposes_model_alias_as_a_runtime_slug() { let models = runtime(serde_json::json!({ @@ -1033,11 +1115,12 @@ mod tests { let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap(); let model = &output_models(&catalog)[0]; assert_eq!(model["display_name"], "C"); - assert_eq!(reasoning_efforts(model), ALL_REASONING_LEVELS); + assert!(reasoning_efforts(model).is_empty()); assert_eq!(model["context_window"], 200_000); assert_eq!(model["max_context_window"], 200_000); assert_eq!(model["input_modalities"], serde_json::json!(["image"])); - assert_eq!(model["default_reasoning_level"], "high"); + assert_eq!(model["supports_image_detail_original"], false); + assert_eq!(model["default_reasoning_level"], Value::Null); assert_eq!(model["visibility"], "hide"); assert_eq!( model["base_instructions"], @@ -1055,6 +1138,139 @@ mod tests { assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"])); } + #[test] + fn generated_fallback_uses_official_defaults_with_fast_available() { + let sources = parse_sources(MODEL_CATALOG_JSON).unwrap(); + let runtime = runtime(serde_json::json!({"models":[{"id":"unknown-model"}]})); + let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap(); + let model = &output_models(&catalog)[0]; + let expected: Value = serde_json::from_str( + r#"{ + "slug": "unknown-model", + "display_name": "unknown-model", + "description": null, + "context_window": 272000, + "max_context_window": 272000, + "auto_compact_token_limit": null, + "comp_hash": null, + "effective_context_window_percent": 95, + "default_reasoning_level": null, + "supported_reasoning_levels": [], + "shell_type": "unified_exec", + "input_modalities": ["text", "image"], + "supports_image_detail_original": false, + "supports_reasoning_summary_parameter": true, + "default_reasoning_summary": "auto", + "support_verbosity": false, + "default_verbosity": null, + "apply_patch_tool_type": null, + "web_search_tool_type": "text", + "truncation_policy": {"mode": "bytes", "limit": 10000}, + "experimental_supported_tools": [], + "supported_in_api": true, + "include_skills_usage_instructions": false, + "include_plugin_usage_instructions": false, + "include_apps_usage_instructions": false, + "supports_search_tool": false, + "use_responses_lite": false, + "guardian": null, + "node_repl_auto_review_required": false, + "node_repl_disabled": false, + "auto_review_model_override": null, + "model_specialty": null, + "tool_mode": null, + "multi_agent_version": null, + "multi_agent_reasoning_effort": null, + "availability_nux": null, + "upgrade": null, + "default_service_tier": null, + "visibility": "list", + "additional_speed_tiers": ["fast"], + "service_tiers": [{ + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + }] + }"#, + ) + .unwrap(); + for (field, value) in expected.as_object().unwrap() { + assert_eq!(model.get(field), Some(value), "field: {field}"); + } + assert_eq!( + model["model_messages"]["instructions_template"], + sources.fallback["base_instructions"] + ); + let messages = model["model_messages"].as_object().unwrap(); + assert_eq!(messages.len(), 12); + assert!(messages + .iter() + .all(|(field, value)| field == "instructions_template" || value.is_null())); + } + + #[test] + fn legacy_fallback_templates_receive_official_message_and_capability_defaults() { + let sources = test_sources(); + let runtime = runtime(serde_json::json!({"models":[{"id":"C"}]})); + let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap(); + let model = &output_models(&catalog)[0]; + + assert_eq!(model["shell_type"], "unified_exec"); + assert_eq!(model["include_apps_usage_instructions"], false); + assert_eq!(model["include_plugin_usage_instructions"], false); + assert_eq!(model["node_repl_disabled"], false); + assert_eq!(model["default_reasoning_level"], Value::Null); + assert!(reasoning_efforts(model).is_empty()); + assert_eq!( + model["model_messages"]["instructions_template"], + sources.fallback["base_instructions"] + ); + assert_eq!(model["default_service_tier"], Value::Null); + assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"])); + } + + #[test] + fn fallback_preserves_explicit_template_reasoning_and_structured_instructions() { + let mut sources = test_sources(); + sources.fallback.insert( + "model_messages".to_string(), + serde_json::json!({ + "instructions_template": "Custom {{ personality }} instructions", + "instructions_variables": {"personality_default": "pragmatic"}, + "tools": {"custom": "Keep this"} + }), + ); + sources.fallback.insert( + "default_reasoning_level".to_string(), + serde_json::json!("low"), + ); + sources.fallback.insert( + "supported_reasoning_levels".to_string(), + serde_json::json!([{"effort": "low", "description": "Low"}]), + ); + sources.fallback.insert( + "supports_image_detail_original".to_string(), + Value::Bool(true), + ); + let runtime = runtime(serde_json::json!({"models":[{ + "id":"C", + "default_reasoning_level":"high", + "model_messages":{"instructions_template":"Untrusted instructions"} + }]})); + let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap(); + let model = &output_models(&catalog)[0]; + + assert_eq!(model["default_reasoning_level"], "low"); + assert_eq!(reasoning_efforts(model), ["low"]); + assert_eq!(model["supports_image_detail_original"], true); + for field in ["instructions_template", "instructions_variables", "tools"] { + assert_eq!( + model["model_messages"][field], + sources.fallback["model_messages"][field] + ); + } + } + #[test] fn fallback_display_name_matches_requested_model_slug() { let runtime = runtime(serde_json::json!({"models":[{ @@ -1071,7 +1287,7 @@ mod tests { } #[test] - fn invalid_runtime_reasoning_does_not_reduce_open_levels() { + fn fallback_does_not_invent_reasoning_capabilities_from_runtime_metadata() { let runtime = runtime(serde_json::json!({"models":[{ "id":"C", "supported_reasoning_levels":["xhigh"], @@ -1086,8 +1302,8 @@ mod tests { assert_eq!(model["web_search_tool_type"], "text"); assert_eq!(model["availability_nux"], Value::Null); assert_eq!(model["upgrade"], Value::Null); - assert_eq!(model["default_reasoning_level"], "medium"); - assert_eq!(reasoning_efforts(model), ALL_REASONING_LEVELS); + assert_eq!(model["default_reasoning_level"], Value::Null); + assert!(reasoning_efforts(model).is_empty()); } #[test] diff --git a/src-tauri/src/configuration_watcher.rs b/src-tauri/src/configuration_watcher.rs index 579bb96..0462d37 100644 --- a/src-tauri/src/configuration_watcher.rs +++ b/src-tauri/src/configuration_watcher.rs @@ -2,9 +2,10 @@ use super::{ agent_managed_paths, apply_gui_managed_settings, consume_software_write, core_config_settings_from_value, core_install_dir, gui_config_path, is_loopback_host, lock_core_config_file, normalized_config_path, path_to_string, - refresh_agent_config_status_cache, validate_gui_config, write_yaml_if_changed, AgentClient, - AgentConfigStatusCache, ConfigFilesChangedPayload, CoreConfigSettings, GuiConfigFile, - GuiConfigState, CONFIG_FILES_CHANGED_EVENT, CORE_CONFIG_FILE, + refresh_agent_config_status_cache, refresh_applied_codex_model_catalog, validate_gui_config, + write_yaml_if_changed, AgentClient, AgentConfigStatusCache, ConfigFilesChangedPayload, + CoreConfigSettings, GuiConfigFile, GuiConfigState, CONFIG_FILES_CHANGED_EVENT, + CORE_CONFIG_FILE, }; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use std::{ @@ -202,6 +203,21 @@ fn handle_configuration_file_changes( break; } } + + let catalog_app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_millis(500)).await; + let config = match catalog_app.state::().snapshot() { + Ok(config) => config, + Err(error) => { + eprintln!("读取配置以刷新 Codex 模型目录失败: {error}"); + return; + } + }; + if let Err(error) = refresh_applied_codex_model_catalog(&catalog_app, &config).await { + eprintln!("配置变化后刷新 Codex 模型目录失败: {error}"); + } + }); } refresh_agents |= tracked_changes diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fb8cefa..caf0976 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2378,6 +2378,8 @@ fn main() { eprintln!("启动配置文件监控失败: {error}"); } + start_codex_model_catalog_sync(app.handle().clone()); + let usage_app = app.handle().clone(); tauri::async_runtime::spawn_blocking(move || { if let Err(error) = usage::initialize_usage_storage() { diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 6b6bc9c..ae8b4a8 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -2185,7 +2185,11 @@ fn codex_model_list_is_empty_when_cpa_has_no_writable_models() { let prepared = prepare_codex_agent_models(&[]).unwrap(); assert!(prepared.models.is_empty()); - assert!(prepared.codex_catalog.is_none()); + assert_eq!( + serde_json::from_str::(prepared.codex_catalog.as_deref().unwrap()) + .unwrap(), + serde_json::json!({"models": []}) + ); } #[test] diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index 58de476..da2cd69 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -1310,6 +1310,315 @@ fn codex_applied_configuration_remains_detectable_after_process_restart() { fs::remove_dir_all(home).unwrap(); } +#[test] +fn codex_catalog_sync_removes_models_missing_from_runtime() { + let home = agent_test_home("codex-runtime-catalog-sync"); + let original_models = test_agent_models(&["gpt-active", "gpt-stale"]); + let original_catalog = test_codex_models(&["gpt-active", "gpt-stale"]); + apply_agent_configuration( + AgentClient::Codex, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-active", + &original_models, + Some(&original_catalog), + ) + .unwrap(); + + let current_models = test_agent_models(&["gpt-active"]); + let current_catalog = test_codex_models(&["gpt-active"]); + assert!(sync_codex_model_catalog_if_configured( + &home, + 8317, + DEFAULT_API_KEY, + ¤t_models, + ¤t_catalog, + ) + .unwrap()); + + let catalog_path = codex_model_catalog_path(&home); + let written = fs::read_to_string(&catalog_path).unwrap(); + assert!(written.contains("gpt-active")); + assert!(!written.contains("gpt-stale")); + restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + assert!(!catalog_path.exists()); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn codex_catalog_sync_replaces_missing_default_and_preserves_user_configuration() { + let home = agent_test_home("codex-runtime-catalog-default-missing"); + let config_path = home.join(".codex/config.toml"); + let original_config = + "# Keep my settings\nmodel = \"personal-model\"\napproval_policy = \"on-request\"\n"; + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, original_config).unwrap(); + let original_models = test_agent_models(&["gpt-stale"]); + let original_catalog = test_codex_models(&["gpt-stale"]); + apply_agent_configuration( + AgentClient::Codex, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-stale", + &original_models, + Some(&original_catalog), + ) + .unwrap(); + + let current_models = test_agent_models(&["gpt-active"]); + let current_catalog = test_codex_models(&["gpt-active"]); + assert!(sync_codex_model_catalog_if_configured( + &home, + 8317, + DEFAULT_API_KEY, + ¤t_models, + ¤t_catalog, + ) + .unwrap()); + + assert_eq!( + fs::read_to_string(codex_model_catalog_path(&home)).unwrap(), + current_catalog + ); + let updated = fs::read_to_string(&config_path).unwrap(); + let document: toml::Value = toml::from_str(&updated).unwrap(); + assert_eq!(document["model"].as_str(), Some("gpt-active")); + assert_eq!(document["approval_policy"].as_str(), Some("on-request")); + assert!(updated.contains("# Keep my settings")); + assert_eq!( + load_agent_applied_state(AgentClient::Codex, &home) + .unwrap() + .unwrap() + .model, + "gpt-active" + ); + assert!(!sync_codex_model_catalog_if_configured( + &home, + 8317, + DEFAULT_API_KEY, + ¤t_models, + ¤t_catalog, + ) + .unwrap()); + restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + assert_eq!(fs::read_to_string(&config_path).unwrap(), original_config); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn codex_catalog_sync_clears_empty_list_and_recovers_when_models_return() { + let home = agent_test_home("codex-runtime-catalog-empty-and-recovery"); + let config_path = home.join(".codex/config.toml"); + let catalog_path = codex_model_catalog_path(&home); + apply_agent_configuration( + AgentClient::Codex, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-stale", + &test_agent_models(&["gpt-stale"]), + Some(&test_codex_models(&["gpt-stale"])), + ) + .unwrap(); + let auth_path = home.join(".codex/auth.json"); + let original_auth = fs::read(&auth_path).unwrap(); + let empty = prepare_codex_agent_models(&[]).unwrap(); + let empty_catalog = empty.codex_catalog.as_deref().unwrap(); + + assert!(sync_codex_model_catalog_if_configured( + &home, + 8317, + DEFAULT_API_KEY, + &empty.models, + empty_catalog, + ) + .unwrap()); + let document: toml::Value = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap(); + assert!(document.get("model").is_none()); + assert_eq!( + document["model_provider"].as_str(), + Some(MANAGED_AGENT_PROVIDER_ID) + ); + assert_eq!(fs::read_to_string(&catalog_path).unwrap(), empty_catalog); + assert_eq!(fs::read(&auth_path).unwrap(), original_auth); + assert!(!sync_codex_model_catalog_if_configured( + &home, + 8317, + DEFAULT_API_KEY, + &empty.models, + empty_catalog, + ) + .unwrap()); + + let returned_models = test_agent_models(&["gpt-returned", "gpt-second"]); + let returned_catalog = test_codex_models(&["gpt-returned", "gpt-second"]); + assert!(sync_codex_model_catalog_if_configured( + &home, + 8317, + DEFAULT_API_KEY, + &returned_models, + &returned_catalog, + ) + .unwrap()); + let document: toml::Value = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap(); + assert_eq!(document["model"].as_str(), Some("gpt-returned")); + assert_eq!(fs::read_to_string(&catalog_path).unwrap(), returned_catalog); + assert_eq!(fs::read(&auth_path).unwrap(), original_auth); + restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + assert!(!config_path.exists()); + assert!(!catalog_path.exists()); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn codex_catalog_sync_after_restart_backs_up_managed_files_without_memory_state() { + let home = agent_test_home("codex-runtime-catalog-after-restart"); + let config_path = home.join(".codex/config.toml"); + let catalog_path = codex_model_catalog_path(&home); + let original_catalog = test_codex_models(&["gpt-stale"]); + apply_agent_configuration( + AgentClient::Codex, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-stale", + &test_agent_models(&["gpt-stale"]), + Some(&original_catalog), + ) + .unwrap(); + let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap(); + clear_codex_applied_state(&state_path).unwrap(); + assert!(load_agent_applied_state(AgentClient::Codex, &home) + .unwrap() + .is_none()); + let current_catalog = test_codex_models(&["gpt-active"]); + assert!(sync_codex_model_catalog_if_configured( + &home, + 8317, + DEFAULT_API_KEY, + &test_agent_models(&["gpt-active"]), + ¤t_catalog, + ) + .unwrap()); + assert_eq!(fs::read_to_string(&catalog_path).unwrap(), current_catalog); + assert_eq!( + fs::read_to_string(dated_agent_backup_path(&catalog_path).unwrap()).unwrap(), + original_catalog + ); + assert!(load_agent_applied_state(AgentClient::Codex, &home) + .unwrap() + .is_some()); + restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn codex_catalog_only_sync_after_restart_preserves_complete_backups() { + let home = agent_test_home("codex-runtime-catalog-only-after-restart"); + let config_path = home.join(".codex/config.toml"); + let catalog_path = codex_model_catalog_path(&home); + let original_catalog = test_codex_models(&["gpt-active", "gpt-stale"]); + apply_agent_configuration( + AgentClient::Codex, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-active", + &test_agent_models(&["gpt-active", "gpt-stale"]), + Some(&original_catalog), + ) + .unwrap(); + let original_config = fs::read(&config_path).unwrap(); + let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap(); + clear_codex_applied_state(&state_path).unwrap(); + assert!(sync_codex_model_catalog_if_configured( + &home, + 8317, + DEFAULT_API_KEY, + &test_agent_models(&["gpt-active"]), + &test_codex_models(&["gpt-active"]), + ) + .unwrap()); + assert_eq!(fs::read(&config_path).unwrap(), original_config); + restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + assert_eq!(fs::read(&config_path).unwrap(), original_config); + assert_eq!(fs::read_to_string(&catalog_path).unwrap(), original_catalog); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn codex_catalog_sync_leaves_unmanaged_configuration_untouched() { + let home = agent_test_home("codex-runtime-catalog-unmanaged"); + let config_path = home.join(".codex/config.toml"); + let catalog_path = codex_model_catalog_path(&home); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + let original_config = "model_provider = \"personal\"\nmodel = \"personal-model\"\n"; + let original_catalog = test_codex_models(&["personal-model"]); + fs::write(&config_path, original_config).unwrap(); + fs::write(&catalog_path, &original_catalog).unwrap(); + assert!(!sync_codex_model_catalog_if_configured( + &home, + 8317, + DEFAULT_API_KEY, + &[], + "{\"models\":[]}", + ) + .unwrap()); + assert_eq!(fs::read_to_string(&config_path).unwrap(), original_config); + assert_eq!(fs::read_to_string(&catalog_path).unwrap(), original_catalog); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn codex_catalog_sync_rejects_invalid_snapshots_without_changing_files() { + let home = agent_test_home("codex-runtime-catalog-invalid-snapshot"); + let config_path = home.join(".codex/config.toml"); + let catalog_path = codex_model_catalog_path(&home); + apply_agent_configuration( + AgentClient::Codex, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-stale", + &test_agent_models(&["gpt-stale"]), + Some(&test_codex_models(&["gpt-stale"])), + ) + .unwrap(); + let config_before = fs::read(&config_path).unwrap(); + let catalog_before = fs::read(&catalog_path).unwrap(); + let state_before = + serde_json::to_value(load_agent_applied_state(AgentClient::Codex, &home).unwrap()).unwrap(); + for invalid in [ + "not json", + "{}", + "{\"models\":null}", + "{\"models\":[{\"slug\":\"gpt-stale\"}]}", + ] { + assert!( + sync_codex_model_catalog_if_configured(&home, 8317, DEFAULT_API_KEY, &[], invalid,) + .is_err() + ); + } + assert!(sync_codex_model_catalog_if_configured( + &home, + 8317, + DEFAULT_API_KEY, + &test_agent_models(&["gpt-active"]), + "{\"models\":[]}", + ) + .is_err()); + assert_eq!(fs::read(&config_path).unwrap(), config_before); + assert_eq!(fs::read(&catalog_path).unwrap(), catalog_before); + assert_eq!( + serde_json::to_value(load_agent_applied_state(AgentClient::Codex, &home).unwrap()).unwrap(), + state_before + ); + restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn codex_session_restore_recovers_from_a_dated_backup_without_state_json() { let home = agent_test_home("codex-session-backup-recovery"); diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 259a315..7d8686c 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -783,6 +783,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP void loadStatuses().catch((requestError) => { if (!disposed) setDetectionError(String(requestError)); }); + void loadModels(selected); }).then((unlisten) => { if (disposed) unlisten(); else stop = unlisten; @@ -791,7 +792,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP disposed = true; stop?.(); }; - }, [loadStatuses]); + }, [loadModels, loadStatuses, selected]); useEffect(() => { writeSelectedAgentClient(selected);