diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 10a7f4ba..e141efb5 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -32,8 +32,8 @@ use application::{ UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, - UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, - UpdateProjectSystemPermissionsInput, UpdateSkillInput, + UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput, + UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, }; use backend::stream::OutputSink; use domain::ports::ModelServerRuntime; @@ -81,7 +81,8 @@ use crate::dto::{ UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, - UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, + UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectOrchestratorRequestDto, + UpdateProjectOrchestratorResponseDto, UpdateProjectPermissionsRequestDto, UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, }; @@ -800,6 +801,27 @@ pub async fn update_agent_contactable( .map_err(ErrorDto::from) } +/// `update_project_orchestrator` — select the single global project-context writer. +/// +/// # Errors +/// Returns an [`ErrorDto`] when ids are invalid, the agent is unknown, or persistence fails. +#[tauri::command] +pub async fn update_project_orchestrator( + request: UpdateProjectOrchestratorRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + state + .update_project_orchestrator + .execute(UpdateProjectOrchestratorInput { project, agent_id }) + .await + .map(|out| UpdateProjectOrchestratorResponseDto { + orchestrator_agent_id: out.orchestrator.to_string(), + }) + .map_err(ErrorDto::from) +} + /// `resolve_agent_permissions` — resolve project defaults plus agent override. /// /// # Errors diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 2ab695d7..a2530005 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -262,6 +262,7 @@ pub fn run() { commands::update_agent_permissions, commands::update_agent_effort, commands::update_agent_contactable, + commands::update_project_orchestrator, commands::resolve_agent_permissions, commands::get_project_system_permissions, commands::update_project_system_permissions, diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index f5dceda1..63436af9 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -6,7 +6,7 @@ use app_tauri_lib::dto::{ parse_agent_id, AgentDto, AgentListDto, CellKind, ConversationDetailsDto, CreateAgentRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, - UpdateAgentEffortRequestDto, + UpdateAgentEffortRequestDto, UpdateProjectOrchestratorRequestDto, }; use application::AppError; use application::{ @@ -188,6 +188,17 @@ fn update_agent_contactable_request_deserialises_camelcase() { assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string()); } +#[test] +fn update_project_orchestrator_request_deserialises_compatible_wire_fields() { + let raw = json!({ + "projectId": Uuid::from_u128(1).to_string(), + "agentId": Uuid::from_u128(2).to_string() + }); + let dto: UpdateProjectOrchestratorRequestDto = serde_json::from_value(raw).unwrap(); + assert_eq!(dto.project_id, Uuid::from_u128(1).to_string()); + assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string()); +} + #[test] fn launch_agent_request_deserialises_camelcase() { let project_id = Uuid::from_u128(1).to_string(); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 6a579d1b..3a84c0d3 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -255,9 +255,10 @@ impl CreateAgentFromScratch { .map_err(|e| AppError::Invalid(e.to_string()))?; // Append the entry and re-validate the whole manifest (unique md_paths). + let orchestrator = manifest.orchestrator; let mut entries = manifest.entries; entries.push(ManifestEntry::from_agent(&agent)); - let manifest = AgentManifest::new(manifest.version, entries) + let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) @@ -345,6 +346,83 @@ impl ListAgentsOutput { } } +// --------------------------------------------------------------------------- +// UpdateProjectOrchestrator +// --------------------------------------------------------------------------- + +/// Input for [`UpdateProjectOrchestrator::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateProjectOrchestratorInput { + /// The project whose global-context writer changes. + pub project: Project, + /// The existing agent that becomes the single effective writer. + pub agent_id: AgentId, +} + +/// Output of [`UpdateProjectOrchestrator::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateProjectOrchestratorOutput { + /// The effective writer after the update. + pub orchestrator: AgentId, +} + +/// Changes the project's single global-context writer. +/// +/// The manifest is validated and persisted as one document before the change event is +/// published. Selecting the already-effective writer is an idempotent no-op. +pub struct UpdateProjectOrchestrator { + contexts: Arc, + events: Arc, +} + +impl UpdateProjectOrchestrator { + /// Builds the use case. + #[must_use] + pub fn new(contexts: Arc, events: Arc) -> Self { + Self { contexts, events } + } + + /// Designates an existing project agent as the global-context writer. + /// + /// # Errors + /// - [`AppError::NotFound`] if the agent is unknown to the project, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute( + &self, + input: UpdateProjectOrchestratorInput, + ) -> Result { + let mut manifest = self.contexts.load_manifest(&input.project).await?; + if !manifest + .entries + .iter() + .any(|entry| entry.agent_id == input.agent_id) + { + return Err(AppError::NotFound(format!("agent {}", input.agent_id))); + } + + if manifest.effective_orchestrator() == Some(input.agent_id) { + return Ok(UpdateProjectOrchestratorOutput { + orchestrator: input.agent_id, + }); + } + + manifest + .designate(input.agent_id) + .map_err(|err| AppError::Invalid(err.to_string()))?; + self.contexts + .save_manifest(&input.project, &manifest) + .await?; + self.events.publish(DomainEvent::OrchestratorChanged { + project_id: input.project.id, + orchestrator: Some(input.agent_id), + }); + + Ok(UpdateProjectOrchestratorOutput { + orchestrator: input.agent_id, + }) + } +} + /// Lists a project's agents by reconstructing them from the manifest entries. pub struct ListAgents { contexts: Arc, @@ -579,8 +657,12 @@ impl UpdateAgentContactable { .with_contactable(input.contactable); *entry = ManifestEntry::from_agent(&agent); - let manifest = AgentManifest::new(manifest.version, manifest.entries) - .map_err(|err| AppError::Invalid(err.to_string()))?; + let manifest = AgentManifest::with_orchestrator( + manifest.version, + manifest.entries, + manifest.orchestrator, + ) + .map_err(|err| AppError::Invalid(err.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) .await?; @@ -625,8 +707,12 @@ impl UpdateAgentEffort { .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()))?; + let manifest = AgentManifest::with_orchestrator( + manifest.version, + manifest.entries, + manifest.orchestrator, + ) + .map_err(|err| AppError::Invalid(err.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) .await?; @@ -800,6 +886,7 @@ impl ChangeAgentProfile { // 4. Mutate the entry (new profile), re-validate (to_agent + manifest) // and persist. + let orchestrator = manifest.orchestrator; let mut entries = manifest.entries; let mut mutated_agent = None; for e in &mut entries { @@ -813,7 +900,7 @@ impl ChangeAgentProfile { } let agent = mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; - let manifest = AgentManifest::new(manifest.version, entries) + let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) @@ -1143,14 +1230,13 @@ impl DeleteAgent { /// - [`AppError::NotFound`] if the agent is not in the manifest, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> { - let manifest = self.contexts.load_manifest(&input.project).await?; + let mut manifest = self.contexts.load_manifest(&input.project).await?; + let previous_orchestrator = manifest.effective_orchestrator(); let before = manifest.entries.len(); - let entries: Vec = manifest + manifest .entries - .into_iter() - .filter(|e| e.agent_id != input.agent_id) - .collect(); - if entries.len() == before { + .retain(|entry| entry.agent_id != input.agent_id); + if manifest.entries.len() == before { return Err(AppError::NotFound(format!("agent {}", input.agent_id))); } if let Some((live, close)) = &self.runtime_cleanup { @@ -1158,11 +1244,17 @@ impl DeleteAgent { stop_live_agent_runtime_in_project(&input.project, input.agent_id, live, close) .await?; } - let manifest = AgentManifest::new(manifest.version, entries) - .map_err(|e| AppError::Invalid(e.to_string()))?; + manifest.on_agent_deleted(input.agent_id); + let next_orchestrator = manifest.effective_orchestrator(); self.contexts .save_manifest(&input.project, &manifest) .await?; + if previous_orchestrator != next_orchestrator { + self.events.publish(DomainEvent::OrchestratorChanged { + project_id: input.project.id, + orchestrator: manifest.orchestrator, + }); + } self.events.publish(DomainEvent::LayoutChanged { project_id: input.project.id, }); diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index ef0ee12e..6d839c7e 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -40,6 +40,7 @@ pub use lifecycle::{ StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContactable, UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput, + UpdateProjectOrchestrator, UpdateProjectOrchestratorInput, UpdateProjectOrchestratorOutput, AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX, }; pub use model_catalogue::{ diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 7644bd78..6897f0fb 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -68,7 +68,8 @@ pub use agent::{ SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome, UpdateAgentContactable, UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, - UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET, + UpdateAgentEffortInput, UpdateAgentEffortOutput, UpdateProjectOrchestrator, + UpdateProjectOrchestratorInput, UpdateProjectOrchestratorOutput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, }; pub use background::{ diff --git a/crates/application/src/orchestrator/context_guard.rs b/crates/application/src/orchestrator/context_guard.rs index 099c0ae2..8963481e 100644 --- a/crates/application/src/orchestrator/context_guard.rs +++ b/crates/application/src/orchestrator/context_guard.rs @@ -243,7 +243,7 @@ impl UpdateProjectContext { let designation = manifest.orchestrator_designation(); let resource = GuardedResource::ProjectContext; if !may_write_directly(requester, &resource, &designation) { - return Err(map_guard_err(GuardError::Forbidden)); + return Err(AppError::Invalid(context_update_denial_message(&manifest))); } let _lease = self @@ -533,6 +533,25 @@ fn map_guard_err(e: GuardError) -> AppError { } } +fn context_update_denial_message(manifest: &domain::AgentManifest) -> String { + let writer = manifest.effective_orchestrator().and_then(|id| { + manifest + .entries + .iter() + .find(|entry| entry.agent_id == id) + .map(|entry| format!("{} ({id})", entry.name)) + }); + match writer { + Some(writer) => format!( + "only the global project context writer may use idea_context_update; current writer: \ + {writer}. Use idea_context_propose instead" + ), + None => "only the global project context writer may use idea_context_update; this project \ + currently has no writer. Use idea_context_propose instead" + .to_owned(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -998,6 +1017,13 @@ mod tests { .unwrap_err(); assert_eq!(err.code(), "INVALID"); + let message = err.to_string(); + assert!( + message.contains("only the global project context writer may use idea_context_update") + ); + assert!(message.contains("Dev")); + assert!(message.contains(&AgentId::from_uuid(uuid::Uuid::from_u128(7)).to_string())); + assert!(message.contains("idea_context_propose")); let files = fs.files.lock().unwrap(); assert_eq!(files.get(project_context_file()).unwrap(), b"# old"); assert!( diff --git a/crates/application/src/skill/usecases.rs b/crates/application/src/skill/usecases.rs index 67d22664..a8e76c8e 100644 --- a/crates/application/src/skill/usecases.rs +++ b/crates/application/src/skill/usecases.rs @@ -533,8 +533,12 @@ impl AssignSkillToAgent { skill_id: SkillId, assigned: bool, ) -> Result<(), AppError> { - let manifest = AgentManifest::new(manifest.version, manifest.entries) - .map_err(|e| AppError::Invalid(e.to_string()))?; + let manifest = AgentManifest::with_orchestrator( + manifest.version, + manifest.entries, + manifest.orchestrator, + ) + .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts.save_manifest(project, &manifest).await?; self.events.publish(DomainEvent::SkillAssigned { agent_id, @@ -591,8 +595,12 @@ impl UnassignSkillFromAgent { *entry = domain::ManifestEntry::from_agent(&agent); if changed { - let manifest = AgentManifest::new(manifest.version, manifest.entries) - .map_err(|e| AppError::Invalid(e.to_string()))?; + let manifest = AgentManifest::with_orchestrator( + manifest.version, + manifest.entries, + manifest.orchestrator, + ) + .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) .await?; diff --git a/crates/application/src/template/usecases.rs b/crates/application/src/template/usecases.rs index d96478ba..183f8d98 100644 --- a/crates/application/src/template/usecases.rs +++ b/crates/application/src/template/usecases.rs @@ -408,9 +408,10 @@ impl CreateAgentFromTemplate { .map_err(|e| AppError::Invalid(e.to_string()))? .with_contactable(template.default_contactable); + let orchestrator = manifest.orchestrator; let mut entries = manifest.entries; entries.push(ManifestEntry::from_agent(&agent)); - let manifest = AgentManifest::new(manifest.version, entries) + let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) @@ -624,8 +625,12 @@ impl SyncAgentWithTemplate { manifest.entries[idx].synced_template_version = Some(template.version); // Persist the manifest (revalidated) and overwrite the agent context. - let manifest = AgentManifest::new(manifest.version, manifest.entries) - .map_err(|e| AppError::Invalid(e.to_string()))?; + let manifest = AgentManifest::with_orchestrator( + manifest.version, + manifest.entries, + manifest.orchestrator, + ) + .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) .await?; diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 0c2ddd5a..cd6b694b 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -59,6 +59,7 @@ use application::{ ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode, StructuredSessions, TerminalSessions, UpdateAgentContactable, UpdateAgentContactableInput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, + UpdateProjectOrchestrator, UpdateProjectOrchestratorInput, }; // --------------------------------------------------------------------------- @@ -1069,6 +1070,63 @@ async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated() assert_eq!(entries[1].is_orchestrator, false); } +#[tokio::test] +async fn update_project_orchestrator_persists_single_writer_and_publishes_change() { + 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 events = SpyBus::default(); + let update = + UpdateProjectOrchestrator::new(Arc::new(contexts.clone()), Arc::new(events.clone())); + + let out = update + .execute(UpdateProjectOrchestratorInput { + project: project(), + agent_id: a2.id, + }) + .await + .unwrap(); + + assert_eq!(out.orchestrator, a2.id); + assert_eq!(contexts.manifest().orchestrator, Some(a2.id)); + assert_eq!(contexts.manifest().effective_orchestrator(), Some(a2.id)); + assert_eq!( + events.events(), + vec![DomainEvent::OrchestratorChanged { + project_id: project().id, + orchestrator: Some(a2.id), + }] + ); +} + +#[tokio::test] +async fn update_project_orchestrator_rejects_unknown_agent_without_mutation_or_event() { + let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let contexts = FakeContexts::with_agent(&a1, "ctx1"); + let events = SpyBus::default(); + let update = + UpdateProjectOrchestrator::new(Arc::new(contexts.clone()), Arc::new(events.clone())); + + let err = update + .execute(UpdateProjectOrchestratorInput { + project: project(), + agent_id: aid(404), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "NOT_FOUND"); + assert_eq!(contexts.manifest().orchestrator, None); + assert!(events.events().is_empty()); +} + #[tokio::test] async fn read_then_update_context_roundtrips() { let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); @@ -1125,6 +1183,30 @@ async fn update_agent_effort_sets_preset_selection_and_persists_manifest() { ); } +#[tokio::test] +async fn agent_mutation_preserves_explicit_project_orchestrator() { + 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 update = UpdateAgentEffort::new(Arc::new(contexts.clone())); + + update + .execute(UpdateAgentEffortInput { + project: project(), + agent_id: a1.id, + effort: Some(EffortSelection::Preset("high".to_owned())), + }) + .await + .unwrap(); + + assert_eq!(contexts.manifest().orchestrator, Some(a2.id)); +} + #[tokio::test] async fn update_agent_effort_none_clears_existing_override() { let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)) @@ -1236,6 +1318,35 @@ async fn delete_removes_entry_then_unknown_is_not_found() { assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } +#[tokio::test] +async fn deleting_writer_clears_explicit_designation_and_falls_back_to_oldest_remaining() { + 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 events = SpyBus::default(); + let delete = DeleteAgent::new(Arc::new(contexts.clone()), Arc::new(events.clone())); + + delete + .execute(DeleteAgentInput { + project: project(), + agent_id: a2.id, + }) + .await + .unwrap(); + + assert_eq!(contexts.manifest().orchestrator, None); + assert_eq!(contexts.manifest().effective_orchestrator(), Some(a1.id)); + assert!(events.events().contains(&DomainEvent::OrchestratorChanged { + project_id: project().id, + orchestrator: None, + })); +} + #[tokio::test] async fn delete_agent_cleans_structured_then_pty_runtime_before_manifest_removal() { let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 492b78ca..e6f8bafe 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -3132,6 +3132,27 @@ pub struct UpdateAgentContactableRequestDto { pub contactable: bool, } +/// Request DTO for `update_project_orchestrator`. +/// +/// The legacy `orchestrator` terminology is intentionally kept on the wire for +/// compatibility; the product-facing role is the global project-context writer. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectOrchestratorRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Existing project agent that becomes the single effective writer. + pub agent_id: String, +} + +/// Response DTO for `update_project_orchestrator`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectOrchestratorResponseDto { + /// Effective writer agent id after the update. + pub orchestrator_agent_id: String, +} + /// 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)] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index eb25af96..8c92c3ad 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -57,9 +57,9 @@ use application::{ UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, - UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions, - UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, - AGENT_MEMORY_RECALL_BUDGET, + UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator, UpdateProjectPermissions, + UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, + WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -1338,6 +1338,8 @@ pub struct BackendCore { pub update_agent_effort: Arc, /// Update one agent's cross-project contactability. pub update_agent_contactable: Arc, + /// Change the single agent allowed to write the global project context. + pub update_project_orchestrator: Arc, /// Resolve effective permissions for one agent. pub resolve_agent_permissions: Arc, /// Read the project system permission document. @@ -2509,6 +2511,10 @@ impl BackendCore { let update_agent_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_port))); let update_agent_contactable = Arc::new(UpdateAgentContactable::new(Arc::clone(&contexts_port))); + let update_project_orchestrator = Arc::new(UpdateProjectOrchestrator::new( + Arc::clone(&contexts_port), + Arc::clone(&events_port), + )); let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone( &permission_store_port, ))); @@ -3363,6 +3369,7 @@ impl BackendCore { update_agent_permissions, update_agent_effort, update_agent_contactable, + update_project_orchestrator, resolve_agent_permissions, get_project_system_permissions, update_project_system_permissions, diff --git a/crates/domain/src/fileguard.rs b/crates/domain/src/fileguard.rs index 30453bdc..0f4095c5 100644 --- a/crates/domain/src/fileguard.rs +++ b/crates/domain/src/fileguard.rs @@ -65,7 +65,9 @@ pub enum GuardError { /// A party that is **not** the orchestrator attempted to `acquire_write` the /// single-writer [`GuardedResource::ProjectContext`]. It must *propose* the /// change for validation rather than writing directly. - #[error("writing the global project context is reserved to the orchestrator; propose instead")] + #[error( + "writing the global project context is reserved to its effective writer; propose instead" + )] Forbidden, } diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index fed3149c..455dc4a0 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -402,10 +402,11 @@ pub fn catalogue() -> Vec { }, 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 \ + description: "Directly update the global project context. Only the project's effective \ + global-context writer may call this tool (reported as isOrchestrator by \ + idea_list_agents); MCP permissions do not grant this governance role. A \ + non-writer call fails without writing or creating a proposal and should \ + use idea_context_propose instead. Pass ifMatch from a prior \ idea_context_read to reject the write if the context changed.", input_schema: json!({ "type": "object", @@ -1094,9 +1095,13 @@ mod tests { tool_access("idea_context_update"), Some(McpToolAccess::WriteAction) ); - assert!(catalogue() + let tool = catalogue() .into_iter() - .any(|tool| tool.name == "idea_context_update")); + .find(|tool| tool.name == "idea_context_update") + .expect("idea_context_update registered"); + assert!(tool.description.contains("global-context writer")); + assert!(tool.description.contains("MCP permissions do not grant")); + assert!(tool.description.contains("idea_context_propose")); let cmd = map_tool_call( "idea_context_update", diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index 6d56d963..37d5f884 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -176,6 +176,18 @@ describe("TauriAgentGateway invoke payloads", () => { }); }); + it("update_project_orchestrator wraps the project and agent in the request DTO", async () => { + invoke.mockResolvedValueOnce({ orchestratorAgentId: "agent-2" }); + + await expect( + new TauriAgentGateway().updateProjectOrchestrator("proj-1", "agent-2"), + ).resolves.toEqual({ orchestratorAgentId: "agent-2" }); + + expect(invoke).toHaveBeenCalledWith("update_project_orchestrator", { + request: { projectId: "proj-1", agentId: "agent-2" }, + }); + }); + it("cancelAgentChat invokes cancel_agent_chat without closing the session", async () => { await new TauriAgentGateway().cancelAgentChat("chat-session-1"); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 4e4dae70..66c71e27 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -154,6 +154,16 @@ export class TauriAgentGateway implements AgentGateway { }); } + updateProjectOrchestrator( + projectId: string, + agentId: string, + ): Promise<{ orchestratorAgentId: string }> { + return invoke<{ orchestratorAgentId: string }>( + "update_project_orchestrator", + { request: { projectId, agentId } }, + ); + } + readContext(projectId: string, agentId: string): Promise { return invoke("read_agent_context", { projectId, diff --git a/frontend/src/adapters/http/agentGateway.test.ts b/frontend/src/adapters/http/agentGateway.test.ts index 0e67b55a..72a094d0 100644 --- a/frontend/src/adapters/http/agentGateway.test.ts +++ b/frontend/src/adapters/http/agentGateway.test.ts @@ -141,6 +141,26 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => { }); }); + it("updates the project orchestrator through the dedicated request DTO", async () => { + const response = { orchestratorAgentId: "agent-2" }; + const { gw, calls } = httpAgentGateway(async () => ({ + ok: true, + status: 200, + json: async () => response, + text: async () => JSON.stringify(response), + })); + + await expect( + gw.updateProjectOrchestrator("proj-1", "agent-2"), + ).resolves.toEqual(response); + + const init = calls[0].init as { body: string }; + expect(JSON.parse(init.body)).toEqual({ + command: "update_project_orchestrator", + args: { request: { projectId: "proj-1", agentId: "agent-2" } }, + }); + }); + it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => { const { gw, sockets } = gateway(); const chunks: Uint8Array[] = []; diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index 7924ac36..49d7571b 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -258,6 +258,15 @@ export class HttpAgentGateway implements AgentGateway { request: { projectId, agentId, contactable }, }); } + updateProjectOrchestrator( + projectId: string, + agentId: string, + ): Promise<{ orchestratorAgentId: string }> { + return this.http.invoke<{ orchestratorAgentId: string }>( + "update_project_orchestrator", + { request: { projectId, agentId } }, + ); + } readContext(projectId: string, agentId: string): Promise { return this.http .invoke("read_agent_context", { projectId, agentId }) diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 205cd3b1..e9fbb5c9 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -720,6 +720,24 @@ export class MockAgentGateway implements AgentGateway { return structuredClone(list[idx]); } + async updateProjectOrchestrator( + projectId: string, + agentId: string, + ): Promise<{ orchestratorAgentId: string }> { + const list = this.getAgents(projectId); + if (!list.some((agent) => agent.id === agentId)) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `agent ${agentId} not found in project ${projectId}`, + }; + throw err; + } + for (let index = 0; index < list.length; index += 1) { + list[index] = { ...list[index], isOrchestrator: list[index].id === agentId }; + } + return { orchestratorAgentId: agentId }; + } + // ── Internal helpers for MockTemplateGateway (same-package use only) ── /** diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 1460da0e..2ce38738 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1317,6 +1317,8 @@ export interface Agent { effort?: EffortSelection; /** Whether agents from other projects may contact this agent. Legacy absence means false. */ contactable?: boolean; + /** Whether this agent is the effective single writer of the global project context. */ + isOrchestrator?: boolean; } /** Response DTO returned by `read_agent_context`; adapters unwrap `content`. */ diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 7cad2a44..7a761598 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -188,6 +188,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { agentId: string; profileId: string; } | null>(null); + const [pendingWriterChange, setPendingWriterChange] = useState<{ + agentId: string; + agentName: string; + currentWriterName: string; + } | null>(null); async function confirmProfileChange() { if (!pendingProfileChange) return; @@ -204,6 +209,27 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { } } + function requestWriterChange(agent: Agent) { + if (agent.isOrchestrator || vm.busy) return; + const currentWriter = vm.agents.find((candidate) => candidate.isOrchestrator); + if (currentWriter && currentWriter.id !== agent.id) { + setPendingWriterChange({ + agentId: agent.id, + agentName: agent.name, + currentWriterName: currentWriter.name, + }); + return; + } + void vm.updateProjectOrchestrator(agent.id); + } + + async function confirmWriterChange() { + if (!pendingWriterChange) return; + const agentId = pendingWriterChange.agentId; + setPendingWriterChange(null); + await vm.updateProjectOrchestrator(agentId); + } + const selectedTemplate = templates.find((t) => t.id === newTemplateId) ?? null; const selectedTemplateMissingProfile = selectedTemplate !== null && selectedTemplate.defaultProfileId.trim().length === 0; @@ -440,6 +466,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { > {a.name} + {a.isOrchestrator && ( + + Writer contexte + + )} {agentDrift && ( + {isSelected && ( +
+

+ Rôle dans le projet +

+ +

+ Peut modifier directement le contexte partagé du projet. + Un seul agent peut avoir ce rôle. +

+
+ )} + {limitState && ( )} + + {pendingWriterChange && ( +
+
+

+ Changer le writer du contexte +

+

+ Le rôle sera retiré de {pendingWriterChange.currentWriterName} et + attribué à {pendingWriterChange.agentName}. Continuer ? +

+
+ + +
+
+
+ )} ); } diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx index ee40caf2..e839c31c 100644 --- a/frontend/src/features/agents/agents.test.tsx +++ b/frontend/src/features/agents/agents.test.tsx @@ -144,6 +144,122 @@ describe("AgentsPanel (with MockAgentGateway)", () => { }); }); + it("shows the context-writer badge and keeps the role separate from permissions", async () => { + const agent = new MockAgentGateway(); + const writer = await agent.createAgent(PROJECT_ID, { + name: "Main", + profileId: "p1", + }); + await agent.updateProjectOrchestrator(PROJECT_ID, writer.id); + + renderPanel(agent); + + expect(await screen.findByText("Writer contexte")).toBeTruthy(); + const rowButton = screen + .getAllByRole("button", { name: /main/i }) + .find((button) => button.hasAttribute("aria-pressed"))!; + fireEvent.click(rowButton); + + expect(await screen.findByText("Rôle dans le projet")).toBeTruthy(); + expect( + screen.getByText( + "Peut modifier directement le contexte partagé du projet. Un seul agent peut avoir ce rôle.", + ), + ).toBeTruthy(); + expect( + (screen.getByRole("radio", { + name: "Writer du contexte projet global pour Main", + }) as HTMLInputElement).checked, + ).toBe(true); + }); + + it("confirms replacement of the current writer then refreshes the agent list", async () => { + const agent = new MockAgentGateway(); + const current = await agent.createAgent(PROJECT_ID, { + name: "Main", + profileId: "p1", + }); + const replacement = await agent.createAgent(PROJECT_ID, { + name: "Architect", + profileId: "p1", + }); + await agent.updateProjectOrchestrator(PROJECT_ID, current.id); + const updateSpy = vi.spyOn(agent, "updateProjectOrchestrator"); + + renderPanel(agent); + const rowButton = ( + await screen.findAllByRole("button", { name: /architect/i }) + ).find((button) => button.hasAttribute("aria-pressed"))!; + fireEvent.click(rowButton); + fireEvent.click( + await screen.findByRole("radio", { + name: "Writer du contexte projet global pour Architect", + }), + ); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog").textContent).toMatch( + /Le rôle sera retiré de Main et attribué à Architect\. Continuer/, + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Confirmer le changement de writer contexte", + }), + ); + + await waitFor(() => { + expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, replacement.id); + expect(screen.queryByRole("dialog")).toBeNull(); + expect( + (screen.getByRole("radio", { + name: "Writer du contexte projet global pour Architect", + }) as HTMLInputElement).checked, + ).toBe(true); + }); + expect( + (await agent.listAgents(PROJECT_ID)).filter( + (item) => item.isOrchestrator, + ), + ).toEqual([expect.objectContaining({ id: replacement.id })]); + }); + + it("surfaces a backend error when changing the context writer fails", async () => { + const agent = new MockAgentGateway(); + const current = await agent.createAgent(PROJECT_ID, { + name: "Main", + profileId: "p1", + }); + await agent.createAgent(PROJECT_ID, { name: "QA", profileId: "p1" }); + await agent.updateProjectOrchestrator(PROJECT_ID, current.id); + vi.spyOn(agent, "updateProjectOrchestrator").mockRejectedValueOnce({ + code: "PERSISTENCE_ERROR", + message: "Impossible de sauvegarder le writer", + }); + + renderPanel(agent); + await screen.findByText("QA"); + const rowButton = screen.getAllByRole("button").find( + (button) => + button.hasAttribute("aria-pressed") && button.textContent?.includes("QA"), + )!; + fireEvent.click(rowButton); + fireEvent.click( + await screen.findByRole("radio", { + name: "Writer du contexte projet global pour QA", + }), + ); + fireEvent.click( + screen.getByRole("button", { + name: "Confirmer le changement de writer contexte", + }), + ); + + expect((await screen.findByRole("alert")).textContent).toContain( + "Impossible de sauvegarder le writer", + ); + }); + it("the Create button is disabled when the name is empty", async () => { renderPanel(); await waitForIdle(); diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index e9c4810c..469c7518 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -130,6 +130,8 @@ export interface AgentsViewModel { agentId: string, effort: EffortSelection | null, ) => Promise; + /** Selects this agent as the single writer of the global project context. */ + updateProjectOrchestrator: (agentId: string) => Promise; /** Deletes an agent; deselects if it was selected. */ deleteAgent: (agentId: string) => Promise; /** @@ -453,6 +455,23 @@ export function useAgents(projectId: string): AgentsViewModel { [agent, projectId], ); + const updateProjectOrchestrator = useCallback( + async (agentId: string): Promise => { + setBusy(true); + setError(null); + try { + await agent.updateProjectOrchestrator(projectId, agentId); + const refreshed = await agent.listAgents(projectId); + setAgents(refreshed); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, + [agent, projectId], + ); + const deleteAgent = useCallback( async (agentId: string) => { setBusy(true); @@ -571,6 +590,7 @@ export function useAgents(projectId: string): AgentsViewModel { saveContext, changeAgentProfile, updateAgentEffort, + updateProjectOrchestrator, deleteAgent, launchAgent, stopAgent, diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 3340a864..59035eb8 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -277,6 +277,11 @@ export interface AgentGateway { agentId: string, contactable: boolean, ): Promise; + /** Selects the project's single writer of the global shared context. */ + updateProjectOrchestrator( + projectId: string, + agentId: string, + ): Promise<{ orchestratorAgentId: string }>; /** Reads an agent's `.md` context by agent id. */ readContext(projectId: string, agentId: string): Promise; /** Overwrites an agent's `.md` context. */