diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index a459701..99e39df 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -196,9 +196,27 @@ impl CloneOpenCodeProfileFromSeed { ) -> Result { let existing = self.store.list().await?; let seed_id = reference_profile_id("opencode-llamacpp"); + // A valid OpenCode seed carries the OpenCode structured adapter and at + // least one backend: local (`opencode`, llamacpp) OR cloud + // (`opencode_provider`, ticket #92). The `opencode_backend_is_consistent` + // invariant (#97) guarantees at most one of the two is set; a profile + // carrying neither is not a usable seed. + // + // A persisted profile with the canonical seed id is preferred when it is + // still a valid OpenCode seed, so local edits — including a cloud + // conversion that reused the deterministic seed id — are preserved as the + // clone template. When the persisted slot is NOT a valid OpenCode seed + // (an unrelated profile squatting the id, or an OpenCode profile missing + // both backends), fall back to the in-memory reference catalogue seed, + // which is always the valid local llamacpp — instead of erroring on an + // unexpected store state. let seed = existing .iter() - .find(|profile| profile.id == seed_id) + .find(|profile| { + profile.id == seed_id + && profile.structured_adapter == Some(StructuredAdapter::OpenCode) + && (profile.opencode.is_some() || profile.opencode_provider.is_some()) + }) .cloned() .or_else(|| { reference_profiles() @@ -209,12 +227,6 @@ impl CloneOpenCodeProfileFromSeed { AppError::Internal("canonical OpenCode seed `opencode-llamacpp` is missing".into()) })?; - if seed.structured_adapter != Some(StructuredAdapter::OpenCode) || seed.opencode.is_none() { - return Err(AppError::Internal( - "canonical OpenCode seed is not an OpenCode profile".into(), - )); - } - let mut profile = seed; profile.id = fresh_profile_id(&*self.ids, &existing)?; profile.name = match input.name { @@ -227,7 +239,11 @@ impl CloneOpenCodeProfileFromSeed { None => format!("{} copy", profile.name), }; if let Some(config) = input.opencode { + // Honour the `opencode_backend_is_consistent` invariant (#97): + // switching the clone to a local llamacpp backend evicts any cloud + // backend carried by the seed (mirrors `AgentProfile::with_opencode`). profile.opencode = Some(config); + profile.opencode_provider = None; } self.store.save(&profile).await?; diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 2593295..7bb154a 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -18,7 +18,9 @@ use domain::ports::{ AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SecretRef, SecretStore, SecretStoreError, SessionPlan, SpawnSpec, StoreError, }; -use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter}; +use domain::profile::{ + AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter, +}; use domain::project::ProjectPath; use application::{ @@ -488,6 +490,68 @@ async fn save_opencode_provider_profile_seals_the_literal_key_behind_a_secret_re assert_eq!(resolved, Some("sk-live-literal-secret".to_owned())); } +#[tokio::test] +async fn save_opencode_provider_profile_drops_stale_local_backend() { + // Regression (ticket #97): the first-run wizard could hand in a profile that + // already carries an `opencode` (llamacpp) backend. Saving a cloud provider + // must clear it, otherwise the persisted profile keeps both backends and + // llamacpp wins at runtime. + let store = FakeProfileStore::default(); + let secrets = FakeSecretStore::default(); + let save = SaveOpenCodeProviderProfile::new( + Arc::new(store.clone()), + Arc::new(secrets.clone()), + Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(9710)])), + ); + + let base = profile(94, "OpenCode Cloud", "opencode") + .with_structured_adapter(StructuredAdapter::OpenCode) + .with_opencode( + OpenCodeConfig::new( + "http://localhost:8080/v1", + None, + "qwen3-coder-30b", + None, + None, + ) + .unwrap(), + ); + assert!( + base.opencode.is_some(), + "precondition: input profile carries the stale local backend" + ); + + let out = save + .execute(SaveOpenCodeProviderProfileInput { + profile: base, + provider_id: "anthropic".to_owned(), + model: "claude-sonnet-5".to_owned(), + api_key: "sk-cloud".to_owned(), + custom: None, + }) + .await + .unwrap(); + + assert!(out.profile.opencode.is_none(), "stale local backend dropped"); + assert_eq!( + out.profile.opencode_provider.as_ref().unwrap().provider_id, + "anthropic" + ); + assert!( + out.profile.opencode_backend_is_consistent(), + "persisted profile honours the mutual-exclusion invariant" + ); + + // The cleared state is what reached the store. + let persisted = store.list().await.unwrap(); + let saved = persisted + .iter() + .find(|p| p.id == out.profile.id) + .expect("profile persisted"); + assert!(saved.opencode.is_none()); + assert!(saved.opencode_provider.is_some()); +} + #[tokio::test] async fn delete_profile_with_opencode_provider_purges_its_secret() { let store = FakeProfileStore::default(); @@ -621,6 +685,149 @@ async fn clone_opencode_profile_prefers_persisted_seed_without_recreating_it() { ); } +#[tokio::test] +async fn clone_opencode_profile_accepts_a_cloud_provider_seed() { + // Regression (ticket #92 + #97): the canonical seed id is deterministic, so + // a cloud-converted profile can end up occupying it — structured OpenCode + // with `opencode_provider` set and `opencode` cleared. Cloning must succeed + // (the seed is a valid OpenCode profile backed by a cloud provider) instead + // of erroring "canonical OpenCode seed is not an OpenCode profile", and the + // cloud backend must be reported on the clone. + let store = FakeProfileStore::default(); + + let cloud_seed = reference_profiles() + .into_iter() + .find(|profile| profile.id == reference_profile_id("opencode-llamacpp")) + .expect("seed exists") + .with_opencode_provider( + OpenCodeProviderConfig::new( + "anthropic".to_owned(), + "claude-sonnet-5".to_owned(), + SecretRef::new("ref-cloud-seed".to_owned()), + ) + .unwrap(), + ); + assert!(cloud_seed.opencode.is_none(), "precondition: cloud seed has no local backend"); + assert!(cloud_seed.opencode_provider.is_some(), "precondition: cloud seed has a provider"); + + SaveProfile::new(Arc::new(store.clone())) + .execute(SaveProfileInput { + profile: cloud_seed.clone(), + }) + .await + .unwrap(); + + let clone = CloneOpenCodeProfileFromSeed::new( + Arc::new(store.clone()), + Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3701)])), + ); + let out = clone + .execute(CloneOpenCodeProfileFromSeedInput { + name: Some("GLM5.2 copy".to_owned()), + opencode: None, + }) + .await + .expect("cloning a cloud-backed seed must succeed"); + + let seed_id = reference_profile_id("opencode-llamacpp"); + assert_ne!(out.profile.id, seed_id, "clone gets a fresh id"); + assert_eq!( + out.profile.id, + ProfileId::from_uuid(uuid::Uuid::from_u128(3701)) + ); + assert_eq!(out.profile.name, "GLM5.2 copy"); + assert_eq!( + out.profile.structured_adapter, + Some(StructuredAdapter::OpenCode), + "structured adapter preserved" + ); + assert!( + out.profile.opencode.is_none(), + "no local llamacpp backend synthesised on the clone" + ); + assert_eq!( + out.profile + .opencode_provider + .as_ref() + .map(|config| config.provider_id.as_str()), + Some("anthropic"), + "cloud backend is reported on the clone" + ); + assert!( + out.profile.opencode_backend_is_consistent(), + "clone honours the mutual-exclusion invariant" + ); + + // The original cloud seed is preserved untouched alongside the new clone. + let profiles = store.0.lock().unwrap().profiles.clone(); + assert_eq!(profiles.len(), 2); + assert!( + profiles + .iter() + .any(|profile| profile.id == seed_id && profile.opencode_provider.is_some()), + "canonical cloud seed preserved" + ); +} + +#[tokio::test] +async fn clone_opencode_profile_falls_back_to_catalogue_when_persisted_seed_is_not_opencode() { + // Robustness: if the persisted slot occupying the canonical seed id is NOT a + // valid OpenCode profile at all (unexpected store state), the use case falls + // back to the in-memory reference catalogue seed instead of crashing. + let store = FakeProfileStore::default(); + + let squatter = AgentProfile::new( + reference_profile_id("opencode-llamacpp"), + "Squatter", + "something-else", + Vec::new(), + ContextInjection::stdin(), + None, + "{projectRoot}", + None, + ) + .unwrap(); + assert_eq!(squatter.structured_adapter, None, "precondition: not an OpenCode profile"); + + SaveProfile::new(Arc::new(store.clone())) + .execute(SaveProfileInput { + profile: squatter, + }) + .await + .unwrap(); + + let clone = CloneOpenCodeProfileFromSeed::new( + Arc::new(store.clone()), + Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3702)])), + ); + let out = clone + .execute(CloneOpenCodeProfileFromSeedInput { + name: None, + opencode: None, + }) + .await + .expect("catalogue fallback must not crash"); + + // The clone is the local llamacpp catalogue seed (not the squatter). + assert_eq!( + out.profile.structured_adapter, + Some(StructuredAdapter::OpenCode) + ); + assert!( + out.profile.opencode.is_some(), + "fell back to the catalogue local llamacpp seed" + ); + assert_eq!( + out.profile + .opencode + .as_ref() + .map(|config| config.model.as_str()), + Some("qwen3-coder-30b"), + "catalogue seed values are reported" + ); + assert_eq!(out.profile.name, "OpenCode + llama.cpp copy"); +} + // --------------------------------------------------------------------------- // ReferenceProfiles / catalogue // ---------------------------------------------------------------------------