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:
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user