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
This commit is contained in:
lzt404 committed 2026-09-05 18:05:57 +08:00
1 parent ca4a3307f3
commit 3f03158f5e
9 files changed
+760 -25

No files matched your search

@@ -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,
+92 -2
View File
@@ -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<Vec<AgentModelOption>, 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<bool, String> {
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::<AgentConfigStatusCache>().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<bool, String> {
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::<GuiConfigState>().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),
+90
View File
@@ -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<bool, String> {
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::<toml_edit::Document>()
.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,
+233 -17
View File
@@ -151,6 +151,9 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
models.push(make_runtime_model(slug, display_name));
}
}
if !values.is_empty() && models.is_empty() {
return Err("Codex 模型列表响应未包含有效的模型 ID".to_string());
}
Ok(models)
}
@@ -357,6 +360,7 @@ fn prepare_catalog_with_sources(
});
} else {
let mut value = sources.fallback.clone();
normalize_fallback_model(&mut value);
apply_runtime_metadata(&mut value, runtime);
disable_fallback_capabilities(&mut value);
enable_fast_mode(&mut value);
@@ -427,6 +431,75 @@ fn prepare_catalog_with_sources(
Ok(PreparedCodexCatalog { models, json })
}
fn normalize_fallback_model(model: &mut Map<String, Value>) {
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<String, Value>, 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<String, Value>, 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]
+19 -3
View File
@@ -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::<GuiConfigState>().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
+2
View File
@@ -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() {
+5 -1
View File
@@ -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::<serde_json::Value>(prepared.codex_catalog.as_deref().unwrap())
.unwrap(),
serde_json::json!({"models": []})
);
}
#[test]
+309
View File
@@ -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,
&current_models,
&current_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,
&current_models,
&current_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,
&current_models,
&current_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"]),
&current_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");
+2 -1
View File
@@ -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);