From ec07502de9017047c81ff49efd31210b24a757c6 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Wed, 16 Sep 2026 23:45:02 +0800 Subject: [PATCH] feat(agents): restore configuration disable and clear actions --- src-tauri/src/agents/backups/tests.rs | 300 ++++++++++++++ src-tauri/src/agents/commands.rs | 12 +- src-tauri/src/agents/configuration.rs | 433 +++++++++++---------- src-tauri/src/agents/transactions.rs | 11 +- src-tauri/src/main.rs | 1 - src-tauri/src/tests/agent_configuration.rs | 4 +- src/i18n/ja.ts | 4 + src/i18n/locales/en.ts | 4 + src/i18n/locales/zh-CN.ts | 4 + src/pages/AgentControls.tsx | 6 +- src/pages/AgentsPage.tsx | 48 ++- tests/agent-management-ui.cjs | 39 ++ tests/agent-native-oauth-ui.cjs | 5 +- tests/fixtures/agent-backups.tsx | 6 + 14 files changed, 641 insertions(+), 236 deletions(-) diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index e90d6c0..92d98b2 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -388,6 +388,306 @@ fn unwritable_backup_directory_does_not_block_updates_sync_clear_or_templates() assert_eq!(fs::read_to_string(data.join("backups")).unwrap(), "blocked"); } +#[test] +fn clear_integration_needs_no_backup_and_removes_state_for_each_client() { + for client in [ + AgentClient::ClaudeCode, + AgentClient::ClaudeDesktop, + AgentClient::OpenCode, + AgentClient::OpenClaw, + AgentClient::Hermes, + AgentClient::DeepSeekHarness, + AgentClient::ZCode, + AgentClient::KimiCode, + AgentClient::GrokBuild, + ] + .into_iter() + .filter(|client| client.supported_platform()) + { + let home = Home::new(); + let paths = config_paths(client.id(), &home.0).unwrap(); + apply(&home.0, client, "gpt-one").unwrap(); + let mut expected = Vec::new(); + for (path, bytes) in config_images(&paths).unwrap() { + let mut value = parse(&path, text(bytes.as_deref()).unwrap()).unwrap(); + value["user_setting"] = serde_json::json!({"keep": true}); + save(&path, render(&path, &value).unwrap()); + expected.push((path, serde_json::json!({"user_setting": {"keep": true}}))); + } + let state = agent_state_path(&paths).unwrap(); + save(&state, "old state without a usable backup"); + let extra_state = match client { + AgentClient::ClaudeDesktop => Some(desktop_mapping_path(&paths).unwrap()), + AgentClient::DeepSeekHarness => { + Some(deepseek_harness_catalog_state_path(&paths).unwrap()) + } + _ => None, + }; + if let Some(path) = &extra_state { + save(path, "stale state"); + } + let backup = paths[0].with_extension("manual.bak"); + save(&backup, "keep manual backup"); + let result = clear_agent_managed_configuration(client, &home.0, 8317).unwrap(); + assert!(!result.enabled, "{}", client.id()); + assert!(!state.exists()); + assert!(extra_state.is_none_or(|path| !path.exists())); + assert_eq!(fs::read_to_string(backup).unwrap(), "keep manual backup"); + assert!(!agent_has_managed_marker(client, &paths).unwrap()); + for (path, expected) in expected { + let value = parse( + &path, + text(read_agent_bytes(&path).unwrap().as_deref()).unwrap(), + ) + .unwrap(); + assert_eq!( + value["user_setting"], + expected["user_setting"], + "{}", + client.id() + ); + } + let result = clear_agent_managed_configuration(client, &home.0, 8317).unwrap(); + assert!(!result.enabled); + assert!(result.changed_files.is_empty(), "{}", client.id()); + assert!(list_backups(client.id(), &home.0) + .unwrap() + .versions + .is_empty()); + apply(&home.0, client, "gpt-two").unwrap(); + assert!(agent_has_managed_marker(client, &paths).unwrap()); + } +} + +#[test] +fn clear_integration_removes_claude_model_overrides_but_keeps_custom_settings() { + let home = Home::new(); + let paths = config_paths("claude-code", &home.0).unwrap(); + let original = serde_json::json!({ + "permissions": {"allow": ["Read"]}, + "env": {"KEEP": "yes", "ANTHROPIC_CUSTOM_MODEL_OPTION_2": "user-model"} + }); + save(&paths[0], original.to_string()); + apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap(); + clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap(); + let result = parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap(); + assert_eq!(result, original); + + apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap(); + let mut edited = parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap(); + edited["model"] = serde_json::json!("user-model"); + save(&paths[0], edited.to_string()); + clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap(); + assert_eq!( + parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap()["model"], + "user-model" + ); + + let external = r#"{"env":{"ANTHROPIC_BASE_URL":"https://api.example.com","ANTHROPIC_AUTH_TOKEN":"other"},"model":"external"}"#; + save(&paths[0], external); + clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap(); + assert_eq!(fs::read_to_string(&paths[0]).unwrap(), external); +} + +#[test] +fn clear_integration_preserves_claude_settings_for_other_endpoints() { + let home = Home::new(); + let paths = config_paths("claude-code", &home.0).unwrap(); + for base_url in [ + "http://localhost:11434", + "http://127.0.0.1:11434", + "http://127.0.0.1:83170", + "http://127.0.0.1:8317/other-proxy", + "http://127.0.0.1:8317?route=other", + "http://127.0.0.1:8317#other", + "http://other:secret@127.0.0.1:8317", + "http://127.0.0.1.example.com:8317", + "https://127.0.0.1:8317", + ] { + let original = serde_json::json!({ + "model": "local-model", "permissions": {"allow": ["Read"]}, + "env": {"ANTHROPIC_BASE_URL": base_url, "ANTHROPIC_AUTH_TOKEN": "other-key", "ANTHROPIC_MODEL": "local-model"} + }).to_string(); + save(&paths[0], &original); + let result = + clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap(); + assert!(result.changed_files.is_empty(), "{base_url}"); + assert_eq!( + fs::read_to_string(&paths[0]).unwrap(), + original, + "{base_url}" + ); + } +} + +#[test] +fn clear_integration_uses_the_configured_claude_endpoint_without_a_backup() { + let home = Home::new(); + let paths = config_paths("claude-code", &home.0).unwrap(); + for port in [8317, 9527] { + // URL parsing accepts the trailing slash without treating another port as CPA. + let base_url = format!("http://127.0.0.1:{port}/"); + save( + &paths[0], + build_claude_agent_config( + Some(r#"{"env":{"KEEP":"yes"}}"#), + &base_url, + "test-secret", + "gpt-one", + &models(), + None, + ) + .unwrap(), + ); + clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, port).unwrap(); + assert_eq!( + parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap(), + serde_json::json!({"env":{"KEEP":"yes"}}) + ); + } + for base_url in ["https://[::1]:9527", "https://192.168.1.10:9527"] { + save( + &paths[0], + build_claude_agent_config(None, base_url, "test-secret", "gpt-one", &models(), None) + .unwrap(), + ); + let plan = prepare_claude_code_managed_removal(&paths, base_url).unwrap(); + assert_eq!(plan, vec![(paths[0].clone(), None)], "{base_url}"); + } +} + +#[test] +fn clear_integration_handles_inline_and_mixed_toml_tables() { + let examples = [ + ( + AgentClient::GrokBuild, + r#"# custom settings +models = { default = "cpa-gui/gpt-one", keep = true } +model = { "cpa-gui/gpt-one" = { model = "gpt-one", base_url = "http://127.0.0.1:8317/v1", api_key = "test-secret" }, other = { model = "other", api_key = "keep-secret" } } +"#, + serde_json::json!({"models":{"keep":true},"model":{"other":{"model":"other","api_key":"keep-secret"}}}), + ), + ( + AgentClient::GrokBuild, + r#"# custom settings +model = { "cpa-gui/gpt-one" = { model = "gpt-one" }, other = { model = "other" } } +[models] +default = "other" +"#, + serde_json::json!({"models":{"default":"other"},"model":{"other":{"model":"other"}}}), + ), + ( + AgentClient::KimiCode, + r#"default_model = "cpa-gui/gpt-one" +# custom settings +providers = { "cpa-gui" = { type = "openai", base_url = "http://127.0.0.1:8317/v1", api_key = "test-secret" }, other = { api_key = "keep-secret" } } +models = { "cpa-gui/gpt-one" = { provider = "cpa-gui", model = "gpt-one" }, other = { provider = "other", model = "other" } } +"#, + serde_json::json!({"providers":{"other":{"api_key":"keep-secret"}},"models":{"other":{"provider":"other","model":"other"}}}), + ), + ( + AgentClient::KimiCode, + r#"# custom settings +default_model = "other" +providers = { "cpa-gui" = { api_key = "test-secret" }, other = { api_key = "keep-secret" } } +[models."cpa-gui/gpt-one"] +provider = "cpa-gui" +model = "gpt-one" +[models.other] +provider = "other" +model = "other" +"#, + serde_json::json!({"default_model":"other","providers":{"other":{"api_key":"keep-secret"}},"models":{"other":{"provider":"other","model":"other"}}}), + ), + ]; + for (client, content, expected) in examples { + let home = Home::new(); + let paths = config_paths(client.id(), &home.0).unwrap(); + save(&paths[0], content); + let result = clear_agent_managed_configuration(client, &home.0, 8317).unwrap(); + assert!(!result.enabled); + assert_eq!(result.changed_files, vec![path_to_string(&paths[0])]); + let rendered = fs::read_to_string(&paths[0]).unwrap(); + assert_eq!(parse(&paths[0], Some(&rendered)).unwrap(), expected); + assert!(rendered.contains("# custom settings")); + assert!(!agent_has_connection_evidence(client, &paths).unwrap()); + assert!(clear_agent_managed_configuration(client, &home.0, 8317) + .unwrap() + .changed_files + .is_empty()); + } +} + +#[test] +fn clear_integration_removes_empty_inline_toml_tables() { + for (client, content) in [ + ( + AgentClient::GrokBuild, + r#"models = { default = "cpa-gui/gpt-one" } +model = { "cpa-gui/gpt-one" = { model = "gpt-one", api_key = "test-secret" } } +"#, + ), + ( + AgentClient::KimiCode, + r#"default_model = "cpa-gui/gpt-one" +providers = { "cpa-gui" = { api_key = "test-secret" } } +models = { "cpa-gui/gpt-one" = { provider = "cpa-gui", model = "gpt-one" } } +"#, + ), + ] { + let home = Home::new(); + let paths = config_paths(client.id(), &home.0).unwrap(); + save(&paths[0], content); + clear_agent_managed_configuration(client, &home.0, 8317).unwrap(); + assert!(!paths[0].exists()); + } +} + +#[test] +fn clear_integration_rejects_invalid_files_before_writing() { + let home = Home::new(); + let paths = config_paths("deepseek-harness", &home.0).unwrap(); + apply(&home.0, AgentClient::DeepSeekHarness, "gpt-one").unwrap(); + save(&paths[1], "refs: [invalid"); + let before = config_images(&paths).unwrap(); + assert!(clear_agent_managed_configuration(AgentClient::DeepSeekHarness, &home.0, 8317).is_err()); + assert_eq!(config_images(&paths).unwrap(), before); +} + +#[test] +fn clear_integration_rolls_back_config_and_state_together() { + let home = Home::new(); + let paths = config_paths("claude-code", &home.0).unwrap(); + apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap(); + let state = agent_state_path(&paths).unwrap(); + save(&state, "legacy state"); + let before = config_images(&paths).unwrap(); + let after = paths.iter().map(|p| (p.clone(), None)).collect(); + let mut first = true; + let error = commit_config_with_writer( + "claude-code", + &paths, + &before, + &after, + "clear-integration", + None, + None, + &mut |client, images| { + write_config_images(client, images)?; + if first { + first = false; + Err("injected failure".into()) + } else { + Ok(()) + } + }, + ) + .unwrap_err(); + assert!(error.contains("已回滚")); + assert_eq!(config_images(&paths).unwrap(), before); + assert_eq!(fs::read_to_string(state).unwrap(), "legacy state"); +} + #[test] fn partial_writes_and_failed_verification_roll_back_without_persistent_snapshots() { let home = Home::new(); diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 3600d15..1393597 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -1124,11 +1124,10 @@ pub(crate) async fn set_agent_config_enabled( .path() .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; - let config = gui_config_state.snapshot()?; - let port = config.port; - let api_key = effective_agent_api_key(&config); - if enabled { + let config = gui_config_state.snapshot()?; + let port = config.port; + let api_key = effective_agent_api_key(&config); validate_agent_can_enable(client, &home, port, api_key)?; let prepared = fetch_prepared_agent_models(client, &config).await?; let model = @@ -1170,11 +1169,14 @@ pub(crate) async fn set_agent_config_enabled( ) }).await } else { + let config = gui_config_state.snapshot()?; let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; let _ = force_restore; - Err("停用智能体配置接口已移除;如需整体重置,请使用“基础配置模板”".to_string()) + let result = clear_agent_managed_configuration(client, &home, config.port)?; + app.state::().clear()?; + Ok(result) } } diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 08b63cc..741f952 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -469,7 +469,6 @@ fn deepseek_harness_credentials_refs_mut<'a>( .ok_or_else(|| format!("{label} refs 必须是映射")) } -#[cfg(test)] fn remove_deepseek_harness_managed_credential( root: &mut serde_norway::Mapping, label: &str, @@ -890,47 +889,94 @@ pub(crate) fn build_claude_desktop_meta(existing: Option<&str>) -> Result Result { + if client == AgentClient::Codex { + return close_codex_configuration(home); + } + let paths = config_paths(client.id(), home)?; + let before = config_images(&paths)?; + validate_client_config_images(client.id(), &before)?; + let mut after = before.clone(); + for (path, bytes) in prepare_agent_managed_removal(client, &paths, port)? { + let entry = after + .iter_mut() + .find(|(candidate, _)| candidate == &path) + .ok_or("配置清理路径不匹配")?; + entry.1 = bytes; + } + let mut result = commit_config( + client.id(), + &paths, + &before, + &after, + "clear-integration", + None, + )?; + result.enabled = false; + Ok(result) +} + #[cfg(test)] -pub(crate) fn update_agent_json_file(path: &Path, label: &str, update: F) -> Result +pub(crate) fn remove_agent_managed_configuration( + client: AgentClient, + paths: &[PathBuf], +) -> Result, String> { + let updates = prepare_agent_managed_removal(client, paths, GuiConfigFile::default().port)?; + write_config_images(client.id(), &updates)?; + Ok(updates + .iter() + .map(|(path, _)| path_to_string(path)) + .collect()) +} + +pub(crate) fn prepare_agent_json_removal( + path: &Path, + label: &str, + update: F, +) -> Result, String> where F: FnOnce(&mut serde_json::Map) -> bool, { if !path.is_file() { - return Ok(false); + return Ok(None); } let content = fs::read_to_string(path) .map_err(|error| format!("读取 {label} 失败 {}: {error}", path_to_string(path)))?; let mut root = parse_agent_json_object(Some(&content), label)?; if !update(&mut root) { - return Ok(false); + return Ok(None); } - if root.is_empty() { - fs::remove_file(path) - .map_err(|error| format!("删除空的 {label} 失败 {}: {error}", path_to_string(path)))?; + let bytes = if root.is_empty() { + None } else { - let rendered = render_agent_json(root, label)?; - write_bytes_directly(path, rendered.as_bytes())?; - } - Ok(true) + Some(render_agent_json(root, label)?.into_bytes()) + }; + Ok(Some((path.to_path_buf(), bytes))) } -#[cfg(test)] -pub(crate) fn remove_claude_desktop_managed_configuration( - paths: &[PathBuf], -) -> Result, String> { +pub(crate) fn prepare_claude_desktop_managed_removal(paths: &[PathBuf]) -> Result { if paths.len() != 4 { return Err("Claude Desktop 当前平台配置路径不可用".to_string()); } + let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop 配置索引")?; + let applied_id = meta.get("appliedId").and_then(serde_json::Value::as_str); + let managed_active = applied_id == Some(CLAUDE_DESKTOP_PROFILE_ID) + || (applied_id.is_none() && agent_has_managed_marker(AgentClient::ClaudeDesktop, paths)?); let mut changed = Vec::new(); for (path, label) in [ (&paths[0], "Claude Desktop 主配置"), (&paths[1], "Claude Desktop 3P 配置"), ] { - if update_agent_json_file(path, label, |root| { - if root - .get("deploymentMode") - .and_then(serde_json::Value::as_str) - == Some("3p") + if let Some(update) = prepare_agent_json_removal(path, label, |root| { + if managed_active + && root + .get("deploymentMode") + .and_then(serde_json::Value::as_str) + == Some("3p") { root.remove("deploymentMode"); true @@ -938,68 +984,74 @@ pub(crate) fn remove_claude_desktop_managed_configuration( false } })? { - changed.push(path_to_string(path)); + changed.push(update); } } - if update_agent_json_file(&paths[2], "Claude Desktop 网关配置", |root| { - let mut updated = false; - for key in [ - "coworkEgressAllowedHosts", - "disableDeploymentModeChooser", - "inferenceGatewayApiKey", - "inferenceGatewayAuthScheme", - "inferenceGatewayBaseUrl", - "inferenceProvider", - "inferenceModels", - ] { - updated |= root.remove(key).is_some(); - } - updated - })? { - changed.push(path_to_string(&paths[2])); + if let Some(update) = + prepare_agent_json_removal(&paths[2], "Claude Desktop 网关配置", |root| { + let mut updated = false; + for key in [ + "coworkEgressAllowedHosts", + "disableDeploymentModeChooser", + "inferenceGatewayApiKey", + "inferenceGatewayAuthScheme", + "inferenceGatewayBaseUrl", + "inferenceProvider", + "inferenceModels", + ] { + updated |= root.remove(key).is_some(); + } + updated + })? + { + changed.push(update); } - if update_agent_json_file(&paths[3], "Claude Desktop 配置索引", |root| { - let mut updated = false; - if root.get("appliedId").and_then(serde_json::Value::as_str) - == Some(CLAUDE_DESKTOP_PROFILE_ID) - { - root.remove("appliedId"); - updated = true; - } - let entries_empty = if let Some(entries) = root - .get_mut("entries") - .and_then(serde_json::Value::as_array_mut) - { - let previous_len = entries.len(); - entries.retain(|entry| { - entry.get("id").and_then(serde_json::Value::as_str) - != Some(CLAUDE_DESKTOP_PROFILE_ID) - }); - updated |= entries.len() != previous_len; - entries.is_empty() - } else { - false - }; - if entries_empty { - root.remove("entries"); - } - updated - })? { - changed.push(path_to_string(&paths[3])); + if let Some(update) = + prepare_agent_json_removal(&paths[3], "Claude Desktop 配置索引", |root| { + let mut updated = false; + if root.get("appliedId").and_then(serde_json::Value::as_str) + == Some(CLAUDE_DESKTOP_PROFILE_ID) + { + root.remove("appliedId"); + updated = true; + } + let entries_empty = if let Some(entries) = root + .get_mut("entries") + .and_then(serde_json::Value::as_array_mut) + { + let previous_len = entries.len(); + entries.retain(|entry| { + entry.get("id").and_then(serde_json::Value::as_str) + != Some(CLAUDE_DESKTOP_PROFILE_ID) + }); + updated |= entries.len() != previous_len; + entries.is_empty() + } else { + false + }; + if entries_empty { + root.remove("entries"); + } + updated + })? + { + changed.push(update); } Ok(changed) } -#[cfg(test)] -pub(crate) fn remove_claude_code_managed_configuration( +pub(crate) fn prepare_claude_code_managed_removal( paths: &[PathBuf], -) -> Result, String> { + expected_base_url: &str, +) -> Result { let Some(path) = paths.first() else { return Err("Claude Code 当前平台配置路径不可用".to_string()); }; - let updated = update_agent_json_file(path, "Claude Code 配置", |root| { + let expected_base_url = reqwest::Url::parse(expected_base_url) + .map_err(|_| "CPA 接入地址无效".to_string())?; + let updated = prepare_agent_json_removal(path, "Claude Code 配置", |root| { let managed_model = root .get("env") .and_then(serde_json::Value::as_object) @@ -1011,7 +1063,9 @@ pub(crate) fn remove_claude_code_managed_configuration( .and_then(serde_json::Value::as_object) .and_then(|env| env.get("ANTHROPIC_BASE_URL")) .and_then(serde_json::Value::as_str) - .is_some_and(is_managed_agent_base_url); + .is_some_and(|url| { + reqwest::Url::parse(url.trim()).is_ok_and(|url| url == expected_base_url) + }); if !managed { return false; } @@ -1035,6 +1089,19 @@ pub(crate) fn remove_claude_code_managed_configuration( CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV, DISABLE_AUTO_COMPACT_ENV, + "CLAUDE_CODE_SUBAGENT_MODEL", + "CLAUDE_CODE_EFFORT_LEVEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", + "ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION", + "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", + "ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION", + "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", + "ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION", + "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", + "ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION", + "ANTHROPIC_CUSTOM_MODEL_OPTION", + "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME", + "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION", ] { env.remove(key); } @@ -1047,11 +1114,10 @@ pub(crate) fn remove_claude_code_managed_configuration( } true })?; - Ok(updated.then(|| path_to_string(path)).into_iter().collect()) + Ok(updated.into_iter().collect()) } -#[cfg(test)] -pub(crate) fn remove_codex_managed_configuration(paths: &[PathBuf]) -> Result, String> { +pub(crate) fn prepare_codex_managed_removal(paths: &[PathBuf]) -> Result { use toml_edit::{Document, Item}; let Some(path) = paths.first() else { @@ -1099,38 +1165,22 @@ pub(crate) fn remove_codex_managed_configuration(paths: &[PathBuf]) -> Result(&rendered) .map_err(|error| format!("验证恢复后的 Codex 配置失败: {error}"))?; - if rendered.trim().is_empty() { - fs::remove_file(path).map_err(|error| { - format!("删除空的 Codex 配置失败 {}: {error}", path_to_string(path)) - })?; - } else { - write_bytes_directly(path, rendered.as_bytes())?; - } - let mut changed = vec![path_to_string(path)]; + let mut changed = vec![( + path.clone(), + (!rendered.trim().is_empty()).then(|| rendered.into_bytes()), + )]; if managed_catalog { - let catalog_path = path.with_file_name(CODEX_MODEL_CATALOG_FILE); - if catalog_path.is_file() { - fs::remove_file(&catalog_path).map_err(|error| { - format!( - "删除 Codex CPA 模型目录失败 {}: {error}", - path_to_string(&catalog_path) - ) - })?; - changed.push(path_to_string(&catalog_path)); - } + changed.push((path.with_file_name(CODEX_MODEL_CATALOG_FILE), None)); } Ok(changed) } -#[cfg(test)] -pub(crate) fn remove_opencode_managed_configuration( - paths: &[PathBuf], -) -> Result, String> { +pub(crate) fn prepare_opencode_managed_removal(paths: &[PathBuf]) -> Result { let Some(path) = paths.first() else { return Err("OpenCode 当前平台配置路径不可用".to_string()); }; let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); - let updated = update_agent_json5_file(path, "OpenCode 配置", |root| { + let updated = prepare_agent_json5_removal(path, "OpenCode 配置", |root| { let mut changed = false; if root .get("model") @@ -1154,18 +1204,17 @@ pub(crate) fn remove_opencode_managed_configuration( } changed })?; - Ok(updated.then(|| path_to_string(path)).into_iter().collect()) + Ok(updated.into_iter().collect()) } -#[cfg(test)] -pub(crate) fn remove_zcode_managed_configuration(paths: &[PathBuf]) -> Result, String> { +pub(crate) fn prepare_zcode_managed_removal(paths: &[PathBuf]) -> Result { if paths.is_empty() { return Err("ZCode 当前平台配置路径不可用".to_string()); } let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); let mut changed_paths = Vec::new(); for path in paths { - let updated = update_agent_json_file(path, "ZCode 配置", |root| { + let updated = prepare_agent_json_removal(path, "ZCode 配置", |root| { let mut changed = false; let remove_model = if root .get("model") @@ -1207,18 +1256,15 @@ pub(crate) fn remove_zcode_managed_configuration(paths: &[PathBuf]) -> Result Result, String> { - remove_managed_toml_client_configuration( +pub(crate) fn prepare_kimi_code_managed_removal(paths: &[PathBuf]) -> Result { + prepare_managed_toml_client_removal( paths, "Kimi Code", Some("providers"), @@ -1228,11 +1274,8 @@ pub(crate) fn remove_kimi_code_managed_configuration( ) } -#[cfg(test)] -pub(crate) fn remove_grok_build_managed_configuration( - paths: &[PathBuf], -) -> Result, String> { - remove_managed_toml_client_configuration( +pub(crate) fn prepare_grok_build_managed_removal(paths: &[PathBuf]) -> Result { + prepare_managed_toml_client_removal( paths, "Grok Build", None, @@ -1242,15 +1285,14 @@ pub(crate) fn remove_grok_build_managed_configuration( ) } -#[cfg(test)] -pub(crate) fn remove_managed_toml_client_configuration( +pub(crate) fn prepare_managed_toml_client_removal( paths: &[PathBuf], label: &str, provider_section: Option<&str>, model_entry_section: &str, default_section: Option<&str>, default_key: &str, -) -> Result, String> { +) -> Result { use toml_edit::{Document, Item}; let Some(path) = paths.first() else { @@ -1267,14 +1309,15 @@ pub(crate) fn remove_managed_toml_client_configuration( let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); let mut changed = false; - let default_table = if let Some(section) = default_section { - document - .as_table_mut() - .get_mut(section) - .and_then(Item::as_table_mut) - } else { - Some(document.as_table_mut()) - }; + let default_table: Option<&mut dyn toml_edit::TableLike> = + if let Some(section) = default_section { + document + .as_table_mut() + .get_mut(section) + .and_then(Item::as_table_like_mut) + } else { + Some(document.as_table_mut()) + }; if let Some(table) = default_table { if table .get(default_key) @@ -1290,7 +1333,7 @@ pub(crate) fn remove_managed_toml_client_configuration( if let Some(providers) = document .as_table_mut() .get_mut(section) - .and_then(Item::as_table_mut) + .and_then(Item::as_table_like_mut) { changed |= providers.remove(MANAGED_AGENT_PROVIDER_ID).is_some(); } @@ -1298,7 +1341,7 @@ pub(crate) fn remove_managed_toml_client_configuration( if let Some(models) = document .as_table_mut() .get_mut(model_entry_section) - .and_then(Item::as_table_mut) + .and_then(Item::as_table_like_mut) { let managed = models .iter() @@ -1314,8 +1357,8 @@ pub(crate) fn remove_managed_toml_client_configuration( let empty = document .as_table() .get(section) - .and_then(Item::as_table) - .is_some_and(toml_edit::Table::is_empty); + .and_then(Item::as_table_like) + .is_some_and(|table| table.is_empty()); if empty { document.as_table_mut().remove(section); } @@ -1324,8 +1367,8 @@ pub(crate) fn remove_managed_toml_client_configuration( let empty = document .as_table() .get(section) - .and_then(Item::as_table) - .is_some_and(toml_edit::Table::is_empty); + .and_then(Item::as_table_like) + .is_some_and(|table| table.is_empty()); if empty { document.as_table_mut().remove(section); } @@ -1336,30 +1379,22 @@ pub(crate) fn remove_managed_toml_client_configuration( let rendered = document.to_string(); toml::from_str::(&rendered) .map_err(|error| format!("验证恢复后的 {label} 配置失败: {error}"))?; - if rendered.trim().is_empty() { - fs::remove_file(path).map_err(|error| { - format!( - "删除空的 {label} 配置失败 {}: {error}", - path_to_string(path) - ) - })?; - } else { - write_bytes_directly(path, rendered.as_bytes())?; - } - Ok(vec![path_to_string(path)]) + Ok(vec![( + path.clone(), + (!rendered.trim().is_empty()).then(|| rendered.into_bytes()), + )]) } -#[cfg(test)] -pub(crate) fn update_agent_json5_file( +pub(crate) fn prepare_agent_json5_removal( path: &Path, label: &str, update: F, -) -> Result +) -> Result, String> where F: FnOnce(&mut serde_json::Map) -> bool, { if !path.is_file() { - return Ok(false); + return Ok(None); } let content = fs::read_to_string(path) .map_err(|error| format!("读取 {label} 失败 {}: {error}", path_to_string(path)))?; @@ -1370,33 +1405,31 @@ where .cloned() .ok_or_else(|| format!("{label} 根节点必须是对象"))?; if !update(&mut root) { - return Ok(false); + return Ok(None); } - if root.is_empty() { - fs::remove_file(path) - .map_err(|error| format!("删除空的 {label} 失败 {}: {error}", path_to_string(path)))?; + let bytes = if root.is_empty() { + None } else { let rendered = render_agent_json(root, label)?; let comments = extract_json5_comments(&content); - let rendered = if comments.is_empty() { - rendered - } else { - format!("{}\n{rendered}", comments.join("\n")) - }; - write_bytes_directly(path, rendered.as_bytes())?; - } - Ok(true) + Some( + if comments.is_empty() { + rendered + } else { + format!("{}\n{rendered}", comments.join("\n")) + } + .into_bytes(), + ) + }; + Ok(Some((path.to_path_buf(), bytes))) } -#[cfg(test)] -pub(crate) fn remove_openclaw_managed_configuration( - paths: &[PathBuf], -) -> Result, String> { +pub(crate) fn prepare_openclaw_managed_removal(paths: &[PathBuf]) -> Result { let Some(path) = paths.first() else { return Err("OpenClaw 当前平台配置路径不可用".to_string()); }; let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); - let updated = update_agent_json5_file(path, "OpenClaw 配置", |root| { + let updated = prepare_agent_json5_removal(path, "OpenClaw 配置", |root| { let mut changed = false; if let Some(models) = root .get_mut("models") @@ -1457,13 +1490,10 @@ pub(crate) fn remove_openclaw_managed_configuration( } changed })?; - Ok(updated.then(|| path_to_string(path)).into_iter().collect()) + Ok(updated.into_iter().collect()) } -#[cfg(test)] -pub(crate) fn remove_hermes_managed_configuration( - paths: &[PathBuf], -) -> Result, String> { +pub(crate) fn prepare_hermes_managed_removal(paths: &[PathBuf]) -> Result { let Some(path) = paths.first() else { return Err("Hermes 当前平台配置路径不可用".to_string()); }; @@ -1519,37 +1549,35 @@ pub(crate) fn remove_hermes_managed_configuration( if !changed { return Ok(Vec::new()); } - if root.is_empty() { - fs::remove_file(path).map_err(|error| { - format!("删除空的 Hermes 配置失败 {}: {error}", path_to_string(path)) - })?; + let bytes = if root.is_empty() { + None } else { - let rendered = render_updated_core_yaml(&mut document, updated)?; - write_bytes_directly(path, rendered.as_bytes())?; - } - Ok(vec![path_to_string(path)]) + Some(render_updated_core_yaml(&mut document, updated)?.into_bytes()) + }; + Ok(vec![(path.clone(), bytes)]) } -#[cfg(test)] -pub(crate) fn remove_agent_managed_configuration( +pub(crate) fn prepare_agent_managed_removal( client: AgentClient, paths: &[PathBuf], -) -> Result, String> { + port: u16, +) -> Result { match client { - AgentClient::ClaudeCode => remove_claude_code_managed_configuration(paths), - AgentClient::ClaudeDesktop => remove_claude_desktop_managed_configuration(paths), - AgentClient::Codex => remove_codex_managed_configuration(paths), - AgentClient::OpenCode => remove_opencode_managed_configuration(paths), - AgentClient::OpenClaw => remove_openclaw_managed_configuration(paths), - AgentClient::Hermes => remove_hermes_managed_configuration(paths), - AgentClient::DeepSeekHarness => remove_deepseek_harness_managed_configuration(paths), - AgentClient::ZCode => remove_zcode_managed_configuration(paths), - AgentClient::KimiCode => remove_kimi_code_managed_configuration(paths), - AgentClient::GrokBuild => remove_grok_build_managed_configuration(paths), + AgentClient::ClaudeCode => { + prepare_claude_code_managed_removal(paths, &managed_core_loopback_origin(port)) + } + AgentClient::ClaudeDesktop => prepare_claude_desktop_managed_removal(paths), + AgentClient::Codex => prepare_codex_managed_removal(paths), + AgentClient::OpenCode => prepare_opencode_managed_removal(paths), + AgentClient::OpenClaw => prepare_openclaw_managed_removal(paths), + AgentClient::Hermes => prepare_hermes_managed_removal(paths), + AgentClient::DeepSeekHarness => prepare_deepseek_harness_managed_removal(paths), + AgentClient::ZCode => prepare_zcode_managed_removal(paths), + AgentClient::KimiCode => prepare_kimi_code_managed_removal(paths), + AgentClient::GrokBuild => prepare_grok_build_managed_removal(paths), } } -#[cfg(test)] pub(crate) fn remove_deepseek_harness_settings_fields(root: &mut serde_norway::Mapping) -> bool { let mut changed = false; let mut remove_llm = false; @@ -1588,10 +1616,9 @@ pub(crate) fn remove_deepseek_harness_settings_fields(root: &mut serde_norway::M changed } -#[cfg(test)] -pub(crate) fn remove_deepseek_harness_managed_configuration( +pub(crate) fn prepare_deepseek_harness_managed_removal( paths: &[PathBuf], -) -> Result, String> { +) -> Result { if paths.len() != 2 { return Err("DeepSeek Harness 配置路径数量无效".to_string()); } @@ -1610,13 +1637,10 @@ pub(crate) fn remove_deepseek_harness_managed_configuration( )?; if changed { let root = parse_agent_yaml_mapping(Some(&rendered), "DeepSeek Harness settings")?; - if root.is_empty() { - fs::remove_file(&paths[0]) - .map_err(|error| format!("删除空的 DeepSeek Harness settings 失败: {error}"))?; - } else { - write_bytes_directly(&paths[0], rendered.as_bytes())?; - } - changed_paths.push(path_to_string(&paths[0])); + changed_paths.push(( + paths[0].clone(), + (!root.is_empty()).then(|| rendered.into_bytes()), + )); } } if paths[1].is_file() { @@ -1636,14 +1660,11 @@ pub(crate) fn remove_deepseek_harness_managed_configuration( )?; if changed { let root = parse_agent_yaml_mapping(Some(&rendered), "DeepSeek Harness credentials")?; - if deepseek_harness_credentials_document_is_empty(&root) { - fs::remove_file(&paths[1]).map_err(|error| { - format!("删除空的 DeepSeek Harness credentials 失败: {error}") - })?; - } else { - write_deepseek_harness_file(&paths[1], rendered.as_bytes(), true)?; - } - changed_paths.push(path_to_string(&paths[1])); + changed_paths.push(( + paths[1].clone(), + (!deepseek_harness_credentials_document_is_empty(&root)) + .then(|| rendered.into_bytes()), + )); } } Ok(changed_paths) diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index cd6e2b8..d2e715d 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -179,7 +179,12 @@ fn commit_config_transaction( } let mut previous = before.clone(); let mut target = after.clone(); - if client == "deepseek-harness" && matches!(source, "template" | "restore") { + if source == "clear-integration" { + let state_path = agent_state_path(paths)?; + previous.push((state_path.clone(), read_agent_bytes(&state_path)?)); + target.push((state_path, None)); + } + if client == "deepseek-harness" && matches!(source, "template" | "restore" | "clear-integration") { let state_path = deepseek_harness_catalog_state_path(paths)?; let state_before = read_agent_bytes(&state_path)?; let state_after = if source == "template" { @@ -193,7 +198,9 @@ fn commit_config_transaction( if client == "claude-desktop" { let state_path = desktop_mapping_path(paths)?; let state_before = read_agent_bytes(&state_path)?; - let next = if source == "restore" { + let next = if source == "clear-integration" { + None + } else if source == "restore" { mappings } else { mappings.or_else(|| matching_desktop_mappings(paths, after)) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c30dace..da6f56e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1401,7 +1401,6 @@ struct PreparedAgentModels { codex_catalog: Option, } -#[cfg(test)] type FileSnapshot = (PathBuf, Option>); #[cfg(test)] type AgentRecordExtension = (AgentModificationRecord, Vec); diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 91a935a..3ae65dd 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -1648,7 +1648,7 @@ fn deepseek_harness_removal_preserves_other_versioned_credentials() { ) .unwrap(); - let changed = remove_deepseek_harness_managed_configuration(&paths).unwrap(); + let changed = remove_agent_managed_configuration(AgentClient::DeepSeekHarness, &paths).unwrap(); let value: serde_norway::Value = serde_norway::from_str(&fs::read_to_string(&paths[1]).unwrap()).unwrap(); @@ -1677,7 +1677,7 @@ fn deepseek_harness_removal_deletes_semantically_empty_credentials_file() { ) .unwrap(); - let changed = remove_deepseek_harness_managed_configuration(&paths).unwrap(); + let changed = remove_agent_managed_configuration(AgentClient::DeepSeekHarness, &paths).unwrap(); assert_eq!(changed, vec![path_to_string(&paths[1])]); assert!(!paths[1].exists()); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 68473c4..3e17b90 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1139,6 +1139,10 @@ export const jaOverrides = { "agents.clear.description": "Codex の auth.json と config.toml を削除します。自動バックアップは作成しません。既存の手動バックアップから全ファイルと独自設定を復元できます。", 'agents.clear.confirm': '設定を消去', 'agents.clear.success': 'Codex 設定を消去しました。', + 'agents.clearIntegration.title': '{name} の接続設定を解除', + 'agents.clearIntegration.description': 'CPA 接続設定を削除し、その他の設定は保持します。解除後はエージェントを再起動してください。', + 'agents.clearIntegration.confirm': '接続設定の解除を確認', + 'agents.clearIntegration.success': 'CPA 接続設定を解除しました。{name} を再起動してください。', 'apiAccess.compatibleName': 'OpenAI 互換 {number}', 'apiAccess.error.headerMissingColon': 'カスタムヘッダーの {number} 行目にコロンがありません', 'apiAccess.error.headerInvalid': 'カスタムヘッダーの {number} 行目の形式が無効です', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3a9126c..2d2659c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1139,6 +1139,10 @@ export const en: Record = { "agents.clear.description": "This deletes auth.json and config.toml from the Codex configuration directory. No automatic backup is created. An existing manual backup can restore the entire file set and custom contents.", 'agents.clear.confirm': 'Clear Configuration', 'agents.clear.success': 'The Codex configuration has been cleared.', + 'agents.clearIntegration.title': 'Clear {name} integration', + 'agents.clearIntegration.description': 'Remove CPA integration settings and keep other settings. Restart the agent afterward.', + 'agents.clearIntegration.confirm': 'Confirm clear integration', + 'agents.clearIntegration.success': 'CPA integration cleared. Restart {name}.', 'apiAccess.compatibleName': 'OpenAI Compatible {number}', 'apiAccess.error.headerMissingColon': 'Custom header line {number} is missing a colon', 'apiAccess.error.headerInvalid': 'Custom header line {number} is invalid', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index dc33f0a..50f3d45 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1138,6 +1138,10 @@ export const zhCN = { "agents.clear.description": "这会删除 Codex 配置目录中的 auth.json 和 config.toml,不会自动备份。已有手动备份可恢复整组原始文件及自定义内容。", 'agents.clear.confirm': '确认清空配置', 'agents.clear.success': 'Codex 配置已清空。', + 'agents.clearIntegration.title': '清除 {name} 接入', + 'agents.clearIntegration.description': '移除 CPA 接入配置并保留其他设置。清除后请重启智能体。', + 'agents.clearIntegration.confirm': '确认清除接入', + 'agents.clearIntegration.success': '已清除 CPA 接入,请重启 {name}。', 'apiAccess.compatibleName': 'OpenAI 兼容 {number}', 'apiAccess.error.headerMissingColon': '自定义请求头第 {number} 行缺少冒号', 'apiAccess.error.headerInvalid': '自定义请求头第 {number} 行格式无效', diff --git a/src/pages/AgentControls.tsx b/src/pages/AgentControls.tsx index 4a8b778..79a9840 100644 --- a/src/pages/AgentControls.tsx +++ b/src/pages/AgentControls.tsx @@ -112,10 +112,10 @@ export function AgentConfigManagementPanel({ : null} - {codex ?
-

{t('agents.modify.clear')}

{t('agents.management.clearDescription')}

+ {!pi ?
+

{t(codex ? 'agents.modify.clear' : 'agents.nativeOAuth.restore')}

{t(codex ? 'agents.management.clearDescription' : 'agents.clearIntegration.description')}

- +
: null} {pi ?
diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 4d672cb..83f60af 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -1032,8 +1032,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const connectionState = activeStatus?.connectionState ?? 'invalid'; const appliedModel = activeStatus?.currentModel ?? activeStatus?.appliedModel ?? ''; - const canCloseCodexConfiguration = selected === 'codex' && !nativeOauth - && connectionState === 'configured'; + const canCloseConfiguration = !isPiClient && !nativeOauth + && (connectionState === 'configured' || (selected !== 'codex' && connectionState === 'needs-update')); const configurationActionLabel = t(nativeOauth ? 'agents.modify.update' : connectionState === 'not-configured' ? selected === 'codex' ? 'agents.modify.apply' : 'agents.modify.connect' : connectionState === 'needs-update' ? 'agents.modify.repair' : 'agents.modify.update'); @@ -1307,10 +1307,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setConfigurationNotice(''); setClearNotice(''); try { - await invoke('close_codex_config_modification'); + await removeSelectedConfiguration(); clearPendingChanges(); + if (isClaudeModelMappingClient) claudeModelMappingsDirtyRef.current[selected] = false; await reloadStatusesAfterAction(); - setConfigurationNotice(t('agents.modify.closed')); + setModelSelectionError(''); + setModelError(''); + setConfigurationNotice(selected === 'codex' ? t('agents.modify.closed') + : t('agents.clearIntegration.success', { name: activeDefinition.name })); } catch (cause) { setConfigurationError(String(cause)); } finally { @@ -1495,15 +1499,29 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP } }; - const clearCodexConfiguration = async () => { + const removeSelectedConfiguration = () => selected === 'codex' + ? invoke('close_codex_config_modification') + : invoke('set_agent_config_enabled', { + client: selected, model: '', enabled: false, forceRestore: false, + claudeCodeModelMappings: null, claudeDesktopModelMappings: null, + }); + + const clearConfiguration = async () => { setBusyAction('clear'); + setConfigurationError(''); + setConfigurationNotice(''); setClearError(''); setClearNotice(''); try { - await invoke('clear_codex_config'); + if (selected === 'codex') await invoke('clear_codex_config'); + else await removeSelectedConfiguration(); clearPendingChanges(); + if (isClaudeModelMappingClient) claudeModelMappingsDirtyRef.current[selected] = false; setClearConfirmOpen(false); - setClearNotice(t('agents.clear.success')); + setModelSelectionError(''); + setModelError(''); + setClearNotice(selected === 'codex' ? t('agents.clear.success') + : t('agents.clearIntegration.success', { name: activeDefinition.name })); await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); } catch (requestError) { @@ -1738,9 +1756,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ) : null; - const closeConfigurationButton = canCloseCodexConfiguration ? ( + const closeConfigurationButton = canCloseConfiguration ? ( -
diff --git a/tests/agent-management-ui.cjs b/tests/agent-management-ui.cjs index 797ba97..ad4ad24 100644 --- a/tests/agent-management-ui.cjs +++ b/tests/agent-management-ui.cjs @@ -80,6 +80,45 @@ 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']) { + await open(mode + 'client=' + client); + assert.ok(await button('关闭配置修改').isEnabled()); + await tab('配置管理').click(); + assert.ok(await button('清除接入').isEnabled()); + await button('清除接入').click(); + const dialog = page.getByRole('alertdialog'); + assert.match(await dialog.innerText(), /保留其他设置/); + await button('取消').click(); + assert.equal((await calls('set_agent_config_enabled')).length, 0); + await button('清除接入').click(); + await button('确认清除接入').click(); + await dialog.waitFor({ state: 'detached' }); + assert.deepEqual((await calls('set_agent_config_enabled')).map(call => call.args), [{ + client, model: '', enabled: false, forceRestore: false, + claudeCodeModelMappings: null, claudeDesktopModelMappings: null, + }]); + assert.equal((await calls('clear_codex_config')).length, 0); + await tab('基础配置').click(); + assert.equal(await button('关闭配置修改').count(), 0); + assert.ok(await button('一键接入').isVisible()); + } + await open(mode + 'client=claude-code&state=needs-update'); + assert.ok(await button('关闭配置修改').isEnabled()); + await open(mode + 'client=claude-code&no-core&defer-clear'); + await button('关闭配置修改').click(); + await page.waitForFunction(() => !!window.fixtureFinishClear); + assert.ok(await tab('配置管理').isDisabled()); + await page.evaluate(() => window.fixtureFinishClear()); + await button('关闭配置修改').waitFor({ state: 'detached' }); + await page.getByText('已清除 CPA 接入,请重启 Claude Code。', { exact: true }).waitFor(); + await open(mode + 'client=claude-code&fail-clear'); + await tab('配置管理').click(); + await button('清除接入').click(); + await button('确认清除接入').click(); + await page.getByText(/模拟清除失败/).waitFor(); + assert.ok(await page.getByRole('alertdialog').isVisible()); + await button('取消').click(); + await open(mode + 'client=pi&update'); assert.equal(await button('卸载 Pi 插件').count(), 0); await tab('配置管理').click(); diff --git a/tests/agent-native-oauth-ui.cjs b/tests/agent-native-oauth-ui.cjs index 2dacdc8..7426c17 100644 --- a/tests/agent-native-oauth-ui.cjs +++ b/tests/agent-native-oauth-ui.cjs @@ -215,10 +215,11 @@ const base = 'http://127.0.0.1:1421'; for (const client of ['opencode', 'zcode']) { await page.goto(`${base}/tests/fixtures/agent-backups.html?reset-selections&client=${client}`, { waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: '更新配置', exact: true }).waitFor(); - assert.equal(await close().count(), 0, 'Other clients keep their existing controls'); + assert.equal(await close().count(), 1, 'Other managed clients can also close configuration'); assert.equal(await restore().count(), 0); await manage(); - assert.equal(await restore().count(), 0, 'Other clients have no official Codex login action in management'); + assert.equal(await restore().count(), 1, 'Other clients can clear CPA integration in management'); + assert.equal(await page.locator('#agent-clear-integration').count(), 0, 'The official Codex login action remains Codex-only'); } assert.deepEqual(errors, []); diff --git a/tests/fixtures/agent-backups.tsx b/tests/fixtures/agent-backups.tsx index 754900b..2910b63 100644 --- a/tests/fixtures/agent-backups.tsx +++ b/tests/fixtures/agent-backups.tsx @@ -70,6 +70,12 @@ mockIPC(async (cmd,args:any) => { if(cmd==='stop_deepseek_harness_process')return harnessStatus={running:false,pid:null,mode:null}; if(cmd==='launch_agent')return null; if(cmd==='clear_codex_config') { currentModel=null; return []; } + if(cmd==='set_agent_config_enabled' && !args.enabled) { + if(params.has('fail-clear'))throw new Error('模拟清除失败,已回滚'); + if(params.has('defer-clear'))await new Promise(resolve=>{(window as any).fixtureFinishClear=resolve;}); + currentModel=null;delete currentMappings[args.client]; + return {outcome:'updated',enabled:false,model:null,changedFiles:[],conflictFiles:[]}; + } if(cmd==='uninstall_pi_provider')return null; if(['update_agent_config','repair_pi_provider','install_pi_provider','update_pi_provider'].includes(cmd)) { count++;if(params.has('fail-apply')&&count===1)throw new Error('模拟配置写入失败');