feat: finalise ticket99 - implémentation agent model configuration v2

- agent/lifecycle.rs: lifecycle management per profile
- agent/provider_catalogue.rs: provider registration with model support
- agent/usecases.rs: usecases for profile-based agent invocation
- agent/mod.rs: expose agent capabilities via AgentManager
- backend/dto.rs: AgentModelConfig, AgentProviderConfig DTOs
- domain/profile.rs: extend Profile avec agent capabilities
- domain/permission.rs: permission checks pour agent access
- infrastructure/assistant/mod.rs: agent integration
- infrastructure/permission/{claude,codex}.rs: permission handlers
- web-server/lib.rs: agent endpoints
- commands.rs: agent commands
- frontend/adapters/{http,profile,mock,domain}.ts: adapters
- frontend/first-run/FirstRunWizard.{test.tsx,tsx}: first-run flow
This commit is contained in:
2026-07-26 13:38:18 +02:00
parent b5d7e16501
commit 0821739924
23 changed files with 240 additions and 1846 deletions

View File

@ -423,125 +423,6 @@ impl OpenCodeProviderConfig {
}
}
/// Configuration additive d'un provider Codex personnalisé (endpoint
/// OpenAI-compatible arbitraire, hors providers natifs Codex).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexCustomProviderConfig {
/// URL de base de l'endpoint OpenAI-compatible.
pub base_url: String,
/// Libellé optionnel écrit dans la table `model_providers`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
impl CodexCustomProviderConfig {
/// Construit une configuration validée (parse-don't-validate).
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `base_url`, ou un `display_name`
/// fourni non vide après trim, est vide.
pub fn new(
base_url: impl Into<String>,
display_name: Option<String>,
) -> Result<Self, DomainError> {
let base_url = base_url.into();
crate::validation::non_empty(&base_url, "codexProvider.custom.baseUrl")?;
if let Some(name) = &display_name {
crate::validation::non_empty(name, "codexProvider.custom.displayName")?;
}
Ok(Self {
base_url,
display_name,
})
}
}
/// Configuration déclarative d'un profil Codex contrôlé par IdeA.
///
/// Le modèle est rendu dans le `$CODEX_HOME/config.toml` isolé du run dir, et la
/// clé API réelle reste dans le [`crate::ports::SecretStore`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexProviderConfig {
/// Identifiant du provider Codex (`"openai"` ou un provider custom).
pub provider_id: String,
/// Nom du modèle servi par ce provider.
pub model: String,
/// Référence opaque vers la clé API réelle, jamais persistée en clair.
pub api_key_ref: crate::ports::SecretRef,
/// Configuration additive d'un provider personnalisé.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<CodexCustomProviderConfig>,
}
impl CodexProviderConfig {
/// Construit une configuration validée.
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `provider_id` ou `model` est vide.
pub fn new(
provider_id: impl Into<String>,
model: impl Into<String>,
api_key_ref: crate::ports::SecretRef,
) -> Result<Self, DomainError> {
let provider_id = provider_id.into();
let model = model.into();
crate::validation::non_empty(&provider_id, "codexProvider.providerId")?;
crate::validation::non_empty(&model, "codexProvider.model")?;
Ok(Self {
provider_id,
model,
api_key_ref,
custom: None,
})
}
/// Attache une configuration de provider personnalisé.
#[must_use]
pub fn with_custom(mut self, custom: CodexCustomProviderConfig) -> Self {
self.custom = Some(custom);
self
}
}
/// Configuration déclarative d'un profil Claude contrôlé par IdeA.
///
/// Le modèle est rendu dans le `.claude/settings.local.json` isolé du run dir, et
/// la clé API réelle reste dans le [`crate::ports::SecretStore`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeProviderConfig {
/// Identifiant du provider Claude. V1 backend: `"anthropic"`.
pub provider_id: String,
/// Nom du modèle Claude à poser au lancement.
pub model: String,
/// Référence opaque vers la clé API réelle, jamais persistée en clair.
pub api_key_ref: crate::ports::SecretRef,
}
impl ClaudeProviderConfig {
/// Construit une configuration validée.
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `provider_id` ou `model` est vide.
pub fn new(
provider_id: impl Into<String>,
model: impl Into<String>,
api_key_ref: crate::ports::SecretRef,
) -> Result<Self, DomainError> {
let provider_id = provider_id.into();
let model = model.into();
crate::validation::non_empty(&provider_id, "claudeProvider.providerId")?;
crate::validation::non_empty(&model, "claudeProvider.model")?;
Ok(Self {
provider_id,
model,
api_key_ref,
})
}
}
/// Configuration additive d'un provider OpenCode **personnalisé** (endpoint
/// OpenAI-compatible arbitraire, hors catalogue OpenCode), portée par
/// [`OpenCodeProviderConfig::custom`].
@ -1015,16 +896,11 @@ pub struct AgentProfile {
/// avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_provider: Option<OpenCodeProviderConfig>,
/// Configuration Codex provider/modèle (ticket #99). `None` pour les profils
/// non-Codex et les profils Codex historiques qui gardent le défaut natif de
/// la CLI.
/// Modèle explicitement configuré pour les CLIs qui supportent un réglage
/// déclaratif direct (Codex/Claude). `None` conserve le défaut natif de la
/// CLI. OpenCode garde ses champs dédiés.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_provider: Option<CodexProviderConfig>,
/// Configuration Claude provider/modèle (ticket #99). `None` pour les profils
/// non-Claude et les profils Claude historiques qui gardent le défaut natif de
/// la CLI.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_provider: Option<ClaudeProviderConfig>,
pub model: Option<String>,
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
@ -1231,8 +1107,7 @@ impl AgentProfile {
chat_http: None,
opencode: None,
opencode_provider: None,
codex_provider: None,
claude_provider: None,
model: None,
mcp: None,
liveness: None,
rate_limit_pattern: None,
@ -1283,17 +1158,10 @@ impl AgentProfile {
self
}
/// Builder : fixe la configuration Codex provider/modèle (ticket #99).
/// Builder : fixe le modèle CLI direct (Codex/Claude, ticket #99).
#[must_use]
pub fn with_codex_provider(mut self, config: CodexProviderConfig) -> Self {
self.codex_provider = Some(config);
self
}
/// Builder : fixe la configuration Claude provider/modèle (ticket #99).
#[must_use]
pub fn with_claude_provider(mut self, config: ClaudeProviderConfig) -> Self {
self.claude_provider = Some(config);
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
@ -1608,50 +1476,20 @@ mod mcp_tests {
}
#[test]
fn codex_and_claude_provider_configs_round_trip_camelcase() {
let codex = CodexProviderConfig::new(
"openai",
"gpt-5",
crate::ports::SecretRef::new("secret-openai"),
)
.unwrap()
.with_custom(
CodexCustomProviderConfig::new(
"https://models.example.test/v1",
Some("Example".to_owned()),
)
.unwrap(),
);
let claude = ClaudeProviderConfig::new(
"anthropic",
"claude-sonnet-4-5",
crate::ports::SecretRef::new("secret-anthropic"),
)
.unwrap();
fn profile_model_round_trips_without_codex_or_claude_provider_config() {
let profile = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::Codex)
.with_codex_provider(codex.clone())
.with_claude_provider(claude.clone());
.with_model("gpt-5-codex");
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("\"codexProvider\""), "got: {json}");
assert!(json.contains("\"claudeProvider\""), "got: {json}");
assert!(json.contains("\"providerId\":\"openai\""), "got: {json}");
assert!(json.contains("\"baseUrl\""), "got: {json}");
assert!(json.contains("\"model\":\"gpt-5-codex\""), "got: {json}");
assert!(!json.contains("codexProvider"), "got: {json}");
assert!(!json.contains("claudeProvider"), "got: {json}");
assert!(!json.contains("apiKeyRef"), "got: {json}");
assert!(!json.contains("providerId"), "got: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back.codex_provider, Some(codex));
assert_eq!(back.claude_provider, Some(claude));
}
#[test]
fn codex_and_claude_provider_configs_reject_empty_fields() {
let secret_ref = crate::ports::SecretRef::new("secret-1");
assert!(CodexProviderConfig::new("", "gpt-5", secret_ref.clone()).is_err());
assert!(CodexProviderConfig::new("openai", "", secret_ref.clone()).is_err());
assert!(ClaudeProviderConfig::new("", "claude-sonnet-4-5", secret_ref.clone()).is_err());
assert!(ClaudeProviderConfig::new("anthropic", "", secret_ref).is_err());
assert!(CodexCustomProviderConfig::new("", None).is_err());
assert_eq!(back.model.as_deref(), Some("gpt-5-codex"));
}
#[test]