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:
@ -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<ProfileModelCatalogDto, ErrorDto> {
|
||||
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<ProfileModelCatalogDto, ErrorDto> {
|
||||
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<ProfileDto, ErrorDto> {
|
||||
state
|
||||
.clone_profile_from_seed
|
||||
.execute(request.into())
|
||||
.await
|
||||
.map(ProfileDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `delete_profile` — delete a profile by id.
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
183
crates/application/src/agent/model_catalogue.rs
Normal file
183
crates/application/src/agent/model_catalogue.rs
Normal file
@ -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<String>,
|
||||
/// 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<ProfileModelCatalogEntry> {
|
||||
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<ProfileModelCatalogEntry> {
|
||||
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<ProfileModelCatalogEntry>,
|
||||
}
|
||||
|
||||
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<ProfileModelCatalogEntry>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String>,
|
||||
/// Optional model override. When absent, the seed model is copied as-is.
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<dyn ProfileStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl CloneProfileFromSeed {
|
||||
/// Builds the use case from the profile store and id generator ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> 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<CloneProfileFromSeedOutput, AppError> {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
|
||||
@ -1047,6 +1047,57 @@ impl From<CloneOpenCodeProfileFromSeedOutput> for ProfileDto {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<application::CloneProfileFromSeedOutput> 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<String>,
|
||||
/// Whether this entry is the conservative default suggestion.
|
||||
pub recommended: bool,
|
||||
}
|
||||
|
||||
impl From<application::ProfileModelCatalogEntry> 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<ProfileModelCatalogEntryDto>);
|
||||
|
||||
impl From<application::ListClaudeModelsOutput> for ProfileModelCatalogDto {
|
||||
fn from(out: application::ListClaudeModelsOutput) -> Self {
|
||||
Self(out.models.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<application::ListCodexModelsOutput> 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<CloneOpenCodeProfileFromSeedRequestDto> 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<String>,
|
||||
/// Optional model override. When omitted, the seed model is copied.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
impl From<CloneProfileFromSeedRequestDto> 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")]
|
||||
|
||||
@ -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<SaveOpenCodeProviderProfile>,
|
||||
/// Static catalogue of OpenCode cloud providers (ticket #92, lot B3).
|
||||
pub list_opencode_providers: Arc<ListOpenCodeProviders>,
|
||||
/// Static curated Claude model catalogue.
|
||||
pub list_claude_models: Arc<ListClaudeModels>,
|
||||
/// Static curated Codex model catalogue.
|
||||
pub list_codex_models: Arc<ListCodexModels>,
|
||||
/// Create a new profile instance from a persisted/reference seed.
|
||||
pub clone_profile_from_seed: Arc<CloneProfileFromSeed>,
|
||||
/// Create a new OpenCode profile instance from the canonical seed.
|
||||
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
|
||||
/// Delete a profile.
|
||||
@ -1468,6 +1474,12 @@ impl BackendCore {
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
));
|
||||
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<dyn IdGenerator>,
|
||||
));
|
||||
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user