feat: finalise multi-profil Codex/Claude avec catalogue de modèles

- Backend : clone_profile_from_seed généralisé (non OpenCode)
- Backend : catalogue static Claude/Codex (3 modèles chacun, 1 recommandé)
- Backend : commandes Tauri list_claude_models/list_codex_models
- Frontend : ProfilesSettings refonte en onglets Codex/Claude + create/duplicate/edit/delete
- Frontend : ModelSelect searchable partagé + fallback saisie manuelle
- Frontend : assignation agent nom · modèle
- Tests QA : 4 profils modèles distincts (2 Claude, 2 Codex) assignés à agents
This commit is contained in:
2026-07-26 14:56:26 +02:00
parent c807a70fea
commit ea7ea71230
21 changed files with 1298 additions and 109 deletions

View File

@ -24,9 +24,10 @@ use domain::profile::{
use domain::project::ProjectPath;
use application::{
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
reference_profile_id, reference_profiles, AppError, CloneOpenCodeProfileFromSeed,
CloneOpenCodeProfileFromSeedInput, CloneProfileFromSeed, CloneProfileFromSeedInput,
ConfigureProfiles, ConfigureProfilesInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, FirstRunState, ListClaudeModels, ListCodexModels, ListProfiles,
ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile,
SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
};
@ -869,6 +870,114 @@ async fn clone_opencode_profile_falls_back_to_catalogue_when_persisted_seed_is_n
assert_eq!(out.profile.name, "OpenCode + llama.cpp copy");
}
#[tokio::test]
async fn clone_profile_from_seed_creates_codex_profile_with_fresh_id_and_model_override() {
let store = FakeProfileStore::default();
let clone = CloneProfileFromSeed::new(
Arc::new(store.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3801)])),
);
let out = clone
.execute(CloneProfileFromSeedInput {
seed_profile_id: reference_profile_id("codex"),
name: Some("Codex GPT-5".to_owned()),
model: Some("gpt-5-codex".to_owned()),
})
.await
.unwrap();
assert_eq!(
out.profile.id,
ProfileId::from_uuid(uuid::Uuid::from_u128(3801))
);
assert_eq!(out.profile.name, "Codex GPT-5");
assert_eq!(out.profile.model.as_deref(), Some("gpt-5-codex"));
assert_eq!(
out.profile.structured_adapter,
Some(StructuredAdapter::Codex)
);
assert_eq!(store.0.lock().unwrap().profiles, vec![out.profile]);
}
#[tokio::test]
async fn clone_profile_from_seed_prefers_persisted_seed_and_preserves_model_by_default() {
let store = FakeProfileStore::default();
let persisted = reference_profiles()
.into_iter()
.find(|profile| profile.id == reference_profile_id("claude"))
.expect("seed exists")
.with_model("claude-opus-4-8");
SaveProfile::new(Arc::new(store.clone()))
.execute(SaveProfileInput {
profile: persisted.clone(),
})
.await
.unwrap();
let clone = CloneProfileFromSeed::new(
Arc::new(store.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3802)])),
);
let out = clone
.execute(CloneProfileFromSeedInput {
seed_profile_id: persisted.id,
name: None,
model: None,
})
.await
.unwrap();
assert_eq!(out.profile.name, "Claude Code copy");
assert_eq!(out.profile.model.as_deref(), Some("claude-opus-4-8"));
assert_eq!(
out.profile.structured_adapter,
Some(StructuredAdapter::Claude)
);
assert_ne!(out.profile.id, persisted.id);
assert_eq!(store.0.lock().unwrap().profiles.len(), 2);
}
#[tokio::test]
async fn clone_profile_from_seed_rejects_blank_model_override() {
let store = FakeProfileStore::default();
let clone = CloneProfileFromSeed::new(
Arc::new(store),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3803)])),
);
let err = clone
.execute(CloneProfileFromSeedInput {
seed_profile_id: reference_profile_id("claude"),
name: Some("Claude blank".to_owned()),
model: Some(" ".to_owned()),
})
.await
.unwrap_err();
assert!(matches!(err, AppError::Invalid(_)));
}
#[tokio::test]
async fn clone_profile_from_seed_rejects_blank_name_override() {
let store = FakeProfileStore::default();
let clone = CloneProfileFromSeed::new(
Arc::new(store),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3804)])),
);
let err = clone
.execute(CloneProfileFromSeedInput {
seed_profile_id: reference_profile_id("codex"),
name: Some(" ".to_owned()),
model: Some("gpt-5-codex".to_owned()),
})
.await
.unwrap_err();
assert!(matches!(err, AppError::Invalid(_)));
}
// ---------------------------------------------------------------------------
// ReferenceProfiles / catalogue
// ---------------------------------------------------------------------------
@ -1049,3 +1158,22 @@ fn catalogue_gemini_and_aider_stay_pty_without_adapter() {
assert_eq!(by_command["gemini"].structured_adapter, None);
assert_eq!(by_command["aider"].structured_adapter, None);
}
#[test]
fn claude_and_codex_model_catalogues_are_static_and_searchable() {
let claude = ListClaudeModels::new().execute().models;
let codex = ListCodexModels::new().execute().models;
assert!(claude
.iter()
.any(|model| model.model_id == "claude-sonnet-5" && model.recommended));
assert!(codex
.iter()
.any(|model| model.model_id == "gpt-5-codex" && model.recommended));
assert!(claude
.iter()
.all(|model| model.adapter == StructuredAdapter::Claude && !model.display_name.is_empty()));
assert!(codex
.iter()
.all(|model| model.adapter == StructuredAdapter::Codex && !model.display_name.is_empty()));
}