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

@ -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?;