From 35459414147a55a56900b6d1f3e5713e06624158 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 21 Sep 2026 14:14:30 +0800 Subject: [PATCH] feat(agents): add WorkBuddy configuration support - Support installation detection, configuration, backup, restore, launch, and restart - Handle both models.json formats while preserving custom settings - Simplify model names and retain existing CPA models when switching - Fix model visibility restoration when disabling integration - Add regression tests, translations, and documentation --- README.ja.md | 1 + README.md | 1 + README.zh-CN.md | 5 + src-tauri/src/agents.rs | 2 + src-tauri/src/agents/backups/tests.rs | 17 + src-tauri/src/agents/commands.rs | 3 +- src-tauri/src/agents/configuration.rs | 9 + src-tauri/src/agents/discovery.rs | 20 +- src-tauri/src/agents/launch.rs | 11 +- src-tauri/src/agents/state.rs | 11 +- src-tauri/src/agents/transactions.rs | 29 +- src-tauri/src/agents/windows_probe.rs | 32 +- src-tauri/src/agents/workbuddy.rs | 473 +++++++++++++++++++++++ src-tauri/src/agents/workbuddy/tests.rs | 490 ++++++++++++++++++++++++ src-tauri/src/configuration_watcher.rs | 1 + src-tauri/src/main.rs | 5 + src-tauri/src/tests/agent_paths.rs | 4 + src/assets/icons/workbuddy.png | Bin 0 -> 3538 bytes src/i18n/ja.ts | 2 + src/i18n/locales/en.ts | 2 + src/i18n/locales/zh-CN.ts | 2 + src/pages/AgentsPage.tsx | 16 +- tests/agent-management-ui.cjs | 4 +- tests/fixtures/agent-backups.tsx | 4 +- 24 files changed, 1121 insertions(+), 23 deletions(-) create mode 100644 src-tauri/src/agents/workbuddy.rs create mode 100644 src-tauri/src/agents/workbuddy/tests.rs create mode 100644 src/assets/icons/workbuddy.png diff --git a/README.ja.md b/README.ja.md index ca1df60..d8ec82f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -99,6 +99,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で - Hermes Agent - Pi(CLIProxyAPI provider 拡張機能) - ZCode +- WorkBuddy / WorkBuddy AI - Kimi Code - Grok Build diff --git a/README.md b/README.md index de033e0..841b0e8 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ proxy. Supported clients include: - Hermes Agent - Pi (with the CLIProxyAPI provider extension) - ZCode +- WorkBuddy / WorkBuddy AI - Kimi Code - Grok Build diff --git a/README.zh-CN.md b/README.zh-CN.md index fbd28b1..c77cd70 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -108,12 +108,17 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 - Hermes Agent - Pi(通过 CLIProxyAPI provider 插件) - ZCode +- WorkBuddy / WorkBuddy AI - Kimi Code - Grok Build 对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置, 以及恢复之前的配置。 +WorkBuddy 接入沿用模型选择、更新配置、关闭配置修改、手动备份/恢复和桌面启动/重启流程。 +支持 WorkBuddy 与 WorkBuddy AI 的 `models.json` 对象及数组格式,并保留已有自定义模型。 +应用后需在 WorkBuddy 内选择 `CPA:模型名`;已有会话不会自动切换模型,未显示时可重启 App。 + ## 其他功能 - 管理内核配置、API Key、远程管理凭证和路由策略。 diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index 553ef7a..d03b8cf 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -10,6 +10,7 @@ mod native_oauth; mod state; mod templates; mod transactions; +mod workbuddy; #[cfg(target_os = "windows")] mod windows_probe; pub(crate) use backups::*; @@ -22,5 +23,6 @@ pub(crate) use native_oauth::*; pub(crate) use state::*; pub(crate) use templates::*; pub(crate) use transactions::*; +pub(crate) use workbuddy::*; #[cfg(target_os = "windows")] pub(crate) use windows_probe::*; diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index 0723fd5..e16a530 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -84,6 +84,7 @@ fn clients() -> Vec { "hermes", "deepseek-harness", "zcode", + "workbuddy", "kimi-code", "grok-build", ] @@ -271,6 +272,21 @@ fn corrupt_raw_and_non_utf8_backups_are_saved_but_not_restorable() { } } +#[test] +fn workbuddy_native_array_backup_restores_exact_bytes_after_template() { + let home = Home::new(); + let client = AgentClient::WorkBuddy; + let paths = config_paths(client.id(), &home.0).unwrap(); + let original = b"[\n {\"id\":\"user-model\",\"apiKey\":\"private-key\"}\n]\n"; + save(&paths[0], original); + let backup = create_backup(client.id(), &home.0).unwrap(); + assert!(backup.restorable); + apply(&home.0, client, "gpt-one").unwrap(); + template(&home.0, client).unwrap(); + test_restore_backup(client, &home.0, &backup.id); + assert_eq!(fs::read(&paths[0]).unwrap(), original); +} + #[test] fn restore_validation_uses_the_clients_actual_json_format() { for (client, restorable) in [ @@ -398,6 +414,7 @@ fn clear_integration_needs_no_backup_and_removes_state_for_each_client() { AgentClient::Hermes, AgentClient::DeepSeekHarness, AgentClient::ZCode, + AgentClient::WorkBuddy, AgentClient::KimiCode, AgentClient::GrokBuild, ] diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 9bd5014..336bb66 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -29,6 +29,7 @@ pub(crate) fn inspect_agent_config_statuses( AgentStatusDetectionTarget::Client(AgentClient::Hermes), AgentStatusDetectionTarget::Client(AgentClient::DeepSeekHarness), AgentStatusDetectionTarget::Client(AgentClient::ZCode), + AgentStatusDetectionTarget::Client(AgentClient::WorkBuddy), AgentStatusDetectionTarget::Client(AgentClient::KimiCode), AgentStatusDetectionTarget::Client(AgentClient::GrokBuild), AgentStatusDetectionTarget::PiProvider, @@ -956,7 +957,7 @@ pub(crate) fn start_codex_model_catalog_sync(app: tauri::AppHandle) { pub(crate) fn agent_uses_cpa_runtime_context_windows(client: AgentClient) -> bool { matches!( client, - AgentClient::ZCode | AgentClient::KimiCode | AgentClient::GrokBuild + AgentClient::ZCode | AgentClient::WorkBuddy | AgentClient::KimiCode | AgentClient::GrokBuild ) } diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 2d9b769..fa7d70d 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -187,6 +187,13 @@ pub(crate) fn build_agent_updates_with_oauth( }, ]) } + AgentClient::WorkBuddy => { + let before = read_optional_text(&paths[0])?; + Ok(vec![AgentFileUpdate { + path: paths[0].clone(), + after: build_workbuddy_agent_config(before.as_deref(), &openai_base, api_key, model, models)?, + }]) + } AgentClient::ZCode => { let app_before = read_optional_text(&paths[0])?; let cli_before = read_optional_text(&paths[1])?; @@ -1621,6 +1628,7 @@ pub(crate) fn prepare_agent_managed_removal( AgentClient::Hermes => prepare_hermes_managed_removal(paths), AgentClient::DeepSeekHarness => prepare_deepseek_harness_managed_removal(paths), AgentClient::ZCode => prepare_zcode_managed_removal(paths), + AgentClient::WorkBuddy => prepare_workbuddy_managed_removal(paths), AgentClient::KimiCode => prepare_kimi_code_managed_removal(paths), AgentClient::GrokBuild => prepare_grok_build_managed_removal(paths), } @@ -2658,6 +2666,7 @@ pub(crate) fn build_agent_session_restored_bytes_with_preference( } } AgentClient::ZCode => build_restored_zcode_config(current, original)?, + AgentClient::WorkBuddy => build_restored_workbuddy_config(current, original)?, AgentClient::KimiCode => build_restored_kimi_code_config(current, original)?, AgentClient::GrokBuild => build_restored_grok_build_config(current, original)?, }; diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index b658703..6ae88a3 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -4,14 +4,14 @@ const AGENT_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); const AGENT_VERSION_PROBE_POLL_INTERVAL: Duration = Duration::from_millis(20); #[cfg(not(test))] -fn agent_configuration_environment(name: &str) -> Option { +pub(crate) fn agent_configuration_environment(name: &str) -> Option { env::var_os(name) .map(PathBuf::from) .filter(|path| !path.as_os_str().is_empty()) } #[cfg(test)] -fn agent_configuration_environment(_: &str) -> Option { +pub(crate) fn agent_configuration_environment(_: &str) -> Option { None } @@ -47,6 +47,7 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec vec![workbuddy_home(home).join("models.json")], AgentClient::ZCode => vec![ home.join(".zcode/v2").join(ZCODE_CONFIG_FILE), home.join(".zcode/cli").join(ZCODE_CONFIG_FILE), @@ -980,6 +981,7 @@ pub(crate) fn inspect_agent_config( .and_then(read_opencode_desktop_version), AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home), AgentClient::ZCode => executable.as_deref().and_then(read_zcode_app_version), + AgentClient::WorkBuddy => executable.as_deref().and_then(read_workbuddy_app_version), _ => None, }; let version = cli_version.clone().or_else(|| app_version.clone()); @@ -1065,13 +1067,13 @@ pub(crate) fn agent_installation_detected( version.is_some() || (matches!( client, - AgentClient::ClaudeDesktop | AgentClient::OpenCode | AgentClient::ZCode + AgentClient::ClaudeDesktop | AgentClient::OpenCode | AgentClient::ZCode | AgentClient::WorkBuddy ) && executable_found) || app_installed } pub(crate) fn should_probe_primary_agent_executable_version(client: AgentClient) -> bool { - !matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode) + !matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy) } pub(crate) fn agent_launch_targets( @@ -1125,11 +1127,11 @@ pub(crate) fn agent_launch_targets( }); } } - AgentClient::ZCode => { + AgentClient::ZCode | AgentClient::WorkBuddy => { if let Some(executable) = executable { targets.push(AgentLaunchTarget { id: "app".to_string(), - label: "ZCode".to_string(), + label: client.name().to_string(), detail: path_to_string(executable), }); } @@ -1262,6 +1264,8 @@ pub(crate) fn inspect_agent_managed_config( false, )) } + AgentClient::WorkBuddy => inspect_workbuddy_agent_config(&paths[0], port, api_key) + .map(|(configured, model)| (configured, 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) @@ -1468,6 +1472,7 @@ pub(crate) fn agent_has_managed_marker( Ok(provider_exists && model_selected) } AgentClient::DeepSeekHarness => deepseek_harness_has_managed_marker(paths), + AgentClient::WorkBuddy => workbuddy_has_managed_marker(&paths[0]), AgentClient::ZCode => { let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); for path in paths { @@ -2390,6 +2395,9 @@ pub(crate) fn find_agent_executable(client: AgentClient, home: &Path) -> Option< if client == AgentClient::ZCode { return find_zcode_desktop_executable(home); } + if client == AgentClient::WorkBuddy { + return find_workbuddy_desktop_executable(home); + } if client == AgentClient::KimiCode { return find_kimi_code_executable(home); } diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index ca28d9f..7e4db89 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -181,6 +181,11 @@ pub(crate) fn launch_agent( let requested_target = requested_target.unwrap_or(default_target); match (client, requested_target) { (AgentClient::ClaudeDesktop, "app") => launch_claude_desktop(&home), + (AgentClient::WorkBuddy, "app") => { + let executable = find_workbuddy_desktop_executable(&home) + .ok_or_else(|| "未找到 WorkBuddy 应用程序".to_string())?; + launch_desktop_agent(&executable, client.name()) + } (AgentClient::ZCode, "app") => { let executable = find_zcode_desktop_executable(&home) .ok_or_else(|| "未找到 ZCode 应用程序".to_string())?; @@ -188,7 +193,7 @@ pub(crate) fn launch_agent( } (AgentClient::Codex, "app") => launch_codex_desktop(&home), (AgentClient::OpenCode, "app") => launch_opencode_desktop(&home), - (AgentClient::ClaudeDesktop | AgentClient::ZCode, "cli") => { + (AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy, "cli") => { Err(format!("{} 不支持 CLI 启动方式", client.name())) } (_, "cli") => { @@ -695,6 +700,7 @@ pub(crate) async fn restart_agent_app(app: tauri::AppHandle, client: String) -> | AgentClient::OpenCode | AgentClient::ClaudeDesktop | AgentClient::ZCode + | AgentClient::WorkBuddy ) { return Err(format!("{} 不支持桌面应用重启", client.name())); } @@ -738,6 +744,9 @@ fn find_desktop_restart_target( AgentClient::ZCode => { find_zcode_desktop_executable(home).map(DesktopAppTarget::Application) } + AgentClient::WorkBuddy => { + find_workbuddy_desktop_executable(home).map(DesktopAppTarget::Application) + } AgentClient::ClaudeDesktop => { let executable = find_claude_desktop_executable(home).map(DesktopAppTarget::Application); diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index 26f18f2..c58100c 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -727,6 +727,9 @@ 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::WorkBuddy => Ok(vec![build_workbuddy_agent_config( + None, &openai_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)?, @@ -1458,7 +1461,13 @@ pub(crate) fn apply_agent_configuration_with_oauth( validate_config_images(&before)?; let mappings = options.claude_desktop_model_mappings; let updates = build_agent_updates_with_oauth(client, home, port, api_key, model, options) - .map_err(|_| "配置构建失败,请检查配置结构或使用手动备份恢复、基础配置模板修复".to_string())?; + .map_err(|error| { + if client == AgentClient::WorkBuddy { + error + } else { + "配置构建失败,请检查配置结构或使用手动备份恢复、基础配置模板修复".to_string() + } + })?; config_updates(client.id(), home, &before, &updates, "update", Some(model.to_string()), mappings) } diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index 242d29b..06b4af6 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -113,6 +113,7 @@ fn merge_user_value( } fn merge_user_image( + client: &str, path: &Path, baseline: Option<&[u8]>, applied: Option<&[u8]>, @@ -139,17 +140,23 @@ fn merge_user_image( .map(|bytes| std::str::from_utf8(bytes).map_err(|_| "原始状态不是 UTF-8 文本")) .transpose()?, )?; - let merged = merge_user_value( + let mut merged = merge_user_value( baseline.map(|_| &baseline_value), applied.map(|_| &applied_value), Some(¤t_value), ); + if client == "workbuddy" { + if let Some(merged) = &mut merged { + merge_workbuddy_visibility(&baseline_value, &applied_value, ¤t_value, merged); + } + } merged .map(|value| render(path, &value).map(|value| value.into_bytes())) .transpose() } fn promote_user_changes( + client: &str, paths: &[PathBuf], baseline: &Images, applied: &Images, @@ -164,6 +171,7 @@ fn promote_user_changes( Ok(( path.clone(), merge_user_image( + client, path, baseline.as_deref(), applied.as_deref(), @@ -181,6 +189,7 @@ fn prepare_integration_restore( state: &AgentIntegrationState, ) -> Result { let effective_original = promote_user_changes( + client.id(), paths, &state.original, &state.applied, @@ -410,6 +419,7 @@ fn commit_config_transaction( let parsed_client = AgentClient::parse(client)?; let next = if let Some(state) = current_state { let original = promote_user_changes( + client, managed_paths, &state.original, &state.applied, @@ -563,6 +573,17 @@ pub(crate) fn validate_config_images(images: &Images) -> Result<(), String> { pub(crate) fn validate_client_config_images(client: &str, images: &Images) -> Result<(), String> { validate_config_images(images)?; + if client == "workbuddy" { + for (_, bytes) in images { + parse_workbuddy_config(text(bytes.as_deref())?)?; + } + return Ok(()); + } + for (path, bytes) in images { + if !parse(path, text(bytes.as_deref())?)?.is_object() { + return Err("配置根节点必须是对象".into()); + } + } if matches!(client, "opencode" | "openclaw") { return Ok(()); } @@ -811,6 +832,9 @@ fn validate_unmanaged_preserved( before: &Value, after: &Value, ) -> Result<(), String> { + if client == "workbuddy" { + return validate_workbuddy_unmanaged_preserved(before, after); + } let project = |value: &Value| -> Result { let mut value = value.clone(); if client == "claude-desktop" && paths.get(3).is_some_and(|p| p == path) { @@ -901,7 +925,8 @@ pub(crate) fn parse(path: &Path, content: Option<&str>) -> Result path_to_string(path) ) })?; - if !value.is_object() { + // WorkBuddy also saves models.json as a top-level array. + if !value.is_object() && !(path.file_name().is_some_and(|n| n == "models.json") && value.is_array()) { return Err(format!("{} 配置根节点必须是对象", path_to_string(path))); } Ok(value) diff --git a/src-tauri/src/agents/windows_probe.rs b/src-tauri/src/agents/windows_probe.rs index 6b69feb..bfa35c2 100644 --- a/src-tauri/src/agents/windows_probe.rs +++ b/src-tauri/src/agents/windows_probe.rs @@ -235,11 +235,15 @@ pub(crate) fn windows_app_path_executable(file_name: &str) -> Option { } pub(crate) fn collect_windows_zcode_registrations() -> Vec { + collect_windows_desktop_registrations("ZCode.exe", windows_display_name_matches_zcode) +} + +fn collect_windows_desktop_registrations(executable: &str, matches_name: fn(&str) -> bool) -> Vec { let mut registrations = Vec::new(); for root in current_version_roots() { if let Some(path) = RegistryKey::open( root.hive, - &join_registry_path(root.prefix, r"App Paths\ZCode.exe"), + &join_registry_path(root.prefix, &format!(r"App Paths\{executable}")), ) .and_then(|key| key.string("")) { @@ -263,7 +267,7 @@ pub(crate) fn collect_windows_zcode_registrations() -> Vec bool { } pub(crate) fn parse_windows_zcode_registration(kind: &str, value: &str) -> Option { + parse_windows_desktop_registration(kind, value, "ZCode.exe") +} + +pub(crate) fn parse_windows_desktop_registration(kind: &str, value: &str, executable_name: &str) -> Option { let value = value.trim(); let value = if let Some(quoted) = value.strip_prefix('"') { @@ -325,14 +333,14 @@ pub(crate) fn parse_windows_zcode_registration(kind: &str, value: &str) -> Optio } let executable = match kind { "executable" => path, - "directory" => path.join("ZCode.exe"), - "icon" | "uninstaller" => path.parent()?.join("ZCode.exe"), + "directory" => path.join(executable_name), + "icon" | "uninstaller" => path.parent()?.join(executable_name), _ => return None, }; (executable .file_name()? .to_str()? - .eq_ignore_ascii_case("ZCode.exe") + .eq_ignore_ascii_case(executable_name) && executable.is_file()) .then_some(executable) } @@ -345,6 +353,20 @@ pub(crate) fn find_windows_registered_zcode_executable() -> Option { }) } +#[cfg(not(test))] +pub(crate) fn find_windows_registered_workbuddy_executable() -> Option { + for executable in ["WorkBuddyAI.exe", "WorkBuddy.exe"] { + if let Some(path) = collect_windows_desktop_registrations(executable, |name| { + ["WorkBuddyAI", "WorkBuddy AI", "WorkBuddy"].iter().any(|prefix| { + name.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '('])) + }) + }).into_iter().find_map(|r| parse_windows_desktop_registration(r.kind, &r.value, executable)) { + return Some(path); + } + } + None +} + pub(crate) fn read_windows_executable_version(path: &Path) -> Option { let wide = to_wide(&path_to_string(path)); let mut handle = 0; diff --git a/src-tauri/src/agents/workbuddy.rs b/src-tauri/src/agents/workbuddy.rs new file mode 100644 index 0000000..ebe733a --- /dev/null +++ b/src-tauri/src/agents/workbuddy.rs @@ -0,0 +1,473 @@ +use super::*; +use serde_json::{json, Value}; + +#[cfg(test)] +mod tests; + +// WorkBuddy AI 5.5 reads models.json as either LanguageModel[] or { models, availableModels }. +// Its chat selection is account/session-local; publishing a model must not rewrite that state. +pub(crate) fn workbuddy_home(home: &Path) -> PathBuf { + workbuddy_home_from_environment( + home, + agent_configuration_environment("WORKBUDDY_CONFIG_DIR").as_deref(), + agent_configuration_environment("CODEBUDDY_CONFIG_DIR").as_deref(), + find_workbuddy_desktop_executable(home).as_deref(), + ) +} + +pub(crate) fn workbuddy_home_from_environment( + home: &Path, + workbuddy_dir: Option<&Path>, + codebuddy_dir: Option<&Path>, + executable: Option<&Path>, +) -> PathBuf { + if let Some(directory) = workbuddy_dir.or(codebuddy_dir) { + return directory.to_path_buf(); + } + if let Some(executable) = executable { + let resources = workbuddy_resources(executable); + if let Some(folder) = resources + .and_then(|p| fs::read_to_string(p.join("app.asar.unpacked/cli/product.json")).ok()) + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| { + v.get("dataFolderName") + .and_then(Value::as_str) + .map(str::to_string) + }) + .filter(|s| !s.trim().is_empty()) + { + return home.join(folder.trim()); + } + } + let ai = home.join(".workbuddy-ai"); + let legacy = home.join(".workbuddy"); + if !ai.exists() && legacy.join("settings.json").is_file() { + legacy + } else { + ai + } +} + +fn workbuddy_resources(executable: &Path) -> Option { + #[cfg(target_os = "macos")] + { + Some(executable.parent()?.parent()?.join("Resources")) + } + #[cfg(not(target_os = "macos"))] + { + Some(executable.parent()?.join("resources")) + } +} + +pub(crate) fn find_workbuddy_desktop_executable(home: &Path) -> Option { + let mut candidates = Vec::new(); + #[cfg(target_os = "windows")] + { + let local = agent_configuration_environment("LOCALAPPDATA") + .unwrap_or_else(|| home.join("AppData/Local")); + let mut roots = vec![local.join("Programs"), local]; + for variable in ["ProgramFiles", "ProgramFiles(x86)"] { + if let Some(root) = agent_configuration_environment(variable) { + roots.push(root); + } + } + if let Some(root) = agent_configuration_environment("WORKBUDDY_INSTALL_DIR") { + candidates.extend([root.join("WorkBuddyAI.exe"), root.join("WorkBuddy.exe")]); + } + for root in roots { + for (directory, executable) in [ + ("WorkBuddyAI", "WorkBuddyAI.exe"), + ("WorkBuddy", "WorkBuddy.exe"), + ("WorkBuddy AI", "WorkBuddyAI.exe"), + ] { + candidates.push(root.join(directory).join(executable)); + } + } + } + #[cfg(target_os = "macos")] + for root in [PathBuf::from("/Applications"), home.join("Applications")] { + for (app, executable) in [("WorkBuddy AI", "WorkBuddy AI"), ("WorkBuddy", "WorkBuddy")] { + candidates.push(root.join(format!("{app}.app/Contents/MacOS/{executable}"))); + } + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + candidates.extend([ + home.join(".local/bin/workbuddy"), + PathBuf::from("/opt/WorkBuddy/workbuddy"), + PathBuf::from("/opt/WorkBuddyAI/workbuddy"), + ]); + } + let found = candidates.into_iter().find(|path| path.is_file()); + #[cfg(all(target_os = "windows", not(test)))] + let found = found.or_else(find_windows_registered_workbuddy_executable); + found +} + +pub(crate) fn read_workbuddy_app_version(executable: &Path) -> Option { + // Use file metadata, never run an Electron application with --version during detection. + read_zcode_app_version(executable) +} + +pub(crate) fn parse_workbuddy_config(content: Option<&str>) -> Result { + let value = match content { + Some(content) => serde_json::from_str(content.strip_prefix('\u{feff}').unwrap_or(content)) + .map_err(|_| "WorkBuddy models.json 格式无效,请使用手动备份恢复或基础配置模板修复")?, + None => json!({}), + }; + validate_workbuddy_config(&value)?; + Ok(value) +} + +pub(crate) fn validate_workbuddy_config(value: &Value) -> Result<(), String> { + let entries = if let Some(array) = value.as_array() { + Some(array) + } else if let Some(root) = value.as_object() { + if root.get("availableModels").is_some_and(|v| { + v.as_array() + .is_none_or(|a| a.iter().any(|v| !v.is_string())) + }) { + return Err("WorkBuddy availableModels 必须是字符串数组".into()); + } + match root.get("models") { + None => None, + Some(v) => Some(v.as_array().ok_or("WorkBuddy models 必须是数组")?), + } + } else { + return Err("WorkBuddy models.json 根节点必须是对象或数组".into()); + }; + let mut ids = std::collections::HashSet::new(); + for entry in entries.into_iter().flatten() { + let id = entry + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + .ok_or("WorkBuddy 模型缺少有效 id")?; + if !ids.insert(workbuddy_request_model(id)) { + return Err("WorkBuddy 存在重复模型 id,请先修复配置".into()); + } + } + Ok(()) +} + +fn workbuddy_request_model(id: &str) -> &str { + id.strip_prefix("custom-local:").unwrap_or(id) +} + +fn workbuddy_models(value: &Value) -> &[Value] { + value + .as_array() + .or_else(|| value.get("models").and_then(Value::as_array)) + .map(Vec::as_slice) + .unwrap_or_default() +} + +fn workbuddy_managed_model(value: &Value) -> bool { + value.get("vendor").and_then(Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) +} + +fn set_workbuddy_models(value: &mut Value, models: Vec) { + if value.is_array() { + *value = Value::Array(models); + } else if models.is_empty() { + value.as_object_mut().unwrap().remove("models"); + } else { + value["models"] = Value::Array(models); + } +} + +pub(crate) fn build_workbuddy_agent_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, + available_models: &[AgentModelOption], +) -> Result { + let mut root = parse_workbuddy_config(existing)?; + let mut entries = workbuddy_models(&root) + .iter() + .filter(|m| !workbuddy_managed_model(m)) + .cloned() + .collect::>(); + if entries.iter().any(|m| { + m.get("id") + .and_then(Value::as_str) + .is_some_and(|id| workbuddy_request_model(id) == model) + }) { + return Err( + "WorkBuddy 已有同名自定义模型,请先在 WorkBuddy 中修改该模型 ID,或选择其他 CPA 模型" + .into(), + ); + } + let mut entry = workbuddy_models(&root) + .iter() + .find(|m| { + workbuddy_managed_model(m) + && m.get("id") + .and_then(Value::as_str) + .map(workbuddy_request_model) + == Some(model) + }) + .cloned() + .unwrap_or_else(|| json!({})); + entry["id"] = json!(model); + update_workbuddy_model(&mut entry, base_url, api_key, model, available_models); + entries.push(entry); + for previous in workbuddy_models(&root) + .iter() + .filter(|m| workbuddy_managed_model(m)) + { + let id = previous["id"].as_str().ok_or("WorkBuddy 模型缺少有效 id")?; + let previous_model = workbuddy_request_model(id); + if previous_model == model { + continue; + } + let mut retained = previous.clone(); + update_workbuddy_model( + &mut retained, + base_url, + api_key, + previous_model, + available_models, + ); + entries.push(retained); + } + set_workbuddy_models(&mut root, entries); + if let Some(visible) = root + .get_mut("availableModels") + .and_then(Value::as_array_mut) + { + if !visible.is_empty() + && !visible + .iter() + .any(|v| v.as_str().map(workbuddy_request_model) == Some(model)) + { + visible.push(json!(model)); + } + } + serde_json::to_string_pretty(&root).map_err(|_| "生成 WorkBuddy 配置失败".into()) +} + +fn update_workbuddy_model( + entry: &mut Value, + base_url: &str, + api_key: &str, + model: &str, + available_models: &[AgentModelOption], +) { + entry["name"] = json!("CPA"); + entry["vendor"] = json!(MANAGED_AGENT_PROVIDER_ID); + entry["url"] = json!(format!( + "{}/chat/completions", + base_url.trim_end_matches('/') + )); + entry["apiKey"] = json!(api_key); + entry["supportsToolCall"] = json!(true); + entry["disabled"] = json!(false); + // WorkBuddy prefixes local model IDs itself and removes that prefix before sending requests. + entry["useCustomProtocol"] = json!(false); + if let Some(metadata) = available_models.iter().find(|m| m.name == model) { + if let Some(context) = metadata.context_window { + entry["maxInputTokens"] = json!(context); + } + if let Some(input) = &metadata.input_modalities { + entry["supportsImages"] = json!(input.iter().any(|s| s == "image")); + } + } +} + +pub(crate) fn workbuddy_has_managed_marker(path: &Path) -> Result { + let root = parse_workbuddy_config(read_optional_text(path)?.as_deref())?; + Ok(workbuddy_models(&root).iter().any(workbuddy_managed_model)) +} + +pub(crate) fn inspect_workbuddy_agent_config( + path: &Path, + port: u16, + api_key: &str, +) -> Result<(bool, Option), String> { + let root = parse_workbuddy_config(read_optional_text(path)?.as_deref())?; + let managed = workbuddy_models(&root) + .iter() + .filter(|m| workbuddy_managed_model(m)) + .collect::>(); + let Some(entry) = managed.first() else { + return Ok((false, None)); + }; + let model = entry + .get("id") + .and_then(Value::as_str) + .map(workbuddy_request_model); + let visible = root + .get("availableModels") + .and_then(Value::as_array) + .is_none_or(|a| { + a.is_empty() + || a.iter() + .any(|v| v.as_str().map(workbuddy_request_model) == model) + }); + let expected_url = format!("{}/v1/chat/completions", managed_core_loopback_origin(port)); + let configured = visible + && managed.iter().all(|entry| { + entry.get("url").and_then(Value::as_str) == Some(&expected_url) + && entry.get("apiKey").and_then(Value::as_str) == Some(api_key) + && entry.get("disabled").and_then(Value::as_bool) != Some(true) + && entry.get("supportsToolCall").and_then(Value::as_bool) == Some(true) + }); + Ok((configured, model.map(str::to_string))) +} + +pub(crate) fn strip_workbuddy_managed(root: &mut Value) { + let ids = workbuddy_models(root) + .iter() + .filter(|m| workbuddy_managed_model(m)) + .filter_map(|m| m.get("id").and_then(Value::as_str).map(str::to_string)) + .collect::>(); + let entries = workbuddy_models(root) + .iter() + .filter(|m| !workbuddy_managed_model(m)) + .cloned() + .collect(); + set_workbuddy_models(root, entries); + if let Some(visible) = root + .get_mut("availableModels") + .and_then(Value::as_array_mut) + { + visible.retain(|v| { + !v.as_str().is_some_and(|id| { + ids.iter() + .any(|old| workbuddy_request_model(id) == workbuddy_request_model(old)) + }) + }); + } +} + +pub(crate) fn merge_workbuddy_visibility( + baseline: &Value, + applied: &Value, + current: &Value, + merged: &mut Value, +) { + if !merged.is_object() || current.get("availableModels") == applied.get("availableModels") { + return; + } + let Some(now) = current.get("availableModels").and_then(Value::as_array) else { + merged.as_object_mut().unwrap().remove("availableModels"); + return; + }; + let original_ids = baseline + .get("availableModels") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(workbuddy_request_model) + .collect::>(); + let removed_ids = [applied, current] + .into_iter() + .flat_map(workbuddy_models) + .filter(|m| workbuddy_managed_model(m)) + .filter_map(|m| m.get("id").and_then(Value::as_str)) + .map(workbuddy_request_model) + .collect::>(); + let surviving_ids = workbuddy_models(current) + .iter() + .filter(|m| !workbuddy_managed_model(m)) + .filter_map(|m| m.get("id").and_then(Value::as_str)) + .map(workbuddy_request_model) + .collect::>(); + let visible = now + .iter() + .filter(|value| { + let Some(id) = value.as_str().map(workbuddy_request_model) else { + return false; + }; + !removed_ids.contains(&id) || original_ids.contains(&id) || surviving_ids.contains(&id) + }) + .cloned() + .collect::>(); + merged["availableModels"] = json!(visible); +} + +pub(crate) fn validate_workbuddy_unmanaged_preserved( + before: &Value, + after: &Value, +) -> Result<(), String> { + let ids = [before, after] + .into_iter() + .flat_map(workbuddy_models) + .filter(|m| workbuddy_managed_model(m)) + .filter_map(|m| m.get("id").and_then(Value::as_str)) + .map(workbuddy_request_model) + .collect::>(); + let project = |value: &Value| { + let mut value = value.clone(); + strip_workbuddy_managed(&mut value); + if let Some(visible) = value + .get_mut("availableModels") + .and_then(Value::as_array_mut) + { + visible.retain(|v| { + !v.as_str() + .is_some_and(|id| ids.contains(&workbuddy_request_model(id))) + }); + } + value + }; + if project(before) != project(after) { + return Err("更新意外改变了 WorkBuddy 自定义配置,已拒绝写入".into()); + } + Ok(()) +} + +pub(crate) fn build_restored_workbuddy_config( + current: &str, + original: Option<&str>, +) -> Result, String> { + let mut root = parse_workbuddy_config(Some(current))?; + strip_workbuddy_managed(&mut root); + if let Some(original) = original { + let before = parse_workbuddy_config(Some(original))?; + // Keep an explicitly empty models list and the original visibility filter when unchanged. + if root.is_object() && before.get("models").is_some() && root.get("models").is_none() { + root["models"] = json!([]); + } + if root.is_object() { + if let Some(visible) = before.get("availableModels") { + root["availableModels"] = visible.clone(); + } else { + root.as_object_mut().unwrap().remove("availableModels"); + } + } + } + + if original.is_none() + && (root.as_object().is_some_and(|r| r.is_empty()) + || root.as_array().is_some_and(|r| r.is_empty())) + { + return Ok(None); + } + serde_json::to_string_pretty(&root) + .map(Some) + .map_err(|_| "恢复 WorkBuddy 配置失败".into()) +} + +pub(crate) fn prepare_workbuddy_managed_removal(paths: &[PathBuf]) -> Result { + let path = paths.first().ok_or("WorkBuddy 配置路径不可用")?; + let Some(current) = read_optional_text(path)? else { + return Ok(Vec::new()); + }; + let mut value = parse_workbuddy_config(Some(¤t))?; + if !workbuddy_models(&value).iter().any(workbuddy_managed_model) { + return Ok(Vec::new()); + } + strip_workbuddy_managed(&mut value); + let bytes = if value.as_object().is_some_and(|v| v.is_empty()) + || value.as_array().is_some_and(|v| v.is_empty()) + { + None + } else { + Some(serde_json::to_vec_pretty(&value).map_err(|_| "生成 WorkBuddy 配置失败")?) + }; + Ok(vec![(path.clone(), bytes)]) +} diff --git a/src-tauri/src/agents/workbuddy/tests.rs b/src-tauri/src/agents/workbuddy/tests.rs new file mode 100644 index 0000000..e3059de --- /dev/null +++ b/src-tauri/src/agents/workbuddy/tests.rs @@ -0,0 +1,490 @@ +use super::*; + +struct Home(PathBuf); +impl Home { + fn new() -> Self { + let path = env::temp_dir().join(format!( + "cpa-workbuddy-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn path(&self) -> PathBuf { + self.0.join(".workbuddy-ai/models.json") + } + fn save(&self, value: &Value) { + fs::create_dir_all(self.path().parent().unwrap()).unwrap(); + fs::write(self.path(), serde_json::to_string_pretty(value).unwrap()).unwrap(); + } + fn read(&self) -> Value { + parse_workbuddy_config(read_optional_text(&self.path()).unwrap().as_deref()).unwrap() + } + fn apply(&self, model: &str) -> Result { + apply_agent_configuration_with_oauth( + AgentClient::WorkBuddy, + &self.0, + 8317, + "test-key", + model, + AgentConfigurationOptions { + models: &[], + codex_catalog: None, + oauth_configuration: false, + claude_code_model_mappings: None, + claude_desktop_model_mappings: None, + }, + ) + } +} +impl Drop for Home { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn workbuddy_formats_round_trip_through_update_backup_and_disable() { + for original in [ + json!([]), + json!({}), + json!({"models": []}), + json!([{"id":"my-model","url":"https://example.test/v1/chat/completions","apiKey":"private"}]), + json!({"models":[{"id":"my-model","extensions":{"keep":true}}],"availableModels":["my-model"],"custom":true}), + json!({"availableModels":["gpt-one","my-model"]}), + ] { + let home = Home::new(); + home.save(&original); + home.apply("gpt-one").unwrap(); + assert_eq!( + inspect_workbuddy_agent_config(&home.path(), 8317, "test-key").unwrap(), + (true, Some("gpt-one".into())) + ); + let bytes = fs::read(home.path()).unwrap(); + assert_eq!(home.apply("gpt-one").unwrap().outcome, "unchanged"); + assert_eq!(fs::read(home.path()).unwrap(), bytes); + assert_eq!( + serde_json::to_value(create_backup("workbuddy", &home.0).unwrap()).unwrap() + ["restorable"], + true + ); + clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap(); + assert_eq!(home.read(), original); + } +} + +#[test] +fn workbuddy_switching_and_disable_keep_external_models_and_settings() { + for array in [false, true] { + let home = Home::new(); + home.save(&if array { + json!([]) + } else { + json!({"availableModels":["built-in"]}) + }); + home.apply("gpt-one").unwrap(); + let mut edited = home.read(); + let mut entries = workbuddy_models(&edited).to_vec(); + entries[0]["temperature"] = json!(0.2); + let first_model = entries[0].clone(); + entries.push(json!({"id":"user-added","apiKey":"user-key","extra":{"a":[1,2]}})); + set_workbuddy_models(&mut edited, entries); + home.save(&edited); + home.apply("gpt-two").unwrap(); + let updated = home.read(); + assert_eq!(workbuddy_models(&updated).len(), 3); + assert!(workbuddy_models(&updated).contains(&first_model)); + assert_eq!( + inspect_workbuddy_agent_config(&home.path(), 8317, "test-key").unwrap(), + (true, Some("gpt-two".into())) + ); + if !array { + assert_eq!( + updated["availableModels"], + json!(["built-in", "gpt-one", "gpt-two"]) + ); + } + let second_model = workbuddy_models(&updated) + .iter() + .find(|m| m["id"] == "gpt-two") + .unwrap() + .clone(); + home.apply("gpt-one").unwrap(); + let switched_back = home.read(); + assert_eq!(workbuddy_models(&switched_back).len(), 3); + assert!(workbuddy_models(&switched_back).contains(&first_model)); + assert!(workbuddy_models(&switched_back).contains(&second_model)); + assert_eq!( + inspect_workbuddy_agent_config(&home.path(), 8317, "test-key").unwrap(), + (true, Some("gpt-one".into())) + ); + let bytes = fs::read(home.path()).unwrap(); + assert_eq!(home.apply("gpt-one").unwrap().outcome, "unchanged"); + assert_eq!(fs::read(home.path()).unwrap(), bytes); + clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap(); + assert_eq!( + workbuddy_models(&home.read()), + &[json!({"id":"user-added","apiKey":"user-key","extra":{"a":[1,2]}})] + ); + if !array { + assert_eq!(home.read()["availableModels"], json!(["built-in"])); + } + } +} + +#[test] +fn workbuddy_status_checks_endpoint_key_visibility_and_tool_support() { + let home = Home::new(); + home.apply("gpt-one").unwrap(); + assert!( + !inspect_workbuddy_agent_config(&home.path(), 8318, "test-key") + .unwrap() + .0 + ); + assert!( + !inspect_workbuddy_agent_config(&home.path(), 8317, "wrong-key") + .unwrap() + .0 + ); + let original = home.read(); + for (pointer, value) in [ + ("/availableModels", json!(["hidden"])), + ("/models/0/disabled", json!(true)), + ("/models/0/supportsToolCall", json!(false)), + ("/models/0/url", json!("http://127.0.0.1:8317/v1")), + ] { + let mut changed = original.clone(); + if pointer == "/availableModels" { + changed["availableModels"] = value; + } else { + *changed.pointer_mut(pointer).unwrap() = value; + } + home.save(&changed); + assert!( + !inspect_workbuddy_agent_config(&home.path(), 8317, "test-key") + .unwrap() + .0 + ); + assert!(workbuddy_has_managed_marker(&home.path()).unwrap()); + } + let mut prefixed = original; + prefixed["availableModels"] = json!(["custom-local:gpt-one"]); + home.save(&prefixed); + assert!( + inspect_workbuddy_agent_config(&home.path(), 8317, "test-key") + .unwrap() + .0 + ); +} + +#[test] +fn workbuddy_visibility_restores_original_references_and_keeps_user_edits() { + for external_edit in [false, true] { + let home = Home::new(); + home.save(&json!({"availableModels":["gpt-one","built-in"]})); + home.apply("gpt-one").unwrap(); + home.apply("gpt-two").unwrap(); + if external_edit { + let mut current = home.read(); + current["availableModels"] = json!(["gpt-two", "user-added"]); + home.save(¤t); + } + clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap(); + assert_eq!( + home.read()["availableModels"], + if external_edit { + json!(["user-added"]) + } else { + json!(["gpt-one", "built-in"]) + } + ); + } +} + +#[test] +fn workbuddy_new_visibility_filters_remove_managed_references_on_disable() { + for original in [json!({}), json!({"availableModels": []}), json!([])] { + for reapply in [false, true] { + for (visible, expected) in [ + (json!(["gpt-one"]), json!([])), + ( + json!(["custom-local:gpt-one", "custom-local:gpt-two", "built-in"]), + json!(["built-in"]), + ), + ] { + let home = Home::new(); + home.save(&original); + home.apply("gpt-one").unwrap(); + home.apply("gpt-two").unwrap(); + let current = home.read(); + let mut current = if current.is_array() { + json!({"models": current}) + } else { + current + }; + current["availableModels"] = visible; + home.save(¤t); + if reapply { + home.apply("gpt-two").unwrap(); + } + clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap(); + let restored = home.read(); + assert!(workbuddy_models(&restored).is_empty()); + assert_eq!(restored["availableModels"], expected); + } + } + } +} + +#[test] +fn workbuddy_cleared_visibility_does_not_restore_old_references() { + for reapply in [false, true] { + for remove_field in [false, true] { + let home = Home::new(); + home.save(&json!({"availableModels": ["gpt-one", "built-in"]})); + home.apply("gpt-one").unwrap(); + home.apply("gpt-two").unwrap(); + let mut current = home.read(); + if remove_field { + current.as_object_mut().unwrap().remove("availableModels"); + } else { + current["availableModels"] = json!([]); + } + home.save(¤t); + if reapply { + home.apply("gpt-two").unwrap(); + } + clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap(); + let restored = home.read(); + assert!(workbuddy_models(&restored).is_empty()); + if remove_field { + assert!(restored.get("availableModels").is_none()); + } else { + assert_eq!(restored["availableModels"], json!([])); + } + } + } +} + +#[test] +fn workbuddy_visibility_keeps_models_detached_from_cpa() { + for reapply in [false, true] { + let home = Home::new(); + home.apply("gpt-one").unwrap(); + let mut current = home.read(); + current["models"][0]["vendor"] = json!("user-provider"); + current["availableModels"] = json!(["custom-local:gpt-one", "built-in"]); + home.save(¤t); + if reapply { + home.apply("gpt-two").unwrap(); + } + clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap(); + assert_eq!(home.read(), current); + } +} + +#[test] +fn workbuddy_write_guard_rejects_removing_unrelated_visible_models() { + let before = json!({"availableModels":["user-model"]}); + let rendered = build_workbuddy_agent_config( + Some(&before.to_string()), + "http://127.0.0.1:8317/v1", + "key", + "gpt-one", + &[], + ) + .unwrap(); + let mut after = parse_workbuddy_config(Some(&rendered)).unwrap(); + validate_workbuddy_unmanaged_preserved(&before, &after).unwrap(); + after["availableModels"] = json!(["gpt-one"]); + assert!(validate_workbuddy_unmanaged_preserved(&before, &after).is_err()); +} + +#[test] +fn workbuddy_rejects_collisions_and_malformed_configs_without_writing() { + for value in [ + json!([{"id":"gpt-one","apiKey":"user-key"}]), + json!({"models":[{"id":"custom-local:gpt-one","apiKey":"user-key"}]}), + json!({"models":{}}), + json!({"availableModels":false}), + json!({"models":[{"id":"same"},{"id":"custom-local:same"}]}), + ] { + let home = Home::new(); + home.save(&value); + let before = fs::read(home.path()).unwrap(); + assert!(home.apply("gpt-one").is_err()); + assert_eq!(fs::read(home.path()).unwrap(), before); + } +} + +#[test] +fn workbuddy_switch_refreshes_retained_credentials_and_metadata() { + let home = Home::new(); + home.apply("vision").unwrap(); + let mut current = home.read(); + current["models"][0]["id"] = json!("custom-local:vision"); + current["models"][0]["temperature"] = json!(0.2); + current["availableModels"] = json!(["custom-local:vision"]); + let next = build_workbuddy_agent_config( + Some(¤t.to_string()), + "http://127.0.0.1:8318/v1", + "new-key", + "gpt-two", + &[AgentModelOption { + name: "vision".into(), + alias: None, + is_alias: false, + context_window: Some(128_000), + input_modalities: Some(vec!["text".into(), "image".into()]), + harness_metadata: None, + }], + ) + .unwrap(); + let updated = parse_workbuddy_config(Some(&next)).unwrap(); + assert_eq!( + updated["availableModels"], + json!(["custom-local:vision", "gpt-two"]) + ); + assert_eq!(workbuddy_models(&updated).len(), 2); + let retained = &updated["models"][1]; + assert_eq!(retained["id"], "custom-local:vision"); + assert_eq!(retained["maxInputTokens"], 128_000); + assert_eq!(retained["supportsImages"], true); + assert_eq!(retained["temperature"], 0.2); + for entry in workbuddy_models(&updated) { + assert_eq!(entry["url"], "http://127.0.0.1:8318/v1/chat/completions"); + assert_eq!(entry["apiKey"], "new-key"); + } + home.save(&updated); + assert_eq!( + inspect_workbuddy_agent_config(&home.path(), 8318, "new-key").unwrap(), + (true, Some("gpt-two".into())) + ); + for (field, value) in [ + ("apiKey", json!("old-key")), + ("url", json!("http://127.0.0.1:8317/v1/chat/completions")), + ("disabled", json!(true)), + ("supportsToolCall", json!(false)), + ] { + let mut stale = updated.clone(); + stale["models"][1][field] = value; + home.save(&stale); + assert_eq!( + inspect_workbuddy_agent_config(&home.path(), 8318, "new-key").unwrap(), + (false, Some("gpt-two".into())) + ); + } + let switched_back = build_workbuddy_agent_config( + Some(&next), + "http://127.0.0.1:8318/v1", + "new-key", + "vision", + &[], + ) + .unwrap(); + let switched_back = parse_workbuddy_config(Some(&switched_back)).unwrap(); + assert_eq!(workbuddy_models(&switched_back).len(), 2); + assert_eq!(switched_back["models"][0]["id"], "vision"); + assert_eq!(switched_back["models"][0]["temperature"], 0.2); + assert_eq!(switched_back["availableModels"], updated["availableModels"]); + home.save(&switched_back); + assert_eq!( + inspect_workbuddy_agent_config(&home.path(), 8318, "new-key").unwrap(), + (true, Some("vision".into())) + ); +} + +#[test] +fn workbuddy_metadata_and_model_extensions_survive_reapply() { + let config = build_workbuddy_agent_config( + None, + "http://127.0.0.1:8317/v1", + "key", + "vision", + &[AgentModelOption { + name: "vision".into(), + alias: None, + is_alias: false, + context_window: Some(128_000), + input_modalities: Some(vec!["text".into(), "image".into()]), + harness_metadata: None, + }], + ) + .unwrap(); + let mut value = parse_workbuddy_config(Some(&config)).unwrap(); + assert_eq!(value["models"][0]["maxInputTokens"], 128_000); + assert_eq!(value["models"][0]["supportsImages"], true); + assert_eq!( + value["models"][0]["url"], + "http://127.0.0.1:8317/v1/chat/completions" + ); + value["models"][0]["temperature"] = json!(0.2); + let next = build_workbuddy_agent_config( + Some(&value.to_string()), + "http://127.0.0.1:8318/v1", + "new", + "vision", + &[], + ) + .unwrap(); + assert_eq!( + parse_workbuddy_config(Some(&next)).unwrap()["models"][0]["temperature"], + 0.2 + ); +} + +#[test] +fn workbuddy_paths_use_environment_then_installed_product_data_folder() { + let home = Home::new(); + let executable = home.0.join("install/WorkBuddyAI.exe"); + let product = workbuddy_resources(&executable) + .unwrap() + .join("app.asar.unpacked/cli/product.json"); + fs::create_dir_all(product.parent().unwrap()).unwrap(); + fs::write(product, r#"{"dataFolderName":".workbuddy-enterprise"}"#).unwrap(); + let resolve = |wb, cb| workbuddy_home_from_environment(&home.0, wb, cb, Some(&executable)); + assert_eq!(resolve(None, None), home.0.join(".workbuddy-enterprise")); + assert_eq!(resolve(Some(&home.0), None), home.0); + assert_eq!(resolve(None, Some(&home.0)), home.0); + assert_eq!(resolve(Some(&home.0), Some(Path::new("unused"))), home.0); +} + +#[cfg(target_os = "windows")] +#[test] +fn workbuddy_detects_local_install_without_launching_it() { + let home = Home::new(); + let executable = home + .0 + .join("AppData/Local/Programs/WorkBuddyAI/WorkBuddyAI.exe"); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); + fs::write(&executable, "not an executable").unwrap(); + assert_eq!( + find_workbuddy_desktop_executable(&home.0), + Some(executable.clone()) + ); + assert!(agent_installation_detected( + AgentClient::WorkBuddy, + None, + true, + false + )); + assert!(!should_probe_primary_agent_executable_version( + AgentClient::WorkBuddy + )); + assert_eq!(read_workbuddy_app_version(&executable), None); + let targets = agent_launch_targets(AgentClient::WorkBuddy, Some(&executable), None, false); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].id, "app"); + assert_eq!( + parse_windows_desktop_registration( + "icon", + &format!("\"{}\",0", executable.display()), + "WorkBuddyAI.exe" + ), + Some(executable) + ); +} diff --git a/src-tauri/src/configuration_watcher.rs b/src-tauri/src/configuration_watcher.rs index 2190df8..6b623c4 100644 --- a/src-tauri/src/configuration_watcher.rs +++ b/src-tauri/src/configuration_watcher.rs @@ -74,6 +74,7 @@ fn tracked_configuration_paths(app: &tauri::AppHandle) -> Result, S AgentClient::Hermes, AgentClient::DeepSeekHarness, AgentClient::ZCode, + AgentClient::WorkBuddy, AgentClient::KimiCode, AgentClient::GrokBuild, ] { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fe07775..b692d65 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1362,6 +1362,7 @@ enum AgentClient { Hermes, DeepSeekHarness, ZCode, + WorkBuddy, KimiCode, GrokBuild, } @@ -1384,6 +1385,7 @@ impl AgentClient { "hermes" => Ok(Self::Hermes), "deepseek-harness" => Ok(Self::DeepSeekHarness), "zcode" => Ok(Self::ZCode), + "workbuddy" => Ok(Self::WorkBuddy), "kimi-code" => Ok(Self::KimiCode), "grok-build" => Ok(Self::GrokBuild), _ => Err(format!("不支持的智能体客户端: {value}")), @@ -1400,6 +1402,7 @@ impl AgentClient { Self::Hermes => "hermes", Self::DeepSeekHarness => "deepseek-harness", Self::ZCode => "zcode", + Self::WorkBuddy => "workbuddy", Self::KimiCode => "kimi-code", Self::GrokBuild => "grok-build", } @@ -1415,6 +1418,7 @@ impl AgentClient { Self::Hermes => "Hermes Agent", Self::DeepSeekHarness => "DeepSeek Harness", Self::ZCode => "ZCode", + Self::WorkBuddy => "WorkBuddy", Self::KimiCode => "Kimi Code", Self::GrokBuild => "Grok Build", } @@ -1439,6 +1443,7 @@ impl AgentClient { Self::Hermes => &["hermes"], Self::DeepSeekHarness => &["dsh"], Self::ZCode => &["zcode"], + Self::WorkBuddy => &[], Self::KimiCode => &["kimi"], Self::GrokBuild => &["grok"], } diff --git a/src-tauri/src/tests/agent_paths.rs b/src-tauri/src/tests/agent_paths.rs index 8ed796e..4fc4fcb 100644 --- a/src-tauri/src/tests/agent_paths.rs +++ b/src-tauri/src/tests/agent_paths.rs @@ -13,6 +13,7 @@ fn configuration_paths_ignore_inherited_environment() { "hermes", "deepseek-harness", "zcode", + "workbuddy", "kimi-code", "grok-build", "pi", @@ -42,6 +43,9 @@ fn configuration_paths_ignore_inherited_environment() { "KIMI_CODE_HOME", "GROK_HOME", "DSH_HOME", + "WORKBUDDY_CONFIG_DIR", + "CODEBUDDY_CONFIG_DIR", + "WORKBUDDY_INSTALL_DIR", "PI_CODING_AGENT_DIR", ] { command.env(variable, &outside); diff --git a/src/assets/icons/workbuddy.png b/src/assets/icons/workbuddy.png new file mode 100644 index 0000000000000000000000000000000000000000..024ba293c1f2d1c881ed0226571f2f29733ea935 GIT binary patch literal 3538 zcmV;@4K4DCP)1l)eH()trDxPF>ci~aRH)4jfqPX z+Sp*4Dn`LQHJWA`TGLjm6cquLLZQL{1LG`j=FMB~J?EJ3yYJ4z3@`|(|9D<*-g3{o z=lA=*-?yB5f&Y*H?GTqV+c}yquxr<){cp0Pz1`6&5Q5&X;BY%t75u9+oD;m}B!<4| zg`{v0YYcr&y|D8tPkSxKmJ{$VAE!qEJdMB)_;QJOyfhT|I){Wf4ZyHItP>aV$oZ}6 zHcE)imOj?(*}2{cwE@=LD1@wmlH}-172t#mm#jfTst~KOaL(o-)r-QJ?fIM5>^K+8 zM-R?bl4xas9MN#^<{Jd;mePh2;0kvOWD-V9@w@ zaM~i=KXpES*>fD9``2F(I16pz>`=kN!HL>zo3`%hA%;9ywqlM_l-v5(0Ts!5z723_fpstZAHuZ_R!k3>+++5D<#i zq_sslF;`2sgD^3+bH-Q#yEkgmTKdiN*NEE8N(L{5=g1tqeDsPwkN)lA=VoH&|5F-39t!?}BK606rgo)#TFrJsdN>ttB9)RyV2v?2P zD|1Kz6ifwgZ15ZdKq(qNj!F|p1@ZP35=nqQJ>}vtn|hrJu7 ztMu=G*bD?O3#C#phKkA+%m1!wv7cUZ8|Ei#P_~vmz*ZDsG8EWv{d)%v75kA?8rE6| zrGzuK0H~`AOV@QpSjT!`*Dn67q(5DFD&FyUJDTt5CXai zKBwdO%FG)%(OE|xfFLZwlWD>>8tZrog}FHHl%PMlD=8m32ylDDEY>(Jp{n5NoiapR zDhwDOcht4uo~F4NG-Y@+S-=q{!gem+aRh>4XJ46vfD-V0P~x1wyrvmNYuMtl3Y?Fs zlwv}1aeJs`3F>qVy&Ml=`U|mLgg~U$%Xvk>DVgVkGG~_7SQ-ypbuBU~Odu%2@S4=n28|-jZaqNmK*TebR){na!`^*2@nM&cOOPG9Lp|JH5J`%awz^h)plJbS#k1@YB1T>b z`|GjeydEf6A8-?NutKsghexL8e)>D7OmYFx>!_5m}V4*dqT0*50(1x<)eG? zgYH8JtVLSK#(6a+HI(u&=mOmTQU|Z~`PLiHyZso~7?lQ2Vs-Ny@JP!tb{|P(1PnOC zP;mA5C7kpNcwMFmDXCF3J`&tShH{A(Bu$f?ww&05Z=U)qx+?{wbOH%YmDfyQyfBS; z5QxPR_`)mC;ihyWR_A7-$1h%(K*W%&NSf>8N*L~zD076tD3F#KxnvC%rgEI_h*V6H zlfx^JJ!@5*rS{{0zl1F({)7XCZp2g!SuYW08b|vg40q_~CJp*deVmpGpE&j`+Hx~d z?k9RfB z!=Nc4PE8(luWS?xAuM7-pf@PuvqyF_#u4}v5E$?SXr=H(?@@I6gDeR#s#l4ClWfRV zQ@kX8m+!=z4*Ue4I=Y*)RMt!K!esZ+zzG-~8WzF@_s(tvvj?_hlR^bRnr|2?H?r zaHmzYWh~21#oERNNJ{cufEdfdATBH)9(*+G7#{C>4ks)9h*PWqm=&)>RyxE%b0ZQX z#3$teBET)Jlo<32`1a{PS8xA?)~4}f|FPi`MEh$3uO(14CES^tgQpg*!}sUig~#UJ z2{?+Y5I6x#FcFfGgRw*S(y?FQRHYARDtT<}>|k=q>Rg(~goVZUGIri>G`g!pX$-uh zqCpfWXVW-Z>f>8bVfiBx;VvR@KJKcY$8sxLAFJxF#@Z>@Vb|F`Xx2#xmIB2H#Ribl z8v6!c!iK+ZWdrpDLugQO4p^2J1S&jVp*Va;`2uxdG>vFqK%FHM7!}0S|3~vk_tbn4 znV6LLP46K-L)Dc$`C!WmOrz9_3R`yMFo+{SgHn*L1h^s^wMJY7d@k}-lJ1ST3UYdU z6ptbhvhozlFjRb;pe~VYeA+LB?&6jq98F$|ttlc2@l6baobvEQ&p|xZcbMl>a88ZY z;zLuHBd|rJq+?*wkz{EFNK0x%Nc#ld@;RED5)OI4SG4D@!=4*HU8c2nj@r_U5NO}Y zCM{Zd+yy~_%}q3%#e!r#Pl?)N8q2fO!a~Kr+mHz7eTRe;`09xczKt06O&0HLya_X6 zSx8qwf&hX*+K>pUu~F4L{hks6&KBXDf_q^4ZDLdFon*!147I)8jwe87r2FtoKi=h> z+ba^DtGK{6#IxAky1Y6i?0;yyyKw<(yc8;qv`QBWtF3SbwaUZ(!g1_4^8(9=JV9-O zb0scPVI~^MNO2|-zNe&sb`?}iKNh81@QwK&v>%=J7P8u2Ap~KQV$=hVu|fPaV4p}0W27}8DG69xBAw$k zHH-1-t3Qb~)865-I%{}MDQWG)0M?!FKF@dp^aNznCeeMWwCtdgoo^{cze_x%>n^M)I9A3i*I_{5Q4;gK_czzi<|4*_UGQU}P= zJN)W*`V16<0laU = { 'agents.description.openclaw': 'Uses the CPA model provider and default model', 'agents.description.hermes': 'Uses the CPA custom provider', 'agents.description.deepseekHarness': 'Uses CPA through an isolated, OpenAI-compatible Harness route', + 'agents.description.workbuddy': 'Configures a WorkBuddy custom model using the CPA OpenAI Chat Completions API', + 'agents.modify.workbuddyHint': 'After applying, select “CPA:model name” in WorkBuddy. Restart the app if it is missing; existing conversations keep their selected model.', 'agents.description.zcode': 'Uses the CPA Anthropic Messages API and dynamic model catalog', 'agents.description.kimiCode': 'Uses the CPA OpenAI Chat Completions API and dynamic model catalog', 'agents.description.grokBuild': 'Uses the CPA OpenAI Chat Completions API and custom model catalog', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 5fc6db0..678ef32 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -936,6 +936,8 @@ export const zhCN = { 'agents.description.openclaw': '使用 CPA 模型供应商和默认模型', 'agents.description.hermes': '使用 CPA 自定义提供方', 'agents.description.deepseekHarness': '通过通用 OpenAI 兼容路由使用 CPA,并与 Harness 原生 DeepSeek 配置隔离', + 'agents.description.workbuddy': '使用 CPA 的 OpenAI Chat Completions 接口配置 WorkBuddy 自定义模型', + 'agents.modify.workbuddyHint': '应用后,在 WorkBuddy 的模型选择器中选择「CPA:模型名」。若未显示,请重启 App;已有会话的模型不会自动切换。', 'agents.description.zcode': '使用 CPA 的 Anthropic Messages 接口和动态模型目录', 'agents.description.kimiCode': '使用 CPA 的 OpenAI Chat Completions 接口和动态模型目录', 'agents.description.grokBuild': '使用 CPA 的 OpenAI Chat Completions 接口和自定义模型目录', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index f4dd0eb..6912878 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -36,6 +36,7 @@ import openclawIcon from '../assets/icons/openclaw.svg'; import opencodeIcon from '../assets/icons/opencode.svg'; import piIcon from '../assets/icons/pi-logo-on-light.svg'; import zcodeIcon from '../assets/icons/zcode.png'; +import workbuddyIcon from '../assets/icons/workbuddy.png'; import { agentModelAlias, filterAgentModels, @@ -79,6 +80,7 @@ type AgentClientId = | 'hermes' | 'deepseek-harness' | 'zcode' + | 'workbuddy' | 'kimi-code' | 'grok-build' | 'pi'; @@ -228,7 +230,7 @@ type AgentDefinition = { name: string; icon?: string; Icon?: ComponentType<{ size?: number; 'aria-hidden'?: boolean }>; - descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi'; + descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.workbuddy' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi'; }; type AgentSubpageId = 'core' | 'management' | 'sessions'; @@ -282,6 +284,12 @@ const agentDefinitions: AgentDefinition[] = [ icon: grokIcon, descriptionKey: 'agents.description.grokBuild', }, + { + id: 'workbuddy', + name: 'WorkBuddy', + icon: workbuddyIcon, + descriptionKey: 'agents.description.workbuddy', + }, { id: 'zcode', name: 'ZCode', @@ -1210,6 +1218,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const modificationDescription = activeStatus?.modificationState === 'invalid' ? t('agents.modify.invalid') : nativeOauth ? t('agents.nativeOAuth.activeHint') + : selected === 'workbuddy' + ? t('agents.modify.workbuddyHint') : selected === 'zcode' ? t('agents.modify.zcodeRestart') : ''; @@ -1731,7 +1741,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP }; const restartDesktopApp = async () => { - if (!['codex', 'opencode', 'claude-desktop', 'zcode'].includes(selected)) return; + if (!['codex', 'opencode', 'claude-desktop', 'zcode', 'workbuddy'].includes(selected)) return; setBusyAction('restart-app'); setLaunchError(''); try { @@ -2426,7 +2436,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP { setConfigurationNotice(''); setClearNotice(''); }} /> {activeSubpage === 'core' ? void launchAgent(target)} onRestart={() => void restartDesktopApp()} diff --git a/tests/agent-management-ui.cjs b/tests/agent-management-ui.cjs index 787946d..dceb9ba 100644 --- a/tests/agent-management-ui.cjs +++ b/tests/agent-management-ui.cjs @@ -66,7 +66,7 @@ const path = require('node:path'); assert.equal(await tab('基础配置').getAttribute('aria-selected'), 'true'); assert.equal(await tab('会话管理').count(), 0); - for (const client of ['claude-desktop', 'zcode', 'opencode']) { + for (const client of ['claude-desktop', 'zcode', 'workbuddy', 'opencode']) { await open(mode + 'client=' + client + '&app-only'); assert.ok(await button('重启 App').isEnabled()); await button('重启 App').click(); @@ -80,7 +80,7 @@ const path = require('node:path'); await open(mode + 'client=kimi-code'); assert.equal(await button('重启 App').count(), 0); - for (const client of ['claude-code', 'claude-desktop', 'opencode', 'openclaw', 'hermes', 'deepseek-harness', 'zcode', 'kimi-code', 'grok-build']) { + for (const client of ['claude-code', 'claude-desktop', 'opencode', 'openclaw', 'hermes', 'deepseek-harness', 'zcode', 'workbuddy', 'kimi-code', 'grok-build']) { await open(mode + 'client=' + client); assert.ok(await button('关闭配置修改').isEnabled()); await tab('配置管理').click(); diff --git a/tests/fixtures/agent-backups.tsx b/tests/fixtures/agent-backups.tsx index 47a3f75..b0d4af0 100644 --- a/tests/fixtures/agent-backups.tsx +++ b/tests/fixtures/agent-backups.tsx @@ -9,7 +9,7 @@ if(params.has('reset-selections'))localStorage.removeItem('cpa-gui.agent-model-s localStorage.setItem('easy-cli-proxy-api.locale',params.get('locale') || 'zh-CN'); document.documentElement.dataset.theme = params.get('theme') || 'light'; localStorage.setItem('cpa-gui.agent-selected-client.v1', params.get('client') || 'codex'); -const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','zcode','kimi-code','grok-build','pi']; +const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','workbuddy','zcode','kimi-code','grok-build','pi']; let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one'; let nativeOauth=params.has('native-oauth'); let nativeSwitchCount=0; let codexClosed=false; let closeCount=0; let currentOauth=false; const currentMappings:Record={}; @@ -35,7 +35,7 @@ mockIPC(async (cmd,args:any) => { calls.push({cmd,args}); if(cmd==='plugin:event|listen') return 1; if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null; - if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&¤tOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status); + if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode','workbuddy'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&¤tOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status); if(cmd==='close_codex_config_modification') { closeCount++; if(params.has('fail-close')&&closeCount===1)throw new Error('模拟关闭失败,已回滚');