From 3553d05bf0512afefeb9d88ebb226c2dac6944d3 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Wed, 1 Apr 2026 21:16:41 +0800 Subject: [PATCH] feat(provider): additive provider key lifecycle & fix openclaw serializer panic (#1724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(provider): support additive provider key lifecycle management Add `addToLive` parameter to add_provider so callers can opt out of writing to the live config (e.g. when duplicating an inactive provider). Add `originalId` parameter to update_provider to support provider key renames — the old key is removed from live config before the new one is written. Frontend: ProviderForm now exposes provider-key input for openclaw app type, and EditProviderDialog forwards originalId on save. Deep-link import passes addToLive=true to preserve existing behavior. * test(provider): add integration tests for additive provider key flows Cover openclaw provider duplication scenario to verify that a generated provider key is assigned automatically. Add MSW handlers for get_openclaw_live_provider_ids, get_openclaw_default_model, scan_openclaw_config_health, and check_env_conflicts endpoints. Update EditProviderDialog mock to pass originalId alongside provider. * fix(openclaw): replace json-five serializer to prevent panic on empty collections json-five 0.3.1 panics when pretty-printing nested empty maps/arrays. Switch value_to_rt_value() to serde_json::to_string_pretty() which produces valid JSON5 output without the panic. Add regression test for removing the last provider (empty providers map). * style: apply rustfmt formatting to proxy and provider modules Reformat chained .header() calls in ClaudeAdapter and StreamCheckService for consistent alignment. Reorder imports alphabetically in stream_check. Fix trailing whitespace in transform.rs and merge import lines in provider/mod.rs. * style: fix clippy warnings in live.rs and tray.rs * refactor(provider): simplify live_config_managed and deduplicate tolerant live config checks - Change live_config_managed from Option to bool with #[serde(default)] - Extract repeated tolerant live config query into check_live_config_exists helper - Fix duplicate key generation to also check live-only provider IDs - Fix updateProvider test to match new { provider, originalId } call signature - Add streaming_responses test type annotation for compiler inference * fix(provider): distinguish legacy providers from db-only when tolerating live config errors Change `ProviderMeta.live_config_managed` from `bool` to `Option` to introduce a three-state semantic: - `Some(true)`: provider has been written to live config - `Some(false)`: explicitly db-only, never written to live config - `None`: legacy data or unknown state (pre-existing providers) Previously, legacy providers defaulted to `live_config_managed = false` via `#[serde(default)]`, which silently swallowed live config parse errors. This could mask genuine configuration issues for providers that had actually been synced to live config before the field was introduced. Now, only providers with an explicit `Some(false)` marker tolerate parse errors; legacy `None` providers surface errors as before, preserving safety for already-managed configurations. Also wrap the `ensureQueryData` call for live provider IDs during duplication in a try/catch so that a malformed config file shows a user-facing toast instead of silently failing. Add tests for both the legacy error propagation path and the frontend duplication failure scenario. * refactor(provider): unify OMO variant updates with atomic file-then-db writes and rollback Consolidate the duplicated omo/omo-slim update branches into a single match on the variant. Write the OMO config file from the in-memory provider state *before* persisting to the database, so a file-write or plugin-sync failure leaves the database unchanged. If `add_plugin` fails after the config file is already written, roll back to the previous on-disk contents via snapshot/restore. Also: - `sync_all_providers_to_live` now skips db-only providers (`live_config_managed == Some(false)`) instead of attempting to write them to live config. - `import_{opencode,openclaw}_providers_from_live` mark imported providers as `live_config_managed: Some(true)` so they are correctly recognized during subsequent syncs. - Extract OmoService helpers: `profile_data_from_provider`, `snapshot_config_file`, `restore_config_file`, `write_profile_config`, and the new public `write_provider_config_to_file`. - Add 9 new tests covering sync skip, legacy restore, import marking, OMO persistence, file-write failure, and plugin-sync rollback. * fix(provider): fix additive provider delete/switch regressions and redundancy - fix(delete): replace stale live_config_managed flag check with check_live_config_exists so providers written to live before the flag-flip logic was introduced are still cleaned up on delete - fix(switch): make write_live_with_common_config return Err instead of silently returning Ok when config structure is invalid, preventing live_config_managed from being incorrectly flipped to true - fix(update): block provider key rename for OMO/OMO Slim categories to prevent orphaned current-state markers breaking OMO file syncs - fix(switch): flip live_config_managed to true after successful live write for DB-only additive providers so sync_all_providers_to_live includes them on future syncs; roll back live write if DB update fails - refactor(delete): merge symmetric OMO/OMO-Slim blocks into single match-on-variant path; hoist DB read to top of additive branch - refactor(remove_from_live_config): merge OMO/OMO-Slim if/else-if into single match-on-variant path - refactor(switch_normal): merge two OMO/OMO-Slim if blocks into one OpenCode guard with (enable, disable) variant pair - fix(update): remove redundant duplicate return Ok(true) after OMO current-state write * fix(test): use preferred_filename after OMO field rename The merge from main brought in #1746 which renamed OmoVariant.filename → preferred_filename, but the test helper omo_config_path() was not updated, breaking compilation of all new provider tests. --------- Co-authored-by: Jason --- src-tauri/src/commands/provider.rs | 8 +- src-tauri/src/deeplink/provider.rs | 2 +- src-tauri/src/openclaw_config.rs | 44 +- src-tauri/src/provider.rs | 4 + src-tauri/src/services/omo.rs | 97 +- src-tauri/src/services/provider/live.rs | 56 +- src-tauri/src/services/provider/mod.rs | 991 ++++++++++++++++-- src/App.tsx | 47 +- .../providers/EditProviderDialog.tsx | 18 +- .../providers/forms/ProviderForm.tsx | 157 ++- src/hooks/useProviderActions.ts | 5 +- src/i18n/locales/en.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/zh.json | 6 +- src/lib/api/providers.ts | 20 +- src/lib/query/mutations.ts | 19 +- tests/hooks/useProviderActions.test.tsx | 5 +- tests/integration/App.test.tsx | 107 +- tests/msw/handlers.ts | 17 + tests/msw/state.ts | 20 + 20 files changed, 1422 insertions(+), 213 deletions(-) diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index f186d150d..8f4f07790 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -36,9 +36,11 @@ pub fn add_provider( state: State<'_, AppState>, app: String, provider: Provider, + #[allow(non_snake_case)] addToLive: Option, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string()) + ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true)) + .map_err(|e| e.to_string()) } #[tauri::command] @@ -46,9 +48,11 @@ pub fn update_provider( state: State<'_, AppState>, app: String, provider: Provider, + #[allow(non_snake_case)] originalId: Option, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string()) + ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider) + .map_err(|e| e.to_string()) } #[tauri::command] diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index cdb0f3e39..d94189481 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -110,7 +110,7 @@ pub fn import_provider_from_deeplink( let provider_id = provider.id.clone(); // Use ProviderService to add the provider - ProviderService::add(state, app_type.clone(), provider)?; + ProviderService::add(state, app_type.clone(), provider, true)?; // Add extra endpoints as custom endpoints (skip first one as it's the primary) for ep in all_endpoints.iter().skip(1) { diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 24ef7999c..e8a801325 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -8,7 +8,6 @@ use crate::error::AppError; use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir}; use chrono::Local; use indexmap::IndexMap; -use json_five::parser::{FormatConfiguration, TrailingComma}; use json_five::rt::parser::{ from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair, JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue, @@ -490,11 +489,11 @@ fn derive_entry_separator(leading_ws: &str) -> String { } fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result { - let source = json_five::to_string_formatted( - value, - FormatConfiguration::with_indent(2, TrailingComma::NONE), - ) - .map_err(|e| AppError::Config(format!("Failed to serialize JSON5 section: {e}")))?; + // `json-five` 0.3.1 can panic when pretty-printing nested empty maps/arrays. + // Serialize with `serde_json` instead; the resulting JSON is valid JSON5 and + // can still be parsed back into the round-trip AST we use for insertion. + let source = serde_json::to_string_pretty(value) + .map_err(|e| AppError::Config(format!("Failed to serialize JSON section: {e}")))?; let adjusted = reindent_json5_block(&source, parent_indent); let text = rt_from_str(&adjusted).map_err(|e| { @@ -1051,4 +1050,37 @@ mod tests { assert!(err.to_string().contains("OpenClaw config changed on disk")); }); } + + #[test] + fn remove_last_provider_writes_empty_providers_without_panic() { + let source = r#"{ + models: { + mode: 'merge', + providers: { + '1-copy': { + api: 'anthropic-messages', + }, + }, + }, +} +"#; + + with_test_paths(source, |_| { + let outcome = remove_provider("1-copy").unwrap(); + assert!(outcome.backup_path.is_some()); + + let config = read_openclaw_config().unwrap(); + let providers = config + .get("models") + .and_then(|models| models.get("providers")) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + assert!(providers.is_empty()); + + let written = fs::read_to_string(get_openclaw_config_path()).unwrap(); + assert!(written.contains("\"providers\": {}")); + }); + } } diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 39ece0ed8..9b624a214 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -283,6 +283,10 @@ pub struct ProviderMeta { /// If not set, provider ID is used automatically during format conversion. #[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, + /// 累加模式应用中,该 provider 是否已写入 live config。 + /// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。 + #[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")] + pub live_config_managed: Option, /// 供应商类型标识(用于特殊供应商检测) /// - "github_copilot": GitHub Copilot 供应商 #[serde(rename = "providerType", skip_serializing_if = "Option::is_none")] diff --git a/src-tauri/src/services/omo.rs b/src-tauri/src/services/omo.rs index 9e534cee4..a91a35a5a 100644 --- a/src-tauri/src/services/omo.rs +++ b/src-tauri/src/services/omo.rs @@ -1,6 +1,7 @@ -use crate::config::write_json_file; +use crate::config::{atomic_write, write_json_file}; use crate::error::AppError; use crate::opencode_config::get_opencode_dir; +use crate::provider::Provider; use crate::store::AppState; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -133,6 +134,68 @@ impl OmoService { } } + fn profile_data_from_provider(provider: &Provider, v: &OmoVariant) -> OmoProfileData { + let agents = provider.settings_config.get("agents").cloned(); + let categories = if v.has_categories { + provider.settings_config.get("categories").cloned() + } else { + None + }; + let other_fields = provider.settings_config.get("otherFields").cloned(); + (agents, categories, other_fields) + } + + fn snapshot_config_file(path: &Path) -> Result>, AppError> { + if !path.exists() { + return Ok(None); + } + + std::fs::read(path) + .map(Some) + .map_err(|e| AppError::io(path, e)) + } + + fn restore_config_file(path: &Path, snapshot: Option<&[u8]>) -> Result<(), AppError> { + match snapshot { + Some(bytes) => atomic_write(path, bytes), + None => { + if path.exists() { + std::fs::remove_file(path).map_err(|e| AppError::io(path, e))?; + } + Ok(()) + } + } + } + + fn write_profile_config( + v: &OmoVariant, + profile_data: Option<&OmoProfileData>, + ) -> Result<(), AppError> { + let merged = Self::build_config(v, profile_data); + let config_path = Self::config_path(v); + + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + let previous_contents = Self::snapshot_config_file(&config_path)?; + write_json_file(&config_path, &merged)?; + if let Err(err) = crate::opencode_config::add_plugin(v.plugin_name) { + if let Err(rollback_err) = + Self::restore_config_file(&config_path, previous_contents.as_deref()) + { + log::warn!( + "Failed to roll back {} config after plugin sync error: {}", + v.label, + rollback_err + ); + } + return Err(err); + } + log::info!("{} config written to {config_path:?}", v.label); + Ok(()) + } + // ── Public API (variant-parameterized) ───────────────── pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> { @@ -153,28 +216,18 @@ impl OmoService { pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> { let current_omo = state.db.get_current_omo_provider("opencode", v.category)?; - let profile_data = current_omo.as_ref().map(|p| { - let agents = p.settings_config.get("agents").cloned(); - let categories = if v.has_categories { - p.settings_config.get("categories").cloned() - } else { - None - }; - let other_fields = p.settings_config.get("otherFields").cloned(); - (agents, categories, other_fields) - }); + let profile_data = current_omo + .as_ref() + .map(|provider| Self::profile_data_from_provider(provider, v)); + Self::write_profile_config(v, profile_data.as_ref()) + } - let merged = Self::build_config(v, profile_data.as_ref()); - let config_path = Self::config_path(v); - - if let Some(parent) = config_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } - - write_json_file(&config_path, &merged)?; - crate::opencode_config::add_plugin(v.plugin_name)?; - log::info!("{} config written to {config_path:?}", v.label); - Ok(()) + pub fn write_provider_config_to_file( + provider: &Provider, + v: &OmoVariant, + ) -> Result<(), AppError> { + let profile_data = Self::profile_data_from_provider(provider, v); + Self::write_profile_config(v, Some(&profile_data)) } fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value { diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index b4b3aa0fd..d040f4ea6 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -33,6 +33,19 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value { v } +pub(crate) fn provider_exists_in_live_config( + app_type: &AppType, + provider_id: &str, +) -> Result { + match app_type { + AppType::OpenCode => crate::opencode_config::get_providers() + .map(|providers| providers.contains_key(provider_id)), + AppType::OpenClaw => crate::openclaw_config::get_providers() + .map(|providers| providers.contains_key(provider_id)), + _ => Ok(false), + } +} + fn json_is_subset(target: &Value, source: &Value) -> bool { match source { Value::Object(source_map) => { @@ -727,10 +740,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re provider.id ); } else { - log::error!( - "OpenCode provider '{}' has invalid config structure, skipping write", + return Err(AppError::Message(format!( + "OpenCode provider '{}' has invalid config structure for live config (must contain 'npm' or 'options')", provider.id - ); + ))); } } } @@ -769,10 +782,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re provider.id ); } else { - log::error!( - "OpenClaw provider '{}' has invalid config structure, skipping write", + return Err(AppError::Message(format!( + "OpenClaw provider '{}' has invalid config structure for live config (must contain 'baseUrl', 'api', or 'models')", provider.id - ); + ))); } } } @@ -787,23 +800,30 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re /// Used for OpenCode and other additive mode applications. fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> { let providers = state.db.get_all_providers(app_type.as_str())?; + let mut synced_count = 0usize; for provider in providers.values() { + if provider + .meta + .as_ref() + .and_then(|meta| meta.live_config_managed) + == Some(false) + { + continue; + } + if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) { log::warn!( "Failed to sync {:?} provider '{}' to live: {e}", app_type, provider.id ); - // Continue syncing other providers, don't abort + continue; } + synced_count += 1; } - log::info!( - "Synced {} {:?} providers to live config", - providers.len(), - app_type - ); + log::info!("Synced {synced_count} {app_type:?} providers to live config"); Ok(()) } @@ -1207,12 +1227,16 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result Result std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|err| err.into_inner()) + } + + fn with_test_home(test: impl FnOnce(&AppState, &Path) -> T) -> T { + let _guard = test_guard(); + let temp = tempfile::tempdir().expect("tempdir"); + let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME"); + let old_home = std::env::var_os("HOME"); + std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); + std::env::set_var("HOME", temp.path()); + + let db = Arc::new(Database::memory().expect("in-memory database")); + let state = AppState::new(db); + let result = test(&state, temp.path()); + + match old_test_home { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + match old_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + + result + } + + fn openclaw_provider(id: &str) -> Provider { + Provider { + id: id.to_string(), + name: format!("Provider {id}"), + settings_config: json!({ + "baseUrl": "https://api.deepseek.com", + "apiKey": "test-key", + "api": "openai-completions", + "models": [], + }), + website_url: None, + category: Some("custom".to_string()), + created_at: Some(1), + sort_index: Some(0), + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + fn opencode_provider(id: &str) -> Provider { + Provider { + id: id.to_string(), + name: format!("Provider {id}"), + settings_config: json!({ + "npm": "@ai-sdk/openai-compatible", + "name": format!("Provider {id}"), + "options": { + "baseURL": "https://api.example.com/v1", + "apiKey": "test-key" + }, + "models": { + "gpt-4o": { + "name": "GPT-4o" + } + } + }), + website_url: None, + category: Some("custom".to_string()), + created_at: Some(1), + sort_index: Some(0), + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + fn opencode_omo_provider(id: &str, category: &str) -> Provider { + let mut settings = serde_json::Map::new(); + settings.insert( + "agents".to_string(), + json!({ + "writer": { + "model": "gpt-4o-mini" + } + }), + ); + if category == "omo" { + settings.insert( + "categories".to_string(), + json!({ + "default": ["writer"] + }), + ); + } + settings.insert( + "otherFields".to_string(), + json!({ + "theme": "dark" + }), + ); + + Provider { + id: id.to_string(), + name: format!("Provider {id}"), + settings_config: Value::Object(settings), + website_url: None, + category: Some(category.to_string()), + created_at: Some(1), + sort_index: Some(0), + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + fn omo_config_path(home: &Path, category: &str) -> PathBuf { + home.join(".config").join("opencode").join(match category { + "omo" => crate::services::omo::STANDARD.preferred_filename, + "omo-slim" => crate::services::omo::SLIM.preferred_filename, + other => panic!("unexpected OMO category in test: {other}"), + }) + } #[test] fn validate_provider_settings_rejects_missing_auth() { @@ -129,6 +266,477 @@ base_url = "http://localhost:8080" "should keep mcp_servers.* base_url" ); } + + #[test] + fn rename_rejects_missing_original_provider() { + with_test_home(|state, _| { + let original = openclaw_provider("deepseek"); + ProviderService::add(state, AppType::OpenClaw, original.clone(), false) + .expect("seed db-only provider"); + + let mut renamed = original.clone(); + renamed.id = "deepseek-copy".to_string(); + + let err = ProviderService::update( + state, + AppType::OpenClaw, + Some("missing-provider"), + renamed, + ) + .expect_err("stale originalId should be rejected"); + + assert!( + err.to_string().contains("Original provider"), + "expected missing original provider error, got {err:?}" + ); + assert!( + state + .db + .get_provider_by_id("deepseek-copy", AppType::OpenClaw.as_str()) + .expect("query renamed provider") + .is_none(), + "rename must not create a new row when originalId is stale" + ); + }); + } + + #[test] + fn db_only_additive_update_survives_live_config_parse_errors() { + with_test_home(|state, home| { + let provider = openclaw_provider("deepseek"); + ProviderService::add(state, AppType::OpenClaw, provider.clone(), false) + .expect("seed db-only provider"); + + let stored = state + .db + .get_provider_by_id("deepseek", AppType::OpenClaw.as_str()) + .expect("query stored provider") + .expect("provider should exist"); + assert_eq!( + stored + .meta + .as_ref() + .and_then(|meta| meta.live_config_managed), + Some(false), + "db-only provider should be marked as not live-managed" + ); + + let openclaw_dir = home.join(".openclaw"); + fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); + fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5") + .expect("write malformed config"); + + let mut updated = stored.clone(); + updated.name = "DeepSeek Edited".to_string(); + updated.meta.get_or_insert_with(ProviderMeta::default); + + ProviderService::update(state, AppType::OpenClaw, None, updated) + .expect("db-only update should ignore live parse errors"); + + let saved = state + .db + .get_provider_by_id("deepseek", AppType::OpenClaw.as_str()) + .expect("query updated provider") + .expect("updated provider should exist"); + assert_eq!(saved.name, "DeepSeek Edited"); + }); + } + + #[test] + fn sync_current_provider_for_app_skips_db_only_opencode_provider() { + with_test_home(|state, _| { + let provider = opencode_provider("db-only-opencode"); + ProviderService::add(state, AppType::OpenCode, provider.clone(), false) + .expect("seed db-only opencode provider"); + + ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) + .expect("sync additive opencode providers"); + + let live_providers = crate::opencode_config::get_providers() + .expect("read opencode providers after sync"); + assert!( + !live_providers.contains_key(&provider.id), + "db-only opencode provider should not be written to live during sync" + ); + }); + } + + #[test] + fn sync_current_provider_for_app_skips_db_only_openclaw_provider() { + with_test_home(|state, _| { + let provider = openclaw_provider("db-only-openclaw"); + ProviderService::add(state, AppType::OpenClaw, provider.clone(), false) + .expect("seed db-only openclaw provider"); + + ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw) + .expect("sync additive openclaw providers"); + + let live_providers = crate::openclaw_config::get_providers() + .expect("read openclaw providers after sync"); + assert!( + !live_providers.contains_key(&provider.id), + "db-only openclaw provider should not be written to live during sync" + ); + }); + } + + #[test] + fn sync_current_provider_for_app_preserves_legacy_live_opencode_provider() { + with_test_home(|state, _| { + let provider = opencode_provider("legacy-opencode"); + crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone()) + .expect("seed opencode live provider"); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .expect("seed legacy opencode provider in db"); + + let mut updated = provider.clone(); + updated.settings_config["options"]["apiKey"] = Value::String("updated-key".to_string()); + state + .db + .save_provider(AppType::OpenCode.as_str(), &updated) + .expect("update legacy opencode provider in db"); + + ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) + .expect("sync legacy opencode provider"); + + let live_providers = + crate::opencode_config::get_providers().expect("read opencode providers"); + assert_eq!( + live_providers + .get(&provider.id) + .and_then(|config| config.get("options")) + .and_then(|options| options.get("apiKey")), + Some(&Value::String("updated-key".to_string())), + "legacy provider that already exists in live should still be synced" + ); + }); + } + + #[test] + fn sync_current_provider_for_app_restores_legacy_opencode_provider_after_live_reset() { + with_test_home(|state, _| { + let provider = opencode_provider("legacy-opencode-reset"); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .expect("seed legacy opencode provider in db"); + + ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) + .expect("sync legacy opencode provider after reset"); + + let live_providers = + crate::opencode_config::get_providers().expect("read opencode providers"); + assert!( + live_providers.contains_key(&provider.id), + "legacy opencode provider should be restored when live config is reset" + ); + }); + } + + #[test] + fn sync_current_provider_for_app_restores_legacy_openclaw_provider_after_live_reset() { + with_test_home(|state, _| { + let mut provider = openclaw_provider("legacy-openclaw-reset"); + provider.settings_config["models"] = json!([ + { + "id": "claude-sonnet-4", + "name": "Claude Sonnet 4" + } + ]); + state + .db + .save_provider(AppType::OpenClaw.as_str(), &provider) + .expect("seed legacy openclaw provider in db"); + + ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw) + .expect("sync legacy openclaw provider after reset"); + + let live_providers = + crate::openclaw_config::get_providers().expect("read openclaw providers"); + assert!( + live_providers.contains_key(&provider.id), + "legacy openclaw provider should be restored when live config is reset" + ); + }); + } + + #[test] + fn import_opencode_providers_from_live_marks_provider_as_live_managed() { + with_test_home(|state, _| { + let provider = opencode_provider("imported-opencode"); + crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone()) + .expect("seed opencode live provider"); + + let imported = import_opencode_providers_from_live(state) + .expect("import opencode providers from live"); + assert_eq!(imported, 1); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) + .expect("query imported opencode provider") + .expect("imported opencode provider should exist"); + assert_eq!( + saved + .meta + .as_ref() + .and_then(|meta| meta.live_config_managed), + Some(true), + "providers imported from live should be treated as live-managed" + ); + }); + } + + #[test] + fn import_openclaw_providers_from_live_marks_provider_as_live_managed() { + with_test_home(|state, _| { + let mut provider = openclaw_provider("imported-openclaw"); + provider.settings_config["models"] = json!([ + { + "id": "claude-sonnet-4", + "name": "Claude Sonnet 4" + } + ]); + crate::openclaw_config::set_provider(&provider.id, provider.settings_config.clone()) + .expect("seed openclaw live provider"); + + let imported = import_openclaw_providers_from_live(state) + .expect("import openclaw providers from live"); + assert_eq!(imported, 1); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str()) + .expect("query imported openclaw provider") + .expect("imported openclaw provider should exist"); + assert_eq!( + saved + .meta + .as_ref() + .and_then(|meta| meta.live_config_managed), + Some(true), + "providers imported from live should be treated as live-managed" + ); + }); + } + + #[test] + fn legacy_additive_provider_still_errors_on_live_config_parse_failure() { + with_test_home(|state, home| { + let provider = openclaw_provider("legacy-provider"); + state + .db + .save_provider(AppType::OpenClaw.as_str(), &provider) + .expect("seed legacy provider without live_config_managed marker"); + + let openclaw_dir = home.join(".openclaw"); + fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); + fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5") + .expect("write malformed config"); + + let mut updated = provider.clone(); + updated.name = "Legacy Edited".to_string(); + + let err = ProviderService::update(state, AppType::OpenClaw, None, updated) + .expect_err("legacy providers should still surface live parse errors"); + assert!( + err.to_string().contains("Failed to parse OpenClaw config"), + "expected parse error, got {err:?}" + ); + }); + } + + #[test] + fn update_persists_non_current_omo_variants_in_database() { + with_test_home(|state, _| { + for category in ["omo", "omo-slim"] { + let provider = opencode_omo_provider(&format!("{category}-provider"), category); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .unwrap_or_else(|err| panic!("seed {category} provider: {err}")); + + let mut updated = provider.clone(); + updated.name = format!("Updated {category}"); + updated.settings_config["agents"]["writer"]["model"] = + Value::String(format!("{category}-next-model")); + + ProviderService::update(state, AppType::OpenCode, None, updated) + .unwrap_or_else(|err| panic!("update {category} provider: {err}")); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) + .unwrap_or_else(|err| panic!("query updated {category} provider: {err}")) + .unwrap_or_else(|| panic!("{category} provider should exist")); + + assert_eq!(saved.name, format!("Updated {category}")); + assert_eq!( + saved.settings_config["agents"]["writer"]["model"], + Value::String(format!("{category}-next-model")), + "{category} updates should persist in the database" + ); + } + }); + } + + #[test] + fn update_current_omo_variant_rewrites_config_from_saved_provider() { + with_test_home(|state, home| { + for category in ["omo", "omo-slim"] { + let provider = opencode_omo_provider(&format!("{category}-current"), category); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .unwrap_or_else(|err| panic!("seed current {category} provider: {err}")); + state + .db + .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, category) + .unwrap_or_else(|err| panic!("set current {category} provider: {err}")); + + let mut updated = provider.clone(); + updated.name = format!("Current {category} updated"); + updated.settings_config["agents"]["writer"]["model"] = + Value::String(format!("{category}-saved-model")); + updated.settings_config["otherFields"]["theme"] = + Value::String(format!("{category}-light")); + + ProviderService::update(state, AppType::OpenCode, None, updated) + .unwrap_or_else(|err| panic!("update current {category} provider: {err}")); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) + .unwrap_or_else(|err| panic!("query current {category} provider: {err}")) + .unwrap_or_else(|| panic!("current {category} provider should exist")); + assert_eq!(saved.name, format!("Current {category} updated")); + + let written = fs::read_to_string(omo_config_path(home, category)) + .unwrap_or_else(|err| panic!("read written {category} config: {err}")); + let written_json: Value = serde_json::from_str(&written) + .unwrap_or_else(|err| panic!("parse written {category} config: {err}")); + + assert_eq!( + written_json["agents"]["writer"]["model"], + Value::String(format!("{category}-saved-model")), + "{category} config should be written from the saved provider state" + ); + assert_eq!( + written_json["theme"], + Value::String(format!("{category}-light")), + "{category} top-level config should reflect updated otherFields" + ); + } + }); + } + + #[test] + fn update_current_omo_variant_does_not_persist_database_when_file_write_fails() { + with_test_home(|state, home| { + let provider = opencode_omo_provider("omo-current", "omo"); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .unwrap_or_else(|err| panic!("seed current omo provider: {err}")); + state + .db + .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo") + .unwrap_or_else(|err| panic!("set current omo provider: {err}")); + + let config_dir = home.join(".config").join("opencode"); + fs::create_dir_all(config_dir.parent().expect("config dir parent")) + .expect("create .config dir"); + fs::write(&config_dir, "not a directory").expect("block opencode config dir"); + + let mut updated = provider.clone(); + updated.name = "Current omo updated".to_string(); + updated.settings_config["agents"]["writer"]["model"] = + Value::String("omo-saved-model".to_string()); + + ProviderService::update(state, AppType::OpenCode, None, updated) + .expect_err("update should fail when current omo file write fails"); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) + .unwrap_or_else(|err| panic!("query current omo provider: {err}")) + .unwrap_or_else(|| panic!("current omo provider should exist")); + + assert_eq!(saved.name, provider.name); + assert_eq!( + saved.settings_config["agents"]["writer"]["model"], + provider.settings_config["agents"]["writer"]["model"], + "database should remain unchanged when file write fails" + ); + }); + } + + #[test] + fn update_current_omo_variant_rolls_back_file_when_plugin_sync_fails() { + with_test_home(|state, home| { + let provider = opencode_omo_provider("omo-current", "omo"); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .unwrap_or_else(|err| panic!("seed current omo provider: {err}")); + state + .db + .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo") + .unwrap_or_else(|err| panic!("set current omo provider: {err}")); + + let config_path = omo_config_path(home, "omo"); + fs::create_dir_all(config_path.parent().expect("omo config parent")) + .expect("create omo config dir"); + let previous_content = serde_json::to_string_pretty(&json!({ + "theme": "legacy-live-theme", + "agents": { + "writer": { + "model": "legacy-live-model" + } + }, + "categories": { + "default": ["writer"] + } + })) + .expect("serialize previous config"); + fs::write(&config_path, &previous_content).expect("seed previous omo config"); + + let opencode_config_path = home.join(".config").join("opencode").join("opencode.json"); + fs::write(&opencode_config_path, "{ invalid json").expect("seed malformed opencode"); + + let mut updated = provider.clone(); + updated.name = "Current omo updated".to_string(); + updated.settings_config["agents"]["writer"]["model"] = + Value::String("omo-saved-model".to_string()); + updated.settings_config["otherFields"]["theme"] = + Value::String("omo-light".to_string()); + + ProviderService::update(state, AppType::OpenCode, None, updated) + .expect_err("update should fail when plugin sync fails"); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) + .unwrap_or_else(|err| panic!("query current omo provider: {err}")) + .unwrap_or_else(|| panic!("current omo provider should exist")); + + assert_eq!(saved.name, provider.name); + assert_eq!( + saved.settings_config["agents"]["writer"]["model"], + provider.settings_config["agents"]["writer"]["model"], + "database should remain unchanged when plugin sync fails" + ); + + let written = + fs::read_to_string(&config_path).expect("read rolled back omo config content"); + assert_eq!( + written, previous_content, + "OMO config should roll back to its previous on-disk contents" + ); + }); + } } impl ProviderService { @@ -141,6 +749,34 @@ impl ProviderService { } } + /// Check whether a provider exists in live config, tolerating parse errors + /// only for providers that are explicitly marked as DB-only. + fn check_live_config_exists( + app_type: &AppType, + provider_id: &str, + live_config_managed: Option, + ) -> Result { + if live_config_managed == Some(false) { + Ok(provider_exists_in_live_config(app_type, provider_id).unwrap_or(false)) + } else { + provider_exists_in_live_config(app_type, provider_id) + } + } + + fn provider_live_config_managed(provider: &Provider) -> Option { + provider + .meta + .as_ref() + .and_then(|meta| meta.live_config_managed) + } + + fn set_provider_live_config_managed(provider: &mut Provider, managed: bool) { + provider + .meta + .get_or_insert_with(Default::default) + .live_config_managed = Some(managed); + } + /// List all providers for an app type pub fn list( state: &AppState, @@ -166,17 +802,25 @@ impl ProviderService { } /// Add a new provider - pub fn add(state: &AppState, app_type: AppType, provider: Provider) -> Result { + pub fn add( + state: &AppState, + app_type: AppType, + provider: Provider, + add_to_live: bool, + ) -> Result { let mut provider = provider; // Normalize Claude model keys Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; + if app_type.is_additive_mode() { + Self::set_provider_live_config_managed(&mut provider, add_to_live); + } // Save to database state.db.save_provider(app_type.as_str(), &provider)?; - // Additive mode apps (OpenCode, OpenClaw) - always write to live config + // Additive mode apps (OpenCode, OpenClaw): optionally write to live config. if app_type.is_additive_mode() { // OMO / OMO Slim providers use exclusive mode and write to dedicated config file. if matches!(app_type, AppType::OpenCode) @@ -186,6 +830,9 @@ impl ProviderService { // Users must explicitly switch/apply an OMO provider to activate it. return Ok(true); } + if !add_to_live { + return Ok(true); + } write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; return Ok(true); } @@ -207,53 +854,153 @@ impl ProviderService { pub fn update( state: &AppState, app_type: AppType, + original_id: Option<&str>, provider: Provider, ) -> Result { let mut provider = provider; + let original_id = original_id.unwrap_or(provider.id.as_str()).to_string(); + let provider_id_changed = original_id != provider.id; + let existing_provider = state + .db + .get_provider_by_id(&original_id, app_type.as_str())?; // Normalize Claude model keys Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; - // Save to database - state.db.save_provider(app_type.as_str(), &provider)?; - - // Additive mode apps (OpenCode, OpenClaw) - always update in live config - if app_type.is_additive_mode() { - if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") - { - let is_omo_current = - state - .db - .is_omo_provider_current(app_type.as_str(), &provider.id, "omo")?; - if is_omo_current { - crate::services::OmoService::write_config_to_file( - state, - &crate::services::omo::STANDARD, - )?; - } - return Ok(true); + if provider_id_changed { + if !app_type.is_additive_mode() { + return Err(AppError::Message( + "Only additive-mode providers support changing provider key".to_string(), + )); } + + let Some(existing_provider) = existing_provider else { + return Err(AppError::Message(format!( + "Original provider '{}' does not exist in app '{}'", + original_id, + app_type.as_str() + ))); + }; + + // OMO / OMO Slim providers are activated via a dedicated current-state mechanism + // (set_omo_provider_current) that is NOT captured by provider_exists_in_live_config, + // which only checks opencode.json. A rename would orphan that current-state marker + // and silently break subsequent OMO file syncs. Block it unconditionally. if matches!(app_type, AppType::OpenCode) - && provider.category.as_deref() == Some("omo-slim") + && matches!( + existing_provider.category.as_deref(), + Some("omo") | Some("omo-slim") + ) { + return Err(AppError::Message( + "Provider key cannot be changed for OMO/OMO Slim providers".to_string(), + )); + } + + let original_in_live = Self::check_live_config_exists( + &app_type, + &original_id, + Self::provider_live_config_managed(&existing_provider), + )?; + if original_in_live { + return Err(AppError::Message( + "Provider key cannot be changed after the provider has been added to the app config" + .to_string(), + )); + } + + let next_id_in_live = Self::check_live_config_exists( + &app_type, + &provider.id, + Self::provider_live_config_managed(&existing_provider), + )?; + if state + .db + .get_provider_by_id(&provider.id, app_type.as_str())? + .is_some() + || next_id_in_live + { + return Err(AppError::Message(format!( + "Provider '{}' already exists in app '{}'", + provider.id, + app_type.as_str() + ))); + } + + Self::set_provider_live_config_managed(&mut provider, false); + state.db.save_provider(app_type.as_str(), &provider)?; + state.db.delete_provider(app_type.as_str(), &original_id)?; + + if crate::settings::get_current_provider(&app_type).as_deref() == Some(&original_id) { + crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?; + } + + return Ok(true); + } + + // Additive mode apps (OpenCode, OpenClaw): only sync to live when the provider + // already exists in live config. Editing a DB-only provider must not auto-add it. + if app_type.is_additive_mode() { + let omo_variant = if matches!(app_type, AppType::OpenCode) { + match provider.category.as_deref() { + Some("omo") => Some(&crate::services::omo::STANDARD), + Some("omo-slim") => Some(&crate::services::omo::SLIM), + _ => None, + } + } else { + None + }; + if let Some(variant) = omo_variant { let is_current = state.db.is_omo_provider_current( app_type.as_str(), &provider.id, - "omo-slim", + variant.category, )?; if is_current { - crate::services::OmoService::write_config_to_file( - state, - &crate::services::omo::SLIM, - )?; + crate::services::OmoService::write_provider_config_to_file(&provider, variant)?; } + if let Err(err) = state.db.save_provider(app_type.as_str(), &provider) { + if is_current { + if let Err(rollback_err) = + crate::services::OmoService::write_config_to_file(state, variant) + { + log::warn!( + "Failed to roll back {} config after DB save error: {}", + variant.label, + rollback_err + ); + } + } + return Err(err); + } + return Ok(true); + } + let live_config_managed = Self::check_live_config_exists( + &app_type, + &provider.id, + Self::provider_live_config_managed(&provider).or_else(|| { + existing_provider + .as_ref() + .and_then(Self::provider_live_config_managed) + }), + )?; + Self::set_provider_live_config_managed(&mut provider, live_config_managed); + + // Save to database after live-config presence is resolved so parse errors + // do not report failure after already mutating DB state. + state.db.save_provider(app_type.as_str(), &provider)?; + + if !live_config_managed { return Ok(true); } write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; return Ok(true); } + // Save to database + state.db.save_provider(app_type.as_str(), &provider)?; + // For other apps: Check if this is current provider (use effective current, not just DB) let effective_current = crate::settings::get_effective_current_provider(&state.db, &app_type)?; @@ -295,50 +1042,48 @@ impl ProviderService { pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { // Additive mode apps - no current provider concept if app_type.is_additive_mode() { + // Single DB read shared across all additive-mode sub-paths below. + let existing = state.db.get_provider_by_id(id, app_type.as_str())?; + if matches!(app_type, AppType::OpenCode) { - let provider_category = state - .db - .get_provider_by_id(id, app_type.as_str())? - .and_then(|p| p.category); - - if provider_category.as_deref() == Some("omo") { - let was_current = - state - .db - .is_omo_provider_current(app_type.as_str(), id, "omo")?; - + let provider_category = existing.as_ref().and_then(|p| p.category.clone()); + let omo_variant = match provider_category.as_deref() { + Some("omo") => Some(&crate::services::omo::STANDARD), + Some("omo-slim") => Some(&crate::services::omo::SLIM), + _ => None, + }; + if let Some(variant) = omo_variant { + let was_current = state.db.is_omo_provider_current( + app_type.as_str(), + id, + variant.category, + )?; state.db.delete_provider(app_type.as_str(), id)?; if was_current { - crate::services::OmoService::delete_config_file( - &crate::services::omo::STANDARD, - )?; - } - return Ok(()); - } - - if provider_category.as_deref() == Some("omo-slim") { - let was_current = - state - .db - .is_omo_provider_current(app_type.as_str(), id, "omo-slim")?; - - state.db.delete_provider(app_type.as_str(), id)?; - if was_current { - crate::services::OmoService::delete_config_file( - &crate::services::omo::SLIM, - )?; + crate::services::OmoService::delete_config_file(variant)?; } return Ok(()); } } - // Remove from database + + // Non-OMO path for both OpenCode and OpenClaw: + // remove from live first (atomicity), then DB. + // + // Use check_live_config_exists rather than trusting the flag alone: the flag + // can be stale (Some(false) for a provider that was written to live before the + // live_config_managed flip was introduced). check_live_config_exists reads the + // actual file when the flag is Some(false), so it handles historical data correctly. + let live_managed = existing + .as_ref() + .and_then(Self::provider_live_config_managed); + if Self::check_live_config_exists(&app_type, id, live_managed)? { + match app_type { + AppType::OpenCode => remove_opencode_provider_from_live(id)?, + AppType::OpenClaw => remove_openclaw_provider_from_live(id)?, + _ => {} + } + } state.db.delete_provider(app_type.as_str(), id)?; - // Also remove from live config - match app_type { - AppType::OpenCode => remove_opencode_provider_from_live(id)?, - AppType::OpenClaw => remove_openclaw_provider_from_live(id)?, - _ => {} // Should not reach here - } return Ok(()); } @@ -372,41 +1117,23 @@ impl ProviderService { .get_provider_by_id(id, app_type.as_str())? .and_then(|p| p.category); - if provider_category.as_deref() == Some("omo") { + let omo_variant = match provider_category.as_deref() { + Some("omo") => Some(&crate::services::omo::STANDARD), + Some("omo-slim") => Some(&crate::services::omo::SLIM), + _ => None, + }; + if let Some(variant) = omo_variant { state .db - .clear_omo_provider_current(app_type.as_str(), id, "omo")?; + .clear_omo_provider_current(app_type.as_str(), id, variant.category)?; let still_has_current = state .db - .get_current_omo_provider("opencode", "omo")? + .get_current_omo_provider("opencode", variant.category)? .is_some(); if still_has_current { - crate::services::OmoService::write_config_to_file( - state, - &crate::services::omo::STANDARD, - )?; + crate::services::OmoService::write_config_to_file(state, variant)?; } else { - crate::services::OmoService::delete_config_file( - &crate::services::omo::STANDARD, - )?; - } - } else if provider_category.as_deref() == Some("omo-slim") { - state - .db - .clear_omo_provider_current(app_type.as_str(), id, "omo-slim")?; - let still_has_current = state - .db - .get_current_omo_provider("opencode", "omo-slim")? - .is_some(); - if still_has_current { - crate::services::OmoService::write_config_to_file( - state, - &crate::services::omo::SLIM, - )?; - } else { - crate::services::OmoService::delete_config_file( - &crate::services::omo::SLIM, - )?; + crate::services::OmoService::delete_config_file(variant)?; } } else { remove_opencode_provider_from_live(id)?; @@ -422,6 +1149,12 @@ impl ProviderService { ))); } } + + if let Some(mut provider) = state.db.get_provider_by_id(id, app_type.as_str())? { + Self::set_provider_live_config_managed(&mut provider, false); + state.db.save_provider(app_type.as_str(), &provider)?; + } + Ok(()) } @@ -507,29 +1240,23 @@ impl ProviderService { .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; - if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") { - state - .db - .set_omo_provider_current(app_type.as_str(), id, "omo")?; - crate::services::OmoService::write_config_to_file( - state, - &crate::services::omo::STANDARD, - )?; - // OMO ↔ OMO Slim mutually exclusive: remove Slim config - let _ = crate::services::OmoService::delete_config_file(&crate::services::omo::SLIM); - return Ok(SwitchResult::default()); - } - - if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo-slim") - { - state - .db - .set_omo_provider_current(app_type.as_str(), id, "omo-slim")?; - crate::services::OmoService::write_config_to_file(state, &crate::services::omo::SLIM)?; - // OMO ↔ OMO Slim mutually exclusive: remove Standard config - let _ = - crate::services::OmoService::delete_config_file(&crate::services::omo::STANDARD); - return Ok(SwitchResult::default()); + // OMO ↔ OMO Slim are mutually exclusive; activating one removes the other's config file. + if matches!(app_type, AppType::OpenCode) { + let omo_pair = match provider.category.as_deref() { + Some("omo") => Some((&crate::services::omo::STANDARD, &crate::services::omo::SLIM)), + Some("omo-slim") => { + Some((&crate::services::omo::SLIM, &crate::services::omo::STANDARD)) + } + _ => None, + }; + if let Some((enable, disable)) = omo_pair { + state + .db + .set_omo_provider_current(app_type.as_str(), id, enable.category)?; + crate::services::OmoService::write_config_to_file(state, enable)?; + let _ = crate::services::OmoService::delete_config_file(disable); + return Ok(SwitchResult::default()); + } } let mut result = SwitchResult::default(); @@ -579,6 +1306,40 @@ impl ProviderService { // Sync to live (write_gemini_live handles security flag internally for Gemini) write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; + // For additive-mode providers that were DB-only (live_config_managed == Some(false)), + // flip the flag to true now that the provider has been successfully written to the live + // file. This ensures sync_all_providers_to_live() will include it on future syncs. + // + // If persisting the marker fails, roll back the just-written live config so we don't leave + // the provider in a silent inconsistent state (present in live, but still marked DB-only). + if app_type.is_additive_mode() && Self::provider_live_config_managed(provider) != Some(true) + { + let mut updated = provider.clone(); + Self::set_provider_live_config_managed(&mut updated, true); + if let Err(e) = state.db.save_provider(app_type.as_str(), &updated) { + let rollback_result = match app_type { + AppType::OpenCode => remove_opencode_provider_from_live(&provider.id), + AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id), + _ => Ok(()), + }; + + match rollback_result { + Ok(()) => { + return Err(AppError::Message(format!( + "Failed to persist live_config_managed for '{}' after writing live config; live changes were rolled back: {e}", + provider.id + ))); + } + Err(rollback_err) => { + return Err(AppError::Message(format!( + "Failed to persist live_config_managed for '{}' after writing live config: {e}; additionally failed to roll back live config: {rollback_err}", + provider.id + ))); + } + } + } + } + // Sync MCP McpService::sync_all_enabled(state)?; diff --git a/src/App.tsx b/src/App.tsx index 02b7a5b4d..0665c1889 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -533,8 +533,14 @@ function App() { } }; - const handleEditProvider = async (provider: Provider) => { - await updateProvider(provider); + const handleEditProvider = async ({ + provider, + originalId, + }: { + provider: Provider; + originalId?: string; + }) => { + await updateProvider(provider, originalId); setEditingProvider(null); }; @@ -571,7 +577,7 @@ function App() { setConfirmAction(null); }; - const generateUniqueOpencodeKey = ( + const generateUniqueProviderCopyKey = ( originalKey: string, existingKeys: string[], ): string => { @@ -594,6 +600,7 @@ function App() { const duplicatedProvider: Omit & { providerKey?: string; + addToLive?: boolean; } = { name: `${provider.name} copy`, settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝 @@ -607,12 +614,40 @@ function App() { iconColor: provider.iconColor, }; - if (activeApp === "opencode") { - const existingKeys = Object.keys(providers); - duplicatedProvider.providerKey = generateUniqueOpencodeKey( + if (activeApp === "opencode" || activeApp === "openclaw") { + let liveProviderIds: string[] = []; + try { + liveProviderIds = + activeApp === "opencode" + ? await queryClient.ensureQueryData({ + queryKey: ["opencodeLiveProviderIds"], + queryFn: () => providersApi.getOpenCodeLiveProviderIds(), + }) + : await queryClient.ensureQueryData({ + queryKey: openclawKeys.liveProviderIds, + queryFn: () => providersApi.getOpenClawLiveProviderIds(), + }); + } catch (error) { + console.error( + "[App] Failed to load live provider IDs for duplication", + error, + ); + const errorMessage = extractErrorMessage(error); + toast.error( + t("provider.duplicateLiveIdsLoadFailed", { + defaultValue: "读取配置中的供应商标识失败,请先修复配置后再试", + }) + (errorMessage ? `: ${errorMessage}` : ""), + ); + return; + } + const existingKeys = Array.from( + new Set([...Object.keys(providers), ...liveProviderIds]), + ); + duplicatedProvider.providerKey = generateUniqueProviderCopyKey( provider.id, existingKeys, ); + duplicatedProvider.addToLive = false; } if (provider.sortIndex !== undefined) { diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 072fdb64d..9b2d5bbbe 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -14,7 +14,10 @@ interface EditProviderDialogProps { open: boolean; provider: Provider | null; onOpenChange: (open: boolean) => void; - onSubmit: (provider: Provider) => Promise | void; + onSubmit: (payload: { + provider: Provider; + originalId?: string; + }) => Promise | void; appId: AppId; isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置) } @@ -165,9 +168,15 @@ export function EditProviderDialog({ string, unknown >; + const nextProviderId = + (appId === "opencode" || appId === "openclaw") && + values.providerKey?.trim() + ? values.providerKey.trim() + : provider.id; const updatedProvider: Provider = { ...provider, + id: nextProviderId, name: values.name.trim(), notes: values.notes?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined, @@ -179,10 +188,13 @@ export function EditProviderDialog({ ...(values.meta ? { meta: values.meta } : {}), }; - await onSubmit(updatedProvider); + await onSubmit({ + provider: updatedProvider, + originalId: provider.id, + }); onOpenChange(false); }, - [onSubmit, onOpenChange, provider], + [appId, onSubmit, onOpenChange, provider], ); if (!provider || !initialData) { diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index cead4fc5f..3c8a03b29 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1,13 +1,14 @@ import { useEffect, useMemo, useState, useCallback } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; -import type { AppId } from "@/lib/api"; +import { providersApi, type AppId } from "@/lib/api"; import type { ProviderCategory, ProviderMeta, @@ -91,6 +92,7 @@ import { normalizePricingSource, } from "./helpers/opencodeFormUtils"; import { resolveManagedAccountId } from "@/lib/authBinding"; +import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw"; type PresetEntry = { id: string; @@ -577,6 +579,15 @@ export function ProviderForm({ existingOpencodeKeys, } = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId }); + const { + data: opencodeLiveProviderIds = [], + isLoading: isOpencodeLiveProviderIdsLoading, + } = useQuery({ + queryKey: ["opencodeLiveProviderIds"], + queryFn: () => providersApi.getOpenCodeLiveProviderIds(), + enabled: appId === "opencode" && !isAnyOmoCategory, + }); + const opencodeForm = useOpencodeFormState({ initialData, appId, @@ -605,6 +616,78 @@ export function ProviderForm({ onSettingsConfigChange: (config) => form.setValue("settingsConfig", config), getSettingsConfig: () => form.getValues("settingsConfig"), }); + const { + data: openclawLiveProviderIds = [], + isLoading: isOpenclawLiveProviderIdsLoading, + } = useOpenClawLiveProviderIds(appId === "openclaw"); + + const additiveExistingProviderKeys = useMemo(() => { + if (appId === "opencode" && !isAnyOmoCategory) { + return Array.from( + new Set( + [...existingOpencodeKeys, ...opencodeLiveProviderIds].filter( + (key) => key !== providerId, + ), + ), + ); + } + + if (appId === "openclaw") { + return Array.from( + new Set( + [ + ...openclawForm.existingOpenclawKeys, + ...openclawLiveProviderIds, + ].filter((key) => key !== providerId), + ), + ); + } + + return []; + }, [ + appId, + existingOpencodeKeys, + isAnyOmoCategory, + openclawForm.existingOpenclawKeys, + openclawLiveProviderIds, + opencodeLiveProviderIds, + providerId, + ]); + + const isProviderKeyLockStateLoading = useMemo(() => { + if (!isEditMode) return false; + if (appId === "opencode" && !isAnyOmoCategory) { + return isOpencodeLiveProviderIdsLoading; + } + if (appId === "openclaw") { + return isOpenclawLiveProviderIdsLoading; + } + return false; + }, [ + appId, + isAnyOmoCategory, + isEditMode, + isOpenclawLiveProviderIdsLoading, + isOpencodeLiveProviderIdsLoading, + ]); + + const isProviderKeyLocked = useMemo(() => { + if (!isEditMode || !providerId) return false; + if (appId === "opencode" && !isAnyOmoCategory) { + return opencodeLiveProviderIds.includes(providerId); + } + if (appId === "openclaw") { + return openclawLiveProviderIds.includes(providerId); + } + return false; + }, [ + appId, + isAnyOmoCategory, + isEditMode, + openclawLiveProviderIds, + opencodeLiveProviderIds, + providerId, + ]); const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); @@ -641,9 +724,17 @@ export function ProviderForm({ toast.error(t("opencode.providerKeyInvalid")); return; } + if (isProviderKeyLockStateLoading) { + toast.error( + t("providerForm.providerKeyStatusLoading", { + defaultValue: "正在加载供应商标识状态,请稍后再试", + }), + ); + return; + } if ( - !isEditMode && - existingOpencodeKeys.includes(opencodeForm.opencodeProviderKey) + !isProviderKeyLocked && + additiveExistingProviderKeys.includes(opencodeForm.opencodeProviderKey) ) { toast.error(t("opencode.providerKeyDuplicate")); return; @@ -665,11 +756,17 @@ export function ProviderForm({ toast.error(t("openclaw.providerKeyInvalid")); return; } + if (isProviderKeyLockStateLoading) { + toast.error( + t("providerForm.providerKeyStatusLoading", { + defaultValue: "正在加载供应商标识状态,请稍后再试", + }), + ); + return; + } if ( - !isEditMode && - openclawForm.existingOpenclawKeys.includes( - openclawForm.openclawProviderKey, - ) + !isProviderKeyLocked && + additiveExistingProviderKeys.includes(openclawForm.openclawProviderKey) ) { toast.error(t("openclaw.providerKeyDuplicate")); return; @@ -1253,12 +1350,14 @@ export function ProviderForm({ ) } placeholder={t("opencode.providerKeyPlaceholder")} - disabled={isEditMode} + disabled={ + isProviderKeyLocked || isProviderKeyLockStateLoading + } className={ - (existingOpencodeKeys.includes( + (additiveExistingProviderKeys.includes( opencodeForm.opencodeProviderKey, ) && - !isEditMode) || + !isProviderKeyLocked) || (opencodeForm.opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( opencodeForm.opencodeProviderKey, @@ -1267,10 +1366,10 @@ export function ProviderForm({ : "" } /> - {existingOpencodeKeys.includes( + {additiveExistingProviderKeys.includes( opencodeForm.opencodeProviderKey, ) && - !isEditMode && ( + !isProviderKeyLocked && (

{t("opencode.providerKeyDuplicate")}

@@ -1284,16 +1383,21 @@ export function ProviderForm({

)} {!( - existingOpencodeKeys.includes( + additiveExistingProviderKeys.includes( opencodeForm.opencodeProviderKey, - ) && !isEditMode + ) && !isProviderKeyLocked ) && (opencodeForm.opencodeProviderKey.trim() === "" || /^[a-z0-9]+(-[a-z0-9]+)*$/.test( opencodeForm.opencodeProviderKey, )) && (

- {t("opencode.providerKeyHint")} + {isProviderKeyLocked + ? t("opencode.providerKeyLockedHint", { + defaultValue: + "该供应商已添加到应用配置中,供应商标识不可修改", + }) + : t("opencode.providerKeyHint")}

)} @@ -1312,12 +1416,14 @@ export function ProviderForm({ ) } placeholder={t("openclaw.providerKeyPlaceholder")} - disabled={isEditMode} + disabled={ + isProviderKeyLocked || isProviderKeyLockStateLoading + } className={ - (openclawForm.existingOpenclawKeys.includes( + (additiveExistingProviderKeys.includes( openclawForm.openclawProviderKey, ) && - !isEditMode) || + !isProviderKeyLocked) || (openclawForm.openclawProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( openclawForm.openclawProviderKey, @@ -1326,10 +1432,10 @@ export function ProviderForm({ : "" } /> - {openclawForm.existingOpenclawKeys.includes( + {additiveExistingProviderKeys.includes( openclawForm.openclawProviderKey, ) && - !isEditMode && ( + !isProviderKeyLocked && (

{t("openclaw.providerKeyDuplicate")}

@@ -1343,16 +1449,21 @@ export function ProviderForm({

)} {!( - openclawForm.existingOpenclawKeys.includes( + additiveExistingProviderKeys.includes( openclawForm.openclawProviderKey, - ) && !isEditMode + ) && !isProviderKeyLocked ) && (openclawForm.openclawProviderKey.trim() === "" || /^[a-z0-9]+(-[a-z0-9]+)*$/.test( openclawForm.openclawProviderKey, )) && (

- {t("openclaw.providerKeyHint")} + {isProviderKeyLocked + ? t("openclaw.providerKeyLockedHint", { + defaultValue: + "该供应商已添加到应用配置中,供应商标识不可修改", + }) + : t("openclaw.providerKeyHint")}

)} diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 344616843..a694e9484 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -65,6 +65,7 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { provider: Omit & { providerKey?: string; suggestedDefaults?: OpenClawSuggestedDefaults; + addToLive?: boolean; }, ) => { await addProviderMutation.mutateAsync(provider); @@ -120,8 +121,8 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { // 更新供应商 const updateProvider = useCallback( - async (provider: Provider) => { - await updateProviderMutation.mutateAsync(provider); + async (provider: Provider, originalId?: string) => { + await updateProviderMutation.mutateAsync({ provider, originalId }); // 更新托盘菜单(失败不影响主操作) try { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 70b7f85f5..228b474e3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -933,7 +933,8 @@ "modelsRequired": "Please add at least one model", "providerKey": "Provider Key", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.", + "providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.", + "providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.", "providerKeyRequired": "Provider key is required", "providerKeyDuplicate": "This key is already in use", "providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.", @@ -1392,7 +1393,8 @@ "backupCreated": "Backup created: {{path}}", "providerKey": "Provider Key", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.", + "providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.", + "providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.", "providerKeyRequired": "Provider key is required", "providerKeyDuplicate": "This key is already in use", "providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 82fed222f..bf3189a0c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -933,7 +933,8 @@ "modelsRequired": "モデルを少なくとも1つ追加してください", "providerKey": "プロバイダーキー", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。", + "providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。", + "providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。", "providerKeyRequired": "プロバイダーキーを入力してください", "providerKeyDuplicate": "このキーは既に使用されています", "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。", @@ -1392,7 +1393,8 @@ "backupCreated": "バックアップを作成しました: {{path}}", "providerKey": "プロバイダーキー", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。", + "providerKeyHint": "設定ファイル内のユニーク識別子。小文字、数字、ハイフンのみ使用可能。", + "providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。", "providerKeyRequired": "プロバイダーキーを入力してください", "providerKeyDuplicate": "このキーは既に使用されています", "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8c435e5fe..88b6ce9ad 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -933,7 +933,8 @@ "modelsRequired": "请至少添加一个模型配置", "providerKey": "供应商标识", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", + "providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符", + "providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改", "providerKeyRequired": "请填写供应商标识", "providerKeyDuplicate": "此标识已被使用,请更换", "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符", @@ -1392,7 +1393,8 @@ "backupCreated": "已创建备份:{{path}}", "providerKey": "供应商标识", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", + "providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符", + "providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改", "providerKeyRequired": "请填写供应商标识", "providerKeyDuplicate": "此标识已被使用,请更换", "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符", diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index 551b05951..89b6b7c7f 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -34,12 +34,24 @@ export const providersApi = { return await invoke("get_current_provider", { app: appId }); }, - async add(provider: Provider, appId: AppId): Promise { - return await invoke("add_provider", { provider, app: appId }); + async add( + provider: Provider, + appId: AppId, + addToLive?: boolean, + ): Promise { + return await invoke("add_provider", { provider, app: appId, addToLive }); }, - async update(provider: Provider, appId: AppId): Promise { - return await invoke("update_provider", { provider, app: appId }); + async update( + provider: Provider, + appId: AppId, + originalId?: string, + ): Promise { + return await invoke("update_provider", { + provider, + app: appId, + originalId, + }); }, async delete(id: string, appId: AppId): Promise { diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index e87ab5c13..e5ab2815c 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -15,7 +15,10 @@ export const useAddProviderMutation = (appId: AppId) => { return useMutation({ mutationFn: async ( - providerInput: Omit & { providerKey?: string }, + providerInput: Omit & { + providerKey?: string; + addToLive?: boolean; + }, ) => { let id: string; @@ -36,7 +39,7 @@ export const useAddProviderMutation = (appId: AppId) => { id = generateUUID(); } - const { providerKey: _providerKey, ...rest } = providerInput; + const { providerKey: _providerKey, addToLive, ...rest } = providerInput; const newProvider: Provider = { ...rest, @@ -45,7 +48,7 @@ export const useAddProviderMutation = (appId: AppId) => { }; delete (newProvider as any).providerKey; - await providersApi.add(newProvider, appId); + await providersApi.add(newProvider, appId, addToLive); return newProvider; }, onSuccess: async () => { @@ -107,8 +110,14 @@ export const useUpdateProviderMutation = (appId: AppId) => { const { t } = useTranslation(); return useMutation({ - mutationFn: async (provider: Provider) => { - await providersApi.update(provider, appId); + mutationFn: async ({ + provider, + originalId, + }: { + provider: Provider; + originalId?: string; + }) => { + await providersApi.update(provider, appId, originalId); return provider; }, onSuccess: async () => { diff --git a/tests/hooks/useProviderActions.test.tsx b/tests/hooks/useProviderActions.test.tsx index d6d858d72..8a7a2e8b4 100644 --- a/tests/hooks/useProviderActions.test.tsx +++ b/tests/hooks/useProviderActions.test.tsx @@ -169,7 +169,10 @@ describe("useProviderActions", () => { await result.current.updateProvider(provider); }); - expect(updateProviderMutateAsync).toHaveBeenCalledWith(provider); + expect(updateProviderMutateAsync).toHaveBeenCalledWith({ + provider, + originalId: undefined, + }); expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1); }); diff --git a/tests/integration/App.test.tsx b/tests/integration/App.test.tsx index 14f8439da..e3a26d1c6 100644 --- a/tests/integration/App.test.tsx +++ b/tests/integration/App.test.tsx @@ -2,7 +2,13 @@ import { Suspense, type ComponentType } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { describe, it, expect, beforeEach, vi } from "vitest"; -import { resetProviderState } from "../msw/state"; +import { providersApi } from "@/lib/api/providers"; +import { + resetProviderState, + setCurrentProviderId, + setLiveProviderIds, + setProviders, +} from "../msw/state"; import { emitTauriEvent } from "../msw/tauriMocks"; const toastSuccessMock = vi.fn(); @@ -75,8 +81,11 @@ vi.mock("@/components/providers/EditProviderDialog", () => ({ + ), })); @@ -230,4 +240,95 @@ describe("App integration with MSW", () => { expect(toastErrorMock).toHaveBeenCalled(); }); }); + + it("duplicates openclaw providers with a generated key that avoids live-only ids", async () => { + setProviders("openclaw", { + deepseek: { + id: "deepseek", + name: "DeepSeek", + settingsConfig: { + baseUrl: "https://api.deepseek.com", + apiKey: "test-key", + api: "openai-completions", + models: [], + }, + category: "custom", + sortIndex: 0, + createdAt: Date.now(), + }, + }); + setCurrentProviderId("openclaw", "deepseek"); + setLiveProviderIds("openclaw", ["deepseek-copy"]); + + const { default: App } = await import("@/App"); + renderApp(App); + + fireEvent.click(screen.getByText("switch-openclaw")); + + await waitFor(() => + expect(screen.getByTestId("provider-list").textContent).toContain( + "deepseek", + ), + ); + + fireEvent.click(screen.getByText("duplicate")); + + await waitFor(() => { + const providerList = screen.getByTestId("provider-list").textContent; + expect(providerList).toContain("deepseek-copy-2"); + expect(providerList).toContain("DeepSeek copy"); + }); + + expect(toastErrorMock).not.toHaveBeenCalledWith( + expect.stringContaining("Provider key is required for openclaw"), + ); + }); + + it("shows toast when duplicate cannot load live provider ids", async () => { + setProviders("openclaw", { + deepseek: { + id: "deepseek", + name: "DeepSeek", + settingsConfig: { + baseUrl: "https://api.deepseek.com", + apiKey: "test-key", + api: "openai-completions", + models: [], + }, + category: "custom", + sortIndex: 0, + createdAt: Date.now(), + }, + }); + setCurrentProviderId("openclaw", "deepseek"); + + const liveIdsSpy = vi + .spyOn(providersApi, "getOpenClawLiveProviderIds") + .mockRejectedValueOnce(new Error("broken config")); + + const { default: App } = await import("@/App"); + renderApp(App); + + fireEvent.click(screen.getByText("switch-openclaw")); + + await waitFor(() => + expect(screen.getByTestId("provider-list").textContent).toContain( + "deepseek", + ), + ); + + fireEvent.click(screen.getByText("duplicate")); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + expect.stringContaining("读取配置中的供应商标识失败"), + ); + }); + + expect(screen.getByTestId("provider-list").textContent).not.toContain( + "deepseek-copy", + ); + + liveIdsSpy.mockRestore(); + }); }); diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index 40231b920..3ae6c2d68 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -6,6 +6,7 @@ import { deleteProvider, deleteSession, getCurrentProviderId, + getLiveProviderIds, getSessionMessages, getProviders, listProviders, @@ -67,6 +68,20 @@ export const handlers = [ http.post(`${TAURI_ENDPOINT}/update_tray_menu`, () => success(true)), + http.post(`${TAURI_ENDPOINT}/get_opencode_live_provider_ids`, () => + success(getLiveProviderIds("opencode")), + ), + + http.post(`${TAURI_ENDPOINT}/get_openclaw_live_provider_ids`, () => + success(getLiveProviderIds("openclaw")), + ), + + http.post(`${TAURI_ENDPOINT}/get_openclaw_default_model`, () => + success({ primary: null, fallback: [] }), + ), + + http.post(`${TAURI_ENDPOINT}/scan_openclaw_config_health`, () => success([])), + http.post(`${TAURI_ENDPOINT}/switch_provider`, async ({ request }) => { const { id, app } = await withJson<{ id: string; app: AppId }>(request); const providers = listProviders(app); @@ -197,6 +212,8 @@ export const handlers = [ http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())), + http.post(`${TAURI_ENDPOINT}/check_env_conflicts`, () => success([])), + http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => { const { settings } = await withJson<{ settings: Settings }>(request); setSettings(settings); diff --git a/tests/msw/state.ts b/tests/msw/state.ts index 291d5201b..9768914d9 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -10,6 +10,7 @@ import type { type ProvidersByApp = Record>; type CurrentProviderState = Record; type McpConfigState = Record>; +type LiveProviderIdsByApp = Record<"opencode" | "openclaw", string[]>; const createDefaultProviders = (): ProvidersByApp => ({ claude: { @@ -77,6 +78,10 @@ const createDefaultCurrent = (): CurrentProviderState => ({ let providers = createDefaultProviders(); let current = createDefaultCurrent(); +let liveProviderIds: LiveProviderIdsByApp = { + opencode: [], + openclaw: [], +}; let settingsState: Settings = { showInTray: true, minimizeToTrayOnClose: true, @@ -184,6 +189,10 @@ const cloneProviders = (value: ProvidersByApp) => export const resetProviderState = () => { providers = createDefaultProviders(); current = createDefaultCurrent(); + liveProviderIds = { + opencode: [], + openclaw: [], + }; sessionsState = createDefaultSessions(); sessionMessagesState = createDefaultSessionMessages(); settingsState = { @@ -243,6 +252,17 @@ export const getProviders = (appType: AppId) => export const getCurrentProviderId = (appType: AppId) => current[appType] ?? ""; +export const getLiveProviderIds = (appType: "opencode" | "openclaw") => [ + ...liveProviderIds[appType], +]; + +export const setLiveProviderIds = ( + appType: "opencode" | "openclaw", + ids: string[], +) => { + liveProviderIds[appType] = [...ids]; +}; + export const setCurrentProviderId = (appType: AppId, providerId: string) => { current[appType] = providerId; };