fix(backend): clone du seed OpenCode tolère un seed persisté converti en cloud
CloneOpenCodeProfileFromSeed::execute rejetait auparavant tout profil persisté squattant l'UUID déterministe du seed canonique opencode-llamacpp avec « internal error: canonical OpenCode seed is not an OpenCode profile ». Root cause: un profil persisté converti en backend cloud GLM5.2 (ticket #92, opencodeProvider) conserve l'UUID du seed ; le find le matchait et le garde-fou seed.opencode.is_none() — non mis à jour pour le backend cloud — déclenchait une erreur Internal bloquante. Correctif: - Le find exige désormais un seed OpenCode valide (structured_adapter OpenCode + au moins un backend opencode local OU opencode_provider cloud), et retombe sur le seed catalogue sinon au lieu d'errer sur un état de store inattendu. - L'override opencode local remet opencode_provider = None pour honorer l'invariant #97 opencode_backend_is_consistent (miroir de AgentProfile::with_opencode). - 2 tests de régression (fallback catalogue, seed cloud accepté) + 1 test de symétrie save ajoutés. Vérification: cargo test -p application --test profile_usecases -> 25 passed.
This commit is contained in:
@ -196,9 +196,27 @@ impl CloneOpenCodeProfileFromSeed {
|
||||
) -> Result<CloneOpenCodeProfileFromSeedOutput, AppError> {
|
||||
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?;
|
||||
|
||||
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user