feat(agents): role orchestrateur du contexte global expose et configurable par agent — isOrchestrator distinct des permissions MCP, DTO update_project_orchestrator (terminologie wire legacy conservee), refus idea_context_update explicite avec writer effectif + orientation idea_context_propose, surface AgentsPanel + adaptateurs frontend — #276 (QA verte: cargo fmt/check, tests cibles application/app-tauri/infrastructure/backend, npm typecheck, 69/69 tests frontend agents/adapters)
This commit is contained in:
@ -32,8 +32,8 @@ use application::{
|
|||||||
UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput,
|
UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput,
|
||||||
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||||
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
||||||
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput,
|
||||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
use backend::stream::OutputSink;
|
use backend::stream::OutputSink;
|
||||||
use domain::ports::ModelServerRuntime;
|
use domain::ports::ModelServerRuntime;
|
||||||
@ -81,7 +81,8 @@ use crate::dto::{
|
|||||||
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectOrchestratorRequestDto,
|
||||||
|
UpdateProjectOrchestratorResponseDto, UpdateProjectPermissionsRequestDto,
|
||||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||||
WriteTerminalRequestDto,
|
WriteTerminalRequestDto,
|
||||||
};
|
};
|
||||||
@ -800,6 +801,27 @@ pub async fn update_agent_contactable(
|
|||||||
.map_err(ErrorDto::from)
|
.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<UpdateProjectOrchestratorResponseDto, ErrorDto> {
|
||||||
|
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.
|
/// `resolve_agent_permissions` — resolve project defaults plus agent override.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@ -262,6 +262,7 @@ pub fn run() {
|
|||||||
commands::update_agent_permissions,
|
commands::update_agent_permissions,
|
||||||
commands::update_agent_effort,
|
commands::update_agent_effort,
|
||||||
commands::update_agent_contactable,
|
commands::update_agent_contactable,
|
||||||
|
commands::update_project_orchestrator,
|
||||||
commands::resolve_agent_permissions,
|
commands::resolve_agent_permissions,
|
||||||
commands::get_project_system_permissions,
|
commands::get_project_system_permissions,
|
||||||
commands::update_project_system_permissions,
|
commands::update_project_system_permissions,
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use app_tauri_lib::dto::{
|
|||||||
parse_agent_id, AgentDto, AgentListDto, CellKind, ConversationDetailsDto,
|
parse_agent_id, AgentDto, AgentListDto, CellKind, ConversationDetailsDto,
|
||||||
CreateAgentRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto,
|
CreateAgentRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto,
|
||||||
TerminalSessionDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto,
|
TerminalSessionDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto,
|
||||||
UpdateAgentEffortRequestDto,
|
UpdateAgentEffortRequestDto, UpdateProjectOrchestratorRequestDto,
|
||||||
};
|
};
|
||||||
use application::AppError;
|
use application::AppError;
|
||||||
use application::{
|
use application::{
|
||||||
@ -188,6 +188,17 @@ fn update_agent_contactable_request_deserialises_camelcase() {
|
|||||||
assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string());
|
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]
|
#[test]
|
||||||
fn launch_agent_request_deserialises_camelcase() {
|
fn launch_agent_request_deserialises_camelcase() {
|
||||||
let project_id = Uuid::from_u128(1).to_string();
|
let project_id = Uuid::from_u128(1).to_string();
|
||||||
|
|||||||
@ -255,9 +255,10 @@ impl CreateAgentFromScratch {
|
|||||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
|
||||||
// Append the entry and re-validate the whole manifest (unique md_paths).
|
// Append the entry and re-validate the whole manifest (unique md_paths).
|
||||||
|
let orchestrator = manifest.orchestrator;
|
||||||
let mut entries = manifest.entries;
|
let mut entries = manifest.entries;
|
||||||
entries.push(ManifestEntry::from_agent(&agent));
|
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()))?;
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
self.contexts
|
self.contexts
|
||||||
.save_manifest(&input.project, &manifest)
|
.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<dyn AgentContextStore>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateProjectOrchestrator {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> 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<UpdateProjectOrchestratorOutput, AppError> {
|
||||||
|
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.
|
/// Lists a project's agents by reconstructing them from the manifest entries.
|
||||||
pub struct ListAgents {
|
pub struct ListAgents {
|
||||||
contexts: Arc<dyn AgentContextStore>,
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
@ -579,8 +657,12 @@ impl UpdateAgentContactable {
|
|||||||
.with_contactable(input.contactable);
|
.with_contactable(input.contactable);
|
||||||
*entry = ManifestEntry::from_agent(&agent);
|
*entry = ManifestEntry::from_agent(&agent);
|
||||||
|
|
||||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
let manifest = AgentManifest::with_orchestrator(
|
||||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
manifest.version,
|
||||||
|
manifest.entries,
|
||||||
|
manifest.orchestrator,
|
||||||
|
)
|
||||||
|
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||||
self.contexts
|
self.contexts
|
||||||
.save_manifest(&input.project, &manifest)
|
.save_manifest(&input.project, &manifest)
|
||||||
.await?;
|
.await?;
|
||||||
@ -625,8 +707,12 @@ impl UpdateAgentEffort {
|
|||||||
.with_effort(input.effort);
|
.with_effort(input.effort);
|
||||||
*entry = ManifestEntry::from_agent(&agent);
|
*entry = ManifestEntry::from_agent(&agent);
|
||||||
|
|
||||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
let manifest = AgentManifest::with_orchestrator(
|
||||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
manifest.version,
|
||||||
|
manifest.entries,
|
||||||
|
manifest.orchestrator,
|
||||||
|
)
|
||||||
|
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||||
self.contexts
|
self.contexts
|
||||||
.save_manifest(&input.project, &manifest)
|
.save_manifest(&input.project, &manifest)
|
||||||
.await?;
|
.await?;
|
||||||
@ -800,6 +886,7 @@ impl ChangeAgentProfile {
|
|||||||
|
|
||||||
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
|
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
|
||||||
// and persist.
|
// and persist.
|
||||||
|
let orchestrator = manifest.orchestrator;
|
||||||
let mut entries = manifest.entries;
|
let mut entries = manifest.entries;
|
||||||
let mut mutated_agent = None;
|
let mut mutated_agent = None;
|
||||||
for e in &mut entries {
|
for e in &mut entries {
|
||||||
@ -813,7 +900,7 @@ impl ChangeAgentProfile {
|
|||||||
}
|
}
|
||||||
let agent =
|
let agent =
|
||||||
mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
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()))?;
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
self.contexts
|
self.contexts
|
||||||
.save_manifest(&input.project, &manifest)
|
.save_manifest(&input.project, &manifest)
|
||||||
@ -1143,14 +1230,13 @@ impl DeleteAgent {
|
|||||||
/// - [`AppError::NotFound`] if the agent is not in the manifest,
|
/// - [`AppError::NotFound`] if the agent is not in the manifest,
|
||||||
/// - [`AppError::Store`] on persistence failure.
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
|
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 before = manifest.entries.len();
|
||||||
let entries: Vec<ManifestEntry> = manifest
|
manifest
|
||||||
.entries
|
.entries
|
||||||
.into_iter()
|
.retain(|entry| entry.agent_id != input.agent_id);
|
||||||
.filter(|e| e.agent_id != input.agent_id)
|
if manifest.entries.len() == before {
|
||||||
.collect();
|
|
||||||
if entries.len() == before {
|
|
||||||
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
|
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
|
||||||
}
|
}
|
||||||
if let Some((live, close)) = &self.runtime_cleanup {
|
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)
|
stop_live_agent_runtime_in_project(&input.project, input.agent_id, live, close)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
let manifest = AgentManifest::new(manifest.version, entries)
|
manifest.on_agent_deleted(input.agent_id);
|
||||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
let next_orchestrator = manifest.effective_orchestrator();
|
||||||
self.contexts
|
self.contexts
|
||||||
.save_manifest(&input.project, &manifest)
|
.save_manifest(&input.project, &manifest)
|
||||||
.await?;
|
.await?;
|
||||||
|
if previous_orchestrator != next_orchestrator {
|
||||||
|
self.events.publish(DomainEvent::OrchestratorChanged {
|
||||||
|
project_id: input.project.id,
|
||||||
|
orchestrator: manifest.orchestrator,
|
||||||
|
});
|
||||||
|
}
|
||||||
self.events.publish(DomainEvent::LayoutChanged {
|
self.events.publish(DomainEvent::LayoutChanged {
|
||||||
project_id: input.project.id,
|
project_id: input.project.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -40,6 +40,7 @@ pub use lifecycle::{
|
|||||||
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContactable,
|
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContactable,
|
||||||
UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext,
|
UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext,
|
||||||
UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput,
|
UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput,
|
||||||
|
UpdateProjectOrchestrator, UpdateProjectOrchestratorInput, UpdateProjectOrchestratorOutput,
|
||||||
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||||
};
|
};
|
||||||
pub use model_catalogue::{
|
pub use model_catalogue::{
|
||||||
|
|||||||
@ -68,7 +68,8 @@ pub use agent::{
|
|||||||
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
|
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
|
||||||
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContactable, UpdateAgentContactableInput,
|
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContactable, UpdateAgentContactableInput,
|
||||||
UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
|
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,
|
CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||||
};
|
};
|
||||||
pub use background::{
|
pub use background::{
|
||||||
|
|||||||
@ -243,7 +243,7 @@ impl UpdateProjectContext {
|
|||||||
let designation = manifest.orchestrator_designation();
|
let designation = manifest.orchestrator_designation();
|
||||||
let resource = GuardedResource::ProjectContext;
|
let resource = GuardedResource::ProjectContext;
|
||||||
if !may_write_directly(requester, &resource, &designation) {
|
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
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -998,6 +1017,13 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert_eq!(err.code(), "INVALID");
|
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();
|
let files = fs.files.lock().unwrap();
|
||||||
assert_eq!(files.get(project_context_file()).unwrap(), b"# old");
|
assert_eq!(files.get(project_context_file()).unwrap(), b"# old");
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@ -533,8 +533,12 @@ impl AssignSkillToAgent {
|
|||||||
skill_id: SkillId,
|
skill_id: SkillId,
|
||||||
assigned: bool,
|
assigned: bool,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
let manifest = AgentManifest::with_orchestrator(
|
||||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
manifest.version,
|
||||||
|
manifest.entries,
|
||||||
|
manifest.orchestrator,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
self.contexts.save_manifest(project, &manifest).await?;
|
self.contexts.save_manifest(project, &manifest).await?;
|
||||||
self.events.publish(DomainEvent::SkillAssigned {
|
self.events.publish(DomainEvent::SkillAssigned {
|
||||||
agent_id,
|
agent_id,
|
||||||
@ -591,8 +595,12 @@ impl UnassignSkillFromAgent {
|
|||||||
*entry = domain::ManifestEntry::from_agent(&agent);
|
*entry = domain::ManifestEntry::from_agent(&agent);
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
let manifest = AgentManifest::with_orchestrator(
|
||||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
manifest.version,
|
||||||
|
manifest.entries,
|
||||||
|
manifest.orchestrator,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
self.contexts
|
self.contexts
|
||||||
.save_manifest(&input.project, &manifest)
|
.save_manifest(&input.project, &manifest)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@ -408,9 +408,10 @@ impl CreateAgentFromTemplate {
|
|||||||
.map_err(|e| AppError::Invalid(e.to_string()))?
|
.map_err(|e| AppError::Invalid(e.to_string()))?
|
||||||
.with_contactable(template.default_contactable);
|
.with_contactable(template.default_contactable);
|
||||||
|
|
||||||
|
let orchestrator = manifest.orchestrator;
|
||||||
let mut entries = manifest.entries;
|
let mut entries = manifest.entries;
|
||||||
entries.push(ManifestEntry::from_agent(&agent));
|
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()))?;
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
self.contexts
|
self.contexts
|
||||||
.save_manifest(&input.project, &manifest)
|
.save_manifest(&input.project, &manifest)
|
||||||
@ -624,8 +625,12 @@ impl SyncAgentWithTemplate {
|
|||||||
manifest.entries[idx].synced_template_version = Some(template.version);
|
manifest.entries[idx].synced_template_version = Some(template.version);
|
||||||
|
|
||||||
// Persist the manifest (revalidated) and overwrite the agent context.
|
// Persist the manifest (revalidated) and overwrite the agent context.
|
||||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
let manifest = AgentManifest::with_orchestrator(
|
||||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
manifest.version,
|
||||||
|
manifest.entries,
|
||||||
|
manifest.orchestrator,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
self.contexts
|
self.contexts
|
||||||
.save_manifest(&input.project, &manifest)
|
.save_manifest(&input.project, &manifest)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@ -59,6 +59,7 @@ use application::{
|
|||||||
ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode,
|
ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode,
|
||||||
StructuredSessions, TerminalSessions, UpdateAgentContactable, UpdateAgentContactableInput,
|
StructuredSessions, TerminalSessions, UpdateAgentContactable, UpdateAgentContactableInput,
|
||||||
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
|
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);
|
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]
|
#[tokio::test]
|
||||||
async fn read_then_update_context_roundtrips() {
|
async fn read_then_update_context_roundtrips() {
|
||||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
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]
|
#[tokio::test]
|
||||||
async fn update_agent_effort_none_clears_existing_override() {
|
async fn update_agent_effort_none_clears_existing_override() {
|
||||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9))
|
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:?}");
|
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]
|
#[tokio::test]
|
||||||
async fn delete_agent_cleans_structured_then_pty_runtime_before_manifest_removal() {
|
async fn delete_agent_cleans_structured_then_pty_runtime_before_manifest_removal() {
|
||||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||||
|
|||||||
@ -3132,6 +3132,27 @@ pub struct UpdateAgentContactableRequestDto {
|
|||||||
pub contactable: bool,
|
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
|
/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly
|
||||||
/// relaunched session when a live session was hot-swapped (absent otherwise).
|
/// relaunched session when a live session was hot-swapped (absent otherwise).
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|||||||
@ -57,9 +57,9 @@ use application::{
|
|||||||
UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort,
|
UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort,
|
||||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||||
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator, UpdateProjectPermissions,
|
||||||
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
||||||
AGENT_MEMORY_RECALL_BUDGET,
|
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -1338,6 +1338,8 @@ pub struct BackendCore {
|
|||||||
pub update_agent_effort: Arc<UpdateAgentEffort>,
|
pub update_agent_effort: Arc<UpdateAgentEffort>,
|
||||||
/// Update one agent's cross-project contactability.
|
/// Update one agent's cross-project contactability.
|
||||||
pub update_agent_contactable: Arc<UpdateAgentContactable>,
|
pub update_agent_contactable: Arc<UpdateAgentContactable>,
|
||||||
|
/// Change the single agent allowed to write the global project context.
|
||||||
|
pub update_project_orchestrator: Arc<UpdateProjectOrchestrator>,
|
||||||
/// Resolve effective permissions for one agent.
|
/// Resolve effective permissions for one agent.
|
||||||
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
|
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
|
||||||
/// Read the project system permission document.
|
/// 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_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_port)));
|
||||||
let update_agent_contactable =
|
let update_agent_contactable =
|
||||||
Arc::new(UpdateAgentContactable::new(Arc::clone(&contexts_port)));
|
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(
|
let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone(
|
||||||
&permission_store_port,
|
&permission_store_port,
|
||||||
)));
|
)));
|
||||||
@ -3363,6 +3369,7 @@ impl BackendCore {
|
|||||||
update_agent_permissions,
|
update_agent_permissions,
|
||||||
update_agent_effort,
|
update_agent_effort,
|
||||||
update_agent_contactable,
|
update_agent_contactable,
|
||||||
|
update_project_orchestrator,
|
||||||
resolve_agent_permissions,
|
resolve_agent_permissions,
|
||||||
get_project_system_permissions,
|
get_project_system_permissions,
|
||||||
update_project_system_permissions,
|
update_project_system_permissions,
|
||||||
|
|||||||
@ -65,7 +65,9 @@ pub enum GuardError {
|
|||||||
/// A party that is **not** the orchestrator attempted to `acquire_write` the
|
/// A party that is **not** the orchestrator attempted to `acquire_write` the
|
||||||
/// single-writer [`GuardedResource::ProjectContext`]. It must *propose* the
|
/// single-writer [`GuardedResource::ProjectContext`]. It must *propose* the
|
||||||
/// change for validation rather than writing directly.
|
/// 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,
|
Forbidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -402,10 +402,11 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
},
|
},
|
||||||
ToolDef {
|
ToolDef {
|
||||||
name: "idea_context_update",
|
name: "idea_context_update",
|
||||||
description: "Directly update the global project context. Strict, orchestrator-only \
|
description: "Directly update the global project context. Only the project's effective \
|
||||||
counterpart to idea_context_propose: if you are not the project's \
|
global-context writer may call this tool (reported as isOrchestrator by \
|
||||||
current orchestrator (see isOrchestrator on idea_list_agents), this call \
|
idea_list_agents); MCP permissions do not grant this governance role. A \
|
||||||
fails and never silently files a proposal. Pass ifMatch from a prior \
|
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.",
|
idea_context_read to reject the write if the context changed.",
|
||||||
input_schema: json!({
|
input_schema: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -1094,9 +1095,13 @@ mod tests {
|
|||||||
tool_access("idea_context_update"),
|
tool_access("idea_context_update"),
|
||||||
Some(McpToolAccess::WriteAction)
|
Some(McpToolAccess::WriteAction)
|
||||||
);
|
);
|
||||||
assert!(catalogue()
|
let tool = catalogue()
|
||||||
.into_iter()
|
.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(
|
let cmd = map_tool_call(
|
||||||
"idea_context_update",
|
"idea_context_update",
|
||||||
|
|||||||
@ -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 () => {
|
it("cancelAgentChat invokes cancel_agent_chat without closing the session", async () => {
|
||||||
await new TauriAgentGateway().cancelAgentChat("chat-session-1");
|
await new TauriAgentGateway().cancelAgentChat("chat-session-1");
|
||||||
|
|
||||||
|
|||||||
@ -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<string> {
|
readContext(projectId: string, agentId: string): Promise<string> {
|
||||||
return invoke<AgentContextDocument>("read_agent_context", {
|
return invoke<AgentContextDocument>("read_agent_context", {
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
@ -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 () => {
|
it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
|
||||||
const { gw, sockets } = gateway();
|
const { gw, sockets } = gateway();
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
|
|||||||
@ -258,6 +258,15 @@ export class HttpAgentGateway implements AgentGateway {
|
|||||||
request: { projectId, agentId, contactable },
|
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<string> {
|
readContext(projectId: string, agentId: string): Promise<string> {
|
||||||
return this.http
|
return this.http
|
||||||
.invoke<AgentContextDocument>("read_agent_context", { projectId, agentId })
|
.invoke<AgentContextDocument>("read_agent_context", { projectId, agentId })
|
||||||
|
|||||||
@ -720,6 +720,24 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
return structuredClone(list[idx]);
|
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) ──
|
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1317,6 +1317,8 @@ export interface Agent {
|
|||||||
effort?: EffortSelection;
|
effort?: EffortSelection;
|
||||||
/** Whether agents from other projects may contact this agent. Legacy absence means false. */
|
/** Whether agents from other projects may contact this agent. Legacy absence means false. */
|
||||||
contactable?: boolean;
|
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`. */
|
/** Response DTO returned by `read_agent_context`; adapters unwrap `content`. */
|
||||||
|
|||||||
@ -188,6 +188,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [pendingWriterChange, setPendingWriterChange] = useState<{
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
currentWriterName: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
async function confirmProfileChange() {
|
async function confirmProfileChange() {
|
||||||
if (!pendingProfileChange) return;
|
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 selectedTemplate = templates.find((t) => t.id === newTemplateId) ?? null;
|
||||||
const selectedTemplateMissingProfile =
|
const selectedTemplateMissingProfile =
|
||||||
selectedTemplate !== null && selectedTemplate.defaultProfileId.trim().length === 0;
|
selectedTemplate !== null && selectedTemplate.defaultProfileId.trim().length === 0;
|
||||||
@ -440,6 +466,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
>
|
>
|
||||||
<span className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
<span className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
||||||
<span className="font-medium text-content">{a.name}</span>
|
<span className="font-medium text-content">{a.name}</span>
|
||||||
|
{a.isOrchestrator && (
|
||||||
|
<span
|
||||||
|
aria-label="Writer contexte"
|
||||||
|
className="rounded-full border border-border-strong bg-raised px-2 py-0.5 text-xs font-medium text-content"
|
||||||
|
>
|
||||||
|
Writer contexte
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{agentDrift && (
|
{agentDrift && (
|
||||||
<span
|
<span
|
||||||
aria-label="update available"
|
aria-label="update available"
|
||||||
@ -471,6 +505,35 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<section
|
||||||
|
aria-label={`Rôle dans le projet — ${a.name}`}
|
||||||
|
className="rounded-md border border-border bg-surface p-3"
|
||||||
|
>
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||||
|
Rôle dans le projet
|
||||||
|
</h4>
|
||||||
|
<label className="mt-2 flex cursor-pointer items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="project-context-writer"
|
||||||
|
aria-label={`Writer du contexte projet global pour ${a.name}`}
|
||||||
|
checked={a.isOrchestrator === true}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onChange={() => requestWriterChange(a)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-content">
|
||||||
|
Writer du contexte projet global
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 pl-5 text-xs text-muted">
|
||||||
|
Peut modifier directement le contexte partagé du projet.
|
||||||
|
Un seul agent peut avoir ce rôle.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{limitState && (
|
{limitState && (
|
||||||
<AgentLimitBadge
|
<AgentLimitBadge
|
||||||
state={limitState}
|
state={limitState}
|
||||||
@ -747,6 +810,44 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingWriterChange && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Confirmer le changement de writer contexte"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
|
||||||
|
<h4 className="text-sm font-semibold text-content">
|
||||||
|
Changer le writer du contexte
|
||||||
|
</h4>
|
||||||
|
<p className="mt-2 text-sm text-muted">
|
||||||
|
Le rôle sera retiré de {pendingWriterChange.currentWriterName} et
|
||||||
|
attribué à {pendingWriterChange.agentName}. Continuer ?
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Annuler le changement de writer contexte"
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => setPendingWriterChange(null)}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
aria-label="Confirmer le changement de writer contexte"
|
||||||
|
loading={vm.busy}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void confirmWriterChange()}
|
||||||
|
>
|
||||||
|
Continuer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 () => {
|
it("the Create button is disabled when the name is empty", async () => {
|
||||||
renderPanel();
|
renderPanel();
|
||||||
await waitForIdle();
|
await waitForIdle();
|
||||||
|
|||||||
@ -130,6 +130,8 @@ export interface AgentsViewModel {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
effort: EffortSelection | null,
|
effort: EffortSelection | null,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
/** Selects this agent as the single writer of the global project context. */
|
||||||
|
updateProjectOrchestrator: (agentId: string) => Promise<void>;
|
||||||
/** Deletes an agent; deselects if it was selected. */
|
/** Deletes an agent; deselects if it was selected. */
|
||||||
deleteAgent: (agentId: string) => Promise<void>;
|
deleteAgent: (agentId: string) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
@ -453,6 +455,23 @@ export function useAgents(projectId: string): AgentsViewModel {
|
|||||||
[agent, projectId],
|
[agent, projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateProjectOrchestrator = useCallback(
|
||||||
|
async (agentId: string): Promise<void> => {
|
||||||
|
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(
|
const deleteAgent = useCallback(
|
||||||
async (agentId: string) => {
|
async (agentId: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -571,6 +590,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
|||||||
saveContext,
|
saveContext,
|
||||||
changeAgentProfile,
|
changeAgentProfile,
|
||||||
updateAgentEffort,
|
updateAgentEffort,
|
||||||
|
updateProjectOrchestrator,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
launchAgent,
|
launchAgent,
|
||||||
stopAgent,
|
stopAgent,
|
||||||
|
|||||||
@ -277,6 +277,11 @@ export interface AgentGateway {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
contactable: boolean,
|
contactable: boolean,
|
||||||
): Promise<Agent>;
|
): Promise<Agent>;
|
||||||
|
/** 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. */
|
/** Reads an agent's `.md` context by agent id. */
|
||||||
readContext(projectId: string, agentId: string): Promise<string>;
|
readContext(projectId: string, agentId: string): Promise<string>;
|
||||||
/** Overwrites an agent's `.md` context. */
|
/** Overwrites an agent's `.md` context. */
|
||||||
|
|||||||
Reference in New Issue
Block a user