//! Project-scoped plugin activation and per-agent capability assignments. use std::collections::HashSet; use std::sync::Arc; use domain::{ AgentContextStore, AgentId, AgentPluginAssignments, DomainEvent, EventBus, MarkdownDoc, PluginId, PluginManifest, PluginManifestValidator, PluginMcpServerId, PluginMcpServerSpec, PluginPackageStore, PluginRegistryStore, PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project, ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore, }; use serde::{Deserialize, Serialize}; use crate::AppError; fn package_error(error: PluginStoreError) -> AppError { match error { PluginStoreError::NotFound => AppError::NotFound("plugin package".to_owned()), PluginStoreError::Invalid(message) => AppError::Invalid(message), other => AppError::Store(other.to_string()), } } /// Shared resolver for installed, runtime-active plugin manifests. pub struct ProjectPluginResolver { settings: Arc, packages: Arc, registry: Arc, validator: Arc, } impl ProjectPluginResolver { /// Builds the resolver from replaceable ports. #[must_use] pub fn new( settings: Arc, packages: Arc, registry: Arc, validator: Arc, ) -> Self { Self { settings, packages, registry, validator, } } /// Loads the persisted project document. pub async fn settings( &self, project_root: &domain::ProjectPath, ) -> Result { self.settings .load(project_root) .await .map_err(AppError::from) } /// Resolves one plugin only when enabled for the project and runtime-active /// globally. Missing/uninstalled plugins yield `None`, preserving tombstones. pub async fn enabled_manifest( &self, project_root: &domain::ProjectPath, plugin_id: &PluginId, ) -> Result, AppError> { let settings = self.settings(project_root).await?; if !settings.is_enabled(plugin_id) { return Ok(None); } let registry = self .registry .load_registry() .await .map_err(|e| AppError::Store(e.to_string()))?; let Some(entry) = registry.find(plugin_id) else { return Ok(None); }; if !entry.lifecycle_state.is_runtime_active() { return Ok(None); } let package = self .packages .list_installed() .await .map_err(package_error)? .into_iter() .find(|p| p.plugin_id.as_ref() == Some(plugin_id)); let Some(package) = package else { return Ok(None); }; let bytes = self .packages .read_manifest(&package) .await .map_err(package_error)?; self.validator .validate(&bytes.bytes, &package) .map(Some) .map_err(|e| AppError::Invalid(e.to_string())) } /// Reads a contributed Markdown body through the confined package port. pub async fn read_skill_body( &self, plugin_id: &PluginId, path: &domain::RelativePath, ) -> Result { let bytes = self .packages .read_installed_file(plugin_id, path) .await .map_err(package_error)?; let body = String::from_utf8(bytes) .map_err(|_| AppError::Invalid("plugin skill must be UTF-8 Markdown".to_owned()))?; if body.trim().is_empty() { return Err(AppError::Invalid( "plugin skill body must not be empty".to_owned(), )); } Ok(MarkdownDoc::new(body)) } /// Resolves the runtime-active MCP servers that carry at least one tool /// explicitly assigned to `agent_id`. /// /// The persisted [`PluginToolRef`] remains the authorization/source of intent; /// this projection only turns its owning server into launch-ready wiring for a /// native MCP client. Disabled, uninstalled, invalid, non-auto-start and /// unassigned servers are deliberately omitted. pub async fn assigned_mcp_servers( &self, project_root: &domain::ProjectPath, agent_id: domain::AgentId, ) -> Result, AppError> { let settings = self.settings(project_root).await?; let Some(assignments) = settings.agents.iter().find(|a| a.agent_id == agent_id) else { return Ok(Vec::new()); }; let packages = self .packages .list_installed() .await .map_err(package_error)?; let app_data_dir = self.packages.app_data_dir_label(); let mut seen = HashSet::new(); let mut specs = Vec::new(); for reference in &assignments.tools { let key = (reference.plugin_id.clone(), reference.server_id.clone()); if !seen.insert(key) { continue; } let Some(manifest) = self .enabled_manifest(project_root, &reference.plugin_id) .await? else { continue; }; let Some(server) = manifest .contributes .mcp_servers .into_iter() .find(|server| server.id == reference.server_id && server.auto_start) else { continue; }; let Some(package_root) = packages .iter() .find(|package| package.plugin_id.as_ref() == Some(&reference.plugin_id)) .map(|package| package.root.as_str()) else { continue; }; let command = substitute_plugin_vars(&server.command, package_root, app_data_dir.as_deref()); let command = if server.allow_absolute_command || looks_absolute(&command) { command } else { format!("{}/{}", package_root.trim_end_matches(['/', '\\']), command) }; specs.push(PluginMcpServerSpec { identity: format!( "plugin:{}:{}", reference.plugin_id.as_str(), server.id.as_str() ), plugin_id: reference.plugin_id.clone(), server_id: server.id, display_name: server.display_name, command, args: server .args .into_iter() .map(|arg| substitute_plugin_vars(&arg, package_root, app_data_dir.as_deref())) .collect(), env: server .env .into_iter() .map(|(key, value)| { ( key, substitute_plugin_vars(&value, package_root, app_data_dir.as_deref()), ) }) .collect(), cwd: substitute_plugin_vars( server.cwd.as_deref().unwrap_or("${pluginRoot}"), package_root, app_data_dir.as_deref(), ), transport: server.transport, }); } Ok(specs) } } fn substitute_plugin_vars(raw: &str, plugin_root: &str, app_data_dir: Option<&str>) -> String { let value = raw.replace("${pluginRoot}", plugin_root); match app_data_dir { Some(app_data_dir) => value.replace("${appDataDir}", app_data_dir), None => value, } } fn looks_absolute(path: &str) -> bool { path.starts_with('/') || path.starts_with('\\') || path.as_bytes().get(1) == Some(&b':') } /// Read model consumed by project settings surfaces. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProjectPluginSettingsView { /// Persisted activation and assignment document. pub settings: ProjectPluginSettings, /// Installed plugin manifests. Runtime failures are omitted from this /// catalogue but their settings remain in `settings` as tombstones. pub installed: Vec, } /// Lists project plugin settings and the globally installed catalogue. pub struct ListProjectPluginSettings { resolver: Arc, packages: Arc, validator: Arc, } impl ListProjectPluginSettings { /// Builds the use case. #[must_use] pub fn new( resolver: Arc, packages: Arc, validator: Arc, ) -> Self { Self { resolver, packages, validator, } } /// Executes the read. pub async fn execute(&self, project: Project) -> Result { let settings = self.resolver.settings(&project.root).await?; let mut installed = Vec::new(); for package in self .packages .list_installed() .await .map_err(package_error)? { let bytes = self .packages .read_manifest(&package) .await .map_err(package_error)?; if let Ok(manifest) = self.validator.validate(&bytes.bytes, &package) { installed.push(manifest); } } installed.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str())); Ok(ProjectPluginSettingsView { settings, installed, }) } } /// Toggles one globally installed plugin for a project. pub struct SetProjectPluginEnabled { store: Arc, packages: Arc, events: Arc, } impl SetProjectPluginEnabled { /// Builds the use case. #[must_use] pub fn new( store: Arc, packages: Arc, events: Arc, ) -> Self { Self { store, packages, events, } } /// Persists activation. Enabling requires a currently installed plugin; /// disabling a missing plugin remains valid and keeps assignments intact. pub async fn execute( &self, project: Project, plugin_id: PluginId, enabled: bool, ) -> Result { if enabled { let exists = self .packages .list_installed() .await .map_err(package_error)? .iter() .any(|p| p.plugin_id.as_ref() == Some(&plugin_id)); if !exists { return Err(AppError::NotFound(format!("plugin {}", plugin_id.as_str()))); } } let mut settings = self.store.load(&project.root).await?; match settings .plugins .iter_mut() .find(|p| p.plugin_id == plugin_id) { Some(entry) => entry.enabled = enabled, None => settings .plugins .push(ProjectPluginActivation { plugin_id, enabled }), } self.store.save(&project.root, &settings).await?; self.events .publish(DomainEvent::ProjectPluginSettingsChanged { project_id: project.id, }); Ok(settings) } } /// Mutation kind shared by skill/tool assignment use cases. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssignmentMutation { /// Add the capability when it is not already assigned. Assign, /// Remove the capability when it is currently assigned. Unassign, } /// Assigns or unassigns one plugin skill while retaining dangling tombstones. pub struct MutateAgentPluginSkill { store: Arc, contexts: Arc, resolver: Arc, events: Arc, } impl MutateAgentPluginSkill { /// Builds the use case. #[must_use] pub fn new( store: Arc, contexts: Arc, resolver: Arc, events: Arc, ) -> Self { Self { store, contexts, resolver, events, } } /// Applies an idempotent mutation. pub async fn execute( &self, project: Project, agent_id: AgentId, reference: PluginSkillRef, mutation: AssignmentMutation, ) -> Result { ensure_agent(&*self.contexts, &project, agent_id).await?; if mutation == AssignmentMutation::Assign { let manifest = self .resolver .enabled_manifest(&project.root, &reference.plugin_id) .await? .ok_or_else(|| { AppError::Invalid( "plugin is not enabled and runtime-active for this project".to_owned(), ) })?; if !manifest .contributes .skills .iter() .any(|s| s.id == reference.skill_id) { return Err(AppError::NotFound(format!( "plugin skill {}", reference.skill_id.as_str() ))); } } let mut settings = self.store.load(&project.root).await?; let assignments = assignments_mut(&mut settings, agent_id); match mutation { AssignmentMutation::Assign if !assignments.skills.contains(&reference) => { assignments.skills.push(reference) } AssignmentMutation::Unassign => assignments.skills.retain(|r| r != &reference), _ => {} } self.store.save(&project.root, &settings).await?; self.events .publish(DomainEvent::ProjectPluginSettingsChanged { project_id: project.id, }); Ok(settings) } } /// Assigns or unassigns one namespaced external MCP tool. pub struct MutateAgentPluginTool { store: Arc, contexts: Arc, resolver: Arc, events: Arc, } impl MutateAgentPluginTool { /// Builds the use case. #[must_use] pub fn new( store: Arc, contexts: Arc, resolver: Arc, events: Arc, ) -> Self { Self { store, contexts, resolver, events, } } /// Applies an idempotent mutation. Assignment validates the enabled server; /// the dynamic tool name is validated when its MCP catalogue is available. pub async fn execute( &self, project: Project, agent_id: AgentId, reference: PluginToolRef, mutation: AssignmentMutation, ) -> Result { ensure_agent(&*self.contexts, &project, agent_id).await?; if reference.tool_name.trim().is_empty() { return Err(AppError::Invalid("plugin tool name is required".to_owned())); } if mutation == AssignmentMutation::Assign { let manifest = self .resolver .enabled_manifest(&project.root, &reference.plugin_id) .await? .ok_or_else(|| { AppError::Invalid( "plugin is not enabled and runtime-active for this project".to_owned(), ) })?; if !manifest .contributes .mcp_servers .iter() .any(|s| s.id == reference.server_id) { return Err(AppError::NotFound(format!( "plugin MCP server {}", reference.server_id.as_str() ))); } } let mut settings = self.store.load(&project.root).await?; let assignments = assignments_mut(&mut settings, agent_id); match mutation { AssignmentMutation::Assign if !assignments.tools.contains(&reference) => { assignments.tools.push(reference) } AssignmentMutation::Unassign => assignments.tools.retain(|r| r != &reference), _ => {} } self.store.save(&project.root, &settings).await?; self.events .publish(DomainEvent::ProjectPluginSettingsChanged { project_id: project.id, }); Ok(settings) } } async fn ensure_agent( store: &dyn AgentContextStore, project: &Project, agent_id: AgentId, ) -> Result<(), AppError> { if store .load_manifest(project) .await? .entries .iter() .any(|e| e.agent_id == agent_id) { Ok(()) } else { Err(AppError::NotFound(format!("agent {agent_id}"))) } } fn assignments_mut( settings: &mut ProjectPluginSettings, agent_id: AgentId, ) -> &mut AgentPluginAssignments { if let Some(index) = settings.agents.iter().position(|a| a.agent_id == agent_id) { return &mut settings.agents[index]; } settings.agents.push(AgentPluginAssignments { agent_id, skills: Vec::new(), tools: Vec::new(), }); settings .agents .last_mut() .expect("assignment was just inserted") } /// Convenience constructors for driving adapters accepting string ids. pub fn plugin_skill_ref(plugin_id: String, skill_id: String) -> Result { Ok(PluginSkillRef { plugin_id: PluginId::new(plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?, skill_id: PluginSkillId::new(skill_id).map_err(|e| AppError::Invalid(e.to_string()))?, }) } /// Convenience constructor for a namespaced plugin tool reference. pub fn plugin_tool_ref( plugin_id: String, server_id: String, tool_name: String, ) -> Result { Ok(PluginToolRef { plugin_id: PluginId::new(plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?, server_id: PluginMcpServerId::new(server_id) .map_err(|e| AppError::Invalid(e.to_string()))?, tool_name, }) } #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use domain::{ Agent, AgentManifest, AgentOrigin, ContentHash, LocalPath, PluginActivationScope, PluginCapability, PluginContributionSet, PluginInstallSource, PluginLifecycleState, PluginManifestBytes, PluginManifestError, PluginMcpServerContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry, PluginTrustLevel, PluginVersion, ProfileId, ProjectPath, RelativePath, RemoteRef, RemovalOutcome, StagedPluginPackage, }; use std::sync::Mutex; struct MemorySettings { settings: Mutex, fail_save: bool, } #[derive(Clone, Default)] struct SpyEvents(Arc>>); impl SpyEvents { fn events(&self) -> Vec { self.0.lock().unwrap().clone() } } impl EventBus for SpyEvents { fn publish(&self, event: DomainEvent) { self.0.lock().unwrap().push(event); } fn subscribe(&self) -> domain::EventStream { Box::new(std::iter::empty()) } } struct FakeContexts; #[async_trait] impl AgentContextStore for FakeContexts { async fn read_context( &self, _project: &Project, _agent: &AgentId, ) -> Result { Ok(MarkdownDoc::new("# Agent")) } async fn write_context( &self, _project: &Project, _agent: &AgentId, _md: &MarkdownDoc, ) -> Result<(), domain::StoreError> { Ok(()) } async fn load_manifest( &self, _project: &Project, ) -> Result { let agent = Agent::new( agent_id(), "Dev", "agents/dev.md", ProfileId::from_uuid(uuid::Uuid::from_u128(2)), AgentOrigin::Scratch, false, ) .unwrap(); Ok(AgentManifest::new(1, vec![domain::ManifestEntry::from_agent(&agent)]).unwrap()) } async fn save_manifest( &self, _project: &Project, _manifest: &AgentManifest, ) -> Result<(), domain::StoreError> { Ok(()) } } #[async_trait] impl ProjectPluginSettingsStore for MemorySettings { async fn load( &self, _project_root: &ProjectPath, ) -> Result { Ok(self.settings.lock().unwrap().clone()) } async fn save( &self, _project_root: &ProjectPath, settings: &ProjectPluginSettings, ) -> Result<(), domain::StoreError> { if self.fail_save { return Err(domain::StoreError::Io("save failed".to_owned())); } *self.settings.lock().unwrap() = settings.clone(); Ok(()) } } struct MemoryRegistry { registry: PluginRegistry, } #[async_trait] impl PluginRegistryStore for MemoryRegistry { async fn load_registry(&self) -> Result { Ok(self.registry.clone()) } async fn save_registry( &self, _registry: &PluginRegistry, ) -> Result<(), domain::PluginRegistryError> { Ok(()) } } struct MemoryPackages { packages: Vec, app_data_dir: Option, } #[async_trait] impl PluginPackageStore for MemoryPackages { async fn list_installed(&self) -> Result, PluginStoreError> { Ok(self.packages.clone()) } async fn read_manifest( &self, _package: &PluginPackageRef, ) -> Result { Ok(PluginManifestBytes { bytes: Vec::new() }) } async fn install_from_archive( &self, _archive: &LocalPath, ) -> Result { Err(PluginStoreError::Invalid("not implemented".to_owned())) } async fn install_from_directory( &self, _dir: &LocalPath, ) -> Result { Err(PluginStoreError::Invalid("not implemented".to_owned())) } async fn commit_install( &self, _staged: StagedPluginPackage, _plugin_id: &PluginId, ) -> Result { Err(PluginStoreError::Invalid("not implemented".to_owned())) } async fn remove_package( &self, _plugin_id: &PluginId, ) -> Result { Err(PluginStoreError::Invalid("not implemented".to_owned())) } fn bundle_url( &self, _plugin_id: &PluginId, _entry: &RelativePath, _hash: &ContentHash, ) -> Result { Err(PluginStoreError::Invalid("not implemented".to_owned())) } fn app_data_dir_label(&self) -> Option { self.app_data_dir.clone() } } struct FixedManifest(PluginManifest); impl PluginManifestValidator for FixedManifest { fn validate( &self, _bytes: &[u8], _package_root: &PluginPackageRef, ) -> Result { Ok(self.0.clone()) } } fn plugin_id() -> PluginId { PluginId::new("com.idea.unity-tools").unwrap() } fn agent_id() -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(1)) } fn project_root() -> ProjectPath { ProjectPath::new("/project").unwrap() } fn project() -> Project { Project::new( domain::ProjectId::from_uuid(uuid::Uuid::from_u128(328)), "Plugin project", project_root(), RemoteRef::Local, 0, ) .unwrap() } fn manifest(auto_start: bool) -> PluginManifest { PluginManifest { idea_plugin_manifest_version: 1, id: plugin_id(), display_name: "Unity Tools".to_owned(), publisher: None, version: PluginVersion::new("1.0.0").unwrap(), description: None, engine_idea: None, main: RelativePath::new("dist/index.js").unwrap(), icon: None, trust_level: PluginTrustLevel::Full, capabilities: vec![PluginCapability::Mcp], activation_scope: PluginActivationScope::Project, contributes: PluginContributionSet { mcp_servers: vec![ PluginMcpServerContribution { id: PluginMcpServerId::new("unity-editor").unwrap(), display_name: "Unity Editor".to_owned(), command: "bin/server".to_owned(), args: vec!["--socket".to_owned(), "${appDataDir}/unity.sock".to_owned()], env: vec![("PLUGIN_ROOT".to_owned(), "${pluginRoot}".to_owned())], cwd: Some("${pluginRoot}/work".to_owned()), transport: "stdio".to_owned(), auto_start, allow_absolute_command: false, tools: Vec::new(), }, PluginMcpServerContribution { id: PluginMcpServerId::new("unassigned").unwrap(), display_name: "Unassigned".to_owned(), command: "bin/other".to_owned(), args: Vec::new(), env: Vec::new(), cwd: None, transport: "stdio".to_owned(), auto_start: true, allow_absolute_command: false, tools: Vec::new(), }, ], ..PluginContributionSet::default() }, } } fn registry(state: PluginLifecycleState) -> PluginRegistry { PluginRegistry { version: 1, plugins: vec![PluginRegistryEntry { id: plugin_id(), lifecycle_state: state, source: PluginInstallSource::Directory { path_label: "/source".to_owned(), }, content_hash: ContentHash::new("abcdef").unwrap(), restart_required: false, error: None, }], } } fn settings(enabled: bool, tools: Vec) -> ProjectPluginSettings { ProjectPluginSettings { version: 1, plugins: vec![ProjectPluginActivation { plugin_id: plugin_id(), enabled, }], agents: vec![AgentPluginAssignments { agent_id: agent_id(), skills: Vec::new(), tools, }], } } fn resolver( settings: ProjectPluginSettings, registry: PluginRegistry, manifest: PluginManifest, ) -> ProjectPluginResolver { ProjectPluginResolver::new( Arc::new(MemorySettings { settings: Mutex::new(settings), fail_save: false, }), Arc::new(MemoryPackages { packages: vec![PluginPackageRef { plugin_id: Some(plugin_id()), root: "/plugins/unity".to_owned(), }], app_data_dir: Some("/app-data".to_owned()), }), Arc::new(MemoryRegistry { registry }), Arc::new(FixedManifest(manifest)), ) } #[tokio::test] async fn assigned_mcp_servers_project_assigned_active_autostart_server_only() { let tool = PluginToolRef { plugin_id: plugin_id(), server_id: PluginMcpServerId::new("unity-editor").unwrap(), tool_name: "unity_detect_project".to_owned(), }; let resolver = resolver( settings(true, vec![tool]), registry(PluginLifecycleState::Enabled), manifest(true), ); let servers = resolver .assigned_mcp_servers(&project_root(), agent_id()) .await .unwrap(); assert_eq!(servers.len(), 1); let server = &servers[0]; assert_eq!( server.client_name(), "idea_plugin__com_idea_unity_tools__unity___904dacd241fbd4e7" ); assert_eq!(server.command, "/plugins/unity/bin/server"); assert_eq!( server.args, vec!["--socket".to_owned(), "/app-data/unity.sock".to_owned()] ); assert_eq!( server.env, vec![("PLUGIN_ROOT".to_owned(), "/plugins/unity".to_owned())] ); assert_eq!(server.cwd, "/plugins/unity/work"); } #[tokio::test] async fn assigned_mcp_servers_omits_unassigned_disabled_and_non_autostart_servers() { let assigned_tool = PluginToolRef { plugin_id: plugin_id(), server_id: PluginMcpServerId::new("unity-editor").unwrap(), tool_name: "unity_detect_project".to_owned(), }; let unassigned_only = resolver( settings(true, Vec::new()), registry(PluginLifecycleState::Enabled), manifest(true), ); assert!(unassigned_only .assigned_mcp_servers(&project_root(), agent_id()) .await .unwrap() .is_empty()); let project_disabled = resolver( settings(false, vec![assigned_tool.clone()]), registry(PluginLifecycleState::Enabled), manifest(true), ); assert!(project_disabled .assigned_mcp_servers(&project_root(), agent_id()) .await .unwrap() .is_empty()); let globally_disabled = resolver( settings(true, vec![assigned_tool.clone()]), registry(PluginLifecycleState::Disabled), manifest(true), ); assert!(globally_disabled .assigned_mcp_servers(&project_root(), agent_id()) .await .unwrap() .is_empty()); let non_autostart = resolver( settings(true, vec![assigned_tool]), registry(PluginLifecycleState::Enabled), manifest(false), ); assert!(non_autostart .assigned_mcp_servers(&project_root(), agent_id()) .await .unwrap() .is_empty()); } #[tokio::test] async fn project_plugin_activation_publishes_change_after_successful_save() { let store = Arc::new(MemorySettings { settings: Mutex::new(ProjectPluginSettings::default()), fail_save: false, }); let packages = Arc::new(MemoryPackages { packages: vec![PluginPackageRef { plugin_id: Some(plugin_id()), root: "/plugins/unity".to_owned(), }], app_data_dir: None, }); let events = SpyEvents::default(); let use_case = SetProjectPluginEnabled::new(store, packages, Arc::new(events.clone())); let project = project(); use_case .execute(project.clone(), plugin_id(), true) .await .unwrap(); assert_eq!( events.events(), vec![DomainEvent::ProjectPluginSettingsChanged { project_id: project.id, }] ); } #[tokio::test] async fn project_plugin_activation_does_not_publish_when_save_fails() { let store = Arc::new(MemorySettings { settings: Mutex::new(ProjectPluginSettings::default()), fail_save: true, }); let packages = Arc::new(MemoryPackages { packages: vec![PluginPackageRef { plugin_id: Some(plugin_id()), root: "/plugins/unity".to_owned(), }], app_data_dir: None, }); let events = SpyEvents::default(); let use_case = SetProjectPluginEnabled::new(store, packages, Arc::new(events.clone())); let error = use_case .execute(project(), plugin_id(), true) .await .unwrap_err(); assert_eq!(error.code(), "STORE"); assert!(events.events().is_empty()); } #[tokio::test] async fn project_plugin_assignment_publishes_change_after_successful_save() { let store = Arc::new(MemorySettings { settings: Mutex::new(settings(true, Vec::new())), fail_save: false, }); let packages = Arc::new(MemoryPackages { packages: vec![PluginPackageRef { plugin_id: Some(plugin_id()), root: "/plugins/unity".to_owned(), }], app_data_dir: None, }); let resolver = Arc::new(ProjectPluginResolver::new( store.clone(), packages, Arc::new(MemoryRegistry { registry: registry(PluginLifecycleState::Enabled), }), Arc::new(FixedManifest(manifest(true))), )); let events = SpyEvents::default(); let use_case = MutateAgentPluginTool::new( store, Arc::new(FakeContexts), resolver, Arc::new(events.clone()), ); let project = project(); use_case .execute( project.clone(), agent_id(), PluginToolRef { plugin_id: plugin_id(), server_id: PluginMcpServerId::new("unity-editor").unwrap(), tool_name: "unity_detect_project".to_owned(), }, AssignmentMutation::Assign, ) .await .unwrap(); assert_eq!( events.events(), vec![DomainEvent::ProjectPluginSettingsChanged { project_id: project.id, }] ); } }