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:
2026-07-25 01:47:58 +02:00
parent 55330732dd
commit 69e5878679
2 changed files with 231 additions and 8 deletions

View File

@ -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
// ---------------------------------------------------------------------------