feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139

État d'intégration confiné à la branche batch. Les tickets #119 (skills →
capacités agent découvrables), #122 (override permissions par défaut), #131
(effort par agent/presets) et #132 (outil MCP d'édition du contexte projet)
sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance
plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de
câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs,
backend/dto.rs), inséparable sans staging interactif (indisponible ici).

Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée.
NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:06:23 +02:00
parent 22c6bd803d
commit 171c6c923c
59 changed files with 3654 additions and 291 deletions

View File

@ -846,6 +846,53 @@ fn toml_string(s: &str) -> String {
json_string(s)
}
/// One effort/reasoning preset a profile natively exposes to the UI.
/// Declaration order is light to deep and is never reordered by consumers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EffortOption {
/// Raw value forwarded to the CLI/session (e.g. Codex's `"medium"`).
pub value: String,
/// Human-readable label for the UI droplist.
pub label: String,
/// Optional short description.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
}
/// A per-agent effort choice, preserving whether the value came from a profile
/// preset or free text.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum EffortSelection {
/// Picked from the profile's declared `effort_options`.
Preset(String),
/// Freehand value.
Custom(String),
}
impl EffortSelection {
/// Raw effort value forwarded to the session, regardless of provenance.
#[must_use]
pub fn value(&self) -> &str {
match self {
Self::Preset(value) | Self::Custom(value) => value,
}
}
}
/// Resolves the raw effort value forwarded to a session launch: the agent's
/// explicit selection wins; otherwise the profile's static default.
#[must_use]
pub fn resolve_effort(
profile_default: Option<&str>,
agent_selection: Option<&EffortSelection>,
) -> Option<String> {
agent_selection
.map(|selection| selection.value().to_owned())
.or_else(|| profile_default.map(str::to_owned))
}
/// Declarative runtime configuration for one AI CLI.
///
/// Invariants:
@ -914,6 +961,10 @@ pub struct AgentProfile {
/// conserve le défaut natif de la CLI ; seules les sessions Codex le consomment.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_reasoning_effort: Option<String>,
/// Effort/reasoning presets this profile natively exposes (ticket #131).
/// Empty means the provider declares none.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effort_options: Vec<EffortOption>,
/// 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
@ -1122,6 +1173,7 @@ impl AgentProfile {
opencode_provider: None,
model: None,
model_reasoning_effort: None,
effort_options: Vec::new(),
mcp: None,
liveness: None,
rate_limit_pattern: None,
@ -1186,6 +1238,13 @@ impl AgentProfile {
self
}
/// Builder : fixe les presets d'effort exposés par ce profil.
#[must_use]
pub fn with_effort_options(mut self, options: Vec<EffortOption>) -> Self {
self.effort_options = options;
self
}
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans MCP ne l'appellent simplement pas.
@ -1519,6 +1578,88 @@ mod mcp_tests {
assert_eq!(back.model_reasoning_effort.as_deref(), Some("medium"));
}
#[test]
fn effort_option_round_trips_camel_case() {
let option = EffortOption {
value: "medium".to_owned(),
label: "Medium".to_owned(),
hint: Some("Balanced".to_owned()),
};
let json = serde_json::to_string(&option).expect("serialise");
assert_eq!(
json,
r#"{"value":"medium","label":"Medium","hint":"Balanced"}"#
);
let back: EffortOption = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back, option);
}
#[test]
fn effort_selection_preset_and_custom_value_accessor() {
assert_eq!(EffortSelection::Preset("high".to_owned()).value(), "high");
assert_eq!(
EffortSelection::Custom("provider-x".to_owned()).value(),
"provider-x"
);
}
#[test]
fn effort_selection_tagged_serde_shape() {
let preset = serde_json::to_string(&EffortSelection::Preset("medium".to_owned()))
.expect("serialise");
assert_eq!(preset, r#"{"kind":"preset","value":"medium"}"#);
let custom = serde_json::to_string(&EffortSelection::Custom("x-deep".to_owned()))
.expect("serialise");
assert_eq!(custom, r#"{"kind":"custom","value":"x-deep"}"#);
}
#[test]
fn resolve_effort_prefers_agent_selection_over_profile_default() {
let selection = EffortSelection::Preset("high".to_owned());
assert_eq!(
resolve_effort(Some("low"), Some(&selection)).as_deref(),
Some("high")
);
}
#[test]
fn resolve_effort_falls_back_to_profile_default_when_agent_selection_absent() {
assert_eq!(resolve_effort(Some("low"), None).as_deref(), Some("low"));
}
#[test]
fn resolve_effort_none_when_neither_present() {
assert_eq!(resolve_effort(None, None), None);
}
#[test]
fn agent_profile_new_effort_options_defaults_empty_and_serialises_omitted() {
let profile = profile_without_mcp();
assert!(profile.effort_options.is_empty());
let json = serde_json::to_string(&profile).expect("serialise");
assert!(
!json.contains("\"effortOptions\""),
"a profile without effort options must keep the legacy JSON shape: {json}"
);
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert!(back.effort_options.is_empty());
}
#[test]
fn with_effort_options_builder_sets_field() {
let option = EffortOption {
value: "high".to_owned(),
label: "High".to_owned(),
hint: None,
};
let profile = profile_without_mcp().with_effort_options(vec![option.clone()]);
assert_eq!(profile.effort_options, vec![option]);
}
#[test]
fn opencode_backend_consistency_rejects_both_configs_set() {
let local = OpenCodeConfig::new(