diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 61b6865..9895848 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -70,3 +70,4 @@ - [ticket101-cross-talk-multi-project-rootcause](ticket101-cross-talk-multi-project-rootcause.md) — memory note ticket101-cross-talk-multi-project-rootcause - [ticket103-network-permission-ux-surface](ticket103-network-permission-ux-surface.md) — Stable UX convention for agent network permissions in IdeA. - [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix +- [multi-profile-codex-claude-model-catalogue-scoping](multi-profile-codex-claude-model-catalogue-scoping.md) — memory note multi-profile-codex-claude-model-catalogue-scoping diff --git a/.ideai/memory/multi-profile-codex-claude-model-catalogue-scoping.md b/.ideai/memory/multi-profile-codex-claude-model-catalogue-scoping.md new file mode 100644 index 0000000..19f3b49 --- /dev/null +++ b/.ideai/memory/multi-profile-codex-claude-model-catalogue-scoping.md @@ -0,0 +1,50 @@ +--- +name: multi-profile-codex-claude-model-catalogue-scoping +description: memory note multi-profile-codex-claude-model-catalogue-scoping +metadata: + type: project +--- +# Cadrage : profils multiples Codex/Claude + catalogue de modèles + +Demande utilisateur : plusieurs profils Codex et Claude, chacun avec son modèle, assignables aux +agents ; lister les modèles plutôt que saisie manuelle quand possible. + +## État vérifié de l'existant (2026-07-26) + +Le backend est déjà générique multi-profils, contrairement à ce qu'on pourrait croire à la lecture +seule des mémoires F35/F36 (qui documentaient le cas OpenCode) : + +- `AgentProfile` (crates/domain/src/profile.rs) porte déjà `model: Option` (ticket #99, + explicitement prévu pour Codex/Claude), et `profiles.json` (FsProfileStore) est une **liste** + indexée par `id`, pas un slot singleton par provider. +- Commandes déjà câblées : `list_profiles`, `save_profile` (upsert générique par id), + `delete_profile`, `reference_profiles`, `detect_profiles`. +- Ce qui existe **seulement pour OpenCode** : `clone_opencode_profile_from_seed` (alloue un id + frais via IdGenerator) et `save_opencode_provider_profile`, plus un vrai catalogue de modèles + (`crates/application/src/agent/provider_catalogue.rs`, lit le cache models.dev d'OpenCode avec + repli statique). +- `catalogue.rs` : un seul profil de référence Claude et un seul Codex, aucun `.with_model(...)`. +- Frontend `ProfilesSettings.tsx` : simple list+delete, pas de create/duplicate/edit inline ; + toute édition rouvre `FirstRunWizard`. + +## Gaps identifiés (pas de migration de schéma nécessaire) + +1. Backend : généraliser le pattern `CloneOpenCodeProfileFromSeed` (fresh_profile_id via + IdGenerator) en un use case `CloneProfileFromSeed` non spécifique à OpenCode, pour dupliquer un + profil Claude/Codex avec un nom + `model` en override. Ne PAS laisser le frontend miner l'id + (romprait la discipline IdGenerator déjà en place). +2. Backend : catalogue de modèles Claude/Codex — aucune API fiable côté CLI, donc liste statique + curée (même esprit que `static_fallback_catalogue()` d'OpenCode), exposée par commande Tauri + infaillible (`list_claude_models`/`list_codex_models` ou générique par `structuredAdapter`). + Le frontend garde toujours un champ de saisie manuelle en repli (liste jamais garantie + exhaustive). +3. Frontend : refonte `ProfilesSettings.tsx` en onglets Codex/Claude/OpenCode avec + create/duplicate/edit/delete par onglet + `ModelSelect` searchable partagé ; simplifier + `FirstRunWizard` pour ne créer qu'un profil par défaut par provider détecté, avec renvoi vers + Settings pour en ajouter d'autres. + +## Découpage de livraison +DevBackend (use case + catalogues + commandes, petit lot, zéro migration) → DevFrontend (refonte +Settings + first-run simplifié) → QA (créer 2 profils Claude modèles différents + 2 Codex, assigner +à des agents distincts, vérifier le bon modèle atteint la CLI au lancement, non-régression +OpenCode) → Git (branche feature unique, lot petit et couplé). \ No newline at end of file diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index dd9d93d..c14d689 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -41,22 +41,23 @@ use crate::dto::{ AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, - ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, - CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, - CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, - DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, - DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto, - EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, - FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, - GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, - HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, - LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, - MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, - ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, - PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, - ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto, - ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, - ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, + CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, + CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, + CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, + CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, + DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, + EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, + ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, + GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, + GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, + InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, + LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, + ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto, + OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, + ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, + ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, + ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, + ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, @@ -1188,6 +1189,22 @@ pub async fn list_opencode_providers( Ok(state.list_opencode_providers.execute().into()) } +/// `list_claude_models` — static curated Claude model catalogue. +#[tauri::command] +pub async fn list_claude_models( + state: State<'_, AppState>, +) -> Result { + Ok(state.list_claude_models.execute().into()) +} + +/// `list_codex_models` — static curated Codex model catalogue. +#[tauri::command] +pub async fn list_codex_models( + state: State<'_, AppState>, +) -> Result { + Ok(state.list_codex_models.execute().into()) +} + /// `save_opencode_provider_profile` — create or replace an OpenCode profile /// backed by a cloud provider (ticket #92, lot B3). The literal API key is /// sealed into the `SecretStore`, never persisted in `profiles.json`. @@ -1227,6 +1244,25 @@ pub async fn clone_opencode_profile_from_seed( .map_err(ErrorDto::from) } +/// `clone_profile_from_seed` — create a new profile instance from a +/// persisted/reference seed, with optional name/model overrides. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`NOT_FOUND` for an unknown seed, `STORE` on profiles +/// I/O failure, `INVALID` for a blank requested name/model). +#[tauri::command] +pub async fn clone_profile_from_seed( + request: CloneProfileFromSeedRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .clone_profile_from_seed + .execute(request.into()) + .await + .map(ProfileDto::from) + .map_err(ErrorDto::from) +} + /// `delete_profile` — delete a profile by id. /// /// # Errors diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 19c8ab1..f760220 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -255,6 +255,9 @@ pub fn run() { commands::save_profile, commands::save_opencode_provider_profile, commands::list_opencode_providers, + commands::list_claude_models, + commands::list_codex_models, + commands::clone_profile_from_seed, commands::clone_opencode_profile_from_seed, commands::delete_profile, commands::configure_profiles, diff --git a/crates/app-tauri/tests/dto_profiles.rs b/crates/app-tauri/tests/dto_profiles.rs index 1615d28..81cb35e 100644 --- a/crates/app-tauri/tests/dto_profiles.rs +++ b/crates/app-tauri/tests/dto_profiles.rs @@ -4,15 +4,17 @@ use app_tauri_lib::dto::{ parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto, - ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, - FirstRunStateDto, ProfileListDto, SaveProfileRequestDto, + CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, DetectProfilesRequestDto, + DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, ProfileModelCatalogDto, + SaveProfileRequestDto, }; use application::{ - CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput, - DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput, + CloneOpenCodeProfileFromSeedInput, CloneProfileFromSeedInput, ConfigureProfilesInput, + DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, + SaveProfileInput, }; use domain::ids::{LocalModelServerId, ProfileId}; -use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig}; +use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter}; use serde_json::json; use uuid::Uuid; @@ -121,6 +123,41 @@ fn clone_opencode_profile_from_seed_request_deserialises_camelcase_config() { assert_eq!(opencode.local_model_server_id, Some(server_id)); } +#[test] +fn clone_profile_from_seed_request_deserialises_camelcase_overrides() { + let seed_id = Uuid::from_u128(42); + let raw = json!({ + "seedProfileId": seed_id.to_string(), + "name": "Codex GPT-5", + "model": "gpt-5-codex" + }); + + let dto: CloneProfileFromSeedRequestDto = serde_json::from_value(raw).unwrap(); + let input: CloneProfileFromSeedInput = dto.into(); + assert_eq!(input.seed_profile_id, ProfileId::from_uuid(seed_id)); + assert_eq!(input.name.as_deref(), Some("Codex GPT-5")); + assert_eq!(input.model.as_deref(), Some("gpt-5-codex")); +} + +#[test] +fn profile_model_catalogue_dto_serialises_searchable_camelcase_entries() { + let dto = ProfileModelCatalogDto(vec![app_tauri_lib::dto::ProfileModelCatalogEntryDto { + adapter: StructuredAdapter::Codex, + model_id: "gpt-5-codex".to_owned(), + display_name: "GPT-5 Codex".to_owned(), + aliases: vec!["codex".to_owned()], + recommended: true, + }]); + + let value = serde_json::to_value(&dto).unwrap(); + let arr = value.as_array().expect("transparent array"); + assert_eq!(arr[0]["adapter"], "codex"); + assert_eq!(arr[0]["modelId"], "gpt-5-codex"); + assert_eq!(arr[0]["displayName"], "GPT-5 Codex"); + assert_eq!(arr[0]["aliases"], json!(["codex"])); + assert_eq!(arr[0]["recommended"], true); +} + #[test] fn opencode_config_dto_omits_local_model_server_id_when_none() { let config = OpenCodeConfig::new( diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 9035c68..5dbb49f 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -9,6 +9,7 @@ mod catalogue; mod inspect; mod lifecycle; +mod model_catalogue; mod provider_catalogue; mod resume; mod session_limit; @@ -39,6 +40,10 @@ pub use lifecycle::{ StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX, }; +pub use model_catalogue::{ + claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput, + ListCodexModels, ListCodexModelsOutput, ProfileModelCatalogEntry, +}; pub use provider_catalogue::{ opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders, ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry, @@ -48,10 +53,11 @@ pub use resume::{ }; pub use usecases::{ CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput, - CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, - ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles, - DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles, - ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput, - SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, - SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, + CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput, + CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, + DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, + FirstRunState, FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, + ReferenceProfiles, ReferenceProfilesOutput, SaveOpenCodeProviderProfile, + SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile, + SaveProfileInput, SaveProfileOutput, }; diff --git a/crates/application/src/agent/model_catalogue.rs b/crates/application/src/agent/model_catalogue.rs new file mode 100644 index 0000000..f4d2fb1 --- /dev/null +++ b/crates/application/src/agent/model_catalogue.rs @@ -0,0 +1,183 @@ +//! Static curated model catalogues for structured Claude/Codex profiles. +//! +//! The CLIs do not expose a stable machine-readable model catalogue. These lists +//! are therefore intentionally small, static and infallible; the UI must still +//! keep manual entry as a fallback for models not listed here. + +use domain::profile::StructuredAdapter; + +/// One searchable model entry for a structured profile adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProfileModelCatalogEntry { + /// Structured adapter this model belongs to. + pub adapter: StructuredAdapter, + /// Exact model identifier to persist on [`domain::profile::AgentProfile::model`]. + pub model_id: String, + /// Human-readable label for picker display. + pub display_name: String, + /// Extra search tokens useful to the frontend. + pub aliases: Vec, + /// Whether this entry is the conservative default suggestion. + pub recommended: bool, +} + +fn entry( + adapter: StructuredAdapter, + model_id: &str, + display_name: &str, + aliases: &[&str], + recommended: bool, +) -> ProfileModelCatalogEntry { + ProfileModelCatalogEntry { + adapter, + model_id: model_id.to_owned(), + display_name: display_name.to_owned(), + aliases: aliases.iter().map(|alias| (*alias).to_owned()).collect(), + recommended, + } +} + +/// Static Claude Code model catalogue. +#[must_use] +pub fn claude_model_catalogue() -> Vec { + vec![ + entry( + StructuredAdapter::Claude, + "claude-sonnet-5", + "Claude Sonnet 5", + &["sonnet"], + true, + ), + entry( + StructuredAdapter::Claude, + "claude-opus-4-8", + "Claude Opus 4.8", + &["opus"], + false, + ), + entry( + StructuredAdapter::Claude, + "claude-haiku-4-5-20251001", + "Claude Haiku 4.5", + &["haiku"], + false, + ), + ] +} + +/// Static OpenAI Codex CLI model catalogue. +#[must_use] +pub fn codex_model_catalogue() -> Vec { + vec![ + entry( + StructuredAdapter::Codex, + "gpt-5-codex", + "GPT-5 Codex", + &["codex"], + true, + ), + entry( + StructuredAdapter::Codex, + "gpt-5", + "GPT-5", + &["general"], + false, + ), + entry( + StructuredAdapter::Codex, + "gpt-5-mini", + "GPT-5 mini", + &["mini", "fast"], + false, + ), + ] +} + +/// Use case exposing the static Claude model catalogue. +pub struct ListClaudeModels; + +/// Output of [`ListClaudeModels::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListClaudeModelsOutput { + /// The catalogue entries. + pub models: Vec, +} + +impl ListClaudeModels { + /// Builds the use case (stateless, no ports to inject). + #[must_use] + pub const fn new() -> Self { + Self + } + + /// Lists curated Claude models. Infallible. + #[must_use] + pub fn execute(&self) -> ListClaudeModelsOutput { + ListClaudeModelsOutput { + models: claude_model_catalogue(), + } + } +} + +impl Default for ListClaudeModels { + fn default() -> Self { + Self::new() + } +} + +/// Use case exposing the static Codex model catalogue. +pub struct ListCodexModels; + +/// Output of [`ListCodexModels::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListCodexModelsOutput { + /// The catalogue entries. + pub models: Vec, +} + +impl ListCodexModels { + /// Builds the use case (stateless, no ports to inject). + #[must_use] + pub const fn new() -> Self { + Self + } + + /// Lists curated Codex models. Infallible. + #[must_use] + pub fn execute(&self) -> ListCodexModelsOutput { + ListCodexModelsOutput { + models: codex_model_catalogue(), + } + } +} + +impl Default for ListCodexModels { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn static_catalogues_are_non_empty_searchable_and_have_one_default() { + for (adapter, models) in [ + (StructuredAdapter::Claude, claude_model_catalogue()), + (StructuredAdapter::Codex, codex_model_catalogue()), + ] { + assert!(!models.is_empty()); + assert_eq!( + models.iter().filter(|model| model.recommended).count(), + 1, + "{adapter:?} should expose one default suggestion" + ); + for model in models { + assert_eq!(model.adapter, adapter); + assert!(!model.model_id.trim().is_empty()); + assert!(!model.display_name.trim().is_empty()); + } + } + } +} diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index 5cfd809..8cd82fa 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -146,6 +146,92 @@ pub struct SaveProfileOutput { pub profile: AgentProfile, } +// --------------------------------------------------------------------------- +// CloneProfileFromSeed +// --------------------------------------------------------------------------- + +/// Input for [`CloneProfileFromSeed::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloneProfileFromSeedInput { + /// Id of the persisted or reference profile to clone. + pub seed_profile_id: ProfileId, + /// Optional display name for the cloned profile. When absent, a copy label is + /// derived from the seed name. + pub name: Option, + /// Optional model override. When absent, the seed model is copied as-is. + pub model: Option, +} + +/// Output of [`CloneProfileFromSeed::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloneProfileFromSeedOutput { + /// The newly persisted profile. + pub profile: AgentProfile, +} + +/// Creates a new profile instance from an existing persisted/reference seed. +/// +/// Persisted profiles are preferred over reference seeds so user edits to the +/// seed are preserved. The clone always receives a fresh [`ProfileId`] from the +/// backend [`IdGenerator`]; callers can override the display name and model +/// without minting ids client-side. +pub struct CloneProfileFromSeed { + store: Arc, + ids: Arc, +} + +impl CloneProfileFromSeed { + /// Builds the use case from the profile store and id generator ports. + #[must_use] + pub fn new(store: Arc, ids: Arc) -> Self { + Self { store, ids } + } + + /// Clones the requested seed into a new persisted profile. + /// + /// # Errors + /// [`AppError::NotFound`] if no persisted/reference profile has the seed id, + /// [`AppError::Invalid`] if `name` or `model` is blank, [`AppError::Store`] + /// on persistence failure. + pub async fn execute( + &self, + input: CloneProfileFromSeedInput, + ) -> Result { + let existing = self.store.list().await?; + let seed = existing + .iter() + .find(|profile| profile.id == input.seed_profile_id) + .cloned() + .or_else(|| { + reference_profiles() + .into_iter() + .find(|profile| profile.id == input.seed_profile_id) + }) + .ok_or(AppError::NotFound("profile seed not found".into()))?; + + let mut profile = seed; + profile.id = fresh_profile_id(&*self.ids, &existing)?; + profile.name = match input.name { + Some(name) => { + if name.trim().is_empty() { + return Err(AppError::Invalid("profile.name must not be empty".into())); + } + name + } + None => format!("{} copy", profile.name), + }; + if let Some(model) = input.model { + if model.trim().is_empty() { + return Err(AppError::Invalid("profile.model must not be empty".into())); + } + profile.model = Some(model); + } + + self.store.save(&profile).await?; + Ok(CloneProfileFromSeedOutput { profile }) + } +} + // --------------------------------------------------------------------------- // CloneOpenCodeProfileFromSeed // --------------------------------------------------------------------------- diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 851181c..307c821 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -45,16 +45,18 @@ pub use agent::{ reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer, AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput, - CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, - ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, - DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, - DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider, - InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput, - LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, - ListAgentsOutput, ListOpenCodeProviders, ListOpenCodeProvidersOutput, ListProfiles, - ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, - LiveStateLeanProvider, McpRuntime, OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, - ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, + CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput, + CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, + CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, + DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, + FirstRunState, FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation, + InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput, + LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListClaudeModels, + ListClaudeModelsOutput, ListCodexModels, ListCodexModelsOutput, ListOpenCodeProviders, + ListOpenCodeProvidersOutput, ListProfiles, ListProfilesOutput, ListResumableAgents, + ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime, + OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, ProfileAvailability, + ProfileModelCatalogEntry, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 9db2a6c..0986e9f 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -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())); +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 258065a..5da2b89 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1047,6 +1047,57 @@ impl From for ProfileDto { } } +impl From for ProfileDto { + fn from(out: application::CloneProfileFromSeedOutput) -> Self { + Self(out.profile) + } +} + +/// One entry of a curated structured-profile model catalogue. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileModelCatalogEntryDto { + /// Structured adapter this model belongs to. + pub adapter: domain::profile::StructuredAdapter, + /// Exact model identifier to persist on `AgentProfile.model`. + pub model_id: String, + /// Human-readable label for picker display. + pub display_name: String, + /// Extra search tokens useful to the frontend. + pub aliases: Vec, + /// Whether this entry is the conservative default suggestion. + pub recommended: bool, +} + +impl From for ProfileModelCatalogEntryDto { + fn from(entry: application::ProfileModelCatalogEntry) -> Self { + Self { + adapter: entry.adapter, + model_id: entry.model_id, + display_name: entry.display_name, + aliases: entry.aliases, + recommended: entry.recommended, + } + } +} + +/// A list of curated structured-profile models. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct ProfileModelCatalogDto(pub Vec); + +impl From for ProfileModelCatalogDto { + fn from(out: application::ListClaudeModelsOutput) -> Self { + Self(out.models.into_iter().map(Into::into).collect()) + } +} + +impl From for ProfileModelCatalogDto { + fn from(out: application::ListCodexModelsOutput) -> Self { + Self(out.models.into_iter().map(Into::into).collect()) + } +} + /// One entry of the static OpenCode cloud-provider catalogue (ticket #92, lot B3). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -1202,6 +1253,30 @@ impl From for CloneOpenCodeProfileFromSe } } +/// Request DTO for `clone_profile_from_seed`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloneProfileFromSeedRequestDto { + /// Id of the persisted or reference profile to clone. + pub seed_profile_id: domain::ids::ProfileId, + /// Optional display name for the new profile. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Optional model override. When omitted, the seed model is copied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +impl From for application::CloneProfileFromSeedInput { + fn from(dto: CloneProfileFromSeedRequestDto) -> Self { + Self { + seed_profile_id: dto.seed_profile_id, + name: dto.name, + model: dto.model, + } + } +} + /// Request DTO for `configure_profiles` (closes the first run). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 5526b71..95f4711 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -15,24 +15,24 @@ use application::{ AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, - CloneOpenCodeProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, - ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, - CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, - CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, - DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, + CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, + CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, + CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, + CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, + DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, - LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, - ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders, - ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents, - ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, - LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, - MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, + LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, + ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, + ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, + ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, + LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, + McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, + OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, @@ -940,6 +940,12 @@ pub struct BackendCore { pub save_opencode_provider_profile: Arc, /// Static catalogue of OpenCode cloud providers (ticket #92, lot B3). pub list_opencode_providers: Arc, + /// Static curated Claude model catalogue. + pub list_claude_models: Arc, + /// Static curated Codex model catalogue. + pub list_codex_models: Arc, + /// Create a new profile instance from a persisted/reference seed. + pub clone_profile_from_seed: Arc, /// Create a new OpenCode profile instance from the canonical seed. pub clone_opencode_profile_from_seed: Arc, /// Delete a profile. @@ -1468,6 +1474,12 @@ impl BackendCore { Arc::clone(&ids) as Arc, )); let list_opencode_providers = Arc::new(ListOpenCodeProviders::new()); + let list_claude_models = Arc::new(ListClaudeModels::new()); + let list_codex_models = Arc::new(ListCodexModels::new()); + let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new( + Arc::clone(&profile_store_port), + Arc::clone(&ids) as Arc, + )); let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new( Arc::clone(&profile_store_port), Arc::clone(&ids) as Arc, @@ -2661,6 +2673,9 @@ impl BackendCore { save_profile, save_opencode_provider_profile, list_opencode_providers, + list_claude_models, + list_codex_models, + clone_profile_from_seed, clone_opencode_profile_from_seed, delete_profile, configure_profiles, diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index c2c7dfc..c44c5e0 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -42,6 +42,7 @@ import type { ProjectWorkState, ProjectSystemPermissions, ProfileAvailability, + ProfileModelCatalogEntry, ResolvedAgentSystemPermissions, SystemPermissionSet, Skill, @@ -50,6 +51,7 @@ import type { TurnPage, } from "@/domain"; import type { + CloneProfileFromSeedInput, CloneOpenCodeProfileFromSeedInput, ConversationGateway, ConversationPageRequest, @@ -176,6 +178,17 @@ export class HttpProfileGateway implements ProfileGateway { async deleteProfile(profileId: string): Promise { await this.http.invoke("delete_profile", { profileId }); } + cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise { + return this.http.invoke("clone_profile_from_seed", { + request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model }, + }); + } + listClaudeModels(): Promise { + return this.http.invoke("list_claude_models"); + } + listCodexModels(): Promise { + return this.http.invoke("list_codex_models"); + } configureProfiles(profiles: AgentProfile[]): Promise { return this.http.invoke("configure_profiles", { request: { profiles } }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 153a036..31b62c7 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -35,6 +35,7 @@ import type { McpToolCatalogue, McpToolPolicy, OpenCodeProviderCatalogEntry, + ProfileModelCatalogEntry, EffectivePermissions, PairedDevice, PairingCode, @@ -80,6 +81,7 @@ import type { ConversationGateway, ConversationPageRequest, ConversationDetails, + CloneProfileFromSeedInput, CloneOpenCodeProfileFromSeedInput, CreateAgentInput, CreateMemoryInput, @@ -1293,6 +1295,54 @@ const MOCK_OPENCODE_PROVIDERS: OpenCodeProviderCatalogEntry[] = [ }, ]; +const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [ + { + adapter: "claude", + modelId: "claude-sonnet-5", + displayName: "Claude Sonnet 5", + aliases: ["sonnet"], + recommended: true, + }, + { + adapter: "claude", + modelId: "claude-opus-4-8", + displayName: "Claude Opus 4.8", + aliases: ["opus"], + recommended: false, + }, + { + adapter: "claude", + modelId: "claude-haiku-4-5-20251001", + displayName: "Claude Haiku 4.5", + aliases: ["haiku"], + recommended: false, + }, +]; + +const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [ + { + adapter: "codex", + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + aliases: ["codex"], + recommended: true, + }, + { + adapter: "codex", + modelId: "gpt-5", + displayName: "GPT-5", + aliases: ["general"], + recommended: false, + }, + { + adapter: "codex", + modelId: "gpt-5-mini", + displayName: "GPT-5 mini", + aliases: ["mini", "fast"], + recommended: false, + }, +]; + /** * In-memory profiles gateway. Tracks configured profiles and a first-run flag so * the wizard can be driven and tested fully offline. By default it reports the @@ -1348,6 +1398,36 @@ export class MockProfileGateway implements ProfileGateway { return structuredClone(profiles); } + async cloneProfileFromSeed( + input: CloneProfileFromSeedInput, + ): Promise { + const seed = [...this.profiles, ...MOCK_REFERENCE_PROFILES].find( + (p) => p.id === input.seedProfileId, + ); + if (!seed) throw new Error(`unknown profile seed: ${input.seedProfileId}`); + this.cloneCounter += 1; + const cloned: AgentProfile = { + ...structuredClone(seed), + id: `mock-profile-clone-${this.cloneCounter}`, + name: input.name ?? `${seed.name} copy`, + model: + input.model !== undefined && input.model.trim() !== "" + ? input.model + : seed.model, + }; + this.profiles.push(cloned); + this.configured = true; + return structuredClone(cloned); + } + + async listClaudeModels(): Promise { + return structuredClone(MOCK_CLAUDE_MODELS); + } + + async listCodexModels(): Promise { + return structuredClone(MOCK_CODEX_MODELS); + } + async cloneOpenCodeProfileFromSeed( input: CloneOpenCodeProfileFromSeedInput = {}, ): Promise { diff --git a/frontend/src/adapters/profile.ts b/frontend/src/adapters/profile.ts index ea355ed..b68cee5 100644 --- a/frontend/src/adapters/profile.ts +++ b/frontend/src/adapters/profile.ts @@ -12,9 +12,11 @@ import type { AgentProfile, FirstRunState, OpenCodeProviderCatalogEntry, + ProfileModelCatalogEntry, ProfileAvailability, } from "@/domain"; import type { + CloneProfileFromSeedInput, CloneOpenCodeProfileFromSeedInput, ProfileGateway, SaveOpenCodeProviderProfileInput, @@ -47,6 +49,24 @@ export class TauriProfileGateway implements ProfileGateway { await invoke("delete_profile", { profileId }); } + cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise { + return invoke("clone_profile_from_seed", { + request: { + seedProfileId: input.seedProfileId, + name: input.name, + model: input.model, + }, + }); + } + + listClaudeModels(): Promise { + return invoke("list_claude_models"); + } + + listCodexModels(): Promise { + return invoke("list_codex_models"); + } + configureProfiles(profiles: AgentProfile[]): Promise { return invoke("configure_profiles", { request: { profiles }, diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 936164a..7e69149 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1096,6 +1096,20 @@ export interface OpenCodeProviderCatalogEntry { models: string[]; } +/** One searchable model from the Codex/Claude structured-profile catalogues. */ +export interface ProfileModelCatalogEntry { + /** Structured adapter this model belongs to. */ + adapter: "claude" | "codex"; + /** Exact model identifier to persist on `AgentProfile.model`. */ + modelId: string; + /** Human-readable label for picker display. */ + displayName: string; + /** Extra search tokens useful to the frontend. */ + aliases: string[]; + /** Whether this entry is the conservative default suggestion. */ + recommended: boolean; +} + /** * A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a * UUID string; `detect` is the optional detection command line. diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 1c12268..c9c78d8 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -237,6 +237,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { // Determine if a template is chosen → profile selector is hidden (template imposes it). const hasTemplate = newTemplateId !== ""; + const profileLabel = (profile: import("@/domain").AgentProfile): string => { + const model = + profile.model ?? + profile.opencode?.model ?? + profile.opencodeProvider?.model ?? + profile.chatHttp?.model; + return model ? `${profile.name} · ${model}` : profile.name; + }; + return ( {vm.error && ( @@ -326,7 +335,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { {vm.profiles.map((p) => ( ))} @@ -366,7 +375,10 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { const isRunning = a.id === activeAgentId; const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id); const profileName = - vm.profiles.find((p) => p.id === a.profileId)?.name ?? + (() => { + const p = vm.profiles.find((p) => p.id === a.profileId); + return p ? profileLabel(p) : null; + })() ?? a.profileId; const agentDrift = drift.driftByAgentId.get(a.id); // Source of this agent's last orchestration delegation (mcp vs @@ -478,7 +490,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { )} {vm.profiles.map((p) => ( ))} diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx index c7f1232..f1d4338 100644 --- a/frontend/src/features/agents/agents.test.tsx +++ b/frontend/src/features/agents/agents.test.tsx @@ -141,6 +141,42 @@ describe("AgentsPanel (with MockAgentGateway)", () => { expect((btn as HTMLButtonElement).disabled).toBe(true); }); + it("shows profile names with their model in the assignment selector", async () => { + const profile = new MockProfileGateway(); + await profile.saveProfile({ + id: "codex-fast", + name: "Codex fast", + command: "codex", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: "codex --version", + cwdTemplate: "{projectRoot}", + structuredAdapter: "codex", + model: "gpt-5-mini", + }); + await profile.saveProfile({ + id: "claude-opus", + name: "Claude deep", + command: "claude", + args: [], + contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" }, + detect: "claude --version", + cwdTemplate: "{projectRoot}", + structuredAdapter: "claude", + model: "claude-opus-4-8", + }); + + renderPanel(new MockAgentGateway(), profile); + await waitForIdle(); + + const labels = Array.from( + screen.getByLabelText("agent profile").querySelectorAll("option"), + ).map((option) => option.textContent); + + expect(labels).toContain("Codex fast · gpt-5-mini"); + expect(labels).toContain("Claude deep · claude-opus-4-8"); + }); + it("selecting an agent displays its context", async () => { const agent = new MockAgentGateway(); // Pre-seed an agent with initial content. diff --git a/frontend/src/features/first-run/ProfilesSettings.test.tsx b/frontend/src/features/first-run/ProfilesSettings.test.tsx new file mode 100644 index 0000000..75a50fb --- /dev/null +++ b/frontend/src/features/first-run/ProfilesSettings.test.tsx @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; + +import { DIProvider } from "@/app/di"; +import { MockProfileGateway } from "@/adapters/mock"; +import type { Gateways } from "@/ports"; +import type { ProfileModelCatalogEntry } from "@/domain"; +import { ProfilesSettings } from "./ProfilesSettings"; + +function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) { + return { + profile, + ...render( + + + , + ), + }; +} + +async function waitReady() { + await waitFor(() => + expect( + (screen.getByRole("button", { name: "Creer un profil" }) as HTMLButtonElement) + .disabled, + ).toBe(false), + ); +} + +async function createProfile() { + const before = screen.queryAllByRole("listitem").length; + fireEvent.click(screen.getByRole("button", { name: "Creer un profil" })); + await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(before + 1)); +} + +describe("ProfilesSettings", () => { + it("creates multiple named Codex and Claude profiles with different models", async () => { + const { profile } = renderSettings(); + await waitReady(); + + await createProfile(); + await createProfile(); + let rows = screen.getAllByRole("listitem"); + fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), { + target: { value: "Codex mini" }, + }); + fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), { + target: { value: "gpt-5-mini" }, + }); + fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" })); + + fireEvent.click(screen.getByRole("tab", { name: "Claude" })); + await waitReady(); + await createProfile(); + await createProfile(); + rows = screen.getAllByRole("listitem"); + fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), { + target: { value: "Claude Opus" }, + }); + fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), { + target: { value: "claude-opus-4-8" }, + }); + fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + expect(saved.filter((p) => p.structuredAdapter === "codex")).toHaveLength(2); + expect(saved.filter((p) => p.structuredAdapter === "claude")).toHaveLength(2); + expect(saved.map((p) => p.model)).toEqual( + expect.arrayContaining([ + "gpt-5-codex", + "gpt-5-mini", + "claude-sonnet-5", + "claude-opus-4-8", + ]), + ); + }); + }); + + it("duplicates from an existing profile with ' copy' and preserves the model", async () => { + const { profile } = renderSettings(); + await waitReady(); + await createProfile(); + + const row = screen.getAllByRole("listitem")[0]; + fireEvent.click(within(row).getByRole("button", { name: "Dupliquer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + expect(saved.some((p) => p.name === "OpenAI Codex CLI copy copy")).toBe(true); + expect(saved.filter((p) => p.model === "gpt-5-codex")).toHaveLength(2); + }); + }); + + it("keeps manual model entry available when the catalogue fails", async () => { + class CatalogueDownProfileGateway extends MockProfileGateway { + listCodexModels(): Promise { + return Promise.reject(new Error("catalogue down")); + } + } + + renderSettings(new CatalogueDownProfileGateway()); + await waitReady(); + expect(await screen.findByText(/saisie manuelle active/)).toBeTruthy(); + + await createProfile(); + const model = within(screen.getAllByRole("listitem")[0]).getByLabelText( + /modele du profil/, + ) as HTMLInputElement; + fireEvent.change(model, { target: { value: "future-codex-model" } }); + expect(model.value).toBe("future-codex-model"); + }); +}); diff --git a/frontend/src/features/first-run/ProfilesSettings.tsx b/frontend/src/features/first-run/ProfilesSettings.tsx index 7a2d1d9..ab601d0 100644 --- a/frontend/src/features/first-run/ProfilesSettings.tsx +++ b/frontend/src/features/first-run/ProfilesSettings.tsx @@ -1,34 +1,94 @@ /** - * Minimal "Settings → AI Profiles" panel (L5). An always-available entry point - * to review the configured profiles and re-run the setup wizard after the first - * run. Kept intentionally small; richer per-profile editing reuses the wizard. - * - * Pure presentation over the {@link ProfileGateway} port (no `invoke()`). + * Settings -> AI Profiles. This is the durable CRUD surface for named runtime + * profiles; first-run stays a small default-profile bootstrap. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; -import type { AgentProfile, GatewayError } from "@/domain"; +import type { + AgentProfile, + GatewayError, + ProfileModelCatalogEntry, +} from "@/domain"; import { useGateways } from "@/app/di"; -import { Button, Panel } from "@/shared"; -import { FirstRunWizard } from "./FirstRunWizard"; +import { Button, Input, Panel, cn } from "@/shared"; + +type ProfileTab = "codex" | "claude" | "openCode"; + +const TABS: Array<{ id: ProfileTab; label: string }> = [ + { id: "codex", label: "Codex" }, + { id: "claude", label: "Claude" }, + { id: "openCode", label: "OpenCode-local" }, +]; + +const EMPTY_CATALOGUE: Record<"codex" | "claude", ProfileModelCatalogEntry[]> = { + codex: [], + claude: [], +}; + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +function tabFor(profile: AgentProfile): ProfileTab | null { + if (profile.structuredAdapter === "codex") return "codex"; + if (profile.structuredAdapter === "claude") return "claude"; + if (profile.structuredAdapter === "openCode" && profile.opencode) { + return "openCode"; + } + return null; +} + +function modelOf(profile: AgentProfile): string { + if (profile.structuredAdapter === "openCode") { + return profile.opencode?.model ?? profile.opencodeProvider?.model ?? ""; + } + return profile.model ?? ""; +} + +function withModel(profile: AgentProfile, model: string): AgentProfile { + const nextModel = model.trim() || undefined; + if (profile.structuredAdapter === "openCode" && profile.opencode) { + return { + ...profile, + opencode: { ...profile.opencode, model: model.trim() }, + }; + } + return { ...profile, model: nextModel }; +} + +function optionLabel(entry: ProfileModelCatalogEntry): string { + return entry.recommended + ? `${entry.displayName} (${entry.modelId}, recommande)` + : `${entry.displayName} (${entry.modelId})`; +} export function ProfilesSettings() { const { profile } = useGateways(); const [profiles, setProfiles] = useState([]); + const [references, setReferences] = useState([]); + const [catalogue, setCatalogue] = useState(EMPTY_CATALOGUE); + const [activeTab, setActiveTab] = useState("codex"); + const [drafts, setDrafts] = useState>({}); const [error, setError] = useState(null); - const [editing, setEditing] = useState(false); + const [catalogueWarning, setCatalogueWarning] = useState(null); + const [busy, setBusy] = useState(false); const refresh = useCallback(async () => { setError(null); try { - setProfiles(await profile.listProfiles()); + const [saved, refs] = await Promise.all([ + profile.listProfiles(), + profile.referenceProfiles(), + ]); + setProfiles(saved); + setReferences(refs); + setDrafts(Object.fromEntries(saved.map((p) => [p.id, p]))); } catch (e) { - setError( - e && typeof e === "object" && "message" in e - ? String((e as GatewayError).message) - : String(e), - ); + setError(describe(e)); } }, [profile]); @@ -36,64 +96,263 @@ export function ProfilesSettings() { void refresh(); }, [refresh]); - async function del(id: string) { - await profile.deleteProfile(id); - await refresh(); + useEffect(() => { + let cancelled = false; + async function loadCatalogue() { + setCatalogueWarning(null); + const [codex, claude] = await Promise.allSettled([ + profile.listCodexModels(), + profile.listClaudeModels(), + ]); + if (cancelled) return; + setCatalogue({ + codex: codex.status === "fulfilled" ? codex.value : [], + claude: claude.status === "fulfilled" ? claude.value : [], + }); + if (codex.status === "rejected" || claude.status === "rejected") { + setCatalogueWarning( + "Catalogue de modeles indisponible: saisie manuelle active.", + ); + } + } + void loadCatalogue(); + return () => { + cancelled = true; + }; + }, [profile]); + + const visibleProfiles = useMemo( + () => profiles.filter((p) => tabFor(p) === activeTab), + [profiles, activeTab], + ); + + const seed = useMemo( + () => references.find((p) => tabFor(p) === activeTab) ?? null, + [references, activeTab], + ); + + function updateDraft(id: string, updater: (profile: AgentProfile) => AgentProfile) { + setDrafts((prev) => { + const current = prev[id] ?? profiles.find((p) => p.id === id); + if (!current) return prev; + return { ...prev, [id]: updater(current) }; + }); } - if (editing) { - // Reopened after the first run, so force the wizard to render. - return ( - { - setEditing(false); - void refresh(); - }} - /> - ); + async function createFromSeed() { + if (!seed) return; + setBusy(true); + setError(null); + try { + const models = + activeTab === "codex" || activeTab === "claude" + ? catalogue[activeTab] + : []; + const recommended = models.find((m) => m.recommended)?.modelId; + await profile.cloneProfileFromSeed({ + seedProfileId: seed.id, + name: `${seed.name} copy`, + model: recommended, + }); + await refresh(); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } } + async function save(id: string) { + const draft = drafts[id]; + if (!draft) return; + setBusy(true); + setError(null); + try { + await profile.saveProfile(draft); + await refresh(); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + } + + async function duplicate(source: AgentProfile) { + setBusy(true); + setError(null); + try { + await profile.cloneProfileFromSeed({ + seedProfileId: source.id, + name: `${source.name} copy`, + model: + source.structuredAdapter === "codex" || + source.structuredAdapter === "claude" + ? modelOf(source) || undefined + : undefined, + }); + await refresh(); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + } + + async function del(source: AgentProfile) { + setBusy(true); + setError(null); + try { + await profile.deleteProfile(source.id); + await refresh(); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + } + + const modelOptions = + activeTab === "codex" || activeTab === "claude" ? catalogue[activeTab] : []; + return ( setEditing(true)}> - Configurer les profils + } > -
+
+
+ {TABS.map((tab) => ( + + ))} +
+ {error && (

{error}

)} + {catalogueWarning && ( +

{catalogueWarning}

+ )} - {profiles.length === 0 ? ( -

Aucun profil configuré.

+ + {modelOptions.map((entry) => ( + + ))} + + + {visibleProfiles.length === 0 ? ( +

+ Aucun profil {TABS.find((tab) => tab.id === activeTab)?.label} configure. +

) : ( -
    - {profiles.map((p) => ( -
  • - - {p.name} - {p.command} - - -
  • - ))} +
    + + + +
    + +
    + + {draft.command} + {model ? ` · ${model}` : ""} + + + + + + +
    + + ); + })}
)}
diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 790742c..cc4815f 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -37,6 +37,7 @@ import type { McpToolPolicy, OpenCodeConfig, OpenCodeProviderCatalogEntry, + ProfileModelCatalogEntry, EffectivePermissions, PairedDevice, PairingCode, @@ -664,6 +665,15 @@ export interface ProfileGateway { saveProfile(profile: AgentProfile): Promise; /** Deletes a profile by id. */ deleteProfile(profileId: string): Promise; + /** + * Clones a persisted or reference profile seed and saves the fresh profile. + * Used by Settings duplication for Codex/Claude/OpenCode identity copies. + */ + cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise; + /** Curated Claude Code model catalogue. Manual model entry remains supported. */ + listClaudeModels(): Promise; + /** Curated Codex CLI model catalogue. Manual model entry remains supported. */ + listCodexModels(): Promise; /** Persists the batch of chosen profiles, closing the first run. */ configureProfiles(profiles: AgentProfile[]): Promise; /** @@ -701,6 +711,16 @@ export interface CloneOpenCodeProfileFromSeedInput { opencode?: OpenCodeConfig; } +/** Input for {@link ProfileGateway.cloneProfileFromSeed}. */ +export interface CloneProfileFromSeedInput { + /** Id of the persisted or reference profile to clone. */ + seedProfileId: string; + /** Optional display name for the new profile. */ + name?: string; + /** Optional model override. When omitted, the seed model is copied. */ + model?: string; +} + /** Input for {@link ProfileGateway.saveOpenCodeProviderProfile}. */ export interface SaveOpenCodeProviderProfileInput { /** The profile to create or replace (by id). */