From f6101a824966464912957354d35ba9d41347ba1c Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 7 Sep 2026 18:13:27 +0200 Subject: [PATCH] =?UTF-8?q?fix(permissions):=20tools=20MCP=20inter-projets?= =?UTF-8?q?=20expos=C3=A9s=20(idea=5Fproject=5F*)=20+=20contactable=20agen?= =?UTF-8?q?t/template=20persist=C3=A9=20=E2=80=94=20update=5Fagent=5Fconta?= =?UTF-8?q?ctable=20(application/web-server/app-tauri),=20defaultContactab?= =?UTF-8?q?le=20TemplateEditor=20save/reopen,=20groupes=20MCP=20'IdeA=20in?= =?UTF-8?q?ter-projets'=20avec=20fallback,=20permissions=20agent=20Contact?= =?UTF-8?q?able=20=E2=80=94=20#267=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app-tauri/src/commands.rs | 29 +++++++- crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/tests/dto_agents.rs | 19 +++++- crates/application/src/agent/lifecycle.rs | 66 +++++++++++++++++++ crates/application/src/agent/mod.rs | 3 +- crates/application/src/lib.rs | 5 +- crates/application/tests/agent_lifecycle.rs | 51 +++++++++++++- crates/backend/src/dto.rs | 12 ++++ crates/backend/src/lib.rs | 15 +++-- crates/web-server/src/lib.rs | 43 +++++++++--- frontend/src/adapters/agent.test.ts | 14 ++++ frontend/src/adapters/agent.ts | 10 +++ .../src/adapters/http/agentGateway.test.ts | 22 +++++++ .../src/adapters/http/httpInvoker.test.ts | 24 ++++--- .../adapters/http/requestResponseGateways.ts | 2 + frontend/src/adapters/http/streamGateways.ts | 9 +++ frontend/src/adapters/mock/index.ts | 48 ++++++++++++++ frontend/src/adapters/template.ts | 2 + .../src/adapters/templateNormalization.ts | 1 + frontend/src/domain/index.ts | 5 +- .../McpToolPermissionsPanel.test.tsx | 39 ++++++++++- .../permissions/McpToolPermissionsPanel.tsx | 13 +++- .../features/permissions/PermissionsPanel.tsx | 45 ++++++++----- .../src/features/permissions/mcpToolGroups.ts | 42 ++++++++++++ .../features/permissions/permissions.test.tsx | 28 +++++--- .../features/permissions/usePermissions.ts | 26 ++++++++ .../src/features/templates/TemplateEditor.tsx | 44 +++++++------ .../src/features/templates/TemplatesPanel.tsx | 30 ++++++++- .../src/features/templates/templates.test.tsx | 65 +++++++++++++++++- frontend/src/ports/index.ts | 8 +++ 30 files changed, 632 insertions(+), 89 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index f805fb0c..10a7f4ba 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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 { + 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 diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index c7ce18b2..5c5e02f1 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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, diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index 2a5a9657..f5dceda1 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -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(); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index bf639e39..6a579d1b 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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, +} + +impl UpdateAgentContactable { + /// Builds the use case. + #[must_use] + pub fn new(contexts: Arc) -> 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 { + 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. diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 9d011020..ef0ee12e 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -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, }; diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 8a72cdc2..5bd0436a 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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::{ diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 55b49277..0c2ddd5a 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -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)); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index e31fdb9a..47d3dbe7 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -3067,6 +3067,18 @@ pub struct UpdateAgentEffortRequestDto { pub effort: Option, } +/// 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)] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 6b0bdae5..2ff44fc5 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -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, /// Update one agent effort override. pub update_agent_effort: Arc, + /// Update one agent's cross-project contactability. + pub update_agent_contactable: Arc, /// Resolve effective permissions for one agent. pub resolve_agent_permissions: Arc, /// 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, diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 1dafabae..eb9bfbf5 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -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 { + let request = + required_request::("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", diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index 3ec7e811..6d56d963 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -162,6 +162,20 @@ describe("TauriAgentGateway invoke payloads", () => { }); }); + it("update_agent_contactable wraps the dedicated boolean in the request DTO", async () => { + invoke.mockResolvedValueOnce({ id: "agent-2", contactable: true }); + + await new TauriAgentGateway().updateAgentContactable( + "proj-1", + "agent-2", + true, + ); + + expect(invoke).toHaveBeenCalledWith("update_agent_contactable", { + request: { projectId: "proj-1", agentId: "agent-2", contactable: true }, + }); + }); + 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 fa192d6f..4e4dae70 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -144,6 +144,16 @@ export class TauriAgentGateway implements AgentGateway { }); } + updateAgentContactable( + projectId: string, + agentId: string, + contactable: boolean, + ): Promise { + return invoke("update_agent_contactable", { + request: { projectId, agentId, contactable }, + }); + } + 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 8d2ceae5..0e67b55a 100644 --- a/frontend/src/adapters/http/agentGateway.test.ts +++ b/frontend/src/adapters/http/agentGateway.test.ts @@ -119,6 +119,28 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => { }); }); + it("updates agent contactability through the dedicated request DTO", async () => { + const response = { id: "agent-1", contactable: true }; + const { gw, calls } = httpAgentGateway(async () => ({ + ok: true, + status: 200, + json: async () => response, + text: async () => JSON.stringify(response), + })); + + await expect( + gw.updateAgentContactable("proj-1", "agent-1", true), + ).resolves.toEqual(response); + + const init = calls[0].init as { body: string }; + expect(JSON.parse(init.body)).toEqual({ + command: "update_agent_contactable", + args: { + request: { projectId: "proj-1", agentId: "agent-1", contactable: true }, + }, + }); + }); + it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => { const { gw, sockets } = gateway(); const chunks: Uint8Array[] = []; diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts index 298c5e9f..f976a2aa 100644 --- a/frontend/src/adapters/http/httpInvoker.test.ts +++ b/frontend/src/adapters/http/httpInvoker.test.ts @@ -264,7 +264,8 @@ describe("request/response gateways preserve the Tauri command contract", () => contentMd: "# Body", version: 1, defaultProfileId: "p1", - defaultSystemPermissions: { network: "allow", crossProjectContactable: true }, + defaultContactable: true, + defaultSystemPermissions: { network: "allow" }, defaultMcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }; const spy = vi @@ -276,7 +277,8 @@ describe("request/response gateways preserve the Tauri command contract", () => await expect(gw.listTemplates()).resolves.toMatchObject([ { - systemPermissions: { network: "allow", crossProjectContactable: true }, + defaultContactable: true, + systemPermissions: { network: "allow" }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }, ]); @@ -285,11 +287,13 @@ describe("request/response gateways preserve the Tauri command contract", () => name: "Permissioned", content: "# Body", defaultProfileId: "p1", - systemPermissions: { network: "allow", crossProjectContactable: true }, + defaultContactable: true, + systemPermissions: { network: "allow" }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }), ).resolves.toMatchObject({ - systemPermissions: { network: "allow", crossProjectContactable: true }, + defaultContactable: true, + systemPermissions: { network: "allow" }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }); await expect( @@ -297,11 +301,13 @@ describe("request/response gateways preserve the Tauri command contract", () => name: "Permissioned", content: "# Body", defaultProfileId: "p1", - systemPermissions: { network: "allow", crossProjectContactable: true }, + defaultContactable: true, + systemPermissions: { network: "allow" }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }), ).resolves.toMatchObject({ - systemPermissions: { network: "allow", crossProjectContactable: true }, + defaultContactable: true, + systemPermissions: { network: "allow" }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }); @@ -311,7 +317,8 @@ describe("request/response gateways preserve the Tauri command contract", () => content: "# Body", defaultProfileId: "p1", defaultAgentName: null, - defaultSystemPermissions: { network: "allow", crossProjectContactable: true }, + defaultContactable: true, + defaultSystemPermissions: { network: "allow" }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }, }); @@ -322,7 +329,8 @@ describe("request/response gateways preserve the Tauri command contract", () => content: "# Body", defaultProfileId: "p1", defaultAgentName: null, - defaultSystemPermissions: { network: "allow", crossProjectContactable: true }, + defaultContactable: true, + defaultSystemPermissions: { network: "allow" }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }, }); diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index f02bf198..aed8f42c 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -293,6 +293,7 @@ export class HttpTemplateGateway implements TemplateGateway { content: input.content, defaultProfileId: input.defaultProfileId, defaultAgentName: input.defaultAgentName ?? null, + defaultContactable: input.defaultContactable ?? false, defaultSystemPermissions: input.systemPermissions ?? null, mcpToolPermissions: input.mcpToolPermissions ?? null, }, @@ -306,6 +307,7 @@ export class HttpTemplateGateway implements TemplateGateway { content: input.content, defaultProfileId: input.defaultProfileId, defaultAgentName: input.defaultAgentName ?? null, + defaultContactable: input.defaultContactable ?? false, defaultSystemPermissions: input.systemPermissions ?? null, mcpToolPermissions: input.mcpToolPermissions ?? null, }, diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index 38fc04df..7924ac36 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -249,6 +249,15 @@ export class HttpAgentGateway implements AgentGateway { request: { projectId, agentId, effort }, }); } + updateAgentContactable( + projectId: string, + agentId: string, + contactable: boolean, + ): Promise { + return this.http.invoke("update_agent_contactable", { + request: { projectId, agentId, contactable }, + }); + } 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 5993a742..205cd3b1 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -702,6 +702,24 @@ export class MockAgentGateway implements AgentGateway { return structuredClone(next); } + async updateAgentContactable( + projectId: string, + agentId: string, + contactable: boolean, + ): Promise { + const list = this.getAgents(projectId); + const idx = list.findIndex((a) => a.id === agentId); + if (idx === -1) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `agent ${agentId} not found in project ${projectId}`, + }; + throw err; + } + list[idx] = { ...list[idx], contactable }; + return structuredClone(list[idx]); + } + // ── Internal helpers for MockTemplateGateway (same-package use only) ── /** @@ -2232,6 +2250,7 @@ export class MockTemplateGateway implements TemplateGateway { version: 1, defaultProfileId: input.defaultProfileId, defaultAgentName: input.defaultAgentName ?? null, + defaultContactable: input.defaultContactable ?? false, systemPermissions: input.systemPermissions ?? null, mcpToolPermissions: input.mcpToolPermissions ?? null, }; @@ -2259,6 +2278,7 @@ export class MockTemplateGateway implements TemplateGateway { content: input, defaultProfileId: current.defaultProfileId, defaultAgentName: current.defaultAgentName ?? null, + defaultContactable: current.defaultContactable ?? false, systemPermissions: current.systemPermissions ?? null, mcpToolPermissions: current.mcpToolPermissions ?? null, } @@ -2269,6 +2289,7 @@ export class MockTemplateGateway implements TemplateGateway { contentMd: update.content, defaultProfileId: update.defaultProfileId, defaultAgentName: update.defaultAgentName ?? null, + defaultContactable: update.defaultContactable ?? false, systemPermissions: update.systemPermissions ?? null, mcpToolPermissions: update.mcpToolPermissions ?? null, version: current.version + 1, @@ -2329,6 +2350,7 @@ export class MockTemplateGateway implements TemplateGateway { }, synchronized, skills: [], + contactable: template.defaultContactable ?? false, }; this.agentGateway._insertAgent(projectId, agent, template.contentMd); @@ -2940,6 +2962,9 @@ export class MockPermissionGateway implements PermissionGateway { // document from the file/command permissions above. ───────────────────── private mcpCatalogue: McpToolCatalogue = { readOnlyTools: [ + "idea_project_list", + "idea_project_read_context", + "idea_project_list_agents", "idea_list_agents", "idea_context_read", "idea_memory_read", @@ -2948,9 +2973,15 @@ export class MockPermissionGateway implements PermissionGateway { "idea_ticket_read", "idea_ticket_list", "idea_ticket_read_carnet", + "idea_ticket_attachment_read", "idea_sprint_list", + "idea_template_list", + "idea_template_read", + "idea_managed_temp_resolve", ], writeActionTools: [ + "idea_project_ask_agent", + "idea_project_cancel_agent", "idea_ask_agent", "idea_ask_agents", "idea_run_in_background", @@ -2958,6 +2989,7 @@ export class MockPermissionGateway implements PermissionGateway { "idea_stop_agent", "idea_update_context", "idea_context_propose", + "idea_context_update", "idea_memory_write", "idea_workstate_set", "idea_create_skill", @@ -2965,14 +2997,30 @@ export class MockPermissionGateway implements PermissionGateway { "idea_ticket_update", "idea_ticket_update_status", "idea_ticket_update_priority", + "idea_ticket_bulk_update_status", + "idea_ticket_bulk_update_priority", + "idea_ticket_bulk_delete", "idea_ticket_update_carnet", + "idea_ticket_attachment_add", + "idea_ticket_attachment_mark_summarized", "idea_ticket_link", "idea_ticket_unlink", + "idea_template_create", + "idea_template_update", + "idea_template_delete", + "idea_managed_temp_allocate", + "idea_managed_temp_release", + "idea_managed_temp_gc", ], }; private mcpDocs = new Map(); + setMcpToolCatalogue(catalogue: McpToolCatalogue): void { + this.mcpCatalogue = structuredClone(catalogue); + this.mcpDocs.clear(); + } + private mcpDoc(projectId: string): ProjectMcpToolPermissions { if (!this.mcpDocs.has(projectId)) { this.mcpDocs.set(projectId, { diff --git a/frontend/src/adapters/template.ts b/frontend/src/adapters/template.ts index 9a3fc79c..27577465 100644 --- a/frontend/src/adapters/template.ts +++ b/frontend/src/adapters/template.ts @@ -28,6 +28,7 @@ export class TauriTemplateGateway implements TemplateGateway { content: input.content, defaultProfileId: input.defaultProfileId, defaultAgentName: input.defaultAgentName ?? null, + defaultContactable: input.defaultContactable ?? false, defaultSystemPermissions: input.systemPermissions ?? null, mcpToolPermissions: input.mcpToolPermissions ?? null, }, @@ -42,6 +43,7 @@ export class TauriTemplateGateway implements TemplateGateway { content: input.content, defaultProfileId: input.defaultProfileId, defaultAgentName: input.defaultAgentName ?? null, + defaultContactable: input.defaultContactable ?? false, defaultSystemPermissions: input.systemPermissions ?? null, mcpToolPermissions: input.mcpToolPermissions ?? null, }, diff --git a/frontend/src/adapters/templateNormalization.ts b/frontend/src/adapters/templateNormalization.ts index f3b209e8..119bd8dc 100644 --- a/frontend/src/adapters/templateNormalization.ts +++ b/frontend/src/adapters/templateNormalization.ts @@ -9,6 +9,7 @@ export function normalizeTemplate(template: TemplateDto): Template { const { defaultSystemPermissions, defaultMcpToolPermissions, ...rest } = template; return { ...rest, + defaultContactable: rest.defaultContactable ?? false, systemPermissions: rest.systemPermissions ?? defaultSystemPermissions ?? null, mcpToolPermissions: rest.mcpToolPermissions ?? defaultMcpToolPermissions ?? null, }; diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 146104c6..1460da0e 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -820,7 +820,6 @@ export type NetworkPolicy = "allow" | "deny" | "ask"; /** Optional system permission bundle, distinct from file/bash permissions. */ export interface SystemPermissionSet { network?: NetworkPolicy; - crossProjectContactable?: boolean; } /** One sparse agent system-permission override. */ @@ -1316,6 +1315,8 @@ export interface Agent { skills: SkillRef[]; /** Per-agent effort override. Omitted for older manifests/profile default. */ effort?: EffortSelection; + /** Whether agents from other projects may contact this agent. Legacy absence means false. */ + contactable?: boolean; } /** Response DTO returned by `read_agent_context`; adapters unwrap `content`. */ @@ -1502,6 +1503,8 @@ export interface Template { version: number; defaultProfileId: string; defaultAgentName?: string | null; + /** Initial cross-project contactability for agents created from this template. */ + defaultContactable?: boolean; systemPermissions?: SystemPermissionSet | null; mcpToolPermissions?: McpToolPolicy | null; } diff --git a/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx b/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx index 33cd6d69..00d96653 100644 --- a/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx +++ b/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx @@ -121,6 +121,39 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => { })).toBeTruthy(); }); + it("shows cross-project tools in their UX group and catalogued unmapped tools in Autres", async () => { + const agent = new MockAgentGateway(); + const permission = new MockPermissionGateway(); + permission.setMcpToolCatalogue({ + readOnlyTools: ["idea_project_list", "idea_future_read"], + writeActionTools: ["idea_project_ask_agent"], + }); + const gateways = { + agent, + permission, + profile: new MockProfileGateway(), + } as unknown as Gateways; + render( + + + , + ); + + await openMcpToolsTab(); + + expect(screen.getByRole("button", { name: /IdeA inter-projets/ })).toBeTruthy(); + expect( + screen.getByRole("checkbox", { + name: /Demander à un agent d'un autre projet, idea_project_ask_agent$/, + }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: /Autres/ })).toBeTruthy(); + const fallback = screen.getByRole("checkbox", { + name: /idea_future_read, idea_future_read$/, + }) as HTMLInputElement; + expect(fallback.disabled).toBe(false); + }); + it("creating an override prefills the draft with the current effective allowlist", async () => { await renderPanel(); await openMcpToolsTab(); @@ -156,14 +189,14 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => { await selectAgentTarget("DevFrontend"); fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); - expect(screen.getByText(/9 lecture autorisés · 0 écriture autorisé/)).toBeTruthy(); + expect(screen.getByText(/16 lecture autorisés · 0 écriture autorisé/)).toBeTruthy(); const writeCheckbox = screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/, }); fireEvent.click(writeCheckbox); - expect(screen.getByText(/9 lecture autorisés · 1 écriture autorisé/)).toBeTruthy(); + expect(screen.getByText(/16 lecture autorisés · 1 écriture autorisé/)).toBeTruthy(); expect(screen.getByText("Modifications non enregistrées")).toBeTruthy(); expect(screen.getByText("Ajouté")).toBeTruthy(); }); @@ -196,7 +229,7 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => { "idea_ticket_update_carnet", ]), ); - expect(calledPolicy?.allowedTools).toHaveLength(10); + expect(calledPolicy?.allowedTools).toHaveLength(17); // Persisted: the left-column badge flips to Override, and Réinitialiser appears. const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); diff --git a/frontend/src/features/permissions/McpToolPermissionsPanel.tsx b/frontend/src/features/permissions/McpToolPermissionsPanel.tsx index 772d7ef0..511700be 100644 --- a/frontend/src/features/permissions/McpToolPermissionsPanel.tsx +++ b/frontend/src/features/permissions/McpToolPermissionsPanel.tsx @@ -17,7 +17,10 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { Agent, McpToolCatalogue } from "@/domain"; import { Button, Panel, cn } from "@/shared"; -import { MCP_TOOL_GROUPS, mcpToolLabel } from "./mcpToolGroups"; +import { + mcpToolGroupsForCatalogue, + mcpToolLabel, +} from "./mcpToolGroups"; import { useMcpToolPermissions } from "./useMcpToolPermissions"; export interface McpToolPermissionsPanelProps { @@ -243,9 +246,13 @@ function McpToolEditor({ const [pendingConfirm, setPendingConfirm] = useState(false); const [savedFlash, setSavedFlash] = useState(false); const [saveError, setSaveError] = useState(null); + const toolGroups = useMemo( + () => mcpToolGroupsForCatalogue(catalogue), + [catalogue], + ); const [expandedGroups, setExpandedGroups] = useState>(() => Object.fromEntries( - MCP_TOOL_GROUPS.map((group) => [ + mcpToolGroupsForCatalogue(catalogue).map((group) => [ group.id, READ_GROUP_IDS.has(group.id) ? targetKind === "project" : true, ]), @@ -375,7 +382,7 @@ function McpToolEditor({
Détail des tools
- {MCP_TOOL_GROUPS.map((group) => ( + {toolGroups.map((group) => ( = { const CROSS_PROJECT_HELP = "Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte."; -function systemPermissionsFromDraft( - network: NetworkPolicy | "", - crossProjectContactable: boolean, -): SystemPermissionSet | null { +function systemPermissionsFromDraft(network: NetworkPolicy | ""): SystemPermissionSet | null { const permissions: SystemPermissionSet = {}; if (network) permissions.network = network; - if (crossProjectContactable) permissions.crossProjectContactable = true; return Object.keys(permissions).length > 0 ? permissions : null; } @@ -110,11 +106,24 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) { } } - async function handleSaveNetwork(permissions: SystemPermissionSet | null) { + async function handleSaveNetwork( + permissions: SystemPermissionSet | null, + contactable?: boolean, + ) { if (target.type === "project") { await vm.saveProjectSystemPermissions(permissions); } else { - await vm.saveAgentSystemPermissions(target.agentId, permissions); + const networkChanged = + (permissions?.network ?? "") !== (selectedAgent?.systemOverride?.network ?? ""); + const contactableChanged = + contactable !== undefined && + contactable !== (selectedAgent?.agent.contactable ?? false); + if (networkChanged) { + await vm.saveAgentSystemPermissions(target.agentId, permissions); + } + if (contactableChanged) { + await vm.saveAgentContactable(target.agentId, contactable); + } } } @@ -261,8 +270,11 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) { : null } showCrossProjectContactable={target.type === "agent"} + contactable={selectedAgent?.agent.contactable ?? false} busy={vm.busy} - onSave={(permissions) => void handleSaveNetwork(permissions)} + onSave={(permissions, contactable) => + void handleSaveNetwork(permissions, contactable) + } /> @@ -335,8 +347,9 @@ interface NetworkPermissionEditorProps { inherited: SystemPermissionSet | null; resolved: ResolvedAgentSystemPermissions | null; showCrossProjectContactable: boolean; + contactable: boolean; busy: boolean; - onSave: (permissions: SystemPermissionSet | null) => void; + onSave: (permissions: SystemPermissionSet | null, contactable?: boolean) => void; } function NetworkPermissionEditor({ @@ -345,12 +358,12 @@ function NetworkPermissionEditor({ inherited, resolved, showCrossProjectContactable, + contactable, busy, onSave, }: NetworkPermissionEditorProps) { const current = source?.network ?? ""; - const currentCrossProjectContactable = - source?.crossProjectContactable ?? false; + const currentCrossProjectContactable = contactable; const [local, setLocal] = useState(current); const [localCrossProjectContactable, setLocalCrossProjectContactable] = useState(currentCrossProjectContactable); @@ -484,7 +497,7 @@ function NetworkPermissionEditor({ busy || readOnly || (!source?.network && - !(showCrossProjectContactable && source?.crossProjectContactable)) + !(showCrossProjectContactable && currentCrossProjectContactable)) } onClick={() => onSave(null)} > @@ -498,10 +511,10 @@ function NetworkPermissionEditor({ loading={busy && changed} onClick={() => onSave( - systemPermissionsFromDraft( - local, - showCrossProjectContactable && localCrossProjectContactable, - ), + systemPermissionsFromDraft(local), + showCrossProjectContactable + ? localCrossProjectContactable + : undefined, ) } > diff --git a/frontend/src/features/permissions/mcpToolGroups.ts b/frontend/src/features/permissions/mcpToolGroups.ts index 603e0311..8937d06f 100644 --- a/frontend/src/features/permissions/mcpToolGroups.ts +++ b/frontend/src/features/permissions/mcpToolGroups.ts @@ -1,3 +1,5 @@ +import type { McpToolCatalogue } from "@/domain"; + /** * Presentational grouping of the MCP tool catalogue (ticket #82). * @@ -15,6 +17,17 @@ export interface McpToolGroupDef { /** Domain groups, in display order (carnet #82 — Conception UX/F). */ export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [ + { + id: "crossProject", + label: "IdeA inter-projets", + tools: [ + "idea_project_list", + "idea_project_read_context", + "idea_project_list_agents", + "idea_project_ask_agent", + "idea_project_cancel_agent", + ], + }, { id: "readProject", label: "Lecture projet", @@ -78,6 +91,11 @@ export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [ /** Short, human French label per exact MCP tool name. */ export const MCP_TOOL_LABELS: Record = { + idea_project_list: "Lister les projets IdeA", + idea_project_read_context: "Lire le contexte d'un autre projet", + idea_project_list_agents: "Lister les agents d'un autre projet", + idea_project_ask_agent: "Demander à un agent d'un autre projet", + idea_project_cancel_agent: "Annuler une demande inter-projet", idea_list_agents: "Lister les agents", idea_context_read: "Lire le contexte d'un agent", idea_memory_read: "Lire la mémoire projet", @@ -115,3 +133,27 @@ export function mcpToolLabel(tool: string): string { export function mcpToolGroupId(tool: string): string | undefined { return MCP_TOOL_GROUPS.find((group) => group.tools.includes(tool))?.id; } + +/** + * Builds the exhaustive presentation groups from the backend catalogue. + * Local groups only choose placement/labels; every catalogued tool remains + * configurable, with unmapped names collected under the visible fallback. + */ +export function mcpToolGroupsForCatalogue( + catalogue: McpToolCatalogue, +): McpToolGroupDef[] { + const catalogued = Array.from( + new Set([...catalogue.readOnlyTools, ...catalogue.writeActionTools]), + ); + const cataloguedSet = new Set(catalogued); + const mapped = new Set(); + const groups = MCP_TOOL_GROUPS.flatMap((group) => { + const tools = group.tools.filter((tool) => cataloguedSet.has(tool)); + tools.forEach((tool) => mapped.add(tool)); + return tools.length > 0 ? [{ ...group, tools }] : []; + }); + const otherTools = catalogued.filter((tool) => !mapped.has(tool)); + return otherTools.length > 0 + ? [...groups, { id: "other", label: "Autres", tools: otherTools }] + : groups; +} diff --git a/frontend/src/features/permissions/permissions.test.tsx b/frontend/src/features/permissions/permissions.test.tsx index 3c66bbc0..068be60c 100644 --- a/frontend/src/features/permissions/permissions.test.tsx +++ b/frontend/src/features/permissions/permissions.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { @@ -103,7 +103,9 @@ describe("PermissionsPanel", () => { }); it("defaults cross-project contactability off and persists the agent checkbox", async () => { - const { permission } = await renderPanel(); + const { agent, permission } = await renderPanel(); + const contactableSpy = vi.spyOn(agent, "updateAgentContactable"); + const systemSpy = vi.spyOn(permission, "updateAgentSystemPermissions"); fireEvent.click(screen.getByText("Builder")); @@ -117,16 +119,24 @@ describe("PermissionsPanel", () => { ), ).toBeTruthy(); + await waitFor(() => expect(checkbox.disabled).toBe(false)); fireEvent.click(checkbox); - fireEvent.click(screen.getByRole("button", { name: "Save network" })); + const save = screen.getByRole("button", { name: "Save network" }); + await waitFor(() => expect(save).toHaveProperty("disabled", false)); + fireEvent.click(save); - await waitFor(async () => { - const doc = await permission.getProjectSystemPermissions(PROJECT_ID); - expect(doc.agents?.[0]).toEqual({ - agentId: expect.any(String), - permissions: { crossProjectContactable: true }, - }); + await waitFor(() => { + expect(contactableSpy).toHaveBeenCalledWith( + PROJECT_ID, + expect.any(String), + true, + ); }); + await waitFor(async () => { + const agents = await agent.listAgents(PROJECT_ID); + expect(agents[0].contactable).toBe(true); + }); + expect(systemSpy).not.toHaveBeenCalled(); }); it("renders the network control read-only when persisted control says so", async () => { diff --git a/frontend/src/features/permissions/usePermissions.ts b/frontend/src/features/permissions/usePermissions.ts index 657028f3..3c5f2fac 100644 --- a/frontend/src/features/permissions/usePermissions.ts +++ b/frontend/src/features/permissions/usePermissions.ts @@ -54,6 +54,7 @@ export interface PermissionsViewModel { agentId: string, permissions: SystemPermissionSet | null, ) => Promise; + saveAgentContactable: (agentId: string, contactable: boolean) => Promise; } const DEFAULT_DRAFT: PolicyDraft = { @@ -342,6 +343,30 @@ export function usePermissions(projectId: string): PermissionsViewModel { [permission, projectId, refresh], ); + const saveAgentContactable = useCallback( + async (agentId: string, contactable: boolean) => { + setBusy(true); + setError(null); + try { + const updated = await agent.updateAgentContactable( + projectId, + agentId, + contactable, + ); + setAgents((current) => + current.map((candidate) => + candidate.id === updated.id ? updated : candidate, + ), + ); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, + [agent, projectId], + ); + return { agents, rows, @@ -357,5 +382,6 @@ export function usePermissions(projectId: string): PermissionsViewModel { clearAgentOverride, saveProjectSystemPermissions, saveAgentSystemPermissions, + saveAgentContactable, }; } diff --git a/frontend/src/features/templates/TemplateEditor.tsx b/frontend/src/features/templates/TemplateEditor.tsx index 8f009b49..55449e01 100644 --- a/frontend/src/features/templates/TemplateEditor.tsx +++ b/frontend/src/features/templates/TemplateEditor.tsx @@ -4,12 +4,16 @@ import remarkGfm from "remark-gfm"; import type { AgentProfile, + McpToolCatalogue, McpToolPolicy, NetworkPolicy, SystemPermissionSet, Template, } from "@/domain"; -import { MCP_TOOL_GROUPS, mcpToolLabel } from "@/features/permissions/mcpToolGroups"; +import { + mcpToolGroupsForCatalogue, + mcpToolLabel, +} from "@/features/permissions/mcpToolGroups"; import { Button, Input, SmallDropdown, cn } from "@/shared"; import { profileLabel } from "./templatePresentation"; @@ -18,6 +22,7 @@ export interface TemplateEditorSaveInput { content: string; defaultProfileId: string; defaultAgentName: string | null; + defaultContactable: boolean; systemPermissions: SystemPermissionSet | null; mcpToolPermissions: McpToolPolicy | null; } @@ -25,6 +30,7 @@ export interface TemplateEditorSaveInput { export interface TemplateEditorProps { template?: Template | null; profiles: AgentProfile[]; + mcpCatalogue: McpToolCatalogue; onSave: (input: TemplateEditorSaveInput) => Promise; onClose: () => void; busy?: boolean; @@ -42,13 +48,10 @@ const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [ const TEMPLATE_CROSS_PROJECT_HELP = "Les agents créés avec ce template reprendront cette permission."; -const ALL_MCP_TOOLS = Array.from( - new Set(MCP_TOOL_GROUPS.flatMap((group) => group.tools)), -); - export function TemplateEditor({ template, profiles, + mcpCatalogue, onSave, onClose, busy = false, @@ -64,13 +67,21 @@ export function TemplateEditor({ const [network, setNetwork] = useState<"" | NetworkPolicy>( template?.systemPermissions?.network ?? "", ); - const [crossProjectContactable, setCrossProjectContactable] = useState( - template?.systemPermissions?.crossProjectContactable ?? false, + const [defaultContactable, setDefaultContactable] = useState( + template?.defaultContactable ?? false, ); const [allowedTools, setAllowedTools] = useState( template?.mcpToolPermissions?.allowedTools ?? [], ); const [tab, setTab] = useState("edit"); + const toolGroups = useMemo( + () => mcpToolGroupsForCatalogue(mcpCatalogue), + [mcpCatalogue], + ); + const allMcpTools = useMemo( + () => toolGroups.flatMap((group) => group.tools), + [toolGroups], + ); const allowed = useMemo(() => new Set(allowedTools), [allowedTools]); const canSave = name.trim().length > 0 && !busy; @@ -91,15 +102,8 @@ export function TemplateEditor({ content, defaultProfileId, defaultAgentName: defaultAgentName.trim() || null, - systemPermissions: - network || crossProjectContactable - ? { - ...(network ? { network } : {}), - ...(crossProjectContactable - ? { crossProjectContactable: true } - : {}), - } - : null, + defaultContactable, + systemPermissions: network ? { network } : null, mcpToolPermissions: allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null, }); @@ -275,8 +279,8 @@ export function TemplateEditor({ setCrossProjectContactable(e.target.checked)} + checked={defaultContactable} + onChange={(e) => setDefaultContactable(e.target.checked)} disabled={busy} className="mt-0.5 h-4 w-4 shrink-0 accent-primary disabled:cursor-not-allowed disabled:opacity-50" /> @@ -298,7 +302,7 @@ export function TemplateEditor({ {allowedTools.length} autorisé(s) - {ALL_MCP_TOOLS.length === 0 ? ( + {allMcpTools.length === 0 ? (

Aucun tool MCP IdeA disponible.

) : ( <> @@ -308,7 +312,7 @@ export function TemplateEditor({

)}
- {MCP_TOOL_GROUPS.map((group) => ( + {toolGroups.map((group) => (

{group.label}

{group.tools.map((tool) => ( diff --git a/frontend/src/features/templates/TemplatesPanel.tsx b/frontend/src/features/templates/TemplatesPanel.tsx index 384e177d..104cf670 100644 --- a/frontend/src/features/templates/TemplatesPanel.tsx +++ b/frontend/src/features/templates/TemplatesPanel.tsx @@ -17,7 +17,7 @@ import { useEffect, useState } from "react"; -import type { AgentProfile, Template } from "@/domain"; +import type { AgentProfile, McpToolCatalogue, Template } from "@/domain"; import { Button, Panel, Spinner } from "@/shared"; import { useGateways } from "@/app/di"; import { TemplateEditor, type TemplateEditorSaveInput } from "./TemplateEditor"; @@ -33,6 +33,11 @@ export interface TemplatesPanelProps { projectId: string; } +const EMPTY_MCP_CATALOGUE: McpToolCatalogue = { + readOnlyTools: [], + writeActionTools: [], +}; + /** Editor open-state: "create" or edit-mode for a specific template. */ type EditorState = | { mode: "create" } @@ -40,11 +45,12 @@ type EditorState = export function TemplatesPanel({ projectId }: TemplatesPanelProps) { const vm = useTemplates(); - const { profile } = useGateways(); + const { permission, profile } = useGateways(); // Profiles list for the editor's default-profile selector const [profiles, setProfiles] = useState([]); const [profilesLoaded, setProfilesLoaded] = useState(false); + const [mcpCatalogue, setMcpCatalogue] = useState(null); const [editorState, setEditorState] = useState(null); const [editorBusy, setEditorBusy] = useState(false); @@ -69,6 +75,25 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) { }; }, [profile, profilesLoaded]); + useEffect(() => { + if (!permission) { + setMcpCatalogue(EMPTY_MCP_CATALOGUE); + return; + } + let cancelled = false; + void permission + .getMcpToolPermissions(projectId) + .then((doc) => { + if (!cancelled) setMcpCatalogue(doc.catalogue); + }) + .catch(() => { + if (!cancelled) setMcpCatalogue(EMPTY_MCP_CATALOGUE); + }); + return () => { + cancelled = true; + }; + }, [permission, projectId]); + /** Lazily load profiles when the editor is first opened. */ async function openEditor(state: EditorState) { if (!profilesLoaded) { @@ -105,6 +130,7 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) { setEditorState(null)} busy={editorBusy} diff --git a/frontend/src/features/templates/templates.test.tsx b/frontend/src/features/templates/templates.test.tsx index 36a39373..9096e3d4 100644 --- a/frontend/src/features/templates/templates.test.tsx +++ b/frontend/src/features/templates/templates.test.tsx @@ -17,7 +17,12 @@ import { describe, it, expect } from "vitest"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; -import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock"; +import { + MockAgentGateway, + MockPermissionGateway, + MockProfileGateway, + MockTemplateGateway, +} from "@/adapters/mock"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { TemplatesPanel } from "./TemplatesPanel"; @@ -33,6 +38,7 @@ interface RenderOpts { agent?: MockAgentGateway; template?: MockTemplateGateway; profile?: MockProfileGateway; + permission?: MockPermissionGateway; } /** @@ -43,11 +49,13 @@ function renderTemplatesPanel(opts: RenderOpts = {}) { const agent = opts.agent ?? new MockAgentGateway(); const tmpl = opts.template ?? new MockTemplateGateway(agent); const profile = opts.profile ?? new MockProfileGateway(); - const gateways = { agent, profile, template: tmpl } as unknown as Gateways; + const permission = opts.permission ?? new MockPermissionGateway(); + const gateways = { agent, permission, profile, template: tmpl } as unknown as Gateways; return { agent, tmpl, profile, + permission, ...render( @@ -187,11 +195,40 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => { expect(templates[0]).toMatchObject({ defaultAgentName: "Main", defaultProfileId: "profile-main", - systemPermissions: { network: "allow", crossProjectContactable: true }, + defaultContactable: true, + systemPermissions: { network: "allow" }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }); }); + it("preserves defaultContactable after save and reopen", async () => { + const { tmpl } = renderTemplatesPanel(); + await waitForTemplatesIdle(); + + fireEvent.click(screen.getByRole("button", { name: "create template" })); + await screen.findByLabelText("template name"); + fireEvent.change(screen.getByLabelText("template name"), { + target: { value: "Contactable" }, + }); + fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets")); + fireEvent.click(screen.getByRole("button", { name: "Save template" })); + + await screen.findByText("Contactable"); + expect((await tmpl.listTemplates())[0]).toMatchObject({ + defaultContactable: true, + systemPermissions: null, + }); + + fireEvent.click(screen.getByRole("button", { name: "edit Contactable" })); + await waitFor(() => { + expect( + (screen.getByLabelText( + "Contactable depuis d’autres projets", + ) as HTMLInputElement).checked, + ).toBe(true); + }); + }); + it("defaults template cross-project contactability off when the field is absent", async () => { renderTemplatesPanel(); await waitForTemplatesIdle(); @@ -211,6 +248,28 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => { ).toBeTruthy(); }); + it("shows cross-project and ungrouped catalogued MCP tools in the template editor", async () => { + const permission = new MockPermissionGateway(); + permission.setMcpToolCatalogue({ + readOnlyTools: ["idea_project_list", "idea_future_read"], + writeActionTools: ["idea_project_cancel_agent"], + }); + renderTemplatesPanel({ permission }); + await waitForTemplatesIdle(); + + fireEvent.click(screen.getByRole("button", { name: "create template" })); + + expect(await screen.findByText("IdeA inter-projets")).toBeTruthy(); + expect( + screen.getByLabelText("template MCP tool idea_project_cancel_agent"), + ).toBeTruthy(); + expect(screen.getByText("Autres")).toBeTruthy(); + const fallback = screen.getByLabelText( + "template MCP tool idea_future_read", + ) as HTMLInputElement; + expect(fallback.disabled).toBe(false); + }); + it("the Create button is disabled when the name is empty", async () => { renderTemplatesPanel(); await waitForTemplatesIdle(); diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 4177553d..3340a864 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -271,6 +271,12 @@ export interface AgentGateway { agentId: string, effort: EffortSelection | null, ): Promise; + /** Changes whether this agent may be contacted from another project. */ + updateAgentContactable( + projectId: string, + agentId: string, + contactable: boolean, + ): Promise; /** Reads an agent's `.md` context by agent id. */ readContext(projectId: string, agentId: string): Promise; /** Overwrites an agent's `.md` context. */ @@ -630,6 +636,7 @@ export interface CreateTemplateInput { content: string; defaultProfileId: string; defaultAgentName?: string | null; + defaultContactable?: boolean; systemPermissions?: SystemPermissionSet | null; mcpToolPermissions?: McpToolPolicy | null; } @@ -640,6 +647,7 @@ export interface UpdateTemplateInput { content: string; defaultProfileId: string; defaultAgentName?: string | null; + defaultContactable?: boolean; systemPermissions?: SystemPermissionSet | null; mcpToolPermissions?: McpToolPolicy | null; }