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

@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::{AgentId, ProfileId, TemplateId};
use crate::profile::EffortSelection;
use crate::skill::SkillRef;
use crate::template::TemplateVersion;
@ -70,6 +71,10 @@ pub struct Agent {
/// activation (ARCHITECTURE §14.2). Empty by default.
#[serde(default)]
pub skills: Vec<SkillRef>,
/// Per-agent effort selection. `None` falls back to the profile's
/// `model_reasoning_effort` at launch.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<EffortSelection>,
}
impl Agent {
@ -104,6 +109,7 @@ impl Agent {
origin,
synchronized,
skills: Vec::new(),
effort: None,
})
}
@ -128,6 +134,13 @@ impl Agent {
self
}
/// Returns a copy of this agent carrying the given per-agent effort override.
#[must_use]
pub fn with_effort(mut self, effort: Option<EffortSelection>) -> Self {
self.effort = effort;
self
}
/// Assigns a skill to this agent. Idempotent: re-assigning the same
/// `skill_id` is a no-op (returns `false`); a new assignment returns `true`.
pub fn assign_skill(&mut self, skill: SkillRef) -> bool {
@ -183,6 +196,9 @@ pub struct ManifestEntry {
/// backward-compatible deserialisation of pre-L12 manifests.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<SkillRef>,
/// Per-agent effort selection. Missing in older manifests means no override.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<EffortSelection>,
}
impl ManifestEntry {
@ -221,6 +237,7 @@ impl ManifestEntry {
synchronized,
synced_template_version,
skills: Vec::new(),
effort: None,
})
}
@ -246,6 +263,7 @@ impl ManifestEntry {
synchronized: agent.synchronized,
synced_template_version,
skills: agent.skills.clone(),
effort: agent.effort.clone(),
}
}
@ -271,7 +289,8 @@ impl ManifestEntry {
origin,
self.synchronized,
)?
.with_skills(self.skills.clone()))
.with_skills(self.skills.clone())
.with_effort(self.effort.clone()))
}
}
@ -530,4 +549,41 @@ mod orchestrator_tests {
&d,
));
}
#[test]
fn agent_with_effort_round_trips_through_manifest_entry() {
let agent = Agent::new(
agent_id(1),
"agent-1",
"agents/agent-1.md",
ProfileId::from_uuid(uuid::Uuid::from_u128(1001)),
AgentOrigin::Scratch,
false,
)
.unwrap()
.with_effort(Some(EffortSelection::Preset("medium".to_owned())));
let entry = ManifestEntry::from_agent(&agent);
let back = entry.to_agent().unwrap();
assert_eq!(
back.effort,
Some(EffortSelection::Preset("medium".to_owned()))
);
}
#[test]
fn manifest_entry_effort_absent_deserialises_as_none() {
let json = r#"{
"agentId":"00000000-0000-0000-0000-000000000001",
"name":"agent-1",
"mdPath":"agents/agent-1.md",
"profileId":"00000000-0000-0000-0000-0000000003e9",
"synchronized":false
}"#;
let entry: ManifestEntry = serde_json::from_str(json).expect("legacy entry deserialises");
assert_eq!(entry.effort, None);
assert_eq!(entry.to_agent().unwrap().effort, None);
}
}

View File

@ -637,6 +637,16 @@ pub enum DomainEvent {
/// (the oldest agent orchestrates).
orchestrator: Option<AgentId>,
},
/// The project's global context was written directly, as opposed to a
/// non-orchestrator's change being filed as a proposal.
ProjectContextUpdated {
/// The project whose global context changed.
project_id: ProjectId,
/// The party that performed the write.
by: ConversationParty,
/// Epoch-milliseconds of the write.
at_ms: i64,
},
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput {
/// The session.

View File

@ -106,8 +106,8 @@ pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
pub use template::{AgentTemplate, TemplateVersion};
pub use profile::{
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
McpServerWiring, RateLimitPattern, SessionStrategy,
resolve_effort, AgentProfile, ContextInjection, EffortOption, EffortSelection, EmbedderProfile,
EmbedderStrategy, LivenessStrategy, McpServerWiring, RateLimitPattern, SessionStrategy,
};
pub use mailbox::{
@ -205,8 +205,8 @@ pub use permission::{
opencode_permission_block, render_permission_summary, resolve as resolve_permissions,
AgentPermissionOverride, Capability, CommandMatcher, CommandRule, Effect, EffectivePermissions,
Glob, PathScope, PermissionError, PermissionProjection, PermissionProjector, PermissionRule,
PermissionSet, Posture, ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey,
PERMISSIONS_VERSION,
PermissionSet, PermissionShadowReport, Posture, ProjectPermissions, ProjectedFile,
ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
};
pub use system_permissions::{
@ -248,10 +248,10 @@ pub use ports::{
MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
SprintStoreError, StoreError, StructuredSessionEnvironment,
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError,
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError,
RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler,
SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment,
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore,
};

View File

@ -194,6 +194,9 @@ pub struct OrchestratorRequest {
/// `memory.write`, cadrage C7). Required by those actions, ignored otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Optional optimistic-concurrency version for `context.update`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub if_match: Option<String>,
/// Target memory note slug for the memory tools (`memory.read`/`memory.write`,
/// cadrage C7). Required by `memory.write`; optional for `memory.read` (absent ⇒
/// the aggregated index). Ignored by the other actions.
@ -342,6 +345,16 @@ pub enum OrchestratorCommand {
/// The proposing party (handshake identity).
requester: ConversationParty,
},
/// Directly update the global project context. Strict, orchestrator-only
/// counterpart to [`Self::ProposeContext`].
UpdateProjectContext {
/// The new Markdown body.
content: String,
/// Optional expected current version.
if_match: Option<String>,
/// The writing party (handshake identity).
requester: ConversationParty,
},
/// Read a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
/// `slug` absent = the aggregated `MEMORY.md` index; otherwise one note.
ReadMemory {
@ -500,6 +513,11 @@ impl OrchestratorRequest {
content: self.require("content", action, self.content.as_deref())?,
requester: self.requester_party(),
}),
"context.update" => Ok(OrchestratorCommand::UpdateProjectContext {
content: self.require("content", action, self.content.as_deref())?,
if_match: self.if_match.clone(),
requester: self.requester_party(),
}),
"memory.read" => Ok(OrchestratorCommand::ReadMemory {
slug: self.optional_slug(),
requester: self.requester_party(),
@ -1089,6 +1107,34 @@ mod tests {
);
}
#[test]
fn context_update_command_parses_content_and_if_match() {
let uid = uuid::Uuid::from_u128(42);
let r = req(&format!(
r##"{{ "type":"context.update", "requestedBy":"{uid}", "content":"# body", "ifMatch":"abc123" }}"##
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::UpdateProjectContext {
content: "# body".to_owned(),
if_match: Some("abc123".to_owned()),
requester: ConversationParty::agent(AgentId::from_uuid(uid)),
}
);
}
#[test]
fn context_update_requires_content() {
let missing = req(r#"{ "type":"context.update", "ifMatch":"abc123" }"#);
assert_eq!(
missing.validate(),
Err(OrchestratorError::MissingField {
action: "context.update".to_owned(),
field: "content".to_owned(),
})
);
}
#[test]
fn memory_read_optional_slug() {
assert_eq!(

View File

@ -542,6 +542,36 @@ impl ProjectPermissions {
self.agent_permissions(agent_id),
)
}
/// Reports agent-level blanket allows that are shadowed by project-level
/// blanket denies for `agent_id`.
///
/// This is a diagnostic companion to [`Self::resolve_for`]. It does not
/// participate in permission resolution and does not change deny-wins.
#[must_use]
pub fn shadow_for(&self, agent_id: AgentId) -> PermissionShadowReport {
shadow_report(
self.project_defaults.as_ref(),
self.agent_permissions(agent_id),
)
}
}
/// Diagnostic report for agent overrides that cannot loosen the project policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionShadowReport {
/// Agent blanket read allow is shadowed by a project blanket read deny.
pub read: bool,
/// Agent blanket write allow is shadowed by a project blanket write deny.
pub write: bool,
/// Agent blanket delete allow is shadowed by a project blanket delete deny.
pub delete: bool,
/// Agent blanket bash allow is shadowed by a project blanket bash deny.
pub execute_bash: bool,
/// Agent fallback choice is looser than the resolved project-tightened
/// fallback.
pub fallback: bool,
}
/// The normalised, flattened output of [`resolve`] — the **sole input** of the
@ -713,6 +743,72 @@ pub fn resolve(
Some(EffectivePermissions { rules, fallback })
}
/// Reports agent-level blanket allows shadowed by project-level blanket denies.
///
/// This is deliberately **not** a general glob-overlap solver. It is shaped to
/// the current UI contract: file capabilities are considered blanket only when
/// the rule has exactly one glob, `"**"`; bash is considered blanket only when
/// the rule has no command matchers. Scoped rules are ignored by this diagnostic
/// even though normal [`resolve`] and decision methods still honour them.
#[must_use]
pub fn shadow_report(
project: Option<&PermissionSet>,
agent: Option<&PermissionSet>,
) -> PermissionShadowReport {
let Some(agent) = agent else {
return PermissionShadowReport::default();
};
let shadowed = |capability| {
blanket_effect(project, capability, BlanketLookupMode::DenyWins) == Some(Effect::Deny)
&& blanket_effect(Some(agent), capability, BlanketLookupMode::AllowWins)
== Some(Effect::Allow)
};
let fallback = resolve(project, Some(agent))
.is_some_and(|resolved| agent.fallback() != resolved.fallback());
PermissionShadowReport {
read: shadowed(Capability::Read),
write: shadowed(Capability::Write),
delete: shadowed(Capability::Delete),
execute_bash: shadowed(Capability::ExecuteBash),
fallback,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BlanketLookupMode {
DenyWins,
AllowWins,
}
fn blanket_effect(
set: Option<&PermissionSet>,
capability: Capability,
mode: BlanketLookupMode,
) -> Option<Effect> {
let mut found = None;
for rule in set?.rules() {
if rule.capability() != capability || !is_blanket_rule(rule) {
continue;
}
match (mode, rule.effect()) {
(BlanketLookupMode::DenyWins, Effect::Deny) => return Some(Effect::Deny),
(BlanketLookupMode::AllowWins, Effect::Allow) => return Some(Effect::Allow),
_ => found = Some(rule.effect()),
}
}
found
}
fn is_blanket_rule(rule: &PermissionRule) -> bool {
if rule.capability().is_bash() {
return rule.commands().is_empty();
}
let globs = rule.paths().globs();
globs.len() == 1 && globs[0].pattern() == "**"
}
/// Renders a human-readable Markdown **summary** of the resolved policy, suitable
/// for injection into an agent's context (lot LP4-0).
///
@ -1118,6 +1214,10 @@ mod tests {
PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap()
}
fn blanket_file(capability: Capability, effect: Effect) -> PermissionRule {
PermissionRule::file(capability, effect, path_scope(&["**"])).unwrap()
}
// ---- VO construction & invariants -----------------------------------
#[test]
@ -1398,6 +1498,155 @@ mod tests {
);
}
// ---- shadow diagnostics (ticket #122) -------------------------------
#[test]
fn shadow_report_flags_read_write_delete_bash_when_project_blanket_deny_beats_agent_blanket_allow(
) {
let project = PermissionSet::new(
vec![
blanket_file(Capability::Read, Effect::Deny),
blanket_file(Capability::Write, Effect::Deny),
blanket_file(Capability::Delete, Effect::Deny),
PermissionRule::bash(Effect::Deny, vec![]),
],
Posture::Ask,
);
let agent = PermissionSet::new(
vec![
blanket_file(Capability::Read, Effect::Allow),
blanket_file(Capability::Write, Effect::Allow),
blanket_file(Capability::Delete, Effect::Allow),
PermissionRule::bash(Effect::Allow, vec![]),
],
Posture::Ask,
);
assert_eq!(
shadow_report(Some(&project), Some(&agent)),
PermissionShadowReport {
read: true,
write: true,
delete: true,
execute_bash: true,
fallback: false,
}
);
}
#[test]
fn shadow_report_false_when_project_has_no_deny_rule_for_capability() {
let project = PermissionSet::new(
vec![blanket_file(Capability::Read, Effect::Allow)],
Posture::Ask,
);
let agent = PermissionSet::new(
vec![blanket_file(Capability::Read, Effect::Allow)],
Posture::Ask,
);
assert_eq!(shadow_report(Some(&project), Some(&agent)).read, false);
}
#[test]
fn shadow_report_false_when_agent_rule_is_scoped_not_blanket() {
let project = PermissionSet::new(
vec![blanket_file(Capability::Write, Effect::Deny)],
Posture::Ask,
);
let agent = PermissionSet::new(
vec![
PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["src/**"]))
.unwrap(),
],
Posture::Ask,
);
assert_eq!(shadow_report(Some(&project), Some(&agent)).write, false);
}
#[test]
fn shadow_report_false_when_agent_also_denies() {
let project = PermissionSet::new(
vec![PermissionRule::bash(Effect::Deny, vec![])],
Posture::Ask,
);
let agent = PermissionSet::new(
vec![PermissionRule::bash(Effect::Deny, vec![])],
Posture::Ask,
);
assert_eq!(
shadow_report(Some(&project), Some(&agent)).execute_bash,
false
);
}
#[test]
fn shadow_report_fallback_true_when_project_fallback_stricter_than_agent_chosen_fallback() {
let project = PermissionSet::new(vec![], Posture::Deny);
let agent = PermissionSet::new(vec![], Posture::Allow);
assert!(shadow_report(Some(&project), Some(&agent)).fallback);
}
#[test]
fn shadow_report_all_false_when_agent_is_none() {
let project = PermissionSet::new(
vec![PermissionRule::bash(Effect::Deny, vec![])],
Posture::Deny,
);
assert_eq!(
shadow_report(Some(&project), None),
PermissionShadowReport::default()
);
}
#[test]
fn shadow_for_matches_free_function_via_project_permissions() {
let agent = AgentId::new_random();
let project = PermissionSet::new(
vec![PermissionRule::bash(Effect::Deny, vec![])],
Posture::Ask,
);
let custom = PermissionSet::new(
vec![PermissionRule::bash(Effect::Allow, vec![])],
Posture::Ask,
);
let doc = ProjectPermissions::new(
Some(project.clone()),
vec![AgentPermissionOverride::new(agent, custom.clone())],
);
assert_eq!(
doc.shadow_for(agent),
shadow_report(Some(&project), Some(&custom))
);
}
#[test]
fn permission_shadow_report_serialises_camel_case() {
let report = PermissionShadowReport {
read: false,
write: false,
delete: false,
execute_bash: true,
fallback: true,
};
assert_eq!(
serde_json::to_value(report).unwrap(),
serde_json::json!({
"read": false,
"write": false,
"delete": false,
"executeBash": true,
"fallback": true
})
);
}
// ---- LP3: ProjectorKey serde + PermissionProjection invariant ------
#[test]

View File

@ -386,6 +386,48 @@ pub enum PluginMcpError {
Process(String),
}
/// Plugin-owned storage errors.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PluginStorageError {
/// Invalid key or value.
#[error("plugin storage invalid input: {0}")]
Invalid(String),
/// Filesystem failure.
#[error("plugin storage I/O error: {0}")]
Io(String),
/// Serialization failure.
#[error("plugin storage serialization error: {0}")]
Serialization(String),
}
/// Store for plugin-owned key/value JSON data under app-data `plugins/data/<pluginId>/`.
#[async_trait]
pub trait PluginStorageStore: Send + Sync {
/// Reads one plugin-owned value.
async fn get(
&self,
plugin_id: &PluginId,
key: &str,
) -> Result<Option<Value>, PluginStorageError>;
/// Writes one plugin-owned value.
async fn set(
&self,
plugin_id: &PluginId,
key: &str,
value: Value,
) -> Result<(), PluginStorageError>;
/// Deletes one plugin-owned value.
async fn delete(&self, plugin_id: &PluginId, key: &str) -> Result<bool, PluginStorageError>;
/// Purges every plugin-owned value for one plugin.
async fn purge_plugin(
&self,
plugin_id: &PluginId,
) -> Result<RemovalOutcome, PluginStorageError>;
}
/// Store for installed plugin packages under the global app data directory.
#[async_trait]
pub trait PluginPackageStore: Send + Sync {

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(