From b312b904a290384ab4d40bd93a455709b61f756d Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 15 Aug 2026 15:28:01 +0800 Subject: [PATCH] fix(agents): speed up detection and sync ZCode default model --- src-tauri/src/agents/commands.rs | 82 ++++++++--- src-tauri/src/agents/configuration.rs | 137 +++++++++++++----- src-tauri/src/agents/discovery.rs | 155 +++++++++++++++++---- src-tauri/src/agents/state.rs | 59 ++++++-- src-tauri/src/tests/agent_configuration.rs | 105 ++++++++++++++ src-tauri/src/tests/agent_state.rs | 65 +++++++++ src/pages/AgentsPage.tsx | 1 + src/services/agentConfigurationDraft.ts | 3 + tests/agentModelPicker.test.ts | 14 ++ 9 files changed, 525 insertions(+), 96 deletions(-) diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 5140e85..27a5efd 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -1,5 +1,13 @@ use super::*; +const AGENT_STATUS_DETECTION_CONCURRENCY: usize = 4; + +#[derive(Clone, Copy)] +enum AgentStatusDetectionTarget { + Client(AgentClient), + PiProvider, +} + pub(crate) fn inspect_agent_config_statuses( app: &tauri::AppHandle, config: &GuiConfigFile, @@ -9,23 +17,63 @@ pub(crate) fn inspect_agent_config_statuses( .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; let api_key = effective_agent_api_key(config); - let mut statuses = [ - AgentClient::ClaudeCode, - AgentClient::ClaudeDesktop, - AgentClient::Codex, - AgentClient::OpenCode, - AgentClient::OpenClaw, - AgentClient::Hermes, - AgentClient::DeepSeekHarness, - AgentClient::ZCode, - AgentClient::KimiCode, - AgentClient::GrokBuild, - ] - .into_iter() - .map(|client| inspect_agent_config(client, &home, config.port, api_key)) - .collect::>(); - statuses.push(inspect_pi_provider_status(&home, config.port, api_key)); - Ok(statuses) + let targets = [ + AgentStatusDetectionTarget::Client(AgentClient::ClaudeCode), + AgentStatusDetectionTarget::Client(AgentClient::ClaudeDesktop), + AgentStatusDetectionTarget::Client(AgentClient::Codex), + AgentStatusDetectionTarget::Client(AgentClient::OpenCode), + AgentStatusDetectionTarget::Client(AgentClient::OpenClaw), + AgentStatusDetectionTarget::Client(AgentClient::Hermes), + AgentStatusDetectionTarget::Client(AgentClient::DeepSeekHarness), + AgentStatusDetectionTarget::Client(AgentClient::ZCode), + AgentStatusDetectionTarget::Client(AgentClient::KimiCode), + AgentStatusDetectionTarget::Client(AgentClient::GrokBuild), + AgentStatusDetectionTarget::PiProvider, + ]; + let queue = Mutex::new(targets.into_iter().enumerate()); + let results = Mutex::new(vec![None; targets.len()]); + thread::scope(|scope| { + let mut workers = Vec::with_capacity(AGENT_STATUS_DETECTION_CONCURRENCY); + for _ in 0..AGENT_STATUS_DETECTION_CONCURRENCY { + let queue = &queue; + let results = &results; + let home = &home; + workers.push(scope.spawn(move || -> Result<(), String> { + loop { + let next = queue + .lock() + .map_err(|_| "智能体检测任务队列锁已损坏".to_string())? + .next(); + let Some((index, target)) = next else { + return Ok(()); + }; + let status = match target { + AgentStatusDetectionTarget::Client(client) => { + inspect_agent_config(client, home, config.port, api_key) + } + AgentStatusDetectionTarget::PiProvider => { + inspect_pi_provider_status(home, config.port, api_key) + } + }; + results + .lock() + .map_err(|_| "智能体检测结果锁已损坏".to_string())?[index] = Some(status); + } + })); + } + for worker in workers { + worker + .join() + .map_err(|_| "智能体检测工作线程异常退出".to_string())??; + } + Ok::<(), String>(()) + })?; + results + .into_inner() + .map_err(|_| "智能体检测结果锁已损坏".to_string())? + .into_iter() + .map(|status| status.ok_or_else(|| "智能体检测结果不完整".to_string())) + .collect() } pub(crate) fn refresh_agent_config_status_cache( diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index cc0f4e6..c2283a4 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -215,16 +215,31 @@ pub(crate) fn build_agent_updates_with_oauth( ]) } AgentClient::ZCode => { - let before = read_optional_text(&paths[0])?; - let after = - build_zcode_agent_config(before.as_deref(), &root_base, api_key, model, models) + let app_before = read_optional_text(&paths[0])?; + let cli_before = read_optional_text(&paths[1])?; + let app_after = + build_zcode_agent_config(app_before.as_deref(), &root_base, api_key, model, models) .or_else(|_| { build_zcode_agent_config(None, &root_base, api_key, model, models) })?; - Ok(vec![AgentFileUpdate { - path: paths[0].clone(), - after, - }]) + let cli_after = build_zcode_cli_agent_config( + cli_before.as_deref(), + &root_base, + api_key, + model, + models, + ) + .or_else(|_| build_zcode_cli_agent_config(None, &root_base, api_key, model, models))?; + Ok(vec![ + AgentFileUpdate { + path: paths[0].clone(), + after: app_after, + }, + AgentFileUpdate { + path: paths[1].clone(), + after: cli_after, + }, + ]) } AgentClient::KimiCode => { let before = read_optional_text(&paths[0])?; @@ -1031,35 +1046,59 @@ pub(crate) fn remove_opencode_managed_configuration( } pub(crate) fn remove_zcode_managed_configuration(paths: &[PathBuf]) -> Result, String> { - let Some(path) = paths.first() else { + if paths.is_empty() { return Err("ZCode 当前平台配置路径不可用".to_string()); - }; + } let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); - let updated = update_agent_json_file(path, "ZCode 配置", |root| { - let mut changed = false; - if root - .get("model") - .and_then(serde_json::Value::as_str) - .is_some_and(|model| model.starts_with(&prefix)) - { - root.remove("model"); - changed = true; + let mut changed_paths = Vec::new(); + for path in paths { + let updated = update_agent_json_file(path, "ZCode 配置", |root| { + let mut changed = false; + let remove_model = if root + .get("model") + .and_then(serde_json::Value::as_str) + .is_some_and(|model| model.starts_with(&prefix)) + { + true + } else if let Some(model) = root + .get_mut("model") + .and_then(serde_json::Value::as_object_mut) + { + if model + .get("main") + .and_then(serde_json::Value::as_str) + .is_some_and(|model| model.starts_with(&prefix)) + { + model.remove("main"); + changed = true; + } + model.is_empty() + } else { + false + }; + if remove_model { + root.remove("model"); + changed = true; + } + let providers_empty = if let Some(providers) = root + .get_mut("provider") + .and_then(serde_json::Value::as_object_mut) + { + changed |= providers.remove(MANAGED_AGENT_PROVIDER_ID).is_some(); + providers.is_empty() + } else { + false + }; + if providers_empty { + root.remove("provider"); + } + changed + })?; + if updated { + changed_paths.push(path_to_string(path)); } - let providers_empty = if let Some(providers) = root - .get_mut("provider") - .and_then(serde_json::Value::as_object_mut) - { - changed |= providers.remove(MANAGED_AGENT_PROVIDER_ID).is_some(); - providers.is_empty() - } else { - false - }; - if providers_empty { - root.remove("provider"); - } - changed - })?; - Ok(updated.then(|| path_to_string(path)).into_iter().collect()) + } + Ok(changed_paths) } pub(crate) fn remove_kimi_code_managed_configuration( @@ -2706,6 +2745,27 @@ pub(crate) fn build_zcode_agent_config( api_key: &str, model: &str, available_models: &[AgentModelOption], +) -> Result { + build_zcode_config(existing, base_url, api_key, model, available_models, false) +} + +pub(crate) fn build_zcode_cli_agent_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, + available_models: &[AgentModelOption], +) -> Result { + build_zcode_config(existing, base_url, api_key, model, available_models, true) +} + +fn build_zcode_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, + available_models: &[AgentModelOption], + cli_config: bool, ) -> Result { let mut root = parse_agent_json_object(existing, "ZCode config.json")?; let providers = ensure_json_object_entry(&mut root, "provider"); @@ -2739,10 +2799,13 @@ pub(crate) fn build_zcode_agent_config( options.insert("baseURL".to_string(), serde_json::json!(base_url)); options.insert("apiKey".to_string(), serde_json::json!(api_key)); managed_provider.insert("models".to_string(), serde_json::Value::Object(models)); - root.insert( - "model".to_string(), - serde_json::json!(format!("{MANAGED_AGENT_PROVIDER_ID}/{model}")), - ); + let model = format!("{MANAGED_AGENT_PROVIDER_ID}/{model}"); + if cli_config { + ensure_json_object_entry(&mut root, "model") + .insert("main".to_string(), serde_json::json!(model)); + } else { + root.insert("model".to_string(), serde_json::json!(model)); + } render_agent_json(root, "ZCode 配置") } diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index eb1f542..c06b0b3 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -1,5 +1,8 @@ use super::*; +const AGENT_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const AGENT_VERSION_PROBE_POLL_INTERVAL: Duration = Duration::from_millis(20); + pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec { match client { AgentClient::ClaudeCode => { @@ -32,7 +35,10 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec vec![home.join(".zcode/v2").join(ZCODE_CONFIG_FILE)], + AgentClient::ZCode => vec![ + home.join(".zcode/v2").join(ZCODE_CONFIG_FILE), + home.join(".zcode/cli").join(ZCODE_CONFIG_FILE), + ], AgentClient::KimiCode => vec![kimi_code_home(home).join(KIMI_CODE_CONFIG_FILE)], AgentClient::GrokBuild => vec![grok_build_home(home).join(GROK_BUILD_CONFIG_FILE)], } @@ -968,8 +974,19 @@ pub(crate) fn inspect_agent_managed_config( .map(|(configured, model)| (configured, model, false)), AgentClient::DeepSeekHarness => inspect_deepseek_harness_config(paths, port, api_key) .map(|(configured, model)| (configured, model, false)), - AgentClient::ZCode => inspect_zcode_agent_config(&paths[0], port, api_key) - .map(|(configured, model)| (configured, model, false)), + AgentClient::ZCode => { + if paths.len() != 2 { + return Err("ZCode 配置路径数量无效".to_string()); + } + let (app_configured, app_model) = inspect_zcode_agent_config(&paths[0], port, api_key)?; + let (cli_configured, cli_model) = inspect_zcode_agent_config(&paths[1], port, api_key)?; + let models_match = app_model.is_some() && app_model == cli_model; + Ok(( + app_configured && cli_configured && models_match, + cli_model.or(app_model), + false, + )) + } AgentClient::KimiCode => inspect_kimi_code_agent_config(&paths[0], port, api_key) .map(|(configured, model)| (configured, model, false)), AgentClient::GrokBuild => inspect_grok_build_agent_config(&paths[0], port, api_key) @@ -1094,20 +1111,29 @@ pub(crate) fn agent_has_managed_marker( } AgentClient::DeepSeekHarness => deepseek_harness_has_managed_marker(paths), AgentClient::ZCode => { - if !paths[0].is_file() { - return Ok(false); - } - let root = read_agent_json_or_empty(&paths[0], "ZCode 配置")?; let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); - let provider_exists = root - .get("provider") - .and_then(|value| value.get(MANAGED_AGENT_PROVIDER_ID)) - .is_some(); - let model_selected = root - .get("model") - .and_then(serde_json::Value::as_str) - .is_some_and(|model| model.starts_with(&prefix)); - Ok(provider_exists && model_selected) + for path in paths { + if !path.is_file() { + continue; + } + let root = read_agent_json_or_empty(path, "ZCode 配置")?; + let provider_exists = root + .get("provider") + .and_then(|value| value.get(MANAGED_AGENT_PROVIDER_ID)) + .is_some(); + let model_selected = root + .get("model") + .and_then(|model| { + model + .as_str() + .or_else(|| model.get("main").and_then(serde_json::Value::as_str)) + }) + .is_some_and(|model| model.starts_with(&prefix)); + if provider_exists && model_selected { + return Ok(true); + } + } + Ok(false) } AgentClient::KimiCode => inspect_managed_toml_model_marker( &paths[0], @@ -1322,11 +1348,12 @@ pub(crate) fn find_zcode_desktop_executable(home: &Path) -> Option { pub(crate) fn read_macos_app_version(application: &Path) -> Option { let info_plist = application.join("Contents/Info.plist"); for key in ["CFBundleShortVersionString", "CFBundleVersion"] { - let output = Command::new("/usr/bin/plutil") + let mut command = Command::new("/usr/bin/plutil"); + command .args(["-extract", key, "raw", "-o", "-"]) - .arg(&info_plist) - .output() - .ok()?; + .arg(&info_plist); + let output = + command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { continue; } @@ -1420,7 +1447,7 @@ if ($package -and $package.Version) { &encoded_command, ]); configure_background_command(&mut command); - let output = command.output().ok()?; + let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { return None; } @@ -1456,7 +1483,7 @@ if ($package -and $package.Version) { &encoded_command, ]); configure_background_command(&mut command); - let output = command.output().ok()?; + let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { return None; } @@ -1543,7 +1570,7 @@ foreach ($shortcutFile in (Get-ChildItem -LiteralPath $shortcutRoots -Filter '*. &encoded_command, ]); configure_background_command(&mut command); - let output = command.output().ok()?; + let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { return None; } @@ -1596,7 +1623,7 @@ if ($version) {{ &encoded_command, ]); configure_background_command(&mut command); - let output = command.output().ok()?; + let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { return None; } @@ -1642,7 +1669,9 @@ pub(crate) fn find_windows_codex_app_id_via_registry() -> Option { let mut command = Command::new(windows_registry_executable()); command.args(["query", PACKAGES_KEY, "/f", package_name, "/k", "/s"]); configure_background_command(&mut command); - let Ok(output) = command.output() else { + let Ok(Some(output)) = + command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT) + else { continue; }; if !output.status.success() { @@ -1914,6 +1943,74 @@ pub(crate) fn windows_batch_executable_argument(executable: &Path) -> String { format!("\"{}\"", path_to_string(executable)) } +pub(crate) fn command_output_with_timeout( + command: &mut Command, + timeout: Duration, +) -> io::Result> { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn()?; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "无法读取智能体探测标准输出"))?; + let mut stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "无法读取智能体探测错误输出"))?; + thread::scope(|scope| { + let stdout_reader = scope.spawn(move || { + let mut output = Vec::new(); + stdout.read_to_end(&mut output).map(|_| output) + }); + let stderr_reader = scope.spawn(move || { + let mut output = Vec::new(); + stderr.read_to_end(&mut output).map(|_| output) + }); + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait()? { + break Some(status); + } + if Instant::now() >= deadline { + terminate_agent_probe_process(&mut child); + break None; + } + thread::sleep(AGENT_VERSION_PROBE_POLL_INTERVAL); + }; + let stdout = stdout_reader + .join() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "智能体探测输出线程异常退出"))??; + let stderr = stderr_reader + .join() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "智能体探测错误线程异常退出"))??; + Ok(status.map(|status| std::process::Output { + status, + stdout, + stderr, + })) + }) +} + +fn terminate_agent_probe_process(child: &mut Child) { + #[cfg(target_os = "windows")] + { + let process_id = child.id().to_string(); + let mut command = Command::new("taskkill"); + command + .args(["/PID", &process_id, "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_background_command(&mut command); + let _ = command.status(); + } + let _ = child.kill(); + let _ = child.wait(); +} + pub(crate) fn read_agent_version(path: &Path) -> Option { #[cfg(target_os = "windows")] let mut command = windows_command_for_executable(path, false); @@ -1923,7 +2020,7 @@ pub(crate) fn read_agent_version(path: &Path) -> Option { command.arg("--version"); configure_background_command(&mut command); - let output = command.output().ok()?; + let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { return None; } @@ -2471,7 +2568,11 @@ pub(crate) fn inspect_zcode_agent_config( let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); let model = root .get("model") - .and_then(serde_json::Value::as_str) + .and_then(|model| { + model + .as_str() + .or_else(|| model.get("main").and_then(serde_json::Value::as_str)) + }) .map(str::trim) .filter(|value| !value.is_empty()) .and_then(|value| value.strip_prefix(&prefix)) diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index b200016..4600f69 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -189,7 +189,11 @@ pub(crate) fn validate_agent_applied_state( .map(|file| file.path.clone()) .collect::>(); let valid_paths = state_paths == expected_paths - || (client == AgentClient::Codex && state_paths.as_slice() == paths); + || (client == AgentClient::Codex && state_paths.as_slice() == paths) + || (client == AgentClient::ZCode + && paths + .first() + .is_some_and(|path| state_paths == [path.clone()])); if !valid_paths { return Err("智能体应用状态文件数量或路径不匹配".to_string()); } @@ -385,7 +389,11 @@ pub(crate) fn validate_agent_record( .map(|file| file.path.clone()) .collect::>(); let valid_paths = record_paths.as_slice() == paths - || (client == AgentClient::Codex && record_paths == expected_paths); + || (client == AgentClient::Codex && record_paths == expected_paths) + || (client == AgentClient::ZCode + && paths + .first() + .is_some_and(|path| record_paths == [path.clone()])); if !valid_paths { return Err("智能体备份状态文件数量或路径不匹配".to_string()); } @@ -728,9 +736,10 @@ pub(crate) fn fresh_agent_contents_with_oauth( build_deepseek_harness_settings(None, &openai_base, model, models)?, build_deepseek_harness_credentials(None, api_key)?, ]), - AgentClient::ZCode => Ok(vec![build_zcode_agent_config( - None, &root_base, api_key, model, models, - )?]), + AgentClient::ZCode => Ok(vec![ + build_zcode_agent_config(None, &root_base, api_key, model, models)?, + build_zcode_cli_agent_config(None, &root_base, api_key, model, models)?, + ]), AgentClient::KimiCode => Ok(vec![build_kimi_code_agent_config( None, &openai_base, @@ -1430,14 +1439,36 @@ pub(crate) fn commit_agent_configuration( .collect::, String>>()?; snapshots.push((state_path.clone(), previous_state)); - let created_session_backups = existing_state - .as_ref() - .is_none_or(|state| state.backup_files.is_empty()); + let mut created_backup_paths = Vec::new(); let backup_files = if let Some(state) = existing_state.filter(|state| !state.backup_files.is_empty()) { - state.backup_files + let mut backup_files = state.backup_files; + let missing_updates = updates + .iter() + .filter(|update| !backup_files.iter().any(|file| file.path == update.path)) + .map(|update| AgentFileUpdate { + path: update.path.clone(), + after: update.after.clone(), + }) + .collect::>(); + let added_backups = prepare_agent_session_backups(client, &missing_updates)?; + created_backup_paths.extend( + added_backups + .iter() + .filter(|file| file.existed_before) + .map(|file| file.backup_path.clone()), + ); + backup_files.extend(added_backups); + backup_files } else { - prepare_agent_session_backups(client, updates)? + let backup_files = prepare_agent_session_backups(client, updates)?; + created_backup_paths.extend( + backup_files + .iter() + .filter(|file| file.existed_before) + .map(|file| file.backup_path.clone()), + ); + backup_files }; let transaction = (|| -> Result, String> { @@ -1484,11 +1515,9 @@ pub(crate) fn commit_agent_configuration( } Err(error) => { let rollback = restore_agent_snapshots_direct(&snapshots); - if created_session_backups { - for backup in &backup_files { - if backup.existed_before && backup.backup_path.is_file() { - let _ = fs::remove_file(&backup.backup_path); - } + for backup_path in created_backup_paths { + if backup_path.is_file() { + let _ = fs::remove_file(backup_path); } } match rollback { diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 40c5908..038e563 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -684,6 +684,55 @@ fn zcode_agent_config_preserves_other_providers_and_uses_anthropic_messages() { fs::remove_dir_all(home).unwrap(); } +#[test] +fn zcode_cli_config_sets_main_model_and_both_configs_must_match() { + let home = agent_test_home("zcode-cli-default-model"); + let paths = agent_config_paths(AgentClient::ZCode, &home); + fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); + fs::create_dir_all(paths[1].parent().unwrap()).unwrap(); + let models = test_agent_models(&["gpt-test", "deepseek-test"]); + let app_config = build_zcode_agent_config( + None, + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + let cli_config = build_zcode_cli_agent_config( + Some(r#"{"model":{"lite":"other/lite"},"plugins":{"keep":true}}"#), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + let cli_value: serde_json::Value = serde_json::from_str(&cli_config).unwrap(); + + assert_eq!(cli_value["model"]["main"], "cpa-gui/gpt-test"); + assert_eq!(cli_value["model"]["lite"], "other/lite"); + assert_eq!(cli_value["plugins"]["keep"], true); + fs::write(&paths[0], app_config).unwrap(); + fs::write(&paths[1], &cli_config).unwrap(); + assert_eq!( + inspect_agent_managed_config(AgentClient::ZCode, &paths, 8317, DEFAULT_API_KEY).unwrap(), + (true, Some("gpt-test".to_string()), false) + ); + + let mut mismatched = cli_value; + mismatched["model"]["main"] = serde_json::json!("cpa-gui/deepseek-test"); + fs::write( + &paths[1], + serde_json::to_string_pretty(&mismatched).unwrap(), + ) + .unwrap(); + assert_eq!( + inspect_agent_managed_config(AgentClient::ZCode, &paths, 8317, DEFAULT_API_KEY).unwrap(), + (false, Some("deepseek-test".to_string()), false) + ); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn zcode_inspection_requires_the_default_model_to_belong_to_the_managed_provider() { let home = agent_test_home("zcode-default-model-inspection"); @@ -1583,6 +1632,62 @@ fn detected_agent_version_requires_a_real_version_value() { assert!(normalize_detected_agent_version(&"1".repeat(257)).is_none()); } +#[test] +fn agent_probe_command_captures_output_before_timeout() { + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new(windows_command_processor()); + command.args(["/D", "/C", "echo agent 1.2.3"]); + configure_background_command(&mut command); + command + }; + #[cfg(not(target_os = "windows"))] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "printf 'agent 1.2.3\\n'"]); + command + }; + + let output = command_output_with_timeout(&mut command, Duration::from_secs(2)) + .unwrap() + .unwrap(); + + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "agent 1.2.3" + ); +} + +#[test] +fn agent_probe_command_stops_at_timeout() { + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new(windows_powershell_executable()); + command.args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep -Seconds 5", + ]); + configure_background_command(&mut command); + command + }; + #[cfg(not(target_os = "windows"))] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 5"]); + command + }; + + let started = Instant::now(); + let output = command_output_with_timeout(&mut command, Duration::from_millis(100)).unwrap(); + + assert!(output.is_none()); + assert!(started.elapsed() < Duration::from_secs(3)); +} + #[test] fn codex_model_list_is_empty_when_cpa_has_no_writable_models() { let prepared = prepare_codex_agent_models(&[]).unwrap(); diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index 5739fbd..98bbfe1 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -424,6 +424,71 @@ fn zcode_runtime_edits_survive_close_and_original_provider_is_restored() { fs::remove_dir_all(home).unwrap(); } +#[test] +fn zcode_legacy_single_file_state_upgrades_and_restores_cli_config() { + let home = agent_test_home("zcode-legacy-state-upgrade"); + let paths = agent_config_paths(AgentClient::ZCode, &home); + fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); + fs::create_dir_all(paths[1].parent().unwrap()).unwrap(); + let original_app = br#"{"model":"other/original","provider":{"other":{"keep":true}}}"#; + let original_cli = + br#"{"model":{"main":"other/original","lite":"other/lite"},"plugins":{"keep":true}}"#; + let models = test_agent_models(&["gpt-one", "gpt-two"]); + let managed_app = build_zcode_agent_config( + Some(std::str::from_utf8(original_app).unwrap()), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-one", + &models, + ) + .unwrap(); + fs::write(&paths[0], managed_app).unwrap(); + fs::write(&paths[1], original_cli).unwrap(); + let app_backup = dated_agent_backup_path(&paths[0]).unwrap(); + fs::write(&app_backup, original_app).unwrap(); + let state_path = agent_state_path(&paths).unwrap(); + write_agent_applied_state( + &state_path, + &AgentAppliedState { + version: AGENT_APPLIED_STATE_VERSION, + client: AgentClient::ZCode.id().to_string(), + model: "gpt-one".to_string(), + claude_desktop_model_mappings: None, + backup_files: vec![AgentAppliedBackupFile { + path: paths[0].clone(), + backup_path: app_backup, + existed_before: true, + }], + updated_at_unix: 1, + }, + ) + .unwrap(); + + apply_agent_configuration( + AgentClient::ZCode, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-two", + &models, + None, + ) + .unwrap(); + let upgraded = load_agent_applied_state(AgentClient::ZCode, &home) + .unwrap() + .unwrap(); + assert_eq!(upgraded.backup_files.len(), 2); + let cli_value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&paths[1]).unwrap()).unwrap(); + assert_eq!(cli_value["model"]["main"], "cpa-gui/gpt-two"); + + restore_agent_session_configuration(AgentClient::ZCode, &home).unwrap(); + assert_eq!(fs::read(&paths[0]).unwrap(), original_app); + assert_eq!(fs::read(&paths[1]).unwrap(), original_cli); + assert!(!state_path.exists()); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn session_merge_preserves_runtime_fields_for_other_agent_formats() { let claude_original = r#"{"env":{"KEEP_ENV":"original"},"keep":"claude"}"#; diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 3fa4f9b..659d047 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -885,6 +885,7 @@ export function AgentsPage() { const configurationAction = resolveAgentConfigurationAction({ client: selected, modificationState: activeStatus?.modificationState ?? 'unconfigured', + configurationSynchronized: Boolean(activeStatus?.configured), selectedModel, appliedModel, oauthConfiguration, diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index 9f78219..a8bf8cb 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -32,6 +32,7 @@ export type AgentModelMappings = { type ResolveAgentConfigurationActionOptions = { client: AgentConfigurationClientId; modificationState: AgentConfigurationModificationState; + configurationSynchronized?: boolean; selectedModel: string; appliedModel: string; oauthConfiguration: boolean; @@ -82,6 +83,7 @@ export const resolveAgentModelMappingsDraftSourceForClient = ( export function resolveAgentConfigurationAction({ client, modificationState, + configurationSynchronized = true, selectedModel, appliedModel, oauthConfiguration, @@ -91,6 +93,7 @@ export function resolveAgentConfigurationAction({ }: ResolveAgentConfigurationActionOptions): AgentConfigurationAction { if (modificationState !== 'applied') return 'apply'; if (client === 'pi') return 'close'; + if (client === 'zcode' && !configurationSynchronized) return 'update'; const modelChanged = client === 'claude-code' || client === 'claude-desktop' ? !sameAgentModelMappings(modelMappings, appliedModelMappings) diff --git a/tests/agentModelPicker.test.ts b/tests/agentModelPicker.test.ts index e13c5da..ab709df 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -161,6 +161,20 @@ describe('agent configuration update action', () => { expect(appliedConfiguration(client, ' MODEL-A ')).toBe('close'); }); + test('ZCode updates an applied legacy configuration even when the model is unchanged', () => { + expect(resolveAgentConfigurationAction({ + client: 'zcode', + modificationState: 'applied', + configurationSynchronized: false, + selectedModel: 'model-a', + appliedModel: 'model-a', + oauthConfiguration: false, + appliedOauthConfiguration: false, + modelMappings: { opus: '', sonnet: '', haiku: '' }, + appliedModelMappings: { opus: '', sonnet: '', haiku: '' }, + })).toBe('update'); + }); + test('Codex also updates when only OAuth configuration changes', () => { expect(resolveAgentConfigurationAction({ client: 'codex',