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

4
.gitignore vendored
View File

@ -73,3 +73,7 @@ Thumbs.db
.ideai/agents.json
.ideai/background-tasks/
.ideai/mcp-tool-permissions.json
# QA isolated cargo home/target (never commit)
.qa-cargo-home/
.qa-cargo-target/

51
Cargo.lock generated
View File

@ -102,12 +102,22 @@ dependencies = [
"domain",
"serde",
"serde_json",
"sha2",
"subtle",
"thiserror 2.0.18",
"tokio",
"uuid",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@ -744,6 +754,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
@ -1996,6 +2017,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"uuid",
"zip",
]
[[package]]
@ -6236,8 +6258,37 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.14.0",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]

View File

@ -25,9 +25,9 @@ use application::{
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
};
use backend::stream::OutputSink;
@ -47,28 +47,28 @@ use crate::dto::{
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto,
OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto,
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
WriteTerminalRequestDto,
@ -538,6 +538,29 @@ pub async fn update_agent_permissions(
.map_err(ErrorDto::from)
}
/// `update_agent_effort` — set or clear one agent's per-agent effort override.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids or store failure.
#[tauri::command]
pub async fn update_agent_effort(
request: UpdateAgentEffortRequestDto,
state: State<'_, AppState>,
) -> Result<AgentDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.update_agent_effort
.execute(UpdateAgentEffortInput {
project,
agent_id,
effort: request.effort,
})
.await
.map(|out| AgentDto::from_agent(out.agent))
.map_err(ErrorDto::from)
}
/// `resolve_agent_permissions` — resolve project defaults plus agent override.
///
/// # Errors
@ -546,14 +569,14 @@ pub async fn update_agent_permissions(
pub async fn resolve_agent_permissions(
request: ResolveAgentPermissionsRequestDto,
state: State<'_, AppState>,
) -> Result<Option<EffectivePermissionsDto>, ErrorDto> {
) -> Result<ResolveAgentPermissionsResponseDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.resolve_agent_permissions
.execute(ResolveAgentPermissionsInput { project, agent_id })
.await
.map(|out| out.effective.map(EffectivePermissionsDto))
.map(ResolveAgentPermissionsResponseDto::from)
.map_err(ErrorDto::from)
}
@ -3308,12 +3331,10 @@ pub async fn create_skill(
.create_skill
.execute(CreateSkillInput {
name: request.name,
// Description is set via the dedicated frontend field (T6); the create
// path stays None for now so the affordance falls back to the body's
// first line (see `Skill::effective_description`).
description: None,
description: request.description,
content: request.content,
scope: request.scope,
kind: request.kind,
project_root: project.root,
})
.await

View File

@ -257,6 +257,7 @@ pub fn run() {
commands::get_project_permissions,
commands::update_project_permissions,
commands::update_agent_permissions,
commands::update_agent_effort,
commands::resolve_agent_permissions,
commands::get_project_system_permissions,
commands::update_project_system_permissions,
@ -401,6 +402,9 @@ pub fn run() {
plugins::plugin_workspace_read_binary,
plugins::plugin_workspace_write_text,
plugins::plugin_workspace_write_binary,
plugins::plugin_storage_get,
plugins::plugin_storage_set,
plugins::plugin_storage_delete,
plugins::plugin_workspace_list_dir,
plugins::plugin_workspace_stat,
plugins::plugin_query_project_structure,
@ -438,6 +442,9 @@ fn plugin_workspace_invoke_handler<R: tauri::Runtime>(
plugins::plugin_workspace_read_binary,
plugins::plugin_workspace_write_text,
plugins::plugin_workspace_write_binary,
plugins::plugin_storage_get,
plugins::plugin_storage_set,
plugins::plugin_storage_delete,
plugins::plugin_workspace_list_dir,
plugins::plugin_workspace_stat,
plugins::plugin_query_project_structure,
@ -1030,6 +1037,61 @@ mod tests {
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn dto_plugins_storage_commands_are_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-storage-commands");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let get_err = invoke_plugin_command(
&webview,
"plugin_storage_get",
json!({
"input": {
"pluginId": "dev.acme.missing",
"key": "helloPlugin.launches"
}
}),
)
.expect_err("missing plugin must surface through the registered command");
assert_eq!(get_err["code"], "NOT_FOUND");
let set_err = invoke_plugin_command(
&webview,
"plugin_storage_set",
json!({
"input": {
"pluginId": "dev.acme.missing",
"key": "helloPlugin.launches",
"value": 1
}
}),
)
.expect_err("missing plugin must surface through the registered command");
assert_eq!(set_err["code"], "NOT_FOUND");
let delete_err = invoke_plugin_command(
&webview,
"plugin_storage_delete",
json!({
"input": {
"pluginId": "dev.acme.missing",
"key": "helloPlugin.launches"
}
}),
)
.expect_err("missing plugin must surface through the registered command");
assert_eq!(delete_err["code"], "NOT_FOUND");
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn dto_plugins_command_task_commands_are_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-task-commands");

View File

@ -10,11 +10,11 @@ use backend::dto::{
PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto,
PluginRuntimeContributionCatalogDto, PluginTaskDto, PluginTaskStatusDto,
PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, PluginUninstallResultDto,
PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, PluginWorkspacePathDto,
PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, PluginWorkspaceWriteBinaryDto,
PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
};
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
use domain::{PluginId, RelativePath};
@ -216,6 +216,45 @@ pub async fn plugin_workspace_write_binary(
.map_err(ErrorDto::from)
}
/// Reads a plugin-owned JSON storage value.
#[tauri::command]
pub async fn plugin_storage_get(
input: PluginStorageGetDto,
state: State<'_, AppState>,
) -> Result<Option<serde_json::Value>, ErrorDto> {
state
.plugin_storage_access
.get(input.into())
.await
.map_err(ErrorDto::from)
}
/// Writes a plugin-owned JSON storage value.
#[tauri::command]
pub async fn plugin_storage_set(
input: PluginStorageSetDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.plugin_storage_access
.set(input.into())
.await
.map_err(ErrorDto::from)
}
/// Deletes a plugin-owned JSON storage value.
#[tauri::command]
pub async fn plugin_storage_delete(
input: PluginStorageGetDto,
state: State<'_, AppState>,
) -> Result<bool, ErrorDto> {
state
.plugin_storage_access
.delete(input.into())
.await
.map_err(ErrorDto::from)
}
/// Lists a workspace directory for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_list_dir(

View File

@ -5,7 +5,7 @@
use app_tauri_lib::dto::{
parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto,
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto,
UpdateAgentContextRequestDto,
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
};
use application::AppError;
use application::{
@ -17,7 +17,7 @@ use application::{
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
use domain::ports::ConversationDetails;
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
use domain::{Agent, AgentOrigin, ProjectPath, SkillKind};
use domain::{Agent, AgentOrigin, EffortSelection, ProjectPath, SkillKind};
use serde_json::json;
use uuid::Uuid;
@ -60,6 +60,15 @@ fn agent_dto_serialises_camelcase() {
assert!(v.get("profile_id").is_none());
}
#[test]
fn agent_dto_serialises_effort_tagged_shape_when_present() {
let agent = make_agent(1, 2).with_effort(Some(EffortSelection::Preset("medium".to_owned())));
let dto = AgentDto::from_agent(agent);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["effort"], json!({"kind":"preset","value":"medium"}));
}
#[test]
fn agent_list_dto_is_transparent_array() {
let first = make_agent(1, 2);
@ -73,6 +82,7 @@ fn agent_list_dto_is_transparent_array() {
kind: SkillKind::Reference,
}],
}],
effective_orchestrator: Some(first.id),
};
let dto = AgentListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
@ -82,7 +92,9 @@ fn agent_list_dto_is_transparent_array() {
assert_eq!(arr[0]["capabilities"][0]["name"], "review");
assert_eq!(arr[0]["capabilities"][0]["description"], "Reviews changes");
assert_eq!(arr[0]["capabilities"][0]["kind"], "reference");
assert_eq!(arr[0]["isOrchestrator"], true);
assert_eq!(arr[1]["capabilities"], json!([]));
assert_eq!(arr[1]["isOrchestrator"], false);
}
#[test]
@ -139,6 +151,28 @@ fn update_agent_context_request_deserialises_camelcase() {
assert_eq!(dto.content, "# Updated");
}
#[test]
fn update_agent_effort_request_deserialises_and_null_clears() {
let raw = json!({
"projectId": Uuid::from_u128(1).to_string(),
"agentId": Uuid::from_u128(2).to_string(),
"effort": {"kind": "custom", "value": "x-deep"}
});
let dto: UpdateAgentEffortRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(
dto.effort,
Some(EffortSelection::Custom("x-deep".to_owned()))
);
let clear: UpdateAgentEffortRequestDto = serde_json::from_value(json!({
"projectId": Uuid::from_u128(1).to_string(),
"agentId": Uuid::from_u128(2).to_string(),
"effort": null
}))
.unwrap();
assert_eq!(clear.effort, None);
}
#[test]
fn launch_agent_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(1).to_string();

View File

@ -12,6 +12,7 @@ thiserror = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
subtle = { workspace = true }
# Resolves the OpenCode cache dir (`~/.cache/opencode/models.json`) for the
# dynamic provider catalogue (ticket #92 follow-up). See

View File

@ -21,8 +21,8 @@ use domain::ports::{
SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore,
};
use domain::profile::{
McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE,
CODEX_CODE_MODE_FEATURES_TOML,
resolve_effort, EffortSelection, McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter,
CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML,
};
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
use domain::{
@ -123,8 +123,9 @@ pub struct InjectedLiveRow {
pub struct ResolvedAssignedSkill {
/// Agent-facing snapshot metadata.
pub snapshot: domain::AssignedSkillSnapshot,
/// Full Markdown body, used only on non-MCP profiles that cannot call
/// `idea_skill_read`.
/// Full Markdown body kept in the effective snapshot so authorized lazy-read
/// paths can use the same resolution; convention-file rendering exposes only
/// bounded affordances.
pub content: MarkdownDoc,
}
@ -140,7 +141,9 @@ pub struct EffectiveAgentContext {
pub capabilities: OrchestrationCapabilitySnapshot,
/// Compact agent capability affordances resolved by [`ResolveAgentCapabilities`].
pub agent_capabilities: Vec<AgentCapability>,
/// Resolved assigned skill bodies for fallback non-MCP injection.
/// Resolved assigned skill bodies. These are not dumped into provider context
/// by default; agents receive compact affordances and load details through the
/// active IdeA surface.
pub assigned_skills: Vec<ResolvedAssignedSkill>,
/// Project-memory recall selected for this launch.
pub memory: Vec<MemoryIndexEntry>,
@ -281,6 +284,8 @@ pub struct ListAgentsOutput {
pub agents: Vec<Agent>,
/// Resolved discoverable capabilities per agent.
pub capabilities: Vec<ListedAgentCapabilities>,
/// The manifest's resolved orchestrator.
pub effective_orchestrator: Option<AgentId>,
}
/// Resolved capabilities for one listed agent.
@ -301,6 +306,8 @@ pub struct AgentDiscoveryEntry {
pub agent: Agent,
/// Resolved capability affordances.
pub capabilities: Vec<AgentCapability>,
/// Whether this agent is the project's current orchestrator.
pub is_orchestrator: bool,
}
impl ListAgentsOutput {
@ -318,6 +325,7 @@ impl ListAgentsOutput {
.map(|entry| entry.capabilities.clone())
.unwrap_or_default();
AgentDiscoveryEntry {
is_orchestrator: self.effective_orchestrator == Some(agent.id),
agent,
capabilities,
}
@ -390,6 +398,7 @@ impl ListAgents {
Ok(ListAgentsOutput {
agents,
capabilities,
effective_orchestrator: manifest.effective_orchestrator(),
})
}
}
@ -480,6 +489,74 @@ impl UpdateAgentContext {
}
}
// ---------------------------------------------------------------------------
// UpdateAgentEffort
// ---------------------------------------------------------------------------
/// Input for [`UpdateAgentEffort::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentEffortInput {
/// The owning project.
pub project: Project,
/// The agent whose effort override changes.
pub agent_id: AgentId,
/// `None` clears the override, falling back to the profile default.
pub effort: Option<EffortSelection>,
}
/// Output of [`UpdateAgentEffort::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentEffortOutput {
/// The updated agent.
pub agent: Agent,
}
/// Sets or clears an agent's per-agent effort override in the manifest.
///
/// The change applies at the agent's next launch; live sessions are not mutated.
pub struct UpdateAgentEffort {
contexts: Arc<dyn AgentContextStore>,
}
impl UpdateAgentEffort {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Executes the update.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown to the project,
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: UpdateAgentEffortInput,
) -> Result<UpdateAgentEffortOutput, AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter_mut()
.find(|entry| entry.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let agent = entry
.to_agent()
.map_err(|err| AppError::Invalid(err.to_string()))?
.with_effort(input.effort);
*entry = ManifestEntry::from_agent(&agent);
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
Ok(UpdateAgentEffortOutput { agent })
}
}
// ---------------------------------------------------------------------------
// ChangeAgentProfile
// ---------------------------------------------------------------------------
@ -2017,6 +2094,12 @@ impl LaunchAgent {
network_permission,
&input.project.root,
);
let resolved_effort = resolve_effort(
profile.model_reasoning_effort.as_deref(),
agent.effort.as_ref(),
);
let mut launch_profile = profile.clone();
launch_profile.model_reasoning_effort = resolved_effort;
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
// L'intention est explicite sur le launcher : les cellules humaines peuvent
@ -2053,7 +2136,7 @@ impl LaunchAgent {
factory.as_ref(),
structured,
&agent,
&profile,
&launch_profile,
&prepared,
&run_dir,
&session_plan,
@ -2083,7 +2166,7 @@ impl LaunchAgent {
// CODEX_HOME isolé. Passer le modèle du profil sur l'argv garantit que le
// lancement interactif respecte l'édition IdeA, comme le chemin structuré
// le fait déjà dans `CodexExecSession`.
append_codex_pty_model_overrides(&profile, &mut spec);
append_codex_pty_model_overrides(&launch_profile, &mut spec);
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
let handle = self.pty.spawn(spec.clone(), size).await?;
@ -3440,8 +3523,7 @@ fn append_block(input: &str, block: &str) -> String {
/// Composes the convention file IdeA writes into an agent's run directory: an
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
/// so it must be told where to work), the IdeA orchestration contract, the
/// agent's persona `.md`, then the bodies of its assigned `skills` under a
/// `# Skills` section (ARCHITECTURE §14.2).
/// agent's persona `.md` (ARCHITECTURE §14.2).
///
/// A short skill-awareness paragraph is always injected in the orchestration
/// block (followed by the auto-memory harvest directive, Lot E1): it explains that
@ -3451,18 +3533,12 @@ fn append_block(input: &str, block: &str) -> String {
/// protocol). This awareness deliberately does not inject unassigned skill bodies;
/// assignment remains the context boundary.
///
/// On top of that awareness, the assigned skills surface in one of two ways
/// depending on the agent's **surface** (feature « skills à la MCP »), always in
/// the given (manifest) order — making the output deterministic:
/// - **MCP mode** (`mcp_enabled`): a high-altitude `# Skills disponibles` section,
/// right after the orchestration block, listing each as
/// `**<name>** — <effective description> (<kind>)` (affordances only, *no body*), with
/// prose pointing to `idea_skill_read` to load a body on demand. Respects the
/// altitude: the capability is exposed, never the skill content.
/// - **Non-MCP mode**: the legacy `# Skills` section dumping each body in full
/// under a `##` header carrying its name (unchanged — zero regression).
/// When `skills` is empty both sections are omitted entirely, so an agent with no
/// skills gets exactly the previous document.
/// On top of that awareness, assigned skills surface as a high-altitude
/// `# Skills disponibles` section right after the orchestration block, listing
/// each as `**<name>** — <effective description> (<kind>)` (affordances only,
/// *no body*). This bounded surface is shared by MCP and non-MCP profiles; only
/// the instruction for loading details is adapted to the active runtime surface.
/// When `skills` is empty the section is omitted entirely.
///
/// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as
/// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per
@ -3576,22 +3652,28 @@ pub(crate) fn compose_convention_file(
out.push_str(memory_awareness());
out.push_str("---\n\n");
// Skills « à la MCP » (feature skill-awareness) : à HAUTE ALTITUDE, juste après
// le bloc d'orchestration. On expose les skills assignés comme des **affordances
// nommées+décrites** (et NON leur corps complet), à la manière des outils MCP,
// pour que l'agent sache qu'ils existent et charge le détail à la demande via
// `idea_skill_read`. Réservé au mode MCP (le mode sans MCP conserve l'ancien dump
// du corps complet en fin de fichier, plus bas). Omis si zéro skill.
if mcp_enabled && !effective.agent_capabilities.is_empty() {
// Skills « capability-first » : à HAUTE ALTITUDE, juste après le bloc
// d'orchestration. On expose les skills assignés comme des affordances
// nommées+décrites (et NON leur corps complet), à la manière des outils MCP.
if !effective.agent_capabilities.is_empty() {
out.push_str("# Skills disponibles\n\n");
out.push_str("Snapshot version: ");
out.push_str(&effective.capabilities.version.to_string());
out.push_str("\n\n");
out.push_str(
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
workflow déjà couvert par un skill, charge-le.\n\n",
);
if mcp_enabled {
out.push_str(
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
workflow déjà couvert par un skill, charge-le.\n\n",
);
} else {
out.push_str(
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
détail, lis le fichier `.ideai/skills/md/<skill-id>.md` correspondant au \
`skillId` assigné dans le manifeste — n'improvise pas un workflow déjà \
couvert par un skill, charge-le.\n\n",
);
}
for skill in &effective.agent_capabilities {
out.push_str("**");
out.push_str(&skill.name);
@ -3616,24 +3698,6 @@ pub(crate) fn compose_convention_file(
out.push_str(effective.persona.as_str());
// MODE SANS MCP (exigence zéro régression, décision produit 4.2(b)) : on conserve
// l'ancien dump du **corps complet** des skills en fin de fichier. En mode MCP, le
// corps n'est PAS injecté ici (l'agent le charge à la demande via `idea_skill_read`,
// cf. la section « # Skills disponibles » à haute altitude plus haut).
if !effective.assigned_skills.is_empty() && !mcp_enabled {
out.push_str("\n\n---\n\n# Skills\n");
out.push_str("\nSnapshot version: ");
out.push_str(&effective.capabilities.version.to_string());
out.push('\n');
for skill in &effective.assigned_skills {
out.push_str("\n## ");
out.push_str(&skill.snapshot.name);
out.push_str("\n\n");
out.push_str(skill.content.as_str());
out.push('\n');
}
}
if !effective.memory.is_empty() {
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
for entry in &effective.memory {
@ -3891,7 +3955,7 @@ mod tests {
}
#[test]
fn compose_convention_file_appends_assigned_skills_in_order() {
fn compose_convention_file_appends_assigned_skill_affordances_in_order() {
let s = |n: u128, name: &str, body: &str| {
Skill::new(
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
@ -3915,28 +3979,28 @@ mod tests {
false,
);
// Both skill bodies present, after the persona.
assert!(doc.contains("REFAC_BODY"));
assert!(doc.contains("REVIEW_BODY"));
// Both skill affordances are present, but bodies are not dumped.
assert!(doc.contains("**refactor** — REFAC_BODY (workflow)"));
assert!(doc.contains("**review** — REVIEW_BODY (workflow)"));
assert!(!doc.contains("\n\nREFAC_BODY"));
assert!(!doc.contains("\n\nREVIEW_BODY"));
let awareness_at = doc.find("**Skills IdeA**").unwrap();
let skills_at = doc.find("# Skills disponibles").unwrap();
let persona_at = doc.find("# Persona").unwrap();
let skills_at = doc.find("\n# Skills\n").unwrap();
let refac_at = doc.find("REFAC_BODY").unwrap();
let review_at = doc.find("REVIEW_BODY").unwrap();
let refac_at = doc.find("**refactor**").unwrap();
let review_at = doc.find("**review**").unwrap();
assert!(
awareness_at < persona_at,
"skill awareness belongs to orchestration, before persona"
);
assert!(
persona_at < skills_at && skills_at < refac_at,
"assigned skill bodies come under the Skills section after persona"
awareness_at < skills_at && skills_at < persona_at,
"assigned skill affordances come before persona"
);
assert!(persona_at < refac_at, "skills come after the persona");
// Deterministic order: first assigned skill precedes the second.
assert!(refac_at < review_at, "skills emitted in the given order");
// Skill names surface as sub-headers.
assert!(doc.contains("## refactor"));
assert!(doc.contains("## review"));
assert!(!doc.contains("## refactor"));
assert!(!doc.contains("## review"));
}
#[test]
@ -4048,10 +4112,10 @@ mod tests {
}
#[test]
fn compose_convention_file_mcp_mode_exposes_skill_affordances_not_bodies() {
// MCP mode (feature « skills à la MCP »): a high-altitude `# Skills disponibles`
fn compose_convention_file_exposes_skill_affordances_not_bodies() {
// Both surfaces: a high-altitude `# Skills disponibles`
// section after the Orchestration block, listing `**name** — description`
// affordances and pointing to `idea_skill_read`, WITHOUT dumping the bodies.
// affordances, WITHOUT dumping the bodies.
let s = |n: u128, name: &str, desc: Option<&str>, body: &str| {
Skill::new(
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
@ -4062,44 +4126,47 @@ mod tests {
.unwrap()
.with_description(desc.map(str::to_owned))
};
let doc = compose_convention_file(
"/root",
"",
"# Persona",
&[
s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
// No explicit description ⇒ effective_description falls back to the
// body's first line (heading marker stripped).
s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
],
&[],
None,
&[],
true, // mcp_enabled
);
for mcp_enabled in [true, false] {
let doc = compose_convention_file(
"/root",
"",
"# Persona",
&[
s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
// No explicit description ⇒ effective_description falls back to the
// body's first line (heading marker stripped).
s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
],
&[],
None,
&[],
mcp_enabled,
);
// The affordance section is present.
assert!(
doc.contains("# Skills disponibles"),
"MCP skills section present"
);
assert!(doc.contains("idea_skill_read"), "points to the read tool");
// Affordance lines: `**name** — <effective description> (<kind>)`.
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
assert!(doc.contains("**review** — Review skill (workflow)"));
// The full bodies are NOT injected in MCP mode (loaded on demand instead).
assert!(!doc.contains("REFAC_BODY"), "no full body in MCP mode");
assert!(!doc.contains("REVIEW_BODY"), "no full body in MCP mode");
// The legacy `## <name>` body dump headers are absent too.
assert!(!doc.contains("## refactor"));
assert!(
doc.contains("# Skills disponibles"),
"skills section present (mcp_enabled={mcp_enabled})"
);
if mcp_enabled {
assert!(doc.contains("idea_skill_read"), "points to the read tool");
} else {
assert!(
doc.contains(".ideai/skills/md/<skill-id>.md"),
"points to the file-protocol read path"
);
}
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
assert!(doc.contains("**review** — Review skill (workflow)"));
assert!(!doc.contains("REFAC_BODY"), "no full body is injected");
assert!(!doc.contains("REVIEW_BODY"), "no full body is injected");
assert!(!doc.contains("## refactor"));
// The section sits at high altitude: after the Orchestration block, before
// the persona.
let orch_at = doc.find("# Orchestration IdeA").unwrap();
let skills_at = doc.find("# Skills disponibles").unwrap();
let persona_at = doc.find("# Persona").unwrap();
assert!(orch_at < skills_at, "skills come after orchestration");
assert!(skills_at < persona_at, "skills come before the persona");
let orch_at = doc.find("# Orchestration IdeA").unwrap();
let skills_at = doc.find("# Skills disponibles").unwrap();
let persona_at = doc.find("# Persona").unwrap();
assert!(orch_at < skills_at, "skills come after orchestration");
assert!(skills_at < persona_at, "skills come before the persona");
}
}
#[test]

View File

@ -37,8 +37,9 @@ pub use lifecycle::{
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode,
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
};
pub use model_catalogue::{
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,

View File

@ -26,6 +26,11 @@ pub enum AppError {
#[error("invalid input: {0}")]
Invalid(String),
/// An optimistic-concurrency `if_match` did not match the resource's current
/// version. Carries the current version so the caller can retry.
#[error("concurrency conflict: {0}")]
Conflict(String),
/// A filesystem operation failed.
#[error("filesystem error: {0}")]
FileSystem(String),
@ -107,6 +112,7 @@ impl AppError {
match self {
Self::NotFound(_) => "NOT_FOUND",
Self::Invalid(_) => "INVALID",
Self::Conflict(_) => "CONFLICT",
Self::FileSystem(_) => "FILESYSTEM",
Self::Store(_) => "STORE",
Self::Process(_) => "PROCESS",

View File

@ -61,7 +61,8 @@ pub use agent::{
ResumableAgent, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use background::{
@ -162,16 +163,16 @@ pub use plugin::{
PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription,
PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic,
PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReview,
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginTaskStatusInput,
PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
QueryProjectStructureInput, ReconcilePluginMcpServers, ReviewPluginPackage,
ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput, UninstallPlugin,
UninstallPluginInput, UninstallPluginResult,
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginStorageAccess,
PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput, PluginToolDiagnostic,
PluginToolRequirement, PluginToolchainDiagnostic, PluginToolchainDiagnosticInput,
PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceBinaryFile,
PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing, PluginWorkspacePathInput,
PluginWorkspaceStat, PluginWorkspaceTextFile, PluginWorkspaceWriteBinaryInput,
PluginWorkspaceWriteTextInput, ProjectConvention, ProjectModule, ProjectStructureEntry,
ProjectStructureQuery, QueryProjectStructure, QueryProjectStructureInput,
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled,
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
};
pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,

View File

@ -29,8 +29,9 @@ use domain::conversation::ConversationParty;
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
use domain::{AgentId, Project};
use domain::ports::{AgentContextStore, Clock, EventBus, FileSystem, MemoryStore, RemotePath};
use domain::{AgentId, DomainEvent, Project};
use sha2::{Digest, Sha256};
use crate::error::AppError;
@ -45,6 +46,16 @@ fn join_root(project: &Project, rel: &str) -> RemotePath {
RemotePath::new(format!("{base}/{rel}"))
}
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex_encode(&hasher.finalize())
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
/// Resolves an agent display name to its [`AgentId`] via the project manifest
/// (case-insensitive), or [`AppError::NotFound`].
async fn resolve_agent(
@ -80,6 +91,14 @@ pub struct ReadContextInput {
pub requester: ConversationParty,
}
/// Output of [`ReadContext`].
pub struct ReadContextOutput {
/// The context Markdown.
pub content: MarkdownDoc,
/// sha256 hex digest of `content`, only for the global project context.
pub version: Option<String>,
}
impl ReadContext {
/// Builds the use case from its ports.
#[must_use]
@ -99,7 +118,7 @@ impl ReadContext {
///
/// # Errors
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
pub async fn execute(&self, input: ReadContextInput) -> Result<ReadContextOutput, AppError> {
let ReadContextInput {
project,
target,
@ -115,9 +134,13 @@ impl ReadContext {
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
let bytes = self.fs.read(&path).await?;
let version = hex_sha256(&bytes);
let text =
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
Ok(MarkdownDoc::new(text))
Ok(ReadContextOutput {
content: MarkdownDoc::new(text),
version: Some(version),
})
}
Some(name) => {
let agent = resolve_agent(&self.contexts, &project, &name).await?;
@ -126,12 +149,114 @@ impl ReadContext {
.acquire_read(requester, GuardedResource::AgentContext(agent))
.await
.map_err(map_guard_err)?;
Ok(self.contexts.read_context(&project, &agent).await?)
Ok(ReadContextOutput {
content: self.contexts.read_context(&project, &agent).await?,
version: None,
})
}
}
}
}
/// Directly updates the global project context. This is the strict, fail-loud
/// counterpart to [`ProposeContext`]'s soft-degrading global branch.
pub struct UpdateProjectContext {
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
clock: Arc<dyn Clock>,
}
/// Input for [`UpdateProjectContext`].
pub struct UpdateProjectContextInput {
/// The project to write within.
pub project: Project,
/// New global project context Markdown.
pub content: String,
/// Optional expected current version.
pub if_match: Option<String>,
/// The writing party.
pub requester: ConversationParty,
}
/// Output of [`UpdateProjectContext`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProjectContextOutput {
/// sha256 hex digest of the newly written content.
pub new_version: String,
}
impl UpdateProjectContext {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
clock: Arc<dyn Clock>,
) -> Self {
Self {
guard,
contexts,
fs,
events,
clock,
}
}
/// Executes the direct global-context update.
///
/// # Errors
/// - [`AppError::Invalid`] when the requester is not allowed to write directly,
/// - [`AppError::Conflict`] when `if_match` does not match current content,
/// - [`AppError`] on store/fs failure.
pub async fn execute(
&self,
input: UpdateProjectContextInput,
) -> Result<UpdateProjectContextOutput, AppError> {
let UpdateProjectContextInput {
project,
content,
if_match,
requester,
} = input;
let manifest = self.contexts.load_manifest(&project).await?;
let designation = manifest.orchestrator_designation();
let resource = GuardedResource::ProjectContext;
if !may_write_directly(requester, &resource, &designation) {
return Err(map_guard_err(GuardError::Forbidden));
}
let _lease = self
.guard
.acquire_write(requester, resource)
.await
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
let current_bytes = self.fs.read(&path).await?;
let current_version = hex_sha256(&current_bytes);
if let Some(expected) = if_match {
if expected != current_version {
return Err(AppError::Conflict(current_version));
}
}
self.fs.write(&path, content.as_bytes()).await?;
let new_version = hex_sha256(content.as_bytes());
self.events.publish(DomainEvent::ProjectContextUpdated {
project_id: project.id,
by: requester,
at_ms: self.clock.now_millis(),
});
Ok(UpdateProjectContextOutput { new_version })
}
}
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
///
/// For an **agent** context: a direct write under an exclusive write-lease. For the
@ -393,7 +518,7 @@ mod tests {
use domain::agent::{AgentManifest, ManifestEntry};
use domain::conversation::ConversationParty;
use domain::fileguard::{ReadLease, WriteLease};
use domain::ports::{FsError, MemoryError, StoreError};
use domain::ports::{EventStream, FsError, MemoryError, StoreError};
use domain::project::ProjectPath;
use domain::{ProfileId, ProjectId, RemoteRef};
use std::collections::HashMap;
@ -599,6 +724,25 @@ mod tests {
}
}
#[derive(Default)]
struct SpyBus(Mutex<Vec<DomainEvent>>);
impl SpyBus {
fn events(&self) -> Vec<DomainEvent> {
self.0.lock().unwrap().clone()
}
}
impl EventBus for SpyBus {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
fn guard() -> Arc<dyn FileGuard> {
Arc::new(TestGuard::default())
}
@ -619,6 +763,7 @@ mod tests {
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
effort: None,
}],
},
contexts: Mutex::new(contexts),
@ -635,7 +780,7 @@ mod tests {
contexts_with("Dev", agent, "# hello"),
Arc::new(FakeFs::default()),
);
let md = uc
let out = uc
.execute(ReadContextInput {
project: project(),
target: Some("dev".to_owned()), // case-insensitive
@ -643,7 +788,8 @@ mod tests {
})
.await
.unwrap();
assert_eq!(md.as_str(), "# hello");
assert_eq!(out.content.as_str(), "# hello");
assert_eq!(out.version, None);
}
#[tokio::test]
@ -658,7 +804,7 @@ mod tests {
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
fs,
);
let md = uc
let out = uc
.execute(ReadContextInput {
project: project(),
target: None,
@ -666,7 +812,8 @@ mod tests {
})
.await
.unwrap();
assert_eq!(md.as_str(), "# project");
assert_eq!(out.content.as_str(), "# project");
assert_eq!(out.version, Some(hex_sha256(b"# project")));
}
#[tokio::test]
@ -749,6 +896,262 @@ mod tests {
);
}
#[tokio::test]
async fn update_project_context_orchestrator_writes_directly_and_returns_new_version() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
let bus = Arc::new(SpyBus::default());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::clone(&bus) as Arc<dyn EventBus>,
Arc::new(FixedClock),
);
let out = uc
.execute(UpdateProjectContextInput {
project: project(),
content: "# new".to_owned(),
if_match: None,
requester: ConversationParty::agent(agent),
})
.await
.unwrap();
assert_eq!(out.new_version, hex_sha256(b"# new"));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# new"
);
}
#[tokio::test]
async fn update_project_context_non_orchestrator_fails_loud_no_proposal_filed() {
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(SpyBus::default()),
Arc::new(FixedClock),
);
let err = uc
.execute(UpdateProjectContextInput {
project: project(),
content: "# rejected".to_owned(),
if_match: None,
requester: agent_party(8),
})
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID");
let files = fs.files.lock().unwrap();
assert_eq!(files.get("/tmp/demo/CLAUDE.md").unwrap(), b"# old");
assert!(
!files.keys().any(|path| path.contains("/.ideai/proposals/")),
"strict update must fail loud, not file a proposal"
);
}
#[tokio::test]
async fn update_project_context_if_match_mismatch_returns_conflict_with_current_version() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# current".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(SpyBus::default()),
Arc::new(FixedClock),
);
let err = uc
.execute(UpdateProjectContextInput {
project: project(),
content: "# new".to_owned(),
if_match: Some("stale".to_owned()),
requester: ConversationParty::agent(agent),
})
.await
.unwrap_err();
assert_eq!(err, AppError::Conflict(hex_sha256(b"# current")));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# current"
);
}
#[tokio::test]
async fn update_project_context_if_match_matching_succeeds() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(SpyBus::default()),
Arc::new(FixedClock),
);
let out = uc
.execute(UpdateProjectContextInput {
project: project(),
content: "# new".to_owned(),
if_match: Some(hex_sha256(b"# old")),
requester: ConversationParty::agent(agent),
})
.await
.unwrap();
assert_eq!(out.new_version, hex_sha256(b"# new"));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# new"
);
}
#[tokio::test]
async fn update_project_context_no_if_match_is_last_write_wins() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# previous".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(SpyBus::default()),
Arc::new(FixedClock),
);
uc.execute(UpdateProjectContextInput {
project: project(),
content: "# latest".to_owned(),
if_match: None,
requester: ConversationParty::agent(agent),
})
.await
.unwrap();
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# latest"
);
}
#[tokio::test]
async fn update_project_context_publishes_project_context_updated_event() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
let bus = Arc::new(SpyBus::default());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::clone(&bus) as Arc<dyn EventBus>,
Arc::new(FixedClock),
);
uc.execute(UpdateProjectContextInput {
project: project(),
content: "# new".to_owned(),
if_match: None,
requester: ConversationParty::agent(agent),
})
.await
.unwrap();
assert_eq!(
bus.events(),
vec![DomainEvent::ProjectContextUpdated {
project_id: project().id,
by: ConversationParty::agent(agent),
at_ms: 42,
}]
);
}
#[tokio::test]
async fn read_context_and_update_project_context_version_round_trip() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# first".to_vec());
let contexts = contexts_with("Dev", agent, "agent body");
let reader = ReadContext::new(
guard(),
Arc::clone(&contexts),
Arc::clone(&fs) as Arc<dyn FileSystem>,
);
let updater = UpdateProjectContext::new(
guard(),
contexts,
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(SpyBus::default()),
Arc::new(FixedClock),
);
let version = reader
.execute(ReadContextInput {
project: project(),
target: None,
requester: ConversationParty::agent(agent),
})
.await
.unwrap()
.version
.unwrap();
updater
.execute(UpdateProjectContextInput {
project: project(),
content: "# second".to_owned(),
if_match: Some(version.clone()),
requester: ConversationParty::agent(agent),
})
.await
.unwrap();
let stale = updater
.execute(UpdateProjectContextInput {
project: project(),
content: "# third".to_owned(),
if_match: Some(version),
requester: ConversationParty::agent(agent),
})
.await
.unwrap_err();
assert_eq!(stale, AppError::Conflict(hex_sha256(b"# second")));
}
#[tokio::test]
async fn propose_agent_context_writes_directly() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));

View File

@ -10,8 +10,9 @@ mod service;
pub mod wake;
pub use context_guard::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput,
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput,
ReadContextOutput, ReadMemory, ReadMemoryInput, UpdateProjectContext,
UpdateProjectContextInput, UpdateProjectContextOutput, WriteMemory, WriteMemoryInput,
};
pub use rendezvous::{
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,

View File

@ -47,7 +47,8 @@ use crate::error::AppError;
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
use crate::orchestrator::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput,
ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory,
WriteMemoryInput,
};
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
@ -534,6 +535,8 @@ pub struct ContextGuardUseCases {
pub read_context: Arc<ReadContext>,
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
pub propose_context: Arc<ProposeContext>,
/// Écriture directe stricte du contexte projet global.
pub update_project_context: Arc<UpdateProjectContext>,
/// Lecture mémoire sous read-lease.
pub read_memory: Arc<ReadMemory>,
/// Écriture mémoire sous write-lease.
@ -1241,6 +1244,14 @@ impl OrchestratorService {
self.propose_context(project, target, content, requester)
.await
}
OrchestratorCommand::UpdateProjectContext {
content,
if_match,
requester,
} => {
self.update_project_context(project, content, if_match, requester)
.await
}
OrchestratorCommand::ReadMemory { slug, requester } => {
self.read_memory(project, slug, requester).await
}
@ -1363,7 +1374,7 @@ impl OrchestratorService {
target: Option<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let md = self
let out = self
.require_context_guard()?
.read_context
.execute(ReadContextInput {
@ -1372,9 +1383,13 @@ impl OrchestratorService {
requester,
})
.await?;
let mut text = out.content.into_string();
if let Some(version) = &out.version {
text.push_str(&format!("\n\n<!-- idea-context-version: {version} -->"));
}
Ok(OrchestratorOutcome {
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
reply: Some(md.into_string()),
reply: Some(text),
})
}
@ -1411,6 +1426,31 @@ impl OrchestratorService {
})
}
/// `context.update` → strict direct write of the global project context.
async fn update_project_context(
&self,
project: &Project,
content: String,
if_match: Option<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let out = self
.require_context_guard()?
.update_project_context
.execute(UpdateProjectContextInput {
project: project.clone(),
content,
if_match,
requester,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("wrote project context (version {})", out.new_version),
reply: None,
})
}
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
/// content is returned inline in the outcome's `reply`.
async fn read_memory(
@ -2783,6 +2823,7 @@ impl OrchestratorService {
description: None,
content,
scope,
kind: domain::SkillKind::Workflow,
project_root: project.root.clone(),
})
.await?;

View File

@ -7,7 +7,10 @@
use std::sync::Arc;
use domain::ports::PermissionStore;
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
use domain::{
AgentId, EffectivePermissions, PermissionSet, PermissionShadowReport, Project,
ProjectPermissions,
};
use crate::error::AppError;
@ -131,6 +134,7 @@ impl ResolveAgentPermissions {
let doc = self.store.load_permissions(&input.project).await?;
Ok(ResolveAgentPermissionsOutput {
effective: doc.resolve_for(input.agent_id),
shadowed: doc.shadow_for(input.agent_id),
})
}
}
@ -147,4 +151,6 @@ pub struct ResolveAgentPermissionsInput {
pub struct ResolveAgentPermissionsOutput {
/// Resolved policy, or `None` when neither project nor agent policy exists.
pub effective: Option<EffectivePermissions>,
/// Diagnostic report for agent-level allows shadowed by project defaults.
pub shadowed: PermissionShadowReport,
}

View File

@ -8,8 +8,8 @@ use domain::ports::{
BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator,
LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
PluginRegistryStore, PluginStoreError, ProcessError, ProcessSpawner, ProjectStore, RemotePath,
SpawnSpec,
PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, ProcessError,
ProcessSpawner, ProjectStore, RemotePath, SpawnSpec,
};
use domain::{
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
@ -157,6 +157,28 @@ pub struct PluginRuntimePlugin {
pub contributes: PluginContributionSet,
}
/// Input for plugin-owned storage reads/deletes.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageGetInput {
/// Plugin id owning the value.
pub plugin_id: String,
/// Plugin-owned key.
pub key: String,
}
/// Input for plugin-owned storage writes.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageSetInput {
/// Plugin id owning the value.
pub plugin_id: String,
/// Plugin-owned key.
pub key: String,
/// JSON value to persist.
pub value: serde_json::Value,
}
/// Input for reviewing a package.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewPluginPackageInput {
@ -2023,6 +2045,14 @@ fn map_store(e: PluginStoreError) -> AppError {
}
}
fn map_storage(e: PluginStorageError) -> AppError {
match e {
PluginStorageError::Invalid(m) => AppError::Invalid(m),
PluginStorageError::Io(m) => AppError::FileSystem(m),
PluginStorageError::Serialization(m) => AppError::Store(m),
}
}
fn map_registry(e: PluginRegistryError) -> AppError {
match e {
PluginRegistryError::Io(m) => AppError::Store(m),
@ -2516,9 +2546,91 @@ pub struct UninstallPluginInput {
pub plugin_id: String,
}
/// Plugin-owned key/value storage facade.
pub struct PluginStorageAccess {
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
}
impl PluginStorageAccess {
/// Builds the facade.
#[must_use]
pub fn new(
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
) -> Self {
Self { storage, registry }
}
/// Reads one plugin-owned JSON value.
pub async fn get(
&self,
input: PluginStorageGetInput,
) -> Result<Option<serde_json::Value>, AppError> {
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
validate_storage_key(&input.key)?;
self.storage
.get(&plugin_id, &input.key)
.await
.map_err(map_storage)
}
/// Writes one plugin-owned JSON value.
pub async fn set(&self, input: PluginStorageSetInput) -> Result<(), AppError> {
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
validate_storage_key(&input.key)?;
self.storage
.set(&plugin_id, &input.key, input.value)
.await
.map_err(map_storage)
}
/// Deletes one plugin-owned JSON value.
pub async fn delete(&self, input: PluginStorageGetInput) -> Result<bool, AppError> {
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
validate_storage_key(&input.key)?;
self.storage
.delete(&plugin_id, &input.key)
.await
.map_err(map_storage)
}
async fn active_plugin_id(&self, raw: String) -> Result<PluginId, AppError> {
let plugin_id = PluginId::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
let registry = self.registry.load_registry().await.map_err(map_registry)?;
let entry = registry
.find(&plugin_id)
.ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
if !entry.lifecycle_state.is_runtime_active() {
return Err(AppError::Invalid("plugin is not runtime-active".to_owned()));
}
Ok(plugin_id)
}
}
fn validate_storage_key(key: &str) -> Result<(), AppError> {
if key.trim().is_empty() {
return Err(AppError::Invalid(
"plugin storage key must not be empty".to_owned(),
));
}
if key.len() > 512 {
return Err(AppError::Invalid(
"plugin storage key must not exceed 512 bytes".to_owned(),
));
}
if key.contains('\0') {
return Err(AppError::Invalid(
"plugin storage key must not contain NUL bytes".to_owned(),
));
}
Ok(())
}
/// Uninstalls a plugin.
pub struct UninstallPlugin {
packages: Arc<dyn PluginPackageStore>,
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
@ -2529,12 +2641,14 @@ impl UninstallPlugin {
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
) -> Self {
Self {
packages,
storage,
registry,
events,
mcp,
@ -2562,6 +2676,10 @@ impl UninstallPlugin {
.remove_package(&plugin_id)
.await
.map_err(map_store)?;
self.storage
.purge_plugin(&plugin_id)
.await
.map_err(map_storage)?;
self.events.publish(DomainEvent::PluginUninstalled {
plugin_id: plugin_id.clone(),
restart_required: true,
@ -3214,7 +3332,8 @@ mod tests {
use domain::ports::{
BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError,
BackgroundTaskRunner, BackgroundTaskSpec, EventStream, FileMetadata, IdGenerator,
PluginPackageStore, PluginRegistryStore, PluginStoreError, StoreError,
PluginPackageStore, PluginRegistryStore, PluginStorageError, PluginStorageStore,
PluginStoreError, StoreError,
};
use domain::remote::RemoteRef;
use domain::{BackgroundTaskState, ProjectPath};
@ -3411,6 +3530,69 @@ mod tests {
}
}
#[derive(Default)]
struct FakeStorage {
values: Mutex<HashMap<(String, String), serde_json::Value>>,
purged: Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl PluginStorageStore for FakeStorage {
async fn get(
&self,
plugin_id: &PluginId,
key: &str,
) -> Result<Option<serde_json::Value>, PluginStorageError> {
Ok(self
.values
.lock()
.unwrap()
.get(&(plugin_id.as_str().to_owned(), key.to_owned()))
.cloned())
}
async fn set(
&self,
plugin_id: &PluginId,
key: &str,
value: serde_json::Value,
) -> Result<(), PluginStorageError> {
self.values
.lock()
.unwrap()
.insert((plugin_id.as_str().to_owned(), key.to_owned()), value);
Ok(())
}
async fn delete(
&self,
plugin_id: &PluginId,
key: &str,
) -> Result<bool, PluginStorageError> {
Ok(self
.values
.lock()
.unwrap()
.remove(&(plugin_id.as_str().to_owned(), key.to_owned()))
.is_some())
}
async fn purge_plugin(
&self,
plugin_id: &PluginId,
) -> Result<RemovalOutcome, PluginStorageError> {
self.purged
.lock()
.unwrap()
.push(plugin_id.as_str().to_owned());
self.values
.lock()
.unwrap()
.retain(|(id, _), _| id != plugin_id.as_str());
Ok(RemovalOutcome::Removed)
}
}
#[derive(Default)]
struct FakeEvents {
events: Mutex<Vec<DomainEvent>>,
@ -3802,6 +3984,7 @@ mod tests {
#[tokio::test]
async fn uninstall_removes_registry_package_and_stops_mcp() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
@ -3809,6 +3992,7 @@ mod tests {
let mcp = Arc::new(FakeMcp::default());
let uninstall = UninstallPlugin::new(
packages.clone(),
storage.clone(),
registry.clone(),
events.clone(),
mcp.clone(),
@ -3825,6 +4009,7 @@ mod tests {
assert!(result.restart_required);
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]);
assert_eq!(&*storage.purged.lock().unwrap(), &["dev.acme.gitgraph"]);
assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
assert!(events.events.lock().unwrap().iter().any(|event| matches!(
event,
@ -3835,6 +4020,89 @@ mod tests {
)));
}
#[tokio::test]
async fn plugin_storage_round_trips_json_for_runtime_active_plugin() {
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let access = PluginStorageAccess::new(storage, registry);
access
.set(PluginStorageSetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
value: serde_json::json!({"count": 2}),
})
.await
.unwrap();
let value = access
.get(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
})
.await
.unwrap();
assert_eq!(value, Some(serde_json::json!({"count": 2})));
assert!(access
.delete(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
})
.await
.unwrap());
assert_eq!(
access
.get(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
})
.await
.unwrap(),
None
);
}
#[tokio::test]
async fn plugin_storage_rejects_inactive_plugin_and_invalid_key() {
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Disabled)),
});
let access = PluginStorageAccess::new(storage, registry);
let inactive = access
.set(PluginStorageSetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
value: serde_json::json!(1),
})
.await
.unwrap_err();
assert_eq!(
inactive,
AppError::Invalid("plugin is not runtime-active".to_owned())
);
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let access = PluginStorageAccess::new(storage, registry);
let invalid = access
.get(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: " ".to_owned(),
})
.await
.unwrap_err();
assert_eq!(
invalid,
AppError::Invalid("plugin storage key must not be empty".to_owned())
);
}
#[tokio::test]
async fn uninstall_then_reinstall_leaves_runtime_catalog_active_without_residue() {
let packages = Arc::new(FakePackages::with_manifest_and_staged_count(
@ -3844,6 +4112,7 @@ mod tests {
let registry = Arc::new(FakeRegistry::default());
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let storage = Arc::new(FakeStorage::default());
let install = InstallPluginFromDirectory::new(
packages.clone(),
registry.clone(),
@ -3851,8 +4120,13 @@ mod tests {
events.clone(),
mcp.clone(),
);
let uninstall =
UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp.clone());
let uninstall = UninstallPlugin::new(
packages.clone(),
storage.clone(),
registry.clone(),
events,
mcp.clone(),
);
install.execute("/source/plugin".to_owned()).await.unwrap();
uninstall

View File

@ -30,6 +30,9 @@ pub struct CreateSkillInput {
/// `None`/empty ⇒ the skill falls back to the first line of its body when
/// surfaced (see [`domain::Skill::effective_description`]).
pub description: Option<String>,
/// Capability nature to expose for this skill. Defaults to workflow when the
/// caller does not specify it.
pub kind: SkillKind,
/// Initial Markdown body.
pub content: String,
/// Scope the skill is created in (selects its backing store).
@ -67,7 +70,8 @@ impl CreateSkill {
let id = SkillId::from_uuid(self.ids.new_uuid());
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
.map_err(|e| AppError::Invalid(e.to_string()))?
.with_description(input.description);
.with_description(input.description)
.with_kind(input.kind);
self.skills.save(&skill, &input.project_root).await?;
Ok(CreateSkillOutput { skill })
}

View File

@ -35,8 +35,8 @@ use domain::ports::{
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
SessionStrategy, StructuredAdapter,
AgentProfile, ContextInjection, EffortSelection, McpCapability, McpConfigStrategy,
McpTransport, OpenCodeConfig, SessionStrategy, StructuredAdapter,
};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
@ -50,7 +50,7 @@ use application::{
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext,
ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions,
UpdateAgentContext, UpdateAgentContextInput,
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
};
// ---------------------------------------------------------------------------
@ -604,6 +604,7 @@ impl AgentSession for FakeSession {
struct FakeStructuredFactory {
trace: Trace,
starts: Arc<Mutex<Vec<ProfileId>>>,
efforts: Arc<Mutex<Vec<Option<String>>>>,
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
next_session: SessionId,
@ -614,6 +615,7 @@ impl FakeStructuredFactory {
Self {
trace,
starts: Arc::new(Mutex::new(Vec::new())),
efforts: Arc::new(Mutex::new(Vec::new())),
envs: Arc::new(Mutex::new(Vec::new())),
policies: Arc::new(Mutex::new(Vec::new())),
next_session,
@ -624,6 +626,10 @@ impl FakeStructuredFactory {
self.starts.lock().unwrap().clone()
}
fn efforts(&self) -> Vec<Option<String>> {
self.efforts.lock().unwrap().clone()
}
fn envs(&self) -> Vec<Vec<(String, String)>> {
self.envs.lock().unwrap().clone()
}
@ -655,6 +661,10 @@ impl AgentSessionFactory for FakeStructuredFactory {
.unwrap()
.push("structured.start".to_owned());
self.starts.lock().unwrap().push(profile.id);
self.efforts
.lock()
.unwrap()
.push(profile.model_reasoning_effort.clone());
self.envs.lock().unwrap().push(_env.to_vec());
self.policies
.lock()
@ -864,6 +874,54 @@ async fn list_resolves_agent_capabilities_additively() {
);
}
#[tokio::test]
async fn list_agents_marks_effective_orchestrator_as_is_orchestrator_true() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
{
let mut inner = contexts.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
inner.manifest.designate(a2.id).unwrap();
}
let list = ListAgents::new(Arc::new(contexts));
let out = list
.execute(ListAgentsInput { project: project() })
.await
.unwrap();
let entries = out.discovery_entries();
assert_eq!(out.effective_orchestrator, Some(a2.id));
assert_eq!(entries[0].is_orchestrator, false);
assert_eq!(entries[1].is_orchestrator, true);
}
#[tokio::test]
async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
contexts
.0
.lock()
.unwrap()
.manifest
.entries
.push(ManifestEntry::from_agent(&a2));
let list = ListAgents::new(Arc::new(contexts));
let out = list
.execute(ListAgentsInput { project: project() })
.await
.unwrap();
let entries = out.discovery_entries();
assert_eq!(out.effective_orchestrator, Some(a1.id));
assert_eq!(entries[0].is_orchestrator, true);
assert_eq!(entries[1].is_orchestrator, false);
}
#[tokio::test]
async fn read_then_update_context_roundtrips() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
@ -895,6 +953,69 @@ async fn read_then_update_context_roundtrips() {
);
}
#[tokio::test]
async fn update_agent_effort_sets_preset_selection_and_persists_manifest() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&a, "ctx");
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
let out = update
.execute(UpdateAgentEffortInput {
project: project(),
agent_id: a.id,
effort: Some(EffortSelection::Preset("high".to_owned())),
})
.await
.unwrap();
assert_eq!(
out.agent.effort,
Some(EffortSelection::Preset("high".to_owned()))
);
assert_eq!(
contexts.manifest().entries[0].effort,
Some(EffortSelection::Preset("high".to_owned()))
);
}
#[tokio::test]
async fn update_agent_effort_none_clears_existing_override() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9))
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
let contexts = FakeContexts::with_agent(&a, "ctx");
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
let out = update
.execute(UpdateAgentEffortInput {
project: project(),
agent_id: a.id,
effort: None,
})
.await
.unwrap();
assert_eq!(out.agent.effort, None);
assert_eq!(contexts.manifest().entries[0].effort, None);
}
#[tokio::test]
async fn update_agent_effort_not_found_for_unknown_agent_id() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&a, "ctx");
let update = UpdateAgentEffort::new(Arc::new(contexts));
let err = update
.execute(UpdateAgentEffortInput {
project: project(),
agent_id: aid(404),
effort: Some(EffortSelection::Preset("medium".to_owned())),
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn delete_removes_entry_then_unknown_is_not_found() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
@ -965,6 +1086,15 @@ fn launch_fixture_with_profile_and_recall(
recall: FakeRecall,
) -> LaunchFixture {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
launch_fixture_with_profile_agent_and_recall(profile, agent, plan, recall)
}
fn launch_fixture_with_profile_agent_and_recall(
profile: AgentProfile,
agent: Agent,
plan: Option<ContextInjectionPlan>,
recall: FakeRecall,
) -> LaunchFixture {
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
let profiles = FakeProfiles::new(vec![profile]);
let tr = trace();
@ -1127,6 +1257,63 @@ async fn structured_profile_with_factory_routes_to_structured_session_without_pt
);
}
#[tokio::test]
async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() {
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_structured_adapter(StructuredAdapter::Codex)
.with_model_reasoning_effort("low");
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
.with_effort(Some(EffortSelection::Preset("high".to_owned())));
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) =
launch_fixture_with_profile_agent_and_recall(
profile,
agent,
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
FakeRecall::default(),
);
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
let structured = Arc::new(StructuredSessions::new());
let launch = launch
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
.with_structured(Arc::new(factory.clone()), structured);
launch.execute(launch_input(agent.id)).await.unwrap();
assert!(pty.spawns().is_empty());
assert_eq!(factory.efforts(), vec![Some("high".to_owned())]);
}
#[tokio::test]
async fn launch_agent_structured_falls_back_to_profile_default_when_agent_has_no_override() {
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_structured_adapter(StructuredAdapter::Codex)
.with_model_reasoning_effort("low");
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile(
profile,
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
let structured = Arc::new(StructuredSessions::new());
let launch = launch
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
.with_structured(Arc::new(factory.clone()), structured);
launch.execute(launch_input(agent.id)).await.unwrap();
assert!(pty.spawns().is_empty());
assert_eq!(factory.efforts(), vec![Some("low".to_owned())]);
}
#[tokio::test]
async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() {
let profile = profile(
@ -3264,6 +3451,17 @@ fn launch_with_projection_and_env(
env: Vec<(String, String)>,
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
launch_with_projection_agent_and_env(profile, agent, plan, registry, perm_doc, env)
}
fn launch_with_projection_agent_and_env(
profile: AgentProfile,
agent: Agent,
plan: Option<ContextInjectionPlan>,
registry: Option<Arc<PermissionProjectorRegistry>>,
perm_doc: Option<ProjectPermissions>,
env: Vec<(String, String)>,
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
let profiles = FakeProfiles::new(vec![profile]);
let tr = trace();
@ -3896,6 +4094,40 @@ async fn codex_pty_launch_forwards_profile_model_as_config_override() {
);
}
#[tokio::test]
async fn launch_agent_pty_codex_overrides_use_resolved_effort() {
let profile = codex_profile()
.with_projector(ProjectorKey::Codex)
.with_model_reasoning_effort("low");
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
let (launch, agent, _fs, pty, _s) = launch_with_projection_agent_and_env(
profile,
agent,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
Some(full_registry()),
None,
Vec::new(),
);
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let args = &pty.spawns()[0].args;
assert!(
args.windows(2).any(|w| w
== [
"-c".to_owned(),
"model_reasoning_effort=\"x-deep\"".to_owned()
]),
"PTY Codex launch must forward the resolved per-agent effort, got {args:?}"
);
}
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
/// (5) A Codex profile with **no MCP capability** still gets its sandbox projected

View File

@ -9,7 +9,10 @@ use domain::ids::{AgentId, ProjectId};
use domain::ports::{PermissionStore, StoreError};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::{PermissionSet, Posture, ProjectPermissions};
use domain::{
Capability, Effect, PermissionRule, PermissionSet, PermissionShadowReport, Posture,
ProjectPermissions,
};
#[derive(Default)]
struct FakePermissionStore {
@ -125,4 +128,69 @@ async fn resolve_agent_permissions_returns_effective_policy() {
.unwrap();
assert_eq!(out.effective.unwrap().fallback(), Posture::Ask);
assert_eq!(out.shadowed, PermissionShadowReport::default());
}
#[tokio::test]
async fn resolve_agent_permissions_reports_shadowed_alongside_unchanged_effective() {
let agent = AgentId::new_random();
let store = Arc::new(FakePermissionStore {
doc: Mutex::new(ProjectPermissions::new(
Some(PermissionSet::new(
vec![PermissionRule::bash(Effect::Deny, vec![])],
Posture::Ask,
)),
vec![domain::AgentPermissionOverride::new(
agent,
PermissionSet::new(
vec![PermissionRule::bash(Effect::Allow, vec![])],
Posture::Ask,
),
)],
)),
saves: Mutex::new(0),
});
let use_case = ResolveAgentPermissions::new(store);
let out = use_case
.execute(ResolveAgentPermissionsInput {
project: project(),
agent_id: agent,
})
.await
.unwrap();
assert!(out.shadowed.execute_bash);
assert_eq!(out.effective.unwrap().decide_bash("ls"), Posture::Deny);
}
#[tokio::test]
async fn resolve_agent_permissions_shadowed_defaults_when_no_agent_override() {
let agent = AgentId::new_random();
let store = Arc::new(FakePermissionStore {
doc: Mutex::new(ProjectPermissions::new(
Some(PermissionSet::new(
vec![PermissionRule::file(
Capability::Read,
Effect::Deny,
domain::PathScope::new(["**".to_owned()]).unwrap(),
)
.unwrap()],
Posture::Deny,
)),
vec![],
)),
saves: Mutex::new(0),
});
let use_case = ResolveAgentPermissions::new(store);
let out = use_case
.execute(ResolveAgentPermissionsInput {
project: project(),
agent_id: agent,
})
.await
.unwrap();
assert_eq!(out.shadowed, PermissionShadowReport::default());
}

View File

@ -13,7 +13,7 @@ use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, EventBus, EventStream, IdGenerator, SkillStore, StoreError,
};
use domain::skill::{Skill, SkillScope};
use domain::skill::{Skill, SkillKind, SkillScope};
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef, SkillRef};
use uuid::Uuid;
@ -196,6 +196,7 @@ async fn create_skill_persists_in_its_scope() {
.execute(CreateSkillInput {
name: "refactor".to_owned(),
description: Some("Refactors code".to_owned()),
kind: SkillKind::Reference,
content: "# body".to_owned(),
scope: SkillScope::Project,
project_root: root(),
@ -204,6 +205,7 @@ async fn create_skill_persists_in_its_scope() {
.unwrap();
assert_eq!(out.skill.scope, SkillScope::Project);
assert_eq!(out.skill.kind, SkillKind::Reference);
// The one-line affordance description flows through the use case onto the skill.
assert_eq!(out.skill.description.as_deref(), Some("Refactors code"));
assert_eq!(
@ -228,6 +230,7 @@ async fn create_skill_rejects_empty_content() {
.execute(CreateSkillInput {
name: "k".to_owned(),
description: None,
kind: SkillKind::Workflow,
content: String::new(),
scope: SkillScope::Global,
project_root: root(),

View File

@ -337,6 +337,47 @@ impl From<PluginWorkspaceWriteBinaryDto> for application::PluginWorkspaceWriteBi
}
}
/// Plugin-owned storage read/delete request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageGetDto {
/// Plugin id owning the value.
pub plugin_id: String,
/// Plugin-owned key.
pub key: String,
}
impl From<PluginStorageGetDto> for application::PluginStorageGetInput {
fn from(value: PluginStorageGetDto) -> Self {
Self {
plugin_id: value.plugin_id,
key: value.key,
}
}
}
/// Plugin-owned storage write request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageSetDto {
/// Plugin id owning the value.
pub plugin_id: String,
/// Plugin-owned key.
pub key: String,
/// JSON value to persist.
pub value: Value,
}
impl From<PluginStorageSetDto> for application::PluginStorageSetInput {
fn from(value: PluginStorageSetDto) -> Self {
Self {
plugin_id: value.plugin_id,
key: value.key,
value: value.value,
}
}
}
/// Plugin structured config document read request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -2377,8 +2418,8 @@ use application::{
LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput,
};
use domain::{
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, McpToolPolicy, PermissionSet,
ProjectPermissions, SkillKind, TerminalSession,
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, EffortSelection, McpToolPolicy,
PermissionSet, PermissionShadowReport, ProjectPermissions, SkillKind, TerminalSession,
};
/// One discoverable capability carried by an agent.
@ -2413,6 +2454,8 @@ pub struct AgentDto {
pub agent: Agent,
/// Resolved discoverable capabilities.
pub capabilities: Vec<AgentCapabilityDto>,
/// Whether this agent is the effective project orchestrator.
pub is_orchestrator: bool,
}
impl AgentDto {
@ -2422,6 +2465,7 @@ impl AgentDto {
Self {
agent,
capabilities: Vec::new(),
is_orchestrator: false,
}
}
}
@ -2443,6 +2487,7 @@ impl From<ListAgentsOutput> for AgentListDto {
.into_iter()
.map(AgentCapabilityDto::from)
.collect(),
is_orchestrator: entry.is_orchestrator,
})
.collect(),
)
@ -2512,6 +2557,25 @@ pub struct ProjectPermissionsDto(pub ProjectPermissions);
#[serde(transparent)]
pub struct EffectivePermissionsDto(pub EffectivePermissions);
/// Response for resolving one agent's file/bash permissions.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveAgentPermissionsResponseDto {
/// Resolved policy, or `null` when neither project nor agent policy exists.
pub effective: Option<EffectivePermissions>,
/// Diagnostic report for agent-level allows shadowed by project defaults.
pub shadowed: PermissionShadowReport,
}
impl From<application::ResolveAgentPermissionsOutput> for ResolveAgentPermissionsResponseDto {
fn from(out: application::ResolveAgentPermissionsOutput) -> Self {
Self {
effective: out.effective,
shadowed: out.shadowed,
}
}
}
/// Full project system permission document crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
@ -2767,6 +2831,18 @@ pub struct ChangeAgentProfileRequestDto {
pub cols: u16,
}
/// Request DTO for `update_agent_effort`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAgentEffortRequestDto {
/// Id of the owning project.
pub project_id: String,
/// Id of the agent whose effort override changes.
pub agent_id: String,
/// `null` clears the override.
pub effort: Option<EffortSelection>,
}
/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly
/// relaunched session when a live session was hot-swapped (absent otherwise).
#[derive(Debug, Clone, Serialize)]
@ -3997,10 +4073,16 @@ pub struct CreateSkillRequestDto {
pub project_id: String,
/// Display name.
pub name: String,
/// Optional one-line affordance description.
#[serde(default)]
pub description: Option<String>,
/// Initial Markdown content.
pub content: String,
/// Scope the skill is created in.
pub scope: SkillScope,
/// Capability nature. Missing legacy clients create workflow skills.
#[serde(default)]
pub kind: SkillKind,
}
/// Request DTO for `update_skill`.
@ -4703,7 +4785,7 @@ pub struct SpawnBackgroundCommandRequestDto {
mod tests {
use application::McpToolPermissionCatalogue;
use domain::mailbox::TicketId;
use domain::{AgentId, ConversationId, ProjectMcpToolPermissions};
use domain::{AgentId, ConversationId, PermissionShadowReport, ProjectMcpToolPermissions};
use serde_json::json;
use uuid::Uuid;
@ -4755,6 +4837,34 @@ mod tests {
);
}
#[test]
fn resolve_agent_permissions_response_dto_uses_effective_plus_shadowed_shape() {
let dto = ResolveAgentPermissionsResponseDto {
effective: None,
shadowed: PermissionShadowReport {
read: false,
write: false,
delete: false,
execute_bash: true,
fallback: true,
},
};
assert_eq!(
serde_json::to_value(dto).unwrap(),
json!({
"effective": null,
"shadowed": {
"read": false,
"write": false,
"delete": false,
"executeBash": true,
"fallback": true
}
})
);
}
#[test]
fn dto_plugins_workspace_requests_use_stable_camel_case_contract() {
let path = PluginWorkspacePathDto {
@ -4868,6 +4978,42 @@ mod tests {
assert_eq!(input.value["enabled"], true);
}
#[test]
fn dto_plugins_storage_requests_use_stable_camel_case_contract() {
let get = PluginStorageGetDto {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
};
assert_eq!(
serde_json::to_value(&get).unwrap(),
json!({
"pluginId": "dev.acme.gitgraph",
"key": "helloPlugin.launches"
})
);
let input: application::PluginStorageGetInput = get.into();
assert_eq!(input.plugin_id, "dev.acme.gitgraph");
assert_eq!(input.key, "helloPlugin.launches");
let set = PluginStorageSetDto {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.enabled".to_owned(),
value: json!({"enabled": true}),
};
assert_eq!(
serde_json::to_value(&set).unwrap(),
json!({
"pluginId": "dev.acme.gitgraph",
"key": "helloPlugin.enabled",
"value": {"enabled": true}
})
);
let input: application::PluginStorageSetInput = set.into();
assert_eq!(input.plugin_id, "dev.acme.gitgraph");
assert_eq!(input.key, "helloPlugin.enabled");
assert_eq!(input.value, json!({"enabled": true}));
}
#[test]
fn dto_plugins_workspace_outputs_use_stable_camel_case_contract() {
let listing = PluginWorkspaceDirectoryListingDto {

View File

@ -579,6 +579,16 @@ pub enum DomainEventDto {
#[serde(skip_serializing_if = "Option::is_none")]
orchestrator: Option<String>,
},
/// The project's global context was written directly.
#[serde(rename_all = "camelCase")]
ProjectContextUpdated {
/// The project whose global context changed.
project_id: String,
/// Writer party (`"user"` or agent id).
by: String,
/// Epoch-milliseconds of the write.
at_ms: i64,
},
/// A memory note was created or updated.
#[serde(rename_all = "camelCase")]
MemorySaved {
@ -1213,6 +1223,15 @@ impl From<&DomainEvent> for DomainEventDto {
project_id: project_id.to_string(),
orchestrator: orchestrator.as_ref().map(|a| a.to_string()),
},
DomainEvent::ProjectContextUpdated {
project_id,
by,
at_ms,
} => Self::ProjectContextUpdated {
project_id: project_id.to_string(),
by: conversation_party_wire(*by),
at_ms: *at_ms,
},
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
slug: slug.as_str().to_string(),
},
@ -1426,6 +1445,28 @@ mod tests {
);
}
#[test]
fn project_context_updated_relays_writer_to_wire() {
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
let writer = agent(2);
let dto = DomainEventDto::from(&DomainEvent::ProjectContextUpdated {
project_id,
by: ConversationParty::agent(writer),
at_ms: 987_654,
});
assert_eq!(
serde_json::to_value(&dto).unwrap(),
json!({
"type": "projectContextUpdated",
"projectId": project_id.to_string(),
"by": writer.to_string(),
"atMs": 987654,
})
);
}
#[test]
fn background_completion_relays_rendezvous_context_to_wire() {
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));

View File

@ -11,6 +11,7 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
use application::{
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
@ -35,7 +36,7 @@ use application::{
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions,
PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment,
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
@ -49,7 +50,7 @@ use application::{
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext,
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort,
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
@ -64,10 +65,11 @@ use domain::ports::{
EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort,
IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall,
MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore,
ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler,
SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer,
SystemPermissionStore, TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore,
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
WakeError, WakeReason, WindowStateStore,
};
use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
@ -91,15 +93,15 @@ use infrastructure::{
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
FsPluginStorageStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore,
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
@ -1093,6 +1095,8 @@ pub struct BackendCore {
pub update_project_permissions: Arc<UpdateProjectPermissions>,
/// Update one agent permission override.
pub update_agent_permissions: Arc<UpdateAgentPermissions>,
/// Update one agent effort override.
pub update_agent_effort: Arc<UpdateAgentEffort>,
/// Resolve effective permissions for one agent.
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
/// Read the project system permission document.
@ -1142,6 +1146,8 @@ pub struct BackendCore {
pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
/// Public plugin structured config document facade.
pub plugin_config_documents: Arc<PluginConfigDocuments>,
/// Public plugin-owned storage facade.
pub plugin_storage_access: Arc<PluginStorageAccess>,
/// Public plugin project-structure query use case.
pub query_project_structure: Arc<QueryProjectStructure>,
/// Public plugin command/task facade.
@ -1416,11 +1422,13 @@ impl BackendCore {
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone()));
let plugin_registry = Arc::new(FsPluginRegistryStore::new(app_data_dir.clone()));
let plugin_storage = Arc::new(FsPluginStorageStore::new(app_data_dir.clone()));
let plugin_validator =
Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION")));
let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new());
let plugin_package_store = Arc::clone(&plugin_packages) as Arc<dyn PluginPackageStore>;
let plugin_registry_store = Arc::clone(&plugin_registry) as Arc<dyn PluginRegistryStore>;
let plugin_storage_store = Arc::clone(&plugin_storage) as Arc<dyn PluginStorageStore>;
let plugin_manifest_validator =
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
let plugin_mcp_supervisor_port =
@ -2178,6 +2186,7 @@ impl BackendCore {
let update_agent_permissions = Arc::new(UpdateAgentPermissions::new(Arc::clone(
&permission_store_port,
)));
let update_agent_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_port)));
let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone(
&permission_store_port,
)));
@ -2425,6 +2434,7 @@ impl BackendCore {
));
let uninstall_plugin = Arc::new(UninstallPlugin::new(
Arc::clone(&plugin_package_store),
Arc::clone(&plugin_storage_store),
Arc::clone(&plugin_registry_store),
Arc::clone(&events_port),
Arc::clone(&plugin_mcp_supervisor_port),
@ -2448,6 +2458,10 @@ impl BackendCore {
PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
.with_events(Arc::clone(&events_port)),
);
let plugin_storage_access = Arc::new(PluginStorageAccess::new(
Arc::clone(&plugin_storage_store),
Arc::clone(&plugin_registry_store),
));
let query_project_structure = Arc::new(QueryProjectStructure::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
@ -2718,6 +2732,13 @@ impl BackendCore {
Arc::clone(&fs_port),
Arc::clone(&clock) as Arc<dyn Clock>,
)),
update_project_context: Arc::new(GuardedUpdateProjectContext::new(
Arc::clone(&file_guard),
Arc::clone(&contexts_port),
Arc::clone(&fs_port),
Arc::clone(&events_port),
Arc::clone(&clock) as Arc<dyn Clock>,
)),
read_memory: Arc::new(ReadMemory::new(
Arc::clone(&file_guard),
Arc::clone(&memory_store_port),
@ -2976,6 +2997,7 @@ impl BackendCore {
get_project_permissions,
update_project_permissions,
update_agent_permissions,
update_agent_effort,
resolve_agent_permissions,
get_project_system_permissions,
update_project_system_permissions,
@ -3045,6 +3067,7 @@ impl BackendCore {
reconcile_plugin_mcp_servers,
plugin_workspace_access,
plugin_config_documents,
plugin_storage_access,
query_project_structure,
plugin_command_tasks,
plugin_toolchain_diagnostics,
@ -4649,6 +4672,7 @@ mod mcp_serve_peer_tests {
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
effort: None,
});
id
}
@ -5387,6 +5411,7 @@ mod mcp_serve_peer_tests {
// traite ces commandes sans cette erreur — et le réfute sans le câblage.
// -----------------------------------------------------------------------
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
use application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory};
use domain::conversation::ConversationParty;
use domain::memory::{
@ -5481,6 +5506,13 @@ mod mcp_serve_peer_tests {
Arc::new(FakeFs),
Arc::new(FixedClock),
)),
update_project_context: Arc::new(GuardedUpdateProjectContext::new(
Arc::clone(&file_guard),
Arc::new(contexts.clone()),
Arc::new(FakeFs),
Arc::new(NoopBus),
Arc::new(FixedClock),
)),
read_memory: Arc::new(ReadMemory::new(
Arc::clone(&file_guard),
Arc::clone(&memory) as Arc<dyn MemoryStore>,
@ -5977,6 +6009,7 @@ mod mcp_e2e_loopback_tests {
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
effort: None,
});
id
}

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(

View File

@ -24,6 +24,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
zip = { version = "2", default-features = false, features = ["deflate"] }
# AEAD encryption for the at-rest `SecretStore` adapter (ticket #92, lot B2).
# Already vendored transitively (rustls/reqwest use it) — made an explicit direct
# dependency here rather than adding a new crate to the tree.

View File

@ -88,7 +88,9 @@ pub use orchestrator::{
};
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
pub use plugin::{ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore};
pub use plugin::{
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
};
pub use process::{LocalEnvironmentReader, LocalProcessSpawner};
pub use pty::PortablePtyAdapter;
pub use ratelimit::RateLimitParser;

View File

@ -68,6 +68,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_context_update",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
@ -166,7 +167,7 @@ pub fn catalogue() -> Vec<ToolDef> {
name: "idea_list_agents",
description: "List the IdeA agents declared in the project's manifest. Returns the \
agents inline as a JSON array (id, name, profile, origin, raw skills, \
and resolved capabilities).",
resolved capabilities, and isOrchestrator).",
input_schema: json!({
"type": "object",
"properties": {},
@ -308,6 +309,23 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_context_update",
description: "Directly update the global project context. Strict, orchestrator-only \
counterpart to idea_context_propose: if you are not the project's \
current orchestrator (see isOrchestrator on idea_list_agents), this call \
fails and never silently files a proposal. Pass ifMatch from a prior \
idea_context_read to reject the write if the context changed.",
input_schema: json!({
"type": "object",
"properties": {
"content": { "type": "string", "description": "The new Markdown body." },
"ifMatch": { "type": "string", "description": "Optional version from a prior idea_context_read; rejected if stale." }
},
"required": ["content"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_memory_read",
description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
@ -485,6 +503,16 @@ pub fn map_tool_call(
content: s("content"),
..base()
},
"idea_context_update" => {
reject_unknown_keys(name, args, &["content", "ifMatch"])?;
OrchestratorRequest {
request_type: Some("context.update".to_owned()),
requested_by: Some(requester.to_owned()),
content: s("content"),
if_match: s("ifMatch"),
..base()
}
}
"idea_memory_read" => OrchestratorRequest {
request_type: Some("memory.read".to_owned()),
requested_by: Some(requester.to_owned()),
@ -556,6 +584,7 @@ fn base() -> OrchestratorRequest {
result: None,
ticket: None,
content: None,
if_match: None,
slug: None,
status: None,
intent: None,
@ -569,6 +598,20 @@ fn base() -> OrchestratorRequest {
}
}
fn reject_unknown_keys(
tool: &str,
args: &serde_json::Map<String, Value>,
allowed: &[&str],
) -> Result<(), ToolMapError> {
if args
.keys()
.any(|key| !allowed.iter().any(|allowed_key| allowed_key == key))
{
return Err(ToolMapError::BadArguments(tool.to_owned()));
}
Ok(())
}
/// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently
/// dropping a malformed/absent one (validation then rejects a `visible` launch
/// missing its node, with a precise field error).
@ -880,6 +923,51 @@ mod tests {
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn idea_context_update_tool_registered_and_maps_to_context_update_command() {
assert_eq!(
tool_access("idea_context_update"),
Some(McpToolAccess::WriteAction)
);
assert!(catalogue()
.into_iter()
.any(|tool| tool.name == "idea_context_update"));
let cmd = map_tool_call(
"idea_context_update",
&json!({ "content": "# body", "ifMatch": "abc123" }),
REQ,
)
.unwrap();
assert_eq!(
cmd,
OrchestratorCommand::UpdateProjectContext {
content: "# body".to_owned(),
if_match: Some("abc123".to_owned()),
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
}
#[test]
fn idea_context_update_requires_content_and_rejects_unknown_fields() {
let missing = map_tool_call("idea_context_update", &json!({ "ifMatch": "abc123" }), REQ);
assert!(matches!(missing, Err(ToolMapError::Invalid(_))));
let unknown = map_tool_call(
"idea_context_update",
&json!({ "content": "# body", "target": "Dev" }),
REQ,
);
assert_eq!(
unknown,
Err(ToolMapError::BadArguments("idea_context_update".to_owned()))
);
}
#[test]
fn memory_read_and_write_map_to_their_commands() {
assert_eq!(

View File

@ -2,7 +2,7 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::io::Read;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex, MutexGuard};
@ -11,7 +11,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use domain::ports::{
LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
PluginRegistryError, PluginRegistryStore, PluginStoreError,
PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore,
PluginStoreError,
};
use domain::{
ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec,
@ -23,6 +24,14 @@ use tokio::process::Child;
const REGISTRY_FILE: &str = "registry.json";
const MANIFEST_FILE: &str = "idea-plugin.json";
const STORAGE_ENTRIES_DIR: &str = "entries";
#[derive(Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginStorageEntry {
key: String,
value: serde_json::Value,
}
/// Filesystem package store under app-data `plugins/`.
#[derive(Debug, Clone)]
@ -151,18 +160,7 @@ impl PluginPackageStore for FsPluginPackageStore {
archive: &LocalPath,
) -> Result<StagedPluginPackage, PluginStoreError> {
let stage = self.stage_root()?;
let status = std::process::Command::new("unzip")
.arg("-q")
.arg(archive.as_str())
.arg("-d")
.arg(&stage)
.status()
.map_err(|e| PluginStoreError::Io(format!("failed to run unzip: {e}")))?;
if !status.success() {
return Err(PluginStoreError::Format(format!(
"unzip exited with status {status}"
)));
}
extract_archive_confined(Path::new(archive.as_str()), &stage)?;
ensure_manifest(&stage)?;
let content_hash = hash_dir(&stage)?;
Ok(StagedPluginPackage {
@ -278,6 +276,43 @@ fn ensure_manifest(root: &Path) -> Result<(), PluginStoreError> {
}
}
fn extract_archive_confined(archive: &Path, stage: &Path) -> Result<(), PluginStoreError> {
let file = fs::File::open(archive).map_err(|e| PluginStoreError::Io(e.to_string()))?;
let mut archive = zip::ZipArchive::new(file)
.map_err(|e| PluginStoreError::Format(format!("invalid zip archive: {e}")))?;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|e| PluginStoreError::Format(format!("invalid zip entry: {e}")))?;
let entry_name = entry.name().to_owned();
let enclosed = entry.enclosed_name().ok_or_else(|| {
PluginStoreError::Invalid(format!("archive entry escapes plugin root: {entry_name}"))
})?;
if entry
.unix_mode()
.is_some_and(|mode| mode & 0o170000 == 0o120000)
{
return Err(PluginStoreError::Invalid(format!(
"archive entry symlinks are not allowed: {entry_name}"
)));
}
let destination = stage.join(&enclosed);
if entry.is_dir() {
fs::create_dir_all(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?;
continue;
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|e| PluginStoreError::Io(e.to_string()))?;
}
let mut out =
fs::File::create(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?;
io::copy(&mut entry, &mut out).map_err(|e| PluginStoreError::Io(e.to_string()))?;
out.flush()
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
}
Ok(())
}
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
fs::create_dir_all(target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
for entry in fs::read_dir(source).map_err(|e| PluginStoreError::Io(e.to_string()))? {
@ -290,6 +325,16 @@ fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
copy_dir_all(&entry.path(), &dest)?;
} else if ty.is_file() {
fs::copy(entry.path(), dest).map_err(|e| PluginStoreError::Io(e.to_string()))?;
} else if ty.is_symlink() {
return Err(PluginStoreError::Invalid(format!(
"plugin source contains symlink: {}",
entry.path().display()
)));
} else {
return Err(PluginStoreError::Invalid(format!(
"plugin source contains unsupported entry: {}",
entry.path().display()
)));
}
}
Ok(())
@ -329,6 +374,16 @@ fn collect_files(root: &Path, files: &mut Vec<PathBuf>) -> Result<(), PluginStor
collect_files(&entry.path(), files)?;
} else if ty.is_file() {
files.push(entry.path());
} else if ty.is_symlink() {
return Err(PluginStoreError::Invalid(format!(
"plugin package contains symlink: {}",
entry.path().display()
)));
} else {
return Err(PluginStoreError::Invalid(format!(
"plugin package contains unsupported entry: {}",
entry.path().display()
)));
}
}
Ok(())
@ -354,6 +409,97 @@ impl FsPluginRegistryStore {
}
}
/// Filesystem plugin-owned storage under app-data `plugins/data/<pluginId>/`.
#[derive(Debug, Clone)]
pub struct FsPluginStorageStore {
root: PathBuf,
}
impl FsPluginStorageStore {
/// Builds the store.
#[must_use]
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
Self {
root: app_data_dir.into().join("plugins").join("data"),
}
}
fn plugin_dir(&self, plugin_id: &PluginId) -> PathBuf {
self.root.join(plugin_id.as_str())
}
fn entry_path(&self, plugin_id: &PluginId, key: &str) -> PathBuf {
let digest = Sha256::digest(key.as_bytes());
self.plugin_dir(plugin_id)
.join(STORAGE_ENTRIES_DIR)
.join(format!("{}.json", hex::encode(digest)))
}
}
#[async_trait]
impl PluginStorageStore for FsPluginStorageStore {
async fn get(
&self,
plugin_id: &PluginId,
key: &str,
) -> Result<Option<serde_json::Value>, PluginStorageError> {
let path = self.entry_path(plugin_id, key);
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path).map_err(|e| PluginStorageError::Io(e.to_string()))?;
let entry: PluginStorageEntry = serde_json::from_slice(&bytes)
.map_err(|e| PluginStorageError::Serialization(e.to_string()))?;
if entry.key == key {
Ok(Some(entry.value))
} else {
Err(PluginStorageError::Invalid(
"plugin storage key hash collision".to_owned(),
))
}
}
async fn set(
&self,
plugin_id: &PluginId,
key: &str,
value: serde_json::Value,
) -> Result<(), PluginStorageError> {
let path = self.entry_path(plugin_id, key);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| PluginStorageError::Io(e.to_string()))?;
}
let entry = PluginStorageEntry {
key: key.to_owned(),
value,
};
let bytes = serde_json::to_vec_pretty(&entry)
.map_err(|e| PluginStorageError::Serialization(e.to_string()))?;
fs::write(path, bytes).map_err(|e| PluginStorageError::Io(e.to_string()))
}
async fn delete(&self, plugin_id: &PluginId, key: &str) -> Result<bool, PluginStorageError> {
let path = self.entry_path(plugin_id, key);
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(PluginStorageError::Io(e.to_string())),
}
}
async fn purge_plugin(
&self,
plugin_id: &PluginId,
) -> Result<RemovalOutcome, PluginStorageError> {
let dir = self.plugin_dir(plugin_id);
match fs::remove_dir_all(dir) {
Ok(()) => Ok(RemovalOutcome::Removed),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemovalOutcome::NotFound),
Err(e) => Err(PluginStorageError::Io(e.to_string())),
}
}
}
#[async_trait]
impl PluginRegistryStore for FsPluginRegistryStore {
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
@ -574,6 +720,99 @@ mod tests {
.unwrap();
}
struct ZipEntrySpec<'a> {
name: &'a str,
contents: &'a [u8],
unix_mode: Option<u32>,
}
fn write_u16(out: &mut Vec<u8>, value: u16) {
out.extend_from_slice(&value.to_le_bytes());
}
fn write_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
fn crc32(bytes: &[u8]) -> u32 {
let mut crc = 0xffff_ffffu32;
for &byte in bytes {
crc ^= u32::from(byte);
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xedb8_8320 & mask);
}
}
!crc
}
fn write_zip(path: &Path, entries: &[ZipEntrySpec<'_>]) {
let mut file = fs::File::create(path).unwrap();
let mut central = Vec::new();
let mut offset = 0u32;
for entry in entries {
let name = entry.name.as_bytes();
let data = entry.contents;
let crc = crc32(data);
let local_size = 30u32 + name.len() as u32 + data.len() as u32;
let mut local = Vec::new();
write_u32(&mut local, 0x0403_4b50);
write_u16(&mut local, 20);
write_u16(&mut local, 0);
write_u16(&mut local, 0);
write_u16(&mut local, 0);
write_u16(&mut local, 0);
write_u32(&mut local, crc);
write_u32(&mut local, data.len() as u32);
write_u32(&mut local, data.len() as u32);
write_u16(&mut local, name.len() as u16);
write_u16(&mut local, 0);
local.extend_from_slice(name);
local.extend_from_slice(data);
file.write_all(&local).unwrap();
let mut header = Vec::new();
let version_made_by = if entry.unix_mode.is_some() {
(3u16 << 8) | 20
} else {
20
};
let external_attributes = entry.unix_mode.unwrap_or(0) << 16;
write_u32(&mut header, 0x0201_4b50);
write_u16(&mut header, version_made_by);
write_u16(&mut header, 20);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u32(&mut header, crc);
write_u32(&mut header, data.len() as u32);
write_u32(&mut header, data.len() as u32);
write_u16(&mut header, name.len() as u16);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u32(&mut header, external_attributes);
write_u32(&mut header, offset);
header.extend_from_slice(name);
central.extend_from_slice(&header);
offset += local_size;
}
file.write_all(&central).unwrap();
let mut eocd = Vec::new();
write_u32(&mut eocd, 0x0605_4b50);
write_u16(&mut eocd, 0);
write_u16(&mut eocd, 0);
write_u16(&mut eocd, entries.len() as u16);
write_u16(&mut eocd, entries.len() as u16);
write_u32(&mut eocd, central.len() as u32);
write_u32(&mut eocd, offset);
write_u16(&mut eocd, 0);
file.write_all(&eocd).unwrap();
}
#[derive(Default)]
struct RecordingBridge {
started: Mutex<Vec<PluginMcpServerSpec>>,
@ -671,35 +910,23 @@ mod tests {
#[tokio::test]
async fn extracts_archive_without_path_escape() {
if std::process::Command::new("zip")
.arg("-h")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_err()
|| std::process::Command::new("unzip")
.arg("-h")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_err()
{
return;
}
let app = temp_dir("archive-app");
let source = temp_dir("archive-source");
write_plugin(&source, "bundle");
let archive_path = app.join("plugin.ideaplug");
{
let status = std::process::Command::new("zip")
.arg("-qr")
.arg(&archive_path)
.arg(".")
.current_dir(&source)
.status()
.unwrap();
assert!(status.success());
}
write_zip(
&archive_path,
&[
ZipEntrySpec {
name: "idea-plugin.json",
contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
unix_mode: Some(0o100644),
},
ZipEntrySpec {
name: "dist/index.js",
contents: b"bundle",
unix_mode: Some(0o100644),
},
],
);
let store = FsPluginPackageStore::new(app.join("data"));
let staged = store
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
@ -707,9 +934,142 @@ mod tests {
.unwrap();
assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists());
let _ = fs::remove_dir_all(app);
}
#[tokio::test]
async fn install_from_directory_rejects_source_symlink() {
let app = temp_dir("symlink-app");
let source = temp_dir("symlink-source");
write_plugin(&source, "bundle");
let outside = app.join("outside.txt");
fs::write(&outside, "secret").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, source.join("dist/escape.txt")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&outside, source.join("dist/escape.txt")).unwrap();
let store = FsPluginPackageStore::new(&app);
let err = store
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
.await
.unwrap_err();
assert!(matches!(err, PluginStoreError::Invalid(_)));
assert!(err.to_string().contains("symlink"));
assert!(!app.join("plugins/installed/dev.acme.test").exists());
let _ = fs::remove_dir_all(app);
let _ = fs::remove_dir_all(source);
}
#[tokio::test]
async fn install_from_archive_rejects_parent_traversal_without_writing_outside_stage() {
let app = temp_dir("traversal-app");
let archive_path = app.join("plugin.ideaplug");
write_zip(
&archive_path,
&[
ZipEntrySpec {
name: "../../../../outside.txt",
contents: b"pwned",
unix_mode: Some(0o100644),
},
ZipEntrySpec {
name: "idea-plugin.json",
contents: br#"{"ideaPluginManifestVersion":1}"#,
unix_mode: Some(0o100644),
},
],
);
let store = FsPluginPackageStore::new(&app);
let err = store
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
.await
.unwrap_err();
assert!(matches!(err, PluginStoreError::Invalid(_)));
assert!(err.to_string().contains("escapes plugin root"));
assert!(!app.join("outside.txt").exists());
let _ = fs::remove_dir_all(app);
}
#[tokio::test]
async fn install_from_archive_rejects_symlink_entries() {
let app = temp_dir("archive-symlink-app");
let archive_path = app.join("plugin.ideaplug");
write_zip(
&archive_path,
&[
ZipEntrySpec {
name: "idea-plugin.json",
contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
unix_mode: Some(0o100644),
},
ZipEntrySpec {
name: "dist/link.js",
contents: b"/tmp/outside.js",
unix_mode: Some(0o120777),
},
],
);
let store = FsPluginPackageStore::new(&app);
let err = store
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
.await
.unwrap_err();
assert!(matches!(err, PluginStoreError::Invalid(_)));
assert!(err.to_string().contains("symlinks are not allowed"));
assert!(!app.join("plugins/installed/dev.acme.test").exists());
let _ = fs::remove_dir_all(app);
}
#[tokio::test]
async fn plugin_storage_store_round_trips_deletes_and_purges_plugin_data() {
let app = temp_dir("storage-app");
let store = FsPluginStorageStore::new(&app);
let plugin_id = PluginId::new("dev.acme.test").unwrap();
assert_eq!(
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
None
);
store
.set(
&plugin_id,
"helloPlugin.launches",
serde_json::json!({"count": 1}),
)
.await
.unwrap();
assert_eq!(
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
Some(serde_json::json!({"count": 1}))
);
assert!(app.join("plugins/data/dev.acme.test/entries").is_dir());
assert!(store
.delete(&plugin_id, "helloPlugin.launches")
.await
.unwrap());
assert_eq!(
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
None
);
store
.set(&plugin_id, "helloPlugin.enabled", serde_json::json!(true))
.await
.unwrap();
assert_eq!(
store.purge_plugin(&plugin_id).await.unwrap(),
RemovalOutcome::Removed
);
assert!(!app.join("plugins/data/dev.acme.test").exists());
let _ = fs::remove_dir_all(app);
}
#[tokio::test]
async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() {
let bridge = Arc::new(RecordingBridge::default());

View File

@ -108,6 +108,7 @@ impl FakeContexts {
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
effort: None,
});
id
}

View File

@ -97,6 +97,7 @@ impl FakeContexts {
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
effort: None,
});
id
}

View File

@ -8,7 +8,7 @@ use application::{
ListPlugins, UninstallPlugin, UninstallPluginInput,
};
use infrastructure::{
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore,
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
TokioBroadcastEventBus,
};
@ -39,6 +39,48 @@ fn sdk_hello_plugin_path() -> PathBuf {
.join("sdk/IdeaSDK/examples/hello-plugin")
}
fn materialize_sdk_hello_plugin() -> PathBuf {
let source = sdk_hello_plugin_path();
let root = temp_dir("hello-plugin-built");
fs::create_dir_all(root.join("dist")).unwrap();
fs::write(
root.join("idea-plugin.json"),
fs::read(source.join("idea-plugin.json")).unwrap(),
)
.unwrap();
fs::write(
root.join("dist/index.js"),
"export function activate() { return 'hello-plugin'; }",
)
.unwrap();
root
}
fn write_multifile_plugin(root: &Path) {
fs::create_dir_all(root.join("dist/core")).unwrap();
fs::write(
root.join("idea-plugin.json"),
r#"{
"ideaPluginManifestVersion": 1,
"id": "dev.acme.multifile",
"displayName": "Multifile Plugin",
"version": "0.1.0",
"main": "dist/index.js",
"trustLevel": "full",
"capabilities": ["ui"],
"contributes": {}
}"#,
)
.unwrap();
fs::write(
root.join("dist/index.js"),
"import { answer } from './constants.js'; export default answer;",
)
.unwrap();
fs::write(root.join("dist/constants.js"), "export const answer = 42;").unwrap();
fs::write(root.join("dist/core/util.js"), "export const util = true;").unwrap();
}
#[tokio::test]
async fn installs_reference_fixture_and_loads_runtime_catalog() {
let app_data = temp_dir("app-data");
@ -100,7 +142,7 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() {
#[tokio::test]
async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
let app_data = temp_dir("hello-app-data");
let hello_plugin = sdk_hello_plugin_path();
let hello_plugin = materialize_sdk_hello_plugin();
let packages = Arc::new(FsPluginPackageStore::new(&app_data));
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
@ -152,14 +194,16 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
assert_eq!(plugin.contributes.layouts[0].label.as_str(), "hello-world");
let _ = fs::remove_dir_all(app_data);
let _ = fs::remove_dir_all(hello_plugin);
}
#[tokio::test]
async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
let app_data = temp_dir("hello-reinstall-app-data");
let hello_plugin = sdk_hello_plugin_path();
let hello_plugin = materialize_sdk_hello_plugin();
let packages = Arc::new(FsPluginPackageStore::new(&app_data));
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
let storage = Arc::new(FsPluginStorageStore::new(&app_data));
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
let events = Arc::new(TokioBroadcastEventBus::new());
let mcp = Arc::new(ExternalMcpPluginSupervisor::new());
@ -170,7 +214,7 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
events.clone(),
mcp.clone(),
);
let uninstall = UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp);
let uninstall = UninstallPlugin::new(packages.clone(), storage, registry.clone(), events, mcp);
install
.execute(hello_plugin.to_string_lossy().into_owned())
@ -225,6 +269,76 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
assert!(catalog.plugins[0].bundle_url.ends_with("/dist/index.js"));
let _ = fs::remove_dir_all(app_data);
let _ = fs::remove_dir_all(hello_plugin);
}
#[tokio::test]
async fn uninstall_multifile_plugin_removes_package_registry_and_runtime_residue() {
let app_data = temp_dir("multifile-uninstall-app-data");
let source = temp_dir("multifile-source");
write_multifile_plugin(&source);
let packages = Arc::new(FsPluginPackageStore::new(&app_data));
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
let storage = Arc::new(FsPluginStorageStore::new(&app_data));
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
let events = Arc::new(TokioBroadcastEventBus::new());
let mcp = Arc::new(ExternalMcpPluginSupervisor::new());
let install = InstallPluginFromDirectory::new(
packages.clone(),
registry.clone(),
validator.clone(),
events.clone(),
mcp.clone(),
);
let uninstall = UninstallPlugin::new(packages.clone(), storage, registry.clone(), events, mcp);
install
.execute(source.to_string_lossy().into_owned())
.await
.unwrap();
assert!(app_data
.join("plugins/installed/dev.acme.multifile/dist/constants.js")
.is_file());
assert!(app_data
.join("plugins/installed/dev.acme.multifile/dist/core/util.js")
.is_file());
let plugin_data = app_data.join("plugins/data/dev.acme.multifile");
fs::create_dir_all(&plugin_data).unwrap();
fs::write(plugin_data.join("state.json"), r#"{"launches":1}"#).unwrap();
let uninstall_result = uninstall
.execute(UninstallPluginInput {
plugin_id: "dev.acme.multifile".to_owned(),
})
.await
.unwrap();
assert_eq!(
uninstall_result.removal_outcome,
domain::RemovalOutcome::Removed
);
assert!(!app_data
.join("plugins/installed/dev.acme.multifile")
.exists());
assert!(!app_data.join("plugins/data/dev.acme.multifile").exists());
assert!(
ListPlugins::new(packages.clone(), registry.clone(), validator.clone())
.execute()
.await
.unwrap()
.is_empty()
);
assert!(
ListPluginRuntimeContributions::new(packages, registry, validator)
.execute()
.await
.unwrap()
.plugins
.is_empty()
);
let _ = fs::remove_dir_all(app_data);
let _ = fs::remove_dir_all(source);
}
#[tokio::test]

View File

@ -52,10 +52,11 @@ use application::{
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
WriteToTerminalInput,
};
use domain::ports::PtyHandle;
use domain::IssueActor;
@ -74,16 +75,16 @@ use backend::dto::{
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto,
MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
@ -97,8 +98,9 @@ use backend::dto::{
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
};
@ -2426,6 +2428,7 @@ async fn invoke(
"update_agent_permissions" => {
invoke_update_agent_permissions(&request.args, &state.app).await
}
"update_agent_effort" => invoke_update_agent_effort(&request.args, &state.app).await,
"resolve_agent_permissions" => {
invoke_resolve_agent_permissions(&request.args, &state.app).await
}
@ -3482,9 +3485,10 @@ async fn invoke_create_skill(args: &Value, state: &BackendCore) -> Result<Value,
.create_skill
.execute(CreateSkillInput {
name: request.name,
description: None,
description: request.description,
content: request.content,
scope: request.scope,
kind: request.kind,
project_root: project.root,
})
.await
@ -3620,6 +3624,22 @@ async fn invoke_update_agent_permissions(
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_agent_effort(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateAgentEffortRequestDto>("update_agent_effort", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_agent_effort
.execute(UpdateAgentEffortInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
effort: request.effort,
})
.await
.map(|out| AgentDto::from_agent(out.agent))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_resolve_agent_permissions(
args: &Value,
state: &BackendCore,
@ -3634,7 +3654,7 @@ async fn invoke_resolve_agent_permissions(
agent_id: parse_agent_id(&request.agent_id)?,
})
.await
.map(|out| out.effective.map(EffectivePermissionsDto))
.map(ResolveAgentPermissionsResponseDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
@ -7895,6 +7915,7 @@ mod tests {
"get_project_permissions",
"update_project_permissions",
"update_agent_permissions",
"update_agent_effort",
"resolve_agent_permissions",
"get_project_system_permissions",
"update_project_system_permissions",

View File

@ -139,4 +139,25 @@ describe("TauriAgentGateway invoke payloads", () => {
);
expect(out.relaunchedSession).toBeUndefined();
});
it("update_agent_effort wraps the nullable effort override in the request DTO", async () => {
invoke.mockResolvedValueOnce({ id: "agent-2", effort: { kind: "preset", value: "high" } });
await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", {
kind: "preset",
value: "high",
});
expect(invoke).toHaveBeenCalledWith("update_agent_effort", {
request: {
projectId: "proj-1",
agentId: "agent-2",
effort: { kind: "preset", value: "high" },
},
});
invoke.mockClear().mockResolvedValueOnce({ id: "agent-2" });
await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", null);
expect(invoke).toHaveBeenCalledWith("update_agent_effort", {
request: { projectId: "proj-1", agentId: "agent-2", effort: null },
});
});
});

View File

@ -16,6 +16,7 @@ import { Channel, invoke } from "@tauri-apps/api/core";
import type {
Agent,
EffortSelection,
ResumableAgent,
TerminalSession,
} from "@/domain";
@ -105,6 +106,16 @@ export class TauriAgentGateway implements AgentGateway {
);
}
updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
return invoke<Agent>("update_agent_effort", {
request: { projectId, agentId, effort },
});
}
readContext(projectId: string, agentId: string): Promise<string> {
return invoke<string>("read_agent_context", { projectId, agentId });
}

View File

@ -48,6 +48,7 @@ import {
WebPluginConfigGateway,
WebPluginEventGateway,
WebPluginGateway,
WebPluginStorageGateway,
WebPluginTaskGateway,
WebPluginToolchainGateway,
WebPluginWorkspaceGateway,
@ -151,6 +152,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
pluginToolchain: new WebPluginToolchainGateway(),
pluginEvents: new WebPluginEventGateway(),
pluginConfig: new WebPluginConfigGateway(),
pluginStorage: new WebPluginStorageGateway(),
};
}

View File

@ -15,7 +15,6 @@ import type {
Agent,
AgentDrift,
AgentProfile,
EffectivePermissions,
EmbedderEngines,
EmbedderProfile,
FirstRunState,
@ -43,6 +42,7 @@ import type {
ProjectSystemPermissions,
ProfileAvailability,
ProfileModelCatalog,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
Skill,
@ -286,7 +286,14 @@ export class HttpSkillGateway implements SkillGateway {
}
createSkill(input: CreateSkillInput): Promise<Skill> {
return this.http.invoke<Skill>("create_skill", {
request: { projectId: input.projectId, name: input.name, content: input.content, scope: input.scope },
request: {
projectId: input.projectId,
name: input.name,
description: input.description,
kind: input.kind,
content: input.content,
scope: input.scope,
},
});
}
updateSkill(projectId: string, scope: SkillScope, skillId: string, content: string): Promise<Skill> {
@ -381,8 +388,8 @@ export class HttpPermissionGateway implements PermissionGateway {
request: { projectId, agentId, permissions },
});
}
resolveAgentPermissions(projectId: string, agentId: string): Promise<EffectivePermissions | null> {
return this.http.invoke<EffectivePermissions | null>("resolve_agent_permissions", {
resolveAgentPermissions(projectId: string, agentId: string): Promise<ResolvedAgentPermissions> {
return this.http.invoke<ResolvedAgentPermissions>("resolve_agent_permissions", {
request: { projectId, agentId },
});
}

View File

@ -23,6 +23,7 @@ import type {
Agent,
AppExitWorkGuardState,
DomainEvent,
EffortSelection,
HealthReport,
ReplyChunk,
ResumableAgent,
@ -223,6 +224,15 @@ export class HttpAgentGateway implements AgentGateway {
request: { projectId, agentId, profileId, rows, cols },
});
}
updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
return this.http.invoke<Agent>("update_agent_effort", {
request: { projectId, agentId, effort },
});
}
readContext(projectId: string, agentId: string): Promise<string> {
return this.http.invoke<string>("read_agent_context", { projectId, agentId });
}

View File

@ -12,6 +12,7 @@
import type {
EmbeddedServerStatus,
GatewayError,
JsonValue,
PluginAdmin,
PluginCommandTask,
PluginConfigDocument,
@ -44,6 +45,9 @@ import type {
PluginEventSubscribeInput,
PluginEventUnsubscribeInput,
PluginGateway,
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
PluginProjectStructureQuery,
PluginRunCommandInput,
PluginTaskGateway,
@ -263,3 +267,18 @@ export class WebPluginConfigGateway implements PluginConfigGateway {
return unsupportedOnWeb("Plugin structured config documents");
}
}
/** Web stub: plugin-owned storage is owned by the desktop host app-data. */
export class WebPluginStorageGateway implements PluginStorageGateway {
async get(_input: PluginStorageGetInput): Promise<JsonValue | null> {
return unsupportedOnWeb("Plugin storage");
}
async set(_input: PluginStorageSetInput): Promise<void> {
return unsupportedOnWeb("Plugin storage");
}
async delete(_input: PluginStorageGetInput): Promise<boolean> {
return unsupportedOnWeb("Plugin storage");
}
}

View File

@ -40,6 +40,7 @@ import { TauriPluginTaskGateway } from "./pluginTask";
import { TauriPluginToolchainGateway } from "./pluginToolchain";
import { TauriPluginEventGateway } from "./pluginEvents";
import { TauriPluginConfigGateway } from "./pluginConfig";
import { TauriPluginStorageGateway } from "./pluginStorage";
function notImplemented(what: string): never {
const err: GatewayError = {
@ -87,6 +88,7 @@ export function createTauriGateways(): Gateways {
pluginToolchain: new TauriPluginToolchainGateway(),
pluginEvents: new TauriPluginEventGateway(),
pluginConfig: new TauriPluginConfigGateway(),
pluginStorage: new TauriPluginStorageGateway(),
};
}
@ -114,4 +116,5 @@ export {
TauriFocusedProjectGateway,
LocalStorageUiPreferencesGateway,
TauriPluginGateway,
TauriPluginStorageGateway,
};

View File

@ -11,6 +11,7 @@ import type {
AgentProfile,
DiagnosticWarning,
DomainEvent,
EffortSelection,
EmbedderEngines,
EmbedderProfile,
EmbeddedServerStatus,
@ -37,7 +38,6 @@ import type {
OpenCodeProviderCatalogEntry,
ProfileModelCatalog,
ProfileModelCatalogEntry,
EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet,
@ -72,6 +72,7 @@ import type {
ProjectSystemPermissions,
ProfileAvailability,
ResumableAgent,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
ServerExposurePreview,
ServerExposureSettings,
@ -129,6 +130,9 @@ import type {
PluginEventSubscribeInput,
PluginEventUnsubscribeInput,
PluginGateway,
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
PluginProjectStructureQuery,
PluginRunCommandInput,
PluginTaskGateway,
@ -603,6 +607,28 @@ export class MockAgentGateway implements AgentGateway {
};
}
async updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
const next =
effort === null
? (({ effort: _dropped, ...rest }) => rest)(list[idx])
: { ...list[idx], effort };
list[idx] = next;
return structuredClone(next);
}
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
/**
@ -2006,6 +2032,8 @@ export class MockSkillGateway implements SkillGateway {
const skill: Skill = {
id: `mock-skill-${this.seq}`,
name: input.name,
description: input.description ?? null,
kind: input.kind ?? "workflow",
contentMd: input.content,
scope: input.scope,
};
@ -2431,14 +2459,18 @@ export class MockPermissionGateway implements PermissionGateway {
async resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null> {
): Promise<ResolvedAgentPermissions> {
const doc = this.doc(projectId);
const project = doc.projectDefaults;
const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions;
if (!project && !agent) return null;
const shadowed = permissionShadowReport(project, agent);
if (!project && !agent) return { effective: null, shadowed };
return {
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
fallback: mostRestrictive(project?.fallback, agent?.fallback),
effective: {
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
fallback: mostRestrictive(project?.fallback, agent?.fallback),
},
shadowed,
};
}
@ -3466,6 +3498,47 @@ function mostRestrictive(
return rank[agent] >= rank[fallback] ? agent : fallback;
}
function permissionShadowReport(project?: PermissionSet, agent?: PermissionSet) {
const empty = {
read: false,
write: false,
delete: false,
executeBash: false,
fallback: false,
};
if (!agent) return empty;
const shadowed = (capability: "read" | "write" | "delete" | "executeBash") =>
blanketEffect(project, capability, "deny") === "deny" &&
blanketEffect(agent, capability, "allow") === "allow";
return {
read: shadowed("read"),
write: shadowed("write"),
delete: shadowed("delete"),
executeBash: shadowed("executeBash"),
fallback: agent.fallback !== mostRestrictive(project?.fallback, agent.fallback),
};
}
function blanketEffect(
set: PermissionSet | undefined,
capability: "read" | "write" | "delete" | "executeBash",
wins: "allow" | "deny",
) {
let found: "allow" | "deny" | undefined;
for (const rule of set?.rules ?? []) {
if (rule.capability !== capability || !isBlanketRule(rule)) continue;
if (rule.effect === wins) return rule.effect;
found = rule.effect;
}
return found;
}
function isBlanketRule(rule: PermissionSet["rules"][number]) {
if (rule.capability === "executeBash") return (rule.commands ?? []).length === 0;
const paths = rule.paths ?? [];
return paths.length === 1 && paths[0] === "**";
}
/**
* In-memory plugin store (ticket #43, F1). Mirrors the carnet contract closely
* enough to develop/test F1-F4 without the backend (B1-B4, landing in
@ -4016,6 +4089,38 @@ export class MockPluginConfigGateway implements PluginConfigGateway {
}
}
/**
* In-memory plugin-owned storage gateway for plugin runtime tests/dev.
*/
export class MockPluginStorageGateway implements PluginStorageGateway {
private readonly values = new Map<string, JsonValue>();
private storageKey(input: PluginStorageGetInput): string {
if (!input.pluginId.trim()) {
const err: GatewayError = { code: "INVALID", message: "pluginId must not be empty" };
throw err;
}
if (!input.key.trim()) {
const err: GatewayError = { code: "INVALID", message: "key must not be empty" };
throw err;
}
return `${input.pluginId}:${input.key}`;
}
async get(input: PluginStorageGetInput): Promise<JsonValue | null> {
const value = this.values.get(this.storageKey(input));
return value === undefined ? null : cloneJson(value);
}
async set(input: PluginStorageSetInput): Promise<void> {
this.values.set(this.storageKey(input), cloneJson(input.value));
}
async delete(input: PluginStorageGetInput): Promise<boolean> {
return this.values.delete(this.storageKey(input));
}
}
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
@ -4050,6 +4155,7 @@ export function createMockGateways(): Gateways {
pluginToolchain: new MockPluginToolchainGateway(),
pluginEvents: new MockPluginEventGateway(),
pluginConfig: new MockPluginConfigGateway(),
pluginStorage: new MockPluginStorageGateway(),
};
}

View File

@ -1,12 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
import type {
EffectivePermissions,
McpToolPolicy,
PermissionSet,
ProjectMcpToolPermissions,
ProjectPermissions,
ProjectSystemPermissions,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
} from "@/domain";
@ -40,8 +40,8 @@ export class TauriPermissionGateway implements PermissionGateway {
resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null> {
return invoke<EffectivePermissions | null>("resolve_agent_permissions", {
): Promise<ResolvedAgentPermissions> {
return invoke<ResolvedAgentPermissions>("resolve_agent_permissions", {
request: { projectId, agentId },
});
}

View File

@ -0,0 +1,26 @@
/**
* Tauri adapter for plugin-owned JSON storage (#139).
*/
import { invoke } from "@tauri-apps/api/core";
import type { JsonValue } from "@/domain";
import type {
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
} from "@/ports";
export class TauriPluginStorageGateway implements PluginStorageGateway {
get(input: PluginStorageGetInput): Promise<JsonValue | null> {
return invoke<JsonValue | null>("plugin_storage_get", { input });
}
async set(input: PluginStorageSetInput): Promise<void> {
await invoke("plugin_storage_set", { input });
}
delete(input: PluginStorageGetInput): Promise<boolean> {
return invoke<boolean>("plugin_storage_delete", { input });
}
}

View File

@ -22,6 +22,8 @@ export class TauriSkillGateway implements SkillGateway {
request: {
projectId: input.projectId,
name: input.name,
description: input.description,
kind: input.kind,
content: input.content,
scope: input.scope,
},

View File

@ -767,6 +767,21 @@ export interface EffectivePermissions {
fallback: PermissionPosture;
}
/** Agent override choices shadowed by stricter project defaults. */
export interface PermissionShadowReport {
read: boolean;
write: boolean;
delete: boolean;
executeBash: boolean;
fallback: boolean;
}
/** Resolved file/bash permissions plus non-authoritative diagnostics. */
export interface ResolvedAgentPermissions {
effective: EffectivePermissions | null;
shadowed: PermissionShadowReport;
}
/** Wanted/effective network policy for system permissions. */
export type NetworkPolicy = "allow" | "deny" | "ask";
@ -1151,6 +1166,21 @@ export interface ProfileModelCatalog {
warnings: string[];
}
/** One native effort/reasoning preset declared by an AI profile. */
export interface EffortOption {
/** Raw value persisted/forwarded to the provider. */
value: string;
/** Human-readable label shown in selectors. */
label: string;
/** Optional short description from the profile declaration. */
hint?: string;
}
/** Per-agent effort override. `undefined`/`null` means profile default. */
export type EffortSelection =
| { kind: "preset"; value: string }
| { kind: "custom"; value: string };
/**
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
* UUID string; `detect` is the optional detection command line.
@ -1192,6 +1222,16 @@ export interface AgentProfile {
* CLI's own default. OpenCode keeps its dedicated provider/local model fields.
*/
model?: string;
/**
* Optional direct CLI reasoning effort configured on the profile. `undefined`
* keeps the CLI/provider default.
*/
modelReasoningEffort?: string;
/**
* Native effort presets exposed by this profile, in declaration order from
* light to deep. Empty/omitted means the provider declares no native options.
*/
effortOptions?: EffortOption[];
}
/** Availability of a candidate profile after detection (mirror of the DTO). */
@ -1233,6 +1273,8 @@ export interface Agent {
synchronized: boolean;
/** Skills assigned to this agent (injected into its convention file). */
skills: SkillRef[];
/** Per-agent effort override. Omitted for older manifests/profile default. */
effort?: EffortSelection;
}
/**
@ -1284,6 +1326,7 @@ export interface ResumableAgent {
* across projects; `project` skills are specific to one project's `.ideai/`.
*/
export type SkillScope = "global" | "project";
export type SkillKind = "workflow" | "reference";
/**
* A reusable, model-agnostic workflow assignable to agents (mirror of the
@ -1292,6 +1335,8 @@ export type SkillScope = "global" | "project";
export interface Skill {
id: string;
name: string;
description?: string | null;
kind: SkillKind;
contentMd: string;
scope: SkillScope;
}

View File

@ -25,7 +25,13 @@ import { useAgents } from "./useAgents";
import { correlateModelServerStatus } from "./modelServerLaunch";
import { AgentLimitBadge } from "./AgentLimitBadge";
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
import type { ResolvedAgentSystemPermissions } from "@/domain";
import type {
Agent,
AgentProfile,
EffortOption,
EffortSelection,
ResolvedAgentSystemPermissions,
} from "@/domain";
export interface AgentsPanelProps {
/** The project whose agents to manage. */
@ -237,7 +243,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
// Determine if a template is chosen → profile selector is hidden (template imposes it).
const hasTemplate = newTemplateId !== "";
const profileLabel = (profile: import("@/domain").AgentProfile): string => {
const profileLabel = (profile: AgentProfile): string => {
const model =
profile.model ??
profile.opencode?.model ??
@ -365,12 +371,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
const isSelected = a.id === vm.selectedAgentId;
const isRunning = a.id === activeAgentId;
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
const agentProfile = vm.profiles.find((p) => p.id === a.profileId) ?? null;
const profileName =
(() => {
const p = vm.profiles.find((p) => p.id === a.profileId);
return p ? profileLabel(p) : null;
})() ??
a.profileId;
(agentProfile ? profileLabel(agentProfile) : null) ?? a.profileId;
const agentDrift = drift.driftByAgentId.get(a.id);
// Source of this agent's last orchestration delegation (mcp vs
// file), if any has been observed. Absent ⇒ no badge.
@ -480,6 +483,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
]}
/>
)}
<EffortSelector
agent={a}
profile={agentProfile}
busy={vm.busy}
onChange={(effort) =>
void vm.updateAgentEffort(a.id, effort)
}
/>
{agentDrift && (
<Button
size="sm"
@ -706,6 +717,141 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
);
}
const EFFORT_DEFAULT_VALUE = "__profile_default__";
const EFFORT_CUSTOM_VALUE = "__custom_effort__";
const GENERIC_EFFORT_OPTIONS: EffortOption[] = [
{
value: "low",
label: "Rapide (par défaut)",
hint: "Fallback générique léger.",
},
{
value: "medium",
label: "Standard (par défaut)",
hint: "Fallback générique équilibré.",
},
{
value: "high",
label: "Approfondi (par défaut)",
hint: "Fallback générique profond.",
},
];
function rawEffortValue(selection: EffortSelection | undefined): string {
return selection?.value ?? "";
}
function EffortSelector({
agent,
profile,
busy,
onChange,
}: {
agent: Agent;
profile: AgentProfile | null;
busy: boolean;
onChange: (effort: EffortSelection | null) => void;
}) {
const nativeOptions = profile?.effortOptions ?? [];
const hasNativeOptions = nativeOptions.length > 0;
const displayedOptions = hasNativeOptions
? nativeOptions
: GENERIC_EFFORT_OPTIONS;
const [customText, setCustomText] = useState(rawEffortValue(agent.effort));
const [forceCustom, setForceCustom] = useState(agent.effort?.kind === "custom");
useEffect(() => {
setCustomText(rawEffortValue(agent.effort));
setForceCustom(agent.effort?.kind === "custom");
}, [agent.id, agent.effort]);
let selectedOption = EFFORT_DEFAULT_VALUE;
if (forceCustom) {
selectedOption = EFFORT_CUSTOM_VALUE;
} else if (
hasNativeOptions &&
agent.effort?.kind === "preset" &&
nativeOptions.some((option) => option.value === agent.effort?.value)
) {
selectedOption = `preset:${agent.effort.value}`;
} else if (
!hasNativeOptions &&
agent.effort &&
displayedOptions.some((option) => option.value === agent.effort?.value)
) {
selectedOption = `fallback:${agent.effort.value}`;
} else if (agent.effort) {
selectedOption = EFFORT_CUSTOM_VALUE;
}
const customVisible = selectedOption === EFFORT_CUSTOM_VALUE;
function commitCustom() {
const value = customText.trim();
if (value.length === 0) return;
if (agent.effort?.kind === "custom" && agent.effort.value === value) return;
onChange({ kind: "custom", value });
}
return (
<span className="flex min-w-[11rem] max-w-full flex-wrap items-center gap-1.5">
<SmallDropdown
aria-label={`effort for ${agent.name}`}
value={selectedOption}
disabled={busy}
onChange={(value) => {
if (value === EFFORT_DEFAULT_VALUE) {
setForceCustom(false);
onChange(null);
return;
}
if (value === EFFORT_CUSTOM_VALUE) {
setForceCustom(true);
setCustomText(rawEffortValue(agent.effort));
return;
}
if (value.startsWith("preset:")) {
setForceCustom(false);
onChange({ kind: "preset", value: value.slice("preset:".length) });
return;
}
if (value.startsWith("fallback:")) {
setForceCustom(false);
onChange({ kind: "custom", value: value.slice("fallback:".length) });
}
}}
options={[
{ value: EFFORT_DEFAULT_VALUE, label: "Effort: profil" },
...displayedOptions.map((option) => ({
value: `${hasNativeOptions ? "preset" : "fallback"}:${option.value}`,
label: option.label,
})),
{ value: EFFORT_CUSTOM_VALUE, label: "Personnalisé" },
]}
/>
{customVisible && (
<Input
aria-label={`custom effort for ${agent.name}`}
value={customText}
disabled={busy}
placeholder="valeur brute"
onChange={(event) => setCustomText(event.target.value)}
onBlur={commitCustom}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
commitCustom();
}
}}
className="h-8 min-w-[8rem] flex-1 px-2 text-xs"
/>
)}
</span>
);
}
function NetworkPermissionBadge({
state,
}: {

View File

@ -492,6 +492,11 @@ async function seededProfiles(): Promise<MockProfileGateway> {
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
detect: null,
cwdTemplate: "{projectRoot}",
effortOptions: [
{ value: "low", label: "Léger" },
{ value: "medium", label: "Standard" },
{ value: "high", label: "Profond" },
],
},
{
id: "prof-2",
@ -588,6 +593,123 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
});
});
describe("AgentsPanel effort selection (#131)", () => {
it("shows native profile effort options in declaration order with Personnalisé last", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, { name: "Thinker", profileId: "prof-1" });
const profile = await seededProfiles();
renderPanel(agent, profile);
await waitForIdle();
await screen.findByText("Thinker");
openDropdown("effort for Thinker");
const labels = screen
.getAllByRole("option")
.map((option) => option.textContent);
expect(labels).toEqual([
"Effort: profil",
"Léger",
"Standard",
"Profond",
"Personnalisé",
]);
});
it("persists a native effort option as a preset", async () => {
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Thinker",
profileId: "prof-1",
});
const updateSpy = vi.spyOn(agent, "updateAgentEffort");
renderPanel(agent, await seededProfiles());
await waitForIdle();
await screen.findByText("Thinker");
chooseDropdownOption("effort for Thinker", "Profond");
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
kind: "preset",
value: "high",
});
});
const [updated] = await agent.listAgents(PROJECT_ID);
expect(updated.effort).toEqual({ kind: "preset", value: "high" });
});
it("shows generic default fallback options when the profile declares no native options", async () => {
const agent = new MockAgentGateway();
const profile = new MockProfileGateway();
await profile.configureProfiles([
{
id: "plain",
name: "Plain provider",
command: "plain-ai",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
},
]);
const created = await agent.createAgent(PROJECT_ID, {
name: "Fallback",
profileId: "plain",
});
renderPanel(agent, profile);
await waitForIdle();
await screen.findByText("Fallback");
openDropdown("effort for Fallback");
const labels = screen
.getAllByRole("option")
.map((option) => option.textContent);
expect(labels).toEqual([
"Effort: profil",
"Rapide (par défaut)",
"Standard (par défaut)",
"Approfondi (par défaut)",
"Personnalisé",
]);
fireEvent.click(screen.getByRole("option", { name: "Standard (par défaut)" }));
await waitFor(async () => {
const [updated] = await agent.listAgents(PROJECT_ID);
expect(updated.id).toBe(created.id);
expect(updated.effort).toEqual({ kind: "custom", value: "medium" });
});
});
it("reveals an inline custom effort field and persists the free text", async () => {
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Custom",
profileId: "prof-1",
});
const updateSpy = vi.spyOn(agent, "updateAgentEffort");
renderPanel(agent, await seededProfiles());
await waitForIdle();
await screen.findByText("Custom");
chooseDropdownOption("effort for Custom", "Personnalisé");
const input = screen.getByLabelText("custom effort for Custom");
fireEvent.change(input, { target: { value: "x-provider-deep" } });
fireEvent.blur(input);
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
kind: "custom",
value: "x-provider-deep",
});
});
});
});
describe("AgentsPanel live refresh on domain events", () => {
it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => {
const agent = new MockAgentGateway();

View File

@ -12,6 +12,7 @@ import { useCallback, useEffect, useState } from "react";
import type {
Agent,
AgentProfile,
EffortSelection,
GatewayError,
ModelServerStatus,
TerminalSession,
@ -124,6 +125,11 @@ export interface AgentsViewModel {
rows: number,
cols: number,
) => Promise<TerminalSession | undefined>;
/** Sets or clears a per-agent effort override. */
updateAgentEffort: (
agentId: string,
effort: EffortSelection | null,
) => Promise<void>;
/** Deletes an agent; deselects if it was selected. */
deleteAgent: (agentId: string) => Promise<void>;
/**
@ -427,6 +433,26 @@ export function useAgents(projectId: string): AgentsViewModel {
[agent, projectId, refreshLiveAgents],
);
const updateAgentEffort = useCallback(
async (agentId: string, effort: EffortSelection | null): Promise<void> => {
setBusy(true);
setError(null);
try {
const updated = await agent.updateAgentEffort(projectId, agentId, effort);
setAgents((prev) =>
prev.map((candidate) =>
candidate.id === updated.id ? updated : candidate,
),
);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[agent, projectId],
);
const deleteAgent = useCallback(
async (agentId: string) => {
setBusy(true);
@ -544,6 +570,7 @@ export function useAgents(projectId: string): AgentsViewModel {
selectAgent,
saveContext,
changeAgentProfile,
updateAgentEffort,
deleteAgent,
launchAgent,
stopAgent,

View File

@ -8,8 +8,10 @@ import type {
PermissionPosture,
PermissionRule,
PermissionSet,
PermissionShadowReport,
ProjectPermissions,
ProjectSystemPermissions,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
} from "@/domain";
@ -27,6 +29,7 @@ export interface PolicyDraft {
export interface AgentPermissionRow {
agent: Agent;
override: PermissionSet | null;
shadowed: PermissionShadowReport | null;
systemOverride: SystemPermissionSet | null;
resolvedSystem: ResolvedAgentSystemPermissions | null;
}
@ -132,6 +135,9 @@ export function usePermissions(projectId: string): PermissionsViewModel {
const [resolvedSystemByAgent, setResolvedSystemByAgent] = useState<
Record<string, ResolvedAgentSystemPermissions>
>({});
const [resolvedPermissionsByAgent, setResolvedPermissionsByAgent] = useState<
Record<string, ResolvedAgentPermissions>
>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -156,9 +162,29 @@ export function usePermissions(projectId: string): PermissionsViewModel {
}
}),
);
const resolvedPermissionPairs = await Promise.all(
agentList.map(async (candidate) => {
try {
return [
candidate.id,
await permission.resolveAgentPermissions(projectId, candidate.id),
] as const;
} catch {
return [candidate.id, null] as const;
}
}),
);
setAgents(agentList);
setDocument(permissionDoc);
setSystemDocument(systemPermissionDoc);
setResolvedPermissionsByAgent(
Object.fromEntries(
resolvedPermissionPairs.filter(
(pair): pair is readonly [string, ResolvedAgentPermissions] =>
pair[1] !== null,
),
),
);
setResolvedSystemByAgent(
Object.fromEntries(
resolvedPairs.filter(
@ -191,10 +217,17 @@ export function usePermissions(projectId: string): PermissionsViewModel {
return agents.map((candidate) => ({
agent: candidate,
override: overrides.get(candidate.id) ?? null,
shadowed: resolvedPermissionsByAgent[candidate.id]?.shadowed ?? null,
systemOverride: systemOverrides.get(candidate.id) ?? null,
resolvedSystem: resolvedSystemByAgent[candidate.id] ?? null,
}));
}, [agents, document, systemDocument, resolvedSystemByAgent]);
}, [
agents,
document,
systemDocument,
resolvedPermissionsByAgent,
resolvedSystemByAgent,
]);
const projectDraft = useMemo(
() => draftFromSet(document?.projectDefaults ?? null),

View File

@ -80,6 +80,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
pluginToolchain: gateways.pluginToolchain,
pluginEvents: gateways.pluginEvents,
pluginConfig: gateways.pluginConfig,
pluginStorage: gateways.pluginStorage,
}),
)
.then((result) => {

View File

@ -210,6 +210,7 @@ describe("loadPlugins", () => {
"pluginToolchain",
"pluginEvents",
"pluginConfig",
"pluginStorage",
].some((key) => key in ctx);
globalThis.__serviceKeys = Object.keys(ctx.services).sort();
globalThis.__workspaceServiceKeys = Object.keys(ctx.services.workspace).sort();
@ -241,6 +242,7 @@ describe("loadPlugins", () => {
"pluginDisplayName",
"pluginId",
"services",
"storage",
"subscriptions",
"version",
]);
@ -294,6 +296,59 @@ describe("loadPlugins", () => {
]);
});
it("injects plugin-owned storage scoped to the activating plugin id", async () => {
const calls: unknown[] = [];
const storageGateways = {
...gateways,
pluginStorage: {
async get(input: unknown) {
calls.push(["get", input]);
return { launches: 1 };
},
async set(input: unknown) {
calls.push(["set", input]);
},
async delete(input: unknown) {
calls.push(["delete", input]);
return true;
},
},
} as PluginGatewaySet;
const bundle = dataUrl(`
export async function activate(ctx) {
globalThis.__storageKeys = Object.keys(ctx.storage).sort();
globalThis.__storedValue = await ctx.storage.get("helloPlugin.state");
await ctx.storage.set("helloPlugin.state", { launches: 2 });
await ctx.storage.delete("helloPlugin.state");
}
`);
const { failures } = await loadPlugins(
[entry({ id: "dev.acme.storage", displayName: "Storage", bundleUrl: bundle })],
storageGateways,
);
expect(failures).toEqual([]);
expect((globalThis as Record<string, unknown>).__storageKeys).toEqual([
"delete",
"get",
"set",
]);
expect((globalThis as Record<string, unknown>).__storedValue).toEqual({ launches: 1 });
expect(calls).toEqual([
["get", { pluginId: "dev.acme.storage", key: "helloPlugin.state" }],
[
"set",
{
pluginId: "dev.acme.storage",
key: "helloPlugin.state",
value: { launches: 2 },
},
],
["delete", { pluginId: "dev.acme.storage", key: "helloPlugin.state" }],
]);
});
it("loads the hello-plugin command and layout contribution shape", async () => {
const bundle = dataUrl(`
export function activate(ctx) {

View File

@ -15,7 +15,7 @@
* collected, never thrown past `loadPlugins`.
*/
import type { PluginContributionDto, PluginRuntimePlugin } from "@/domain";
import type { JsonValue, PluginContributionDto, PluginRuntimePlugin } from "@/domain";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
@ -38,9 +38,16 @@ export interface IdeaPluginContext {
commands: PluginCommandContext;
layouts: PluginLayoutRegistry;
menu: PluginMenuRegistry;
storage: PluginStorage;
services?: PluginServices;
}
export interface PluginStorage {
get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined>;
set(key: string, value: JsonValue): Promise<void>;
delete(key: string): Promise<void>;
}
export interface PluginActivation {
dispose?: () => void | Promise<void>;
}
@ -241,6 +248,7 @@ async function loadOne(
const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
const menu = new PluginMenuRegistry(pluginId);
const storage = createPluginStorage(gateways, pluginId);
const ctx: IdeaPluginContext = {
pluginId,
@ -251,6 +259,7 @@ async function loadOne(
commands: createCommandContext(commands),
layouts,
menu,
storage,
};
if (hasCapability(entry, "tooling")) {
ctx.services = createPluginServices(gateways);
@ -287,6 +296,21 @@ async function loadOne(
}
}
function createPluginStorage(gateways: PluginGatewaySet, pluginId: string): PluginStorage {
return {
async get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined> {
const value = await gateways.pluginStorage.get({ pluginId, key });
return value === null ? undefined : (value as T);
},
async set(key, value) {
await gateways.pluginStorage.set({ pluginId, key, value });
},
async delete(key) {
await gateways.pluginStorage.delete({ pluginId, key });
},
};
}
/**
* Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}.
* Called once at app bootstrap (and never again in the same session — carnet

View File

@ -25,6 +25,7 @@ import type {
GitGateway,
PluginConfigGateway,
PluginEventGateway,
PluginStorageGateway,
PluginTaskGateway,
PluginToolchainGateway,
PluginWorkspaceGateway,
@ -48,6 +49,7 @@ export interface PluginGatewaySet {
pluginToolchain: PluginToolchainGateway;
pluginEvents: PluginEventGateway;
pluginConfig: PluginConfigGateway;
pluginStorage: PluginStorageGateway;
}
/** A disposable handle returned by every `register*` call. */

View File

@ -15,6 +15,7 @@ import type {
AppExitWorkGuardState,
CustomProviderConfig,
DomainEvent,
EffortSelection,
EmbedderEngines,
EmbedderProfile,
EmbeddedServerStatus,
@ -24,6 +25,7 @@ import type {
GitFileStatus,
GraphCommit,
HealthReport,
JsonValue,
LayoutKind,
LayoutList,
LayoutOperation,
@ -38,7 +40,6 @@ import type {
OpenCodeConfig,
OpenCodeProviderCatalogEntry,
ProfileModelCatalog,
EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet,
@ -71,11 +72,13 @@ import type {
ProjectSystemPermissions,
ProfileAvailability,
ResumableAgent,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
ReplyChunk,
ServerExposurePreview,
ServerExposureSettings,
Skill,
SkillKind,
SkillScope,
SystemPermissionSet,
Sprint,
@ -207,6 +210,15 @@ export interface AgentGateway {
rows: number,
cols: number,
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }>;
/**
* Sets or clears one agent's effort override. `null` clears the override and
* lets launch fall back to the profile default.
*/
updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent>;
/** Reads an agent's `.md` context by agent id. */
readContext(projectId: string, agentId: string): Promise<string>;
/** Overwrites an agent's `.md` context. */
@ -538,6 +550,8 @@ export interface CreateSkillInput {
/** Owning project (resolved to a root; ignored on disk for `global`). */
projectId: string;
name: string;
description?: string | null;
kind?: SkillKind;
content: string;
scope: SkillScope;
}
@ -925,7 +939,7 @@ export interface PermissionGateway {
resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null>;
): Promise<ResolvedAgentPermissions>;
/** Reads the full project system-permission document. */
getProjectSystemPermissions(projectId: string): Promise<ProjectSystemPermissions>;
/** Replaces or removes project-level default system permissions. */
@ -1496,6 +1510,21 @@ export interface PluginConfigGateway {
updateDocument(input: PluginConfigDocumentUpdateInput): Promise<PluginConfigDocumentWriteResult>;
}
export interface PluginStorageGetInput {
pluginId: string;
key: string;
}
export interface PluginStorageSetInput extends PluginStorageGetInput {
value: JsonValue;
}
export interface PluginStorageGateway {
get(input: PluginStorageGetInput): Promise<JsonValue | null>;
set(input: PluginStorageSetInput): Promise<void>;
delete(input: PluginStorageGetInput): Promise<boolean>;
}
export interface Gateways {
system: SystemGateway;
agent: AgentGateway;
@ -1526,4 +1555,5 @@ export interface Gateways {
pluginToolchain: PluginToolchainGateway;
pluginEvents: PluginEventGateway;
pluginConfig: PluginConfigGateway;
pluginStorage: PluginStorageGateway;
}