fix(permissions): tools MCP inter-projets exposés (idea_project_*) + contactable agent/template persisté — update_agent_contactable (application/web-server/app-tauri), defaultContactable TemplateEditor save/reopen, groupes MCP 'IdeA inter-projets' avec fallback, permissions agent Contactable — #267 (QA verte)

This commit is contained in:
2026-09-07 18:13:27 +02:00
parent 9266d121cd
commit f6101a8249
30 changed files with 632 additions and 89 deletions

View File

@ -29,8 +29,8 @@ use application::{
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect,
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput,
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput,
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
@ -78,7 +78,7 @@ use crate::dto::{
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
@ -777,6 +777,29 @@ pub async fn update_agent_effort(
.map_err(ErrorDto::from)
}
/// `update_agent_contactable` — enable or disable cross-project contact for one agent.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids or store failure.
#[tauri::command]
pub async fn update_agent_contactable(
request: UpdateAgentContactableRequestDto,
state: State<'_, AppState>,
) -> Result<AgentDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.update_agent_contactable
.execute(UpdateAgentContactableInput {
project,
agent_id,
contactable: request.contactable,
})
.await
.map(|out| AgentDto::from_agent(out.agent))
.map_err(ErrorDto::from)
}
/// `resolve_agent_permissions` — resolve project defaults plus agent override.
///
/// # Errors

View File

@ -261,6 +261,7 @@ pub fn run() {
commands::update_project_permissions,
commands::update_agent_permissions,
commands::update_agent_effort,
commands::update_agent_contactable,
commands::resolve_agent_permissions,
commands::get_project_system_permissions,
commands::update_project_system_permissions,

View File

@ -5,7 +5,8 @@
use app_tauri_lib::dto::{
parse_agent_id, AgentDto, AgentListDto, CellKind, ConversationDetailsDto,
CreateAgentRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto,
TerminalSessionDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
TerminalSessionDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto,
UpdateAgentEffortRequestDto,
};
use application::AppError;
use application::{
@ -40,7 +41,7 @@ fn make_agent(agent_uuid: u128, profile_uuid: u128) -> Agent {
#[test]
fn agent_dto_serialises_camelcase() {
let agent = make_agent(1, 2);
let agent = make_agent(1, 2).with_contactable(true);
let dto = AgentDto::from_agent(agent.clone());
let v = serde_json::to_value(&dto).unwrap();
@ -52,6 +53,7 @@ fn agent_dto_serialises_camelcase() {
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
);
assert_eq!(v["synchronized"], false);
assert_eq!(v["contactable"], true);
// origin: tagged `{ "type": "scratch" }`
assert_eq!(v["origin"]["type"], "scratch");
assert_eq!(v["capabilities"], json!([]));
@ -173,6 +175,19 @@ fn update_agent_effort_request_deserialises_and_null_clears() {
assert_eq!(clear.effort, None);
}
#[test]
fn update_agent_contactable_request_deserialises_camelcase() {
let raw = json!({
"projectId": Uuid::from_u128(1).to_string(),
"agentId": Uuid::from_u128(2).to_string(),
"contactable": true
});
let dto: UpdateAgentContactableRequestDto = serde_json::from_value(raw).unwrap();
assert!(dto.contactable);
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();

View File

@ -522,6 +522,72 @@ pub struct UpdateAgentEffortOutput {
pub agent: Agent,
}
// ---------------------------------------------------------------------------
// UpdateAgentContactable
// ---------------------------------------------------------------------------
/// Input for [`UpdateAgentContactable::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentContactableInput {
/// The owning project.
pub project: Project,
/// The agent whose cross-project contactability changes.
pub agent_id: AgentId,
/// Whether agents from other IdeA projects may contact this agent.
pub contactable: bool,
}
/// Output of [`UpdateAgentContactable::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentContactableOutput {
/// The updated agent.
pub agent: Agent,
}
/// Updates an agent's cross-project contactability in the project manifest.
pub struct UpdateAgentContactable {
contexts: Arc<dyn AgentContextStore>,
}
impl UpdateAgentContactable {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Executes the update.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown to the project,
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: UpdateAgentContactableInput,
) -> Result<UpdateAgentContactableOutput, AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter_mut()
.find(|entry| entry.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let agent = entry
.to_agent()
.map_err(|err| AppError::Invalid(err.to_string()))?
.with_contactable(input.contactable);
*entry = ManifestEntry::from_agent(&agent);
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
Ok(UpdateAgentContactableOutput { agent })
}
}
/// Sets or clears an agent's per-agent effort override in the manifest.
///
/// The change applies at the agent's next launch; live sessions are not mutated.

View File

@ -37,7 +37,8 @@ pub use lifecycle::{
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
LiveStateLeanProvider, ManagedTempExposureRequest, McpRuntime, PermissionProjectorRegistry,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContext,
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContactable,
UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext,
UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput,
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
};

View File

@ -66,8 +66,9 @@ pub use agent::{
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveOpenCodeProviderProfile,
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContactable, UpdateAgentContactableInput,
UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use background::{

View File

@ -57,8 +57,8 @@ use application::{
DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput,
LiveSessions, ManagedTempExposureRequest, PermissionProjectorRegistry, ReadAgentContext,
ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode,
StructuredSessions, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
UpdateAgentEffort, UpdateAgentEffortInput,
StructuredSessions, TerminalSessions, UpdateAgentContactable, UpdateAgentContactableInput,
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
};
// ---------------------------------------------------------------------------
@ -1163,6 +1163,53 @@ async fn update_agent_effort_not_found_for_unknown_agent_id() {
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn update_agent_contactable_persists_manifest_and_can_be_cleared() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&a, "ctx");
let update = UpdateAgentContactable::new(Arc::new(contexts.clone()));
let enabled = update
.execute(UpdateAgentContactableInput {
project: project(),
agent_id: a.id,
contactable: true,
})
.await
.unwrap();
assert!(enabled.agent.contactable);
assert!(contexts.manifest().entries[0].contactable);
let disabled = update
.execute(UpdateAgentContactableInput {
project: project(),
agent_id: a.id,
contactable: false,
})
.await
.unwrap();
assert!(!disabled.agent.contactable);
assert!(!contexts.manifest().entries[0].contactable);
}
#[tokio::test]
async fn update_agent_contactable_not_found_for_unknown_agent_id() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&a, "ctx");
let update = UpdateAgentContactable::new(Arc::new(contexts));
let err = update
.execute(UpdateAgentContactableInput {
project: project(),
agent_id: aid(404),
contactable: true,
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn delete_removes_entry_then_unknown_is_not_found() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));

View File

@ -3067,6 +3067,18 @@ pub struct UpdateAgentEffortRequestDto {
pub effort: Option<EffortSelection>,
}
/// Request DTO for `update_agent_contactable`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAgentContactableRequestDto {
/// Id of the owning project.
pub project_id: String,
/// Id of the agent whose cross-project contactability changes.
pub agent_id: String,
/// Whether agents from other projects may contact this agent.
pub contactable: bool,
}
/// 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)]

View File

@ -54,11 +54,11 @@ use application::{
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use async_trait::async_trait;
use domain::ports::{
@ -1287,6 +1287,8 @@ pub struct BackendCore {
pub update_agent_permissions: Arc<UpdateAgentPermissions>,
/// Update one agent effort override.
pub update_agent_effort: Arc<UpdateAgentEffort>,
/// Update one agent's cross-project contactability.
pub update_agent_contactable: Arc<UpdateAgentContactable>,
/// Resolve effective permissions for one agent.
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
/// Read the project system permission document.
@ -2454,6 +2456,8 @@ impl BackendCore {
&permission_store_port,
)));
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 resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone(
&permission_store_port,
)));
@ -3296,6 +3300,7 @@ impl BackendCore {
update_project_permissions,
update_agent_permissions,
update_agent_effort,
update_agent_contactable,
resolve_agent_permissions,
get_project_system_permissions,
update_project_system_permissions,

View File

@ -58,11 +58,12 @@ use application::{
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SetActiveLayoutInput, StopLiveAgentInput,
SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContextInput,
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput,
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
WriteToTerminalInput,
};
use domain::ports::{
PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource,
@ -108,10 +109,10 @@ use backend::dto::{
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContactableRequestDto,
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
};
@ -3273,6 +3274,9 @@ async fn invoke(
invoke_update_agent_permissions(&request.args, &state.app).await
}
"update_agent_effort" => invoke_update_agent_effort(&request.args, &state.app).await,
"update_agent_contactable" => {
invoke_update_agent_contactable(&request.args, &state.app).await
}
"resolve_agent_permissions" => {
invoke_resolve_agent_permissions(&request.args, &state.app).await
}
@ -4626,6 +4630,26 @@ async fn invoke_update_agent_effort(args: &Value, state: &BackendCore) -> Result
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_agent_contactable(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<UpdateAgentContactableRequestDto>("update_agent_contactable", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_agent_contactable
.execute(UpdateAgentContactableInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
contactable: request.contactable,
})
.await
.map(|out| AgentDto::from_agent(out.agent))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_resolve_agent_permissions(
args: &Value,
state: &BackendCore,
@ -9939,6 +9963,7 @@ mod tests {
"update_project_permissions",
"update_agent_permissions",
"update_agent_effort",
"update_agent_contactable",
"resolve_agent_permissions",
"get_project_system_permissions",
"update_project_system_permissions",