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:
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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