merge(cross-project): permissions MCP inter-projets + contactable agents/templates — exposition idea_project_*, update_agent_contactable, defaultContactable persistant, fallback groupes MCP — #267 (QA verte; AppImage 0.3.0 reconstruite, racine remplacée, ancienne sauvegardée hors git; #268 clôturé doublon)
This commit is contained in:
52
.ideai/tickets/267/carnet.md
Normal file
52
.ideai/tickets/267/carnet.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#267"
|
||||||
|
version: 4
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedAt: 1788797485001
|
||||||
|
---
|
||||||
|
# Ticket #267 - reprise orchestrée par Main
|
||||||
|
|
||||||
|
Demande utilisateur: corriger le parcours complet permissions cross-project agents/templates et reconstruire/remplacer l'AppImage visible.
|
||||||
|
|
||||||
|
Constats initiaux:
|
||||||
|
- Les tools MCP inter-projets ne sont pas exposés correctement dans l'éditeur MCP.
|
||||||
|
- La case `Contactable depuis d'autres projets` dans les templates est visible mais ne persiste pas après save/reopen.
|
||||||
|
- Les permissions d'agent projet doivent permettre la configuration des tools MCP et de la contactabilité.
|
||||||
|
- Deux AppImages `0.3.0` existent; la racine contenait une ancienne AppImage du 2026-08-17.
|
||||||
|
|
||||||
|
Arbitrage UX:
|
||||||
|
- Groupe MCP attendu: `IdeA inter-projets`.
|
||||||
|
- Tools/libellés: `idea_project_ask_agent` = `Demander à un agent d'un autre projet`; `idea_project_cancel_agent` = `Annuler une demande inter-projet`.
|
||||||
|
- Contactabilité et permissions MCP restent deux réglages distincts: être appelable vs pouvoir appeler.
|
||||||
|
- Save/reopen doit conserver les états cochés/décochés.
|
||||||
|
|
||||||
|
Arbitrage Architecture:
|
||||||
|
- MCP tool groups: frontend pur.
|
||||||
|
- Template.defaultContactable: frontend pur si DTO/adapters existants sont câblés correctement; ne pas sérialiser dans `defaultSystemPermissions`.
|
||||||
|
- Agent.contactable est source de vérité agent projet. Backend devait fournir `update_agent_contactable`.
|
||||||
|
- Les tools catalogués mais non groupés doivent rester visibles via fallback.
|
||||||
|
|
||||||
|
Implémentation:
|
||||||
|
- DevBackend a ajouté le use case/DTO/commande Tauri/route web `update_agent_contactable` et tests associés.
|
||||||
|
- DevFrontend a modifié adapters/domain/ports/panels/tests pour exposer les tools cross-project, fallback visible, `defaultContactable` template séparé et `Agent.contactable` via `updateAgentContactable`.
|
||||||
|
|
||||||
|
Validation QA:
|
||||||
|
- Premier passage QA: code vert, ticket rouge uniquement car AppImage racine non remplacée.
|
||||||
|
- Build AppImage exécuté via skill `build-appimage`: `npm --prefix frontend run build`, puis `CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage` depuis `crates/app-tauri`.
|
||||||
|
- Nouvel artefact: `/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`, 118921720 bytes, mtime 2026-09-07 18:08:44 +0200, sha256 `770b6f59ad4c82222522da5c53a757531b91365e68bd2d33d1fe84155ca2efa0`.
|
||||||
|
- AppImage racine remplacée: `/home/anthony/Documents/Projects/IdeA/IdeA_0.3.0_amd64.AppImage`, même taille/mtime/hash; `cmp` root vs target = 0.
|
||||||
|
- Ancienne AppImage sauvegardée: `/home/anthony/Documents/Projects/IdeA/IdeA_0.3.0_amd64.AppImage.old-before-267-20260907180854`, 118745592 bytes, mtime 2026-08-17 16:31:45 +0200, sha256 `e66a8a9f4a55cdfd5eb55f1ccf8681a58f5c9f08977659cf9531101500675290`.
|
||||||
|
- QA final: VERT.
|
||||||
|
|
||||||
|
Tests verts relevés par QA:
|
||||||
|
- `cargo test -p infrastructure --test mcp_server tools_list_advertises_the_idea_tools_with_schemas -- --nocapture`
|
||||||
|
- `cargo test -p infrastructure orchestrator::mcp::tools::tests -- --nocapture`
|
||||||
|
- `cargo test -p application --test agent_lifecycle update_agent_contactable -- --nocapture`
|
||||||
|
- `cargo test -p web-server web_invoke_routes_permission_commands -- --nocapture`
|
||||||
|
- `cargo test -p app-tauri --test dto_templates -- --nocapture`
|
||||||
|
- `cd frontend && npm test -- --run src/features/permissions/McpToolPermissionsPanel.test.tsx src/features/permissions/permissions.test.tsx src/features/templates/templates.test.tsx src/adapters/agent.test.ts src/adapters/http/agentGateway.test.ts`
|
||||||
|
- `cd frontend && npm run typecheck`
|
||||||
|
|
||||||
|
Etat Git:
|
||||||
|
- Branche préparée par Git: `fix/268-cross-project-permissions-appimage` (nom historique malgré suivi #267).
|
||||||
|
- Aucun commit au moment de ce carnet; Git doit décider commit/merge local.
|
||||||
17
.ideai/tickets/267/issue.md
Normal file
17
.ideai/tickets/267/issue.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
id: "e68681a5-2a9e-4742-912b-9c1d726ca789"
|
||||||
|
number: 267
|
||||||
|
title: "Corriger permissions cross-project agents/templates et reconstruire l’AppImage"
|
||||||
|
status: "qa"
|
||||||
|
priority: "critical"
|
||||||
|
sprint: null
|
||||||
|
links: [{"target":"#260","kind":"relatesTo"},{"target":"#264","kind":"relatesTo"},{"target":"#265","kind":"relatesTo"}]
|
||||||
|
agentRefs: [{"agentId":"fe887179-933f-47d4-960f-c3b06827f86c","role":"assigned"},{"agentId":"8f7da528-58df-4315-97e9-0562230ecc19","role":"assigned"},{"agentId":"b4730d7f-c54d-4736-8a04-c6203aa2fd49","role":"assigned"},{"agentId":"ab328d90-c307-4771-a3b6-6c56089c8506","role":"assigned"},{"agentId":"1ff94b51-3e17-4a39-8543-7715d1ff0f80","role":"assigned"}]
|
||||||
|
attachments: []
|
||||||
|
createdBy: {"kind":"agent","agent_id":"fe887179-933f-47d4-960f-c3b06827f86c"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
createdAt: 1788795096659
|
||||||
|
updatedAt: 1788797485001
|
||||||
|
version: 4
|
||||||
|
---
|
||||||
|
Cycle complet demandé par l’utilisateur. Corriger : (1) exposer les tools MCP cross-project dans l’éditeur de permissions agent/projet et dans les templates ; (2) permettre d’autoriser précisément idea_project_list/read_context/list_agents/ask_agent/cancel_agent selon le catalogue canonique ; (3) persister et réafficher correctement defaultContactable dans TemplateEditor après save/reopen ; (4) conserver la permission agent Contactable depuis d’autres projets dans Permissions ; (5) tests frontend/backend/transport/persistance, QA complète ; (6) intégration Git puis nouvelle AppImage clairement publiée à la place de l’ancienne artefact visible.
|
||||||
22
.ideai/tickets/268/carnet.md
Normal file
22
.ideai/tickets/268/carnet.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#268"
|
||||||
|
version: 4
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedAt: 1788796144919
|
||||||
|
---
|
||||||
|
# Ticket #268 - reprise permissions inter-projets
|
||||||
|
|
||||||
|
Demande utilisateur: reprendre après erreur d'agent et livrer le cycle complet + rebuild AppImage.
|
||||||
|
|
||||||
|
Constats transmis par DevBackend:
|
||||||
|
- Le panneau permissions agent expose deja la case `Contactable depuis d'autres projets` dans `frontend/src/features/permissions/PermissionsPanel.tsx`.
|
||||||
|
- Les tools inter-projets `idea_project_ask_agent` et `idea_project_cancel_agent` ne sont pas dans `frontend/src/features/permissions/mcpToolGroups.ts`, donc l'UI ne permet pas de les autoriser.
|
||||||
|
- Deux AppImages `0.3.0` existent; celle a la racine est ancienne (17 aout), celle de `.appimage-build/...` plus recente (7 septembre) contient seulement une partie.
|
||||||
|
|
||||||
|
Definition of done:
|
||||||
|
- Template: la case contactable est persistée après save/reopen.
|
||||||
|
- Permissions agent projet: contactable configurable et persistant.
|
||||||
|
- Permissions MCP IdeA: les tools inter-projets sont visibles/configurables.
|
||||||
|
- Tests QA reels verts.
|
||||||
|
- Git gère branche/commit/merge local.
|
||||||
|
- AppImage reconstruite et l'ancienne AppImage visible a la racine remplacée explicitement.
|
||||||
17
.ideai/tickets/268/issue.md
Normal file
17
.ideai/tickets/268/issue.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
id: "b8510b67-2378-4292-9f46-83fb3988ac63"
|
||||||
|
number: 268
|
||||||
|
title: "Corriger permissions MCP inter-projets, contactable template/agent et rebuild AppImage"
|
||||||
|
status: "closed"
|
||||||
|
priority: "high"
|
||||||
|
sprint: null
|
||||||
|
links: [{"target":"#267","kind":"duplicates"}]
|
||||||
|
agentRefs: []
|
||||||
|
attachments: []
|
||||||
|
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
createdAt: 1788795950006
|
||||||
|
updatedAt: 1788796144919
|
||||||
|
version: 4
|
||||||
|
---
|
||||||
|
Reprise demandée par l'utilisateur. Problèmes à corriger: (1) les tools MCP inter-projets idea_project_ask_agent et idea_project_cancel_agent ne sont pas exposés dans les groupes de l'éditeur MCP; (2) dans l'éditeur de template, la case contactable depuis d'autres projets est visible mais ne persiste pas après save/reopen; (3) vérifier que les permissions de l'agent projet permettent de configurer tools MCP et contactable; (4) cycle complet QA vert; (5) reconstruire l'AppImage et remplacer explicitement l'ancienne AppImage visible à la racine si validation OK.
|
||||||
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nextNumber": 267
|
"nextNumber": 269
|
||||||
}
|
}
|
||||||
@ -3618,6 +3618,40 @@
|
|||||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
},
|
},
|
||||||
"updatedAt": 1788793585703
|
"updatedAt": 1788793585703
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#267",
|
||||||
|
"path": "267",
|
||||||
|
"title": "Corriger permissions cross-project agents/templates et reconstruire l’AppImage",
|
||||||
|
"status": "qa",
|
||||||
|
"priority": "critical",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [
|
||||||
|
"fe887179-933f-47d4-960f-c3b06827f86c",
|
||||||
|
"8f7da528-58df-4315-97e9-0562230ecc19",
|
||||||
|
"b4730d7f-c54d-4736-8a04-c6203aa2fd49",
|
||||||
|
"ab328d90-c307-4771-a3b6-6c56089c8506",
|
||||||
|
"1ff94b51-3e17-4a39-8543-7715d1ff0f80"
|
||||||
|
],
|
||||||
|
"createdBy": {
|
||||||
|
"kind": "agent",
|
||||||
|
"agent_id": "fe887179-933f-47d4-960f-c3b06827f86c"
|
||||||
|
},
|
||||||
|
"updatedAt": 1788797485001
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#268",
|
||||||
|
"path": "268",
|
||||||
|
"title": "Corriger permissions MCP inter-projets, contactable template/agent et rebuild AppImage",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "high",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"createdBy": {
|
||||||
|
"kind": "agent",
|
||||||
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
|
},
|
||||||
|
"updatedAt": 1788796144919
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -29,8 +29,8 @@ use application::{
|
|||||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||||
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect,
|
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect,
|
||||||
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||||
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput,
|
||||||
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||||
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
||||||
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||||
@ -78,7 +78,7 @@ use crate::dto::{
|
|||||||
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||||
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||||
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||||
@ -777,6 +777,29 @@ pub async fn update_agent_effort(
|
|||||||
.map_err(ErrorDto::from)
|
.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.
|
/// `resolve_agent_permissions` — resolve project defaults plus agent override.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@ -261,6 +261,7 @@ pub fn run() {
|
|||||||
commands::update_project_permissions,
|
commands::update_project_permissions,
|
||||||
commands::update_agent_permissions,
|
commands::update_agent_permissions,
|
||||||
commands::update_agent_effort,
|
commands::update_agent_effort,
|
||||||
|
commands::update_agent_contactable,
|
||||||
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,
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
use app_tauri_lib::dto::{
|
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, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
TerminalSessionDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto,
|
||||||
|
UpdateAgentEffortRequestDto,
|
||||||
};
|
};
|
||||||
use application::AppError;
|
use application::AppError;
|
||||||
use application::{
|
use application::{
|
||||||
@ -40,7 +41,7 @@ fn make_agent(agent_uuid: u128, profile_uuid: u128) -> Agent {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn agent_dto_serialises_camelcase() {
|
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 dto = AgentDto::from_agent(agent.clone());
|
||||||
let v = serde_json::to_value(&dto).unwrap();
|
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()
|
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
|
||||||
);
|
);
|
||||||
assert_eq!(v["synchronized"], false);
|
assert_eq!(v["synchronized"], false);
|
||||||
|
assert_eq!(v["contactable"], true);
|
||||||
// origin: tagged `{ "type": "scratch" }`
|
// origin: tagged `{ "type": "scratch" }`
|
||||||
assert_eq!(v["origin"]["type"], "scratch");
|
assert_eq!(v["origin"]["type"], "scratch");
|
||||||
assert_eq!(v["capabilities"], json!([]));
|
assert_eq!(v["capabilities"], json!([]));
|
||||||
@ -173,6 +175,19 @@ fn update_agent_effort_request_deserialises_and_null_clears() {
|
|||||||
assert_eq!(clear.effort, None);
|
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]
|
#[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();
|
||||||
|
|||||||
@ -522,6 +522,72 @@ pub struct UpdateAgentEffortOutput {
|
|||||||
pub agent: Agent,
|
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.
|
/// 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.
|
/// The change applies at the agent's next launch; live sessions are not mutated.
|
||||||
|
|||||||
@ -37,7 +37,8 @@ pub use lifecycle::{
|
|||||||
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
|
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
|
||||||
LiveStateLeanProvider, ManagedTempExposureRequest, McpRuntime, PermissionProjectorRegistry,
|
LiveStateLeanProvider, ManagedTempExposureRequest, McpRuntime, PermissionProjectorRegistry,
|
||||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||||
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContext,
|
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContactable,
|
||||||
|
UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext,
|
||||||
UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput,
|
UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput,
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -66,8 +66,9 @@ pub use agent::{
|
|||||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveOpenCodeProviderProfile,
|
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveOpenCodeProviderProfile,
|
||||||
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
|
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
|
||||||
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
|
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
|
||||||
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
|
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContactable, UpdateAgentContactableInput,
|
||||||
UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
|
||||||
|
UpdateAgentEffortInput, UpdateAgentEffortOutput, 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::{
|
||||||
|
|||||||
@ -57,8 +57,8 @@ use application::{
|
|||||||
DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput,
|
DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput,
|
||||||
LiveSessions, ManagedTempExposureRequest, PermissionProjectorRegistry, ReadAgentContext,
|
LiveSessions, ManagedTempExposureRequest, PermissionProjectorRegistry, ReadAgentContext,
|
||||||
ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode,
|
ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode,
|
||||||
StructuredSessions, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
|
StructuredSessions, TerminalSessions, UpdateAgentContactable, UpdateAgentContactableInput,
|
||||||
UpdateAgentEffort, UpdateAgentEffortInput,
|
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:?}");
|
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]
|
#[tokio::test]
|
||||||
async fn delete_removes_entry_then_unknown_is_not_found() {
|
async fn delete_removes_entry_then_unknown_is_not_found() {
|
||||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||||
|
|||||||
@ -3067,6 +3067,18 @@ pub struct UpdateAgentEffortRequestDto {
|
|||||||
pub effort: Option<EffortSelection>,
|
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
|
/// 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)]
|
||||||
|
|||||||
@ -54,11 +54,11 @@ use application::{
|
|||||||
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||||
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
|
||||||
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
|
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
|
||||||
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate,
|
||||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -1287,6 +1287,8 @@ pub struct BackendCore {
|
|||||||
pub update_agent_permissions: Arc<UpdateAgentPermissions>,
|
pub update_agent_permissions: Arc<UpdateAgentPermissions>,
|
||||||
/// Update one agent effort override.
|
/// Update one agent effort override.
|
||||||
pub update_agent_effort: Arc<UpdateAgentEffort>,
|
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.
|
/// 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.
|
||||||
@ -2454,6 +2456,8 @@ impl BackendCore {
|
|||||||
&permission_store_port,
|
&permission_store_port,
|
||||||
)));
|
)));
|
||||||
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 =
|
||||||
|
Arc::new(UpdateAgentContactable::new(Arc::clone(&contexts_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,
|
||||||
)));
|
)));
|
||||||
@ -3296,6 +3300,7 @@ impl BackendCore {
|
|||||||
update_project_permissions,
|
update_project_permissions,
|
||||||
update_agent_permissions,
|
update_agent_permissions,
|
||||||
update_agent_effort,
|
update_agent_effort,
|
||||||
|
update_agent_contactable,
|
||||||
resolve_agent_permissions,
|
resolve_agent_permissions,
|
||||||
get_project_system_permissions,
|
get_project_system_permissions,
|
||||||
update_project_system_permissions,
|
update_project_system_permissions,
|
||||||
|
|||||||
@ -58,11 +58,12 @@ use application::{
|
|||||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||||
RotateConversationLogInput, SetActiveLayoutInput, StopLiveAgentInput,
|
RotateConversationLogInput, SetActiveLayoutInput, StopLiveAgentInput,
|
||||||
SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
|
SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
|
||||||
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContextInput,
|
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput,
|
||||||
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
||||||
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
|
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
|
||||||
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
|
||||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
|
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||||
|
WriteToTerminalInput,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource,
|
PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource,
|
||||||
@ -108,10 +109,10 @@ use backend::dto::{
|
|||||||
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
|
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
|
||||||
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
||||||
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
||||||
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContactableRequestDto,
|
||||||
UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||||
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
|
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||||
};
|
};
|
||||||
@ -3273,6 +3274,9 @@ async fn invoke(
|
|||||||
invoke_update_agent_permissions(&request.args, &state.app).await
|
invoke_update_agent_permissions(&request.args, &state.app).await
|
||||||
}
|
}
|
||||||
"update_agent_effort" => invoke_update_agent_effort(&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" => {
|
"resolve_agent_permissions" => {
|
||||||
invoke_resolve_agent_permissions(&request.args, &state.app).await
|
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)
|
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(
|
async fn invoke_resolve_agent_permissions(
|
||||||
args: &Value,
|
args: &Value,
|
||||||
state: &BackendCore,
|
state: &BackendCore,
|
||||||
@ -9939,6 +9963,7 @@ mod tests {
|
|||||||
"update_project_permissions",
|
"update_project_permissions",
|
||||||
"update_agent_permissions",
|
"update_agent_permissions",
|
||||||
"update_agent_effort",
|
"update_agent_effort",
|
||||||
|
"update_agent_contactable",
|
||||||
"resolve_agent_permissions",
|
"resolve_agent_permissions",
|
||||||
"get_project_system_permissions",
|
"get_project_system_permissions",
|
||||||
"update_project_system_permissions",
|
"update_project_system_permissions",
|
||||||
|
|||||||
@ -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 () => {
|
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");
|
||||||
|
|
||||||
|
|||||||
@ -144,6 +144,16 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAgentContactable(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
contactable: boolean,
|
||||||
|
): Promise<Agent> {
|
||||||
|
return invoke<Agent>("update_agent_contactable", {
|
||||||
|
request: { projectId, agentId, contactable },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@ -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 () => {
|
it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
|
||||||
const { gw, sockets } = gateway();
|
const { gw, sockets } = gateway();
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
|
|||||||
@ -264,7 +264,8 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
contentMd: "# Body",
|
contentMd: "# Body",
|
||||||
version: 1,
|
version: 1,
|
||||||
defaultProfileId: "p1",
|
defaultProfileId: "p1",
|
||||||
defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
|
defaultContactable: true,
|
||||||
|
defaultSystemPermissions: { network: "allow" },
|
||||||
defaultMcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
defaultMcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
};
|
};
|
||||||
const spy = vi
|
const spy = vi
|
||||||
@ -276,7 +277,8 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
|
|
||||||
await expect(gw.listTemplates()).resolves.toMatchObject([
|
await expect(gw.listTemplates()).resolves.toMatchObject([
|
||||||
{
|
{
|
||||||
systemPermissions: { network: "allow", crossProjectContactable: true },
|
defaultContactable: true,
|
||||||
|
systemPermissions: { network: "allow" },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@ -285,11 +287,13 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
name: "Permissioned",
|
name: "Permissioned",
|
||||||
content: "# Body",
|
content: "# Body",
|
||||||
defaultProfileId: "p1",
|
defaultProfileId: "p1",
|
||||||
systemPermissions: { network: "allow", crossProjectContactable: true },
|
defaultContactable: true,
|
||||||
|
systemPermissions: { network: "allow" },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
}),
|
}),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
systemPermissions: { network: "allow", crossProjectContactable: true },
|
defaultContactable: true,
|
||||||
|
systemPermissions: { network: "allow" },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
@ -297,11 +301,13 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
name: "Permissioned",
|
name: "Permissioned",
|
||||||
content: "# Body",
|
content: "# Body",
|
||||||
defaultProfileId: "p1",
|
defaultProfileId: "p1",
|
||||||
systemPermissions: { network: "allow", crossProjectContactable: true },
|
defaultContactable: true,
|
||||||
|
systemPermissions: { network: "allow" },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
}),
|
}),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
systemPermissions: { network: "allow", crossProjectContactable: true },
|
defaultContactable: true,
|
||||||
|
systemPermissions: { network: "allow" },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -311,7 +317,8 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
content: "# Body",
|
content: "# Body",
|
||||||
defaultProfileId: "p1",
|
defaultProfileId: "p1",
|
||||||
defaultAgentName: null,
|
defaultAgentName: null,
|
||||||
defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
|
defaultContactable: true,
|
||||||
|
defaultSystemPermissions: { network: "allow" },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -322,7 +329,8 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
content: "# Body",
|
content: "# Body",
|
||||||
defaultProfileId: "p1",
|
defaultProfileId: "p1",
|
||||||
defaultAgentName: null,
|
defaultAgentName: null,
|
||||||
defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
|
defaultContactable: true,
|
||||||
|
defaultSystemPermissions: { network: "allow" },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -293,6 +293,7 @@ export class HttpTemplateGateway implements TemplateGateway {
|
|||||||
content: input.content,
|
content: input.content,
|
||||||
defaultProfileId: input.defaultProfileId,
|
defaultProfileId: input.defaultProfileId,
|
||||||
defaultAgentName: input.defaultAgentName ?? null,
|
defaultAgentName: input.defaultAgentName ?? null,
|
||||||
|
defaultContactable: input.defaultContactable ?? false,
|
||||||
defaultSystemPermissions: input.systemPermissions ?? null,
|
defaultSystemPermissions: input.systemPermissions ?? null,
|
||||||
mcpToolPermissions: input.mcpToolPermissions ?? null,
|
mcpToolPermissions: input.mcpToolPermissions ?? null,
|
||||||
},
|
},
|
||||||
@ -306,6 +307,7 @@ export class HttpTemplateGateway implements TemplateGateway {
|
|||||||
content: input.content,
|
content: input.content,
|
||||||
defaultProfileId: input.defaultProfileId,
|
defaultProfileId: input.defaultProfileId,
|
||||||
defaultAgentName: input.defaultAgentName ?? null,
|
defaultAgentName: input.defaultAgentName ?? null,
|
||||||
|
defaultContactable: input.defaultContactable ?? false,
|
||||||
defaultSystemPermissions: input.systemPermissions ?? null,
|
defaultSystemPermissions: input.systemPermissions ?? null,
|
||||||
mcpToolPermissions: input.mcpToolPermissions ?? null,
|
mcpToolPermissions: input.mcpToolPermissions ?? null,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -249,6 +249,15 @@ export class HttpAgentGateway implements AgentGateway {
|
|||||||
request: { projectId, agentId, effort },
|
request: { projectId, agentId, effort },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
updateAgentContactable(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
contactable: boolean,
|
||||||
|
): Promise<Agent> {
|
||||||
|
return this.http.invoke<Agent>("update_agent_contactable", {
|
||||||
|
request: { projectId, agentId, contactable },
|
||||||
|
});
|
||||||
|
}
|
||||||
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 })
|
||||||
|
|||||||
@ -702,6 +702,24 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
return structuredClone(next);
|
return structuredClone(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateAgentContactable(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
contactable: boolean,
|
||||||
|
): Promise<Agent> {
|
||||||
|
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) ──
|
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -2232,6 +2250,7 @@ export class MockTemplateGateway implements TemplateGateway {
|
|||||||
version: 1,
|
version: 1,
|
||||||
defaultProfileId: input.defaultProfileId,
|
defaultProfileId: input.defaultProfileId,
|
||||||
defaultAgentName: input.defaultAgentName ?? null,
|
defaultAgentName: input.defaultAgentName ?? null,
|
||||||
|
defaultContactable: input.defaultContactable ?? false,
|
||||||
systemPermissions: input.systemPermissions ?? null,
|
systemPermissions: input.systemPermissions ?? null,
|
||||||
mcpToolPermissions: input.mcpToolPermissions ?? null,
|
mcpToolPermissions: input.mcpToolPermissions ?? null,
|
||||||
};
|
};
|
||||||
@ -2259,6 +2278,7 @@ export class MockTemplateGateway implements TemplateGateway {
|
|||||||
content: input,
|
content: input,
|
||||||
defaultProfileId: current.defaultProfileId,
|
defaultProfileId: current.defaultProfileId,
|
||||||
defaultAgentName: current.defaultAgentName ?? null,
|
defaultAgentName: current.defaultAgentName ?? null,
|
||||||
|
defaultContactable: current.defaultContactable ?? false,
|
||||||
systemPermissions: current.systemPermissions ?? null,
|
systemPermissions: current.systemPermissions ?? null,
|
||||||
mcpToolPermissions: current.mcpToolPermissions ?? null,
|
mcpToolPermissions: current.mcpToolPermissions ?? null,
|
||||||
}
|
}
|
||||||
@ -2269,6 +2289,7 @@ export class MockTemplateGateway implements TemplateGateway {
|
|||||||
contentMd: update.content,
|
contentMd: update.content,
|
||||||
defaultProfileId: update.defaultProfileId,
|
defaultProfileId: update.defaultProfileId,
|
||||||
defaultAgentName: update.defaultAgentName ?? null,
|
defaultAgentName: update.defaultAgentName ?? null,
|
||||||
|
defaultContactable: update.defaultContactable ?? false,
|
||||||
systemPermissions: update.systemPermissions ?? null,
|
systemPermissions: update.systemPermissions ?? null,
|
||||||
mcpToolPermissions: update.mcpToolPermissions ?? null,
|
mcpToolPermissions: update.mcpToolPermissions ?? null,
|
||||||
version: current.version + 1,
|
version: current.version + 1,
|
||||||
@ -2329,6 +2350,7 @@ export class MockTemplateGateway implements TemplateGateway {
|
|||||||
},
|
},
|
||||||
synchronized,
|
synchronized,
|
||||||
skills: [],
|
skills: [],
|
||||||
|
contactable: template.defaultContactable ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.agentGateway._insertAgent(projectId, agent, template.contentMd);
|
this.agentGateway._insertAgent(projectId, agent, template.contentMd);
|
||||||
@ -2940,6 +2962,9 @@ export class MockPermissionGateway implements PermissionGateway {
|
|||||||
// document from the file/command permissions above. ─────────────────────
|
// document from the file/command permissions above. ─────────────────────
|
||||||
private mcpCatalogue: McpToolCatalogue = {
|
private mcpCatalogue: McpToolCatalogue = {
|
||||||
readOnlyTools: [
|
readOnlyTools: [
|
||||||
|
"idea_project_list",
|
||||||
|
"idea_project_read_context",
|
||||||
|
"idea_project_list_agents",
|
||||||
"idea_list_agents",
|
"idea_list_agents",
|
||||||
"idea_context_read",
|
"idea_context_read",
|
||||||
"idea_memory_read",
|
"idea_memory_read",
|
||||||
@ -2948,9 +2973,15 @@ export class MockPermissionGateway implements PermissionGateway {
|
|||||||
"idea_ticket_read",
|
"idea_ticket_read",
|
||||||
"idea_ticket_list",
|
"idea_ticket_list",
|
||||||
"idea_ticket_read_carnet",
|
"idea_ticket_read_carnet",
|
||||||
|
"idea_ticket_attachment_read",
|
||||||
"idea_sprint_list",
|
"idea_sprint_list",
|
||||||
|
"idea_template_list",
|
||||||
|
"idea_template_read",
|
||||||
|
"idea_managed_temp_resolve",
|
||||||
],
|
],
|
||||||
writeActionTools: [
|
writeActionTools: [
|
||||||
|
"idea_project_ask_agent",
|
||||||
|
"idea_project_cancel_agent",
|
||||||
"idea_ask_agent",
|
"idea_ask_agent",
|
||||||
"idea_ask_agents",
|
"idea_ask_agents",
|
||||||
"idea_run_in_background",
|
"idea_run_in_background",
|
||||||
@ -2958,6 +2989,7 @@ export class MockPermissionGateway implements PermissionGateway {
|
|||||||
"idea_stop_agent",
|
"idea_stop_agent",
|
||||||
"idea_update_context",
|
"idea_update_context",
|
||||||
"idea_context_propose",
|
"idea_context_propose",
|
||||||
|
"idea_context_update",
|
||||||
"idea_memory_write",
|
"idea_memory_write",
|
||||||
"idea_workstate_set",
|
"idea_workstate_set",
|
||||||
"idea_create_skill",
|
"idea_create_skill",
|
||||||
@ -2965,14 +2997,30 @@ export class MockPermissionGateway implements PermissionGateway {
|
|||||||
"idea_ticket_update",
|
"idea_ticket_update",
|
||||||
"idea_ticket_update_status",
|
"idea_ticket_update_status",
|
||||||
"idea_ticket_update_priority",
|
"idea_ticket_update_priority",
|
||||||
|
"idea_ticket_bulk_update_status",
|
||||||
|
"idea_ticket_bulk_update_priority",
|
||||||
|
"idea_ticket_bulk_delete",
|
||||||
"idea_ticket_update_carnet",
|
"idea_ticket_update_carnet",
|
||||||
|
"idea_ticket_attachment_add",
|
||||||
|
"idea_ticket_attachment_mark_summarized",
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
"idea_ticket_unlink",
|
"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<string, ProjectMcpToolPermissions>();
|
private mcpDocs = new Map<string, ProjectMcpToolPermissions>();
|
||||||
|
|
||||||
|
setMcpToolCatalogue(catalogue: McpToolCatalogue): void {
|
||||||
|
this.mcpCatalogue = structuredClone(catalogue);
|
||||||
|
this.mcpDocs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
private mcpDoc(projectId: string): ProjectMcpToolPermissions {
|
private mcpDoc(projectId: string): ProjectMcpToolPermissions {
|
||||||
if (!this.mcpDocs.has(projectId)) {
|
if (!this.mcpDocs.has(projectId)) {
|
||||||
this.mcpDocs.set(projectId, {
|
this.mcpDocs.set(projectId, {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export class TauriTemplateGateway implements TemplateGateway {
|
|||||||
content: input.content,
|
content: input.content,
|
||||||
defaultProfileId: input.defaultProfileId,
|
defaultProfileId: input.defaultProfileId,
|
||||||
defaultAgentName: input.defaultAgentName ?? null,
|
defaultAgentName: input.defaultAgentName ?? null,
|
||||||
|
defaultContactable: input.defaultContactable ?? false,
|
||||||
defaultSystemPermissions: input.systemPermissions ?? null,
|
defaultSystemPermissions: input.systemPermissions ?? null,
|
||||||
mcpToolPermissions: input.mcpToolPermissions ?? null,
|
mcpToolPermissions: input.mcpToolPermissions ?? null,
|
||||||
},
|
},
|
||||||
@ -42,6 +43,7 @@ export class TauriTemplateGateway implements TemplateGateway {
|
|||||||
content: input.content,
|
content: input.content,
|
||||||
defaultProfileId: input.defaultProfileId,
|
defaultProfileId: input.defaultProfileId,
|
||||||
defaultAgentName: input.defaultAgentName ?? null,
|
defaultAgentName: input.defaultAgentName ?? null,
|
||||||
|
defaultContactable: input.defaultContactable ?? false,
|
||||||
defaultSystemPermissions: input.systemPermissions ?? null,
|
defaultSystemPermissions: input.systemPermissions ?? null,
|
||||||
mcpToolPermissions: input.mcpToolPermissions ?? null,
|
mcpToolPermissions: input.mcpToolPermissions ?? null,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export function normalizeTemplate(template: TemplateDto): Template {
|
|||||||
const { defaultSystemPermissions, defaultMcpToolPermissions, ...rest } = template;
|
const { defaultSystemPermissions, defaultMcpToolPermissions, ...rest } = template;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
defaultContactable: rest.defaultContactable ?? false,
|
||||||
systemPermissions: rest.systemPermissions ?? defaultSystemPermissions ?? null,
|
systemPermissions: rest.systemPermissions ?? defaultSystemPermissions ?? null,
|
||||||
mcpToolPermissions: rest.mcpToolPermissions ?? defaultMcpToolPermissions ?? null,
|
mcpToolPermissions: rest.mcpToolPermissions ?? defaultMcpToolPermissions ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -820,7 +820,6 @@ export type NetworkPolicy = "allow" | "deny" | "ask";
|
|||||||
/** Optional system permission bundle, distinct from file/bash permissions. */
|
/** Optional system permission bundle, distinct from file/bash permissions. */
|
||||||
export interface SystemPermissionSet {
|
export interface SystemPermissionSet {
|
||||||
network?: NetworkPolicy;
|
network?: NetworkPolicy;
|
||||||
crossProjectContactable?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One sparse agent system-permission override. */
|
/** One sparse agent system-permission override. */
|
||||||
@ -1316,6 +1315,8 @@ export interface Agent {
|
|||||||
skills: SkillRef[];
|
skills: SkillRef[];
|
||||||
/** Per-agent effort override. Omitted for older manifests/profile default. */
|
/** Per-agent effort override. Omitted for older manifests/profile default. */
|
||||||
effort?: EffortSelection;
|
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`. */
|
/** Response DTO returned by `read_agent_context`; adapters unwrap `content`. */
|
||||||
@ -1502,6 +1503,8 @@ export interface Template {
|
|||||||
version: number;
|
version: number;
|
||||||
defaultProfileId: string;
|
defaultProfileId: string;
|
||||||
defaultAgentName?: string | null;
|
defaultAgentName?: string | null;
|
||||||
|
/** Initial cross-project contactability for agents created from this template. */
|
||||||
|
defaultContactable?: boolean;
|
||||||
systemPermissions?: SystemPermissionSet | null;
|
systemPermissions?: SystemPermissionSet | null;
|
||||||
mcpToolPermissions?: McpToolPolicy | null;
|
mcpToolPermissions?: McpToolPolicy | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -121,6 +121,39 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => {
|
|||||||
})).toBeTruthy();
|
})).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(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<PermissionsPanel projectId={PROJECT_ID} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
it("creating an override prefills the draft with the current effective allowlist", async () => {
|
||||||
await renderPanel();
|
await renderPanel();
|
||||||
await openMcpToolsTab();
|
await openMcpToolsTab();
|
||||||
@ -156,14 +189,14 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => {
|
|||||||
await selectAgentTarget("DevFrontend");
|
await selectAgentTarget("DevFrontend");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
|
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", {
|
const writeCheckbox = screen.getByRole("checkbox", {
|
||||||
name: /idea_ticket_update_carnet$/,
|
name: /idea_ticket_update_carnet$/,
|
||||||
});
|
});
|
||||||
fireEvent.click(writeCheckbox);
|
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("Modifications non enregistrées")).toBeTruthy();
|
||||||
expect(screen.getByText("Ajouté")).toBeTruthy();
|
expect(screen.getByText("Ajouté")).toBeTruthy();
|
||||||
});
|
});
|
||||||
@ -196,7 +229,7 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => {
|
|||||||
"idea_ticket_update_carnet",
|
"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.
|
// Persisted: the left-column badge flips to Override, and Réinitialiser appears.
|
||||||
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
||||||
|
|||||||
@ -17,7 +17,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import type { Agent, McpToolCatalogue } from "@/domain";
|
import type { Agent, McpToolCatalogue } from "@/domain";
|
||||||
import { Button, Panel, cn } from "@/shared";
|
import { Button, Panel, cn } from "@/shared";
|
||||||
import { MCP_TOOL_GROUPS, mcpToolLabel } from "./mcpToolGroups";
|
import {
|
||||||
|
mcpToolGroupsForCatalogue,
|
||||||
|
mcpToolLabel,
|
||||||
|
} from "./mcpToolGroups";
|
||||||
import { useMcpToolPermissions } from "./useMcpToolPermissions";
|
import { useMcpToolPermissions } from "./useMcpToolPermissions";
|
||||||
|
|
||||||
export interface McpToolPermissionsPanelProps {
|
export interface McpToolPermissionsPanelProps {
|
||||||
@ -243,9 +246,13 @@ function McpToolEditor({
|
|||||||
const [pendingConfirm, setPendingConfirm] = useState(false);
|
const [pendingConfirm, setPendingConfirm] = useState(false);
|
||||||
const [savedFlash, setSavedFlash] = useState(false);
|
const [savedFlash, setSavedFlash] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const toolGroups = useMemo(
|
||||||
|
() => mcpToolGroupsForCatalogue(catalogue),
|
||||||
|
[catalogue],
|
||||||
|
);
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(() =>
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(() =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
MCP_TOOL_GROUPS.map((group) => [
|
mcpToolGroupsForCatalogue(catalogue).map((group) => [
|
||||||
group.id,
|
group.id,
|
||||||
READ_GROUP_IDS.has(group.id) ? targetKind === "project" : true,
|
READ_GROUP_IDS.has(group.id) ? targetKind === "project" : true,
|
||||||
]),
|
]),
|
||||||
@ -375,7 +382,7 @@ function McpToolEditor({
|
|||||||
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
Détail des tools
|
Détail des tools
|
||||||
</h5>
|
</h5>
|
||||||
{MCP_TOOL_GROUPS.map((group) => (
|
{toolGroups.map((group) => (
|
||||||
<ToolGroupSection
|
<ToolGroupSection
|
||||||
key={group.id}
|
key={group.id}
|
||||||
group={group}
|
group={group}
|
||||||
|
|||||||
@ -57,13 +57,9 @@ const NETWORK_LABELS: Record<NetworkPolicy, string> = {
|
|||||||
const CROSS_PROJECT_HELP =
|
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.";
|
"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(
|
function systemPermissionsFromDraft(network: NetworkPolicy | ""): SystemPermissionSet | null {
|
||||||
network: NetworkPolicy | "",
|
|
||||||
crossProjectContactable: boolean,
|
|
||||||
): SystemPermissionSet | null {
|
|
||||||
const permissions: SystemPermissionSet = {};
|
const permissions: SystemPermissionSet = {};
|
||||||
if (network) permissions.network = network;
|
if (network) permissions.network = network;
|
||||||
if (crossProjectContactable) permissions.crossProjectContactable = true;
|
|
||||||
return Object.keys(permissions).length > 0 ? permissions : null;
|
return Object.keys(permissions).length > 0 ? permissions : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,12 +106,25 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveNetwork(permissions: SystemPermissionSet | null) {
|
async function handleSaveNetwork(
|
||||||
|
permissions: SystemPermissionSet | null,
|
||||||
|
contactable?: boolean,
|
||||||
|
) {
|
||||||
if (target.type === "project") {
|
if (target.type === "project") {
|
||||||
await vm.saveProjectSystemPermissions(permissions);
|
await vm.saveProjectSystemPermissions(permissions);
|
||||||
} else {
|
} else {
|
||||||
|
const networkChanged =
|
||||||
|
(permissions?.network ?? "") !== (selectedAgent?.systemOverride?.network ?? "");
|
||||||
|
const contactableChanged =
|
||||||
|
contactable !== undefined &&
|
||||||
|
contactable !== (selectedAgent?.agent.contactable ?? false);
|
||||||
|
if (networkChanged) {
|
||||||
await vm.saveAgentSystemPermissions(target.agentId, permissions);
|
await vm.saveAgentSystemPermissions(target.agentId, permissions);
|
||||||
}
|
}
|
||||||
|
if (contactableChanged) {
|
||||||
|
await vm.saveAgentContactable(target.agentId, contactable);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -261,8 +270,11 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
showCrossProjectContactable={target.type === "agent"}
|
showCrossProjectContactable={target.type === "agent"}
|
||||||
|
contactable={selectedAgent?.agent.contactable ?? false}
|
||||||
busy={vm.busy}
|
busy={vm.busy}
|
||||||
onSave={(permissions) => void handleSaveNetwork(permissions)}
|
onSave={(permissions, contactable) =>
|
||||||
|
void handleSaveNetwork(permissions, contactable)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -335,8 +347,9 @@ interface NetworkPermissionEditorProps {
|
|||||||
inherited: SystemPermissionSet | null;
|
inherited: SystemPermissionSet | null;
|
||||||
resolved: ResolvedAgentSystemPermissions | null;
|
resolved: ResolvedAgentSystemPermissions | null;
|
||||||
showCrossProjectContactable: boolean;
|
showCrossProjectContactable: boolean;
|
||||||
|
contactable: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onSave: (permissions: SystemPermissionSet | null) => void;
|
onSave: (permissions: SystemPermissionSet | null, contactable?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NetworkPermissionEditor({
|
function NetworkPermissionEditor({
|
||||||
@ -345,12 +358,12 @@ function NetworkPermissionEditor({
|
|||||||
inherited,
|
inherited,
|
||||||
resolved,
|
resolved,
|
||||||
showCrossProjectContactable,
|
showCrossProjectContactable,
|
||||||
|
contactable,
|
||||||
busy,
|
busy,
|
||||||
onSave,
|
onSave,
|
||||||
}: NetworkPermissionEditorProps) {
|
}: NetworkPermissionEditorProps) {
|
||||||
const current = source?.network ?? "";
|
const current = source?.network ?? "";
|
||||||
const currentCrossProjectContactable =
|
const currentCrossProjectContactable = contactable;
|
||||||
source?.crossProjectContactable ?? false;
|
|
||||||
const [local, setLocal] = useState<NetworkPolicy | "">(current);
|
const [local, setLocal] = useState<NetworkPolicy | "">(current);
|
||||||
const [localCrossProjectContactable, setLocalCrossProjectContactable] =
|
const [localCrossProjectContactable, setLocalCrossProjectContactable] =
|
||||||
useState(currentCrossProjectContactable);
|
useState(currentCrossProjectContactable);
|
||||||
@ -484,7 +497,7 @@ function NetworkPermissionEditor({
|
|||||||
busy ||
|
busy ||
|
||||||
readOnly ||
|
readOnly ||
|
||||||
(!source?.network &&
|
(!source?.network &&
|
||||||
!(showCrossProjectContactable && source?.crossProjectContactable))
|
!(showCrossProjectContactable && currentCrossProjectContactable))
|
||||||
}
|
}
|
||||||
onClick={() => onSave(null)}
|
onClick={() => onSave(null)}
|
||||||
>
|
>
|
||||||
@ -498,10 +511,10 @@ function NetworkPermissionEditor({
|
|||||||
loading={busy && changed}
|
loading={busy && changed}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onSave(
|
onSave(
|
||||||
systemPermissionsFromDraft(
|
systemPermissionsFromDraft(local),
|
||||||
local,
|
showCrossProjectContactable
|
||||||
showCrossProjectContactable && localCrossProjectContactable,
|
? localCrossProjectContactable
|
||||||
),
|
: undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { McpToolCatalogue } from "@/domain";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presentational grouping of the MCP tool catalogue (ticket #82).
|
* 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). */
|
/** Domain groups, in display order (carnet #82 — Conception UX/F). */
|
||||||
export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
|
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",
|
id: "readProject",
|
||||||
label: "Lecture projet",
|
label: "Lecture projet",
|
||||||
@ -78,6 +91,11 @@ export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
|
|||||||
|
|
||||||
/** Short, human French label per exact MCP tool name. */
|
/** Short, human French label per exact MCP tool name. */
|
||||||
export const MCP_TOOL_LABELS: Record<string, string> = {
|
export const MCP_TOOL_LABELS: Record<string, string> = {
|
||||||
|
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_list_agents: "Lister les agents",
|
||||||
idea_context_read: "Lire le contexte d'un agent",
|
idea_context_read: "Lire le contexte d'un agent",
|
||||||
idea_memory_read: "Lire la mémoire projet",
|
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 {
|
export function mcpToolGroupId(tool: string): string | undefined {
|
||||||
return MCP_TOOL_GROUPS.find((group) => group.tools.includes(tool))?.id;
|
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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -103,7 +103,9 @@ describe("PermissionsPanel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("defaults cross-project contactability off and persists the agent checkbox", async () => {
|
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"));
|
fireEvent.click(screen.getByText("Builder"));
|
||||||
|
|
||||||
@ -117,16 +119,24 @@ describe("PermissionsPanel", () => {
|
|||||||
),
|
),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
|
||||||
|
await waitFor(() => expect(checkbox.disabled).toBe(false));
|
||||||
fireEvent.click(checkbox);
|
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(() => {
|
||||||
|
expect(contactableSpy).toHaveBeenCalledWith(
|
||||||
|
PROJECT_ID,
|
||||||
|
expect.any(String),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const doc = await permission.getProjectSystemPermissions(PROJECT_ID);
|
const agents = await agent.listAgents(PROJECT_ID);
|
||||||
expect(doc.agents?.[0]).toEqual({
|
expect(agents[0].contactable).toBe(true);
|
||||||
agentId: expect.any(String),
|
|
||||||
permissions: { crossProjectContactable: true },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
expect(systemSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the network control read-only when persisted control says so", async () => {
|
it("renders the network control read-only when persisted control says so", async () => {
|
||||||
|
|||||||
@ -54,6 +54,7 @@ export interface PermissionsViewModel {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
permissions: SystemPermissionSet | null,
|
permissions: SystemPermissionSet | null,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
saveAgentContactable: (agentId: string, contactable: boolean) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DRAFT: PolicyDraft = {
|
const DEFAULT_DRAFT: PolicyDraft = {
|
||||||
@ -342,6 +343,30 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
|||||||
[permission, projectId, refresh],
|
[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 {
|
return {
|
||||||
agents,
|
agents,
|
||||||
rows,
|
rows,
|
||||||
@ -357,5 +382,6 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
|||||||
clearAgentOverride,
|
clearAgentOverride,
|
||||||
saveProjectSystemPermissions,
|
saveProjectSystemPermissions,
|
||||||
saveAgentSystemPermissions,
|
saveAgentSystemPermissions,
|
||||||
|
saveAgentContactable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,16 @@ import remarkGfm from "remark-gfm";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
|
McpToolCatalogue,
|
||||||
McpToolPolicy,
|
McpToolPolicy,
|
||||||
NetworkPolicy,
|
NetworkPolicy,
|
||||||
SystemPermissionSet,
|
SystemPermissionSet,
|
||||||
Template,
|
Template,
|
||||||
} from "@/domain";
|
} 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 { Button, Input, SmallDropdown, cn } from "@/shared";
|
||||||
import { profileLabel } from "./templatePresentation";
|
import { profileLabel } from "./templatePresentation";
|
||||||
|
|
||||||
@ -18,6 +22,7 @@ export interface TemplateEditorSaveInput {
|
|||||||
content: string;
|
content: string;
|
||||||
defaultProfileId: string;
|
defaultProfileId: string;
|
||||||
defaultAgentName: string | null;
|
defaultAgentName: string | null;
|
||||||
|
defaultContactable: boolean;
|
||||||
systemPermissions: SystemPermissionSet | null;
|
systemPermissions: SystemPermissionSet | null;
|
||||||
mcpToolPermissions: McpToolPolicy | null;
|
mcpToolPermissions: McpToolPolicy | null;
|
||||||
}
|
}
|
||||||
@ -25,6 +30,7 @@ export interface TemplateEditorSaveInput {
|
|||||||
export interface TemplateEditorProps {
|
export interface TemplateEditorProps {
|
||||||
template?: Template | null;
|
template?: Template | null;
|
||||||
profiles: AgentProfile[];
|
profiles: AgentProfile[];
|
||||||
|
mcpCatalogue: McpToolCatalogue;
|
||||||
onSave: (input: TemplateEditorSaveInput) => Promise<void>;
|
onSave: (input: TemplateEditorSaveInput) => Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
@ -42,13 +48,10 @@ const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [
|
|||||||
const TEMPLATE_CROSS_PROJECT_HELP =
|
const TEMPLATE_CROSS_PROJECT_HELP =
|
||||||
"Les agents créés avec ce template reprendront cette permission.";
|
"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({
|
export function TemplateEditor({
|
||||||
template,
|
template,
|
||||||
profiles,
|
profiles,
|
||||||
|
mcpCatalogue,
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
busy = false,
|
busy = false,
|
||||||
@ -64,13 +67,21 @@ export function TemplateEditor({
|
|||||||
const [network, setNetwork] = useState<"" | NetworkPolicy>(
|
const [network, setNetwork] = useState<"" | NetworkPolicy>(
|
||||||
template?.systemPermissions?.network ?? "",
|
template?.systemPermissions?.network ?? "",
|
||||||
);
|
);
|
||||||
const [crossProjectContactable, setCrossProjectContactable] = useState(
|
const [defaultContactable, setDefaultContactable] = useState(
|
||||||
template?.systemPermissions?.crossProjectContactable ?? false,
|
template?.defaultContactable ?? false,
|
||||||
);
|
);
|
||||||
const [allowedTools, setAllowedTools] = useState<string[]>(
|
const [allowedTools, setAllowedTools] = useState<string[]>(
|
||||||
template?.mcpToolPermissions?.allowedTools ?? [],
|
template?.mcpToolPermissions?.allowedTools ?? [],
|
||||||
);
|
);
|
||||||
const [tab, setTab] = useState<EditorTab>("edit");
|
const [tab, setTab] = useState<EditorTab>("edit");
|
||||||
|
const toolGroups = useMemo(
|
||||||
|
() => mcpToolGroupsForCatalogue(mcpCatalogue),
|
||||||
|
[mcpCatalogue],
|
||||||
|
);
|
||||||
|
const allMcpTools = useMemo(
|
||||||
|
() => toolGroups.flatMap((group) => group.tools),
|
||||||
|
[toolGroups],
|
||||||
|
);
|
||||||
|
|
||||||
const allowed = useMemo(() => new Set(allowedTools), [allowedTools]);
|
const allowed = useMemo(() => new Set(allowedTools), [allowedTools]);
|
||||||
const canSave = name.trim().length > 0 && !busy;
|
const canSave = name.trim().length > 0 && !busy;
|
||||||
@ -91,15 +102,8 @@ export function TemplateEditor({
|
|||||||
content,
|
content,
|
||||||
defaultProfileId,
|
defaultProfileId,
|
||||||
defaultAgentName: defaultAgentName.trim() || null,
|
defaultAgentName: defaultAgentName.trim() || null,
|
||||||
systemPermissions:
|
defaultContactable,
|
||||||
network || crossProjectContactable
|
systemPermissions: network ? { network } : null,
|
||||||
? {
|
|
||||||
...(network ? { network } : {}),
|
|
||||||
...(crossProjectContactable
|
|
||||||
? { crossProjectContactable: true }
|
|
||||||
: {}),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
mcpToolPermissions:
|
mcpToolPermissions:
|
||||||
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
|
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
|
||||||
});
|
});
|
||||||
@ -275,8 +279,8 @@ export function TemplateEditor({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label="Contactable depuis d’autres projets"
|
aria-label="Contactable depuis d’autres projets"
|
||||||
checked={crossProjectContactable}
|
checked={defaultContactable}
|
||||||
onChange={(e) => setCrossProjectContactable(e.target.checked)}
|
onChange={(e) => setDefaultContactable(e.target.checked)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="mt-0.5 h-4 w-4 shrink-0 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
|
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({
|
|||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-faint">{allowedTools.length} autorisé(s)</span>
|
<span className="text-xs text-faint">{allowedTools.length} autorisé(s)</span>
|
||||||
</div>
|
</div>
|
||||||
{ALL_MCP_TOOLS.length === 0 ? (
|
{allMcpTools.length === 0 ? (
|
||||||
<p className="text-sm text-muted">Aucun tool MCP IdeA disponible.</p>
|
<p className="text-sm text-muted">Aucun tool MCP IdeA disponible.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -308,7 +312,7 @@ export function TemplateEditor({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{MCP_TOOL_GROUPS.map((group) => (
|
{toolGroups.map((group) => (
|
||||||
<div key={group.id} className="flex min-w-0 flex-col gap-2">
|
<div key={group.id} className="flex min-w-0 flex-col gap-2">
|
||||||
<h4 className="text-xs font-semibold text-content">{group.label}</h4>
|
<h4 className="text-xs font-semibold text-content">{group.label}</h4>
|
||||||
{group.tools.map((tool) => (
|
{group.tools.map((tool) => (
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { Button, Panel, Spinner } from "@/shared";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { TemplateEditor, type TemplateEditorSaveInput } from "./TemplateEditor";
|
import { TemplateEditor, type TemplateEditorSaveInput } from "./TemplateEditor";
|
||||||
@ -33,6 +33,11 @@ export interface TemplatesPanelProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_MCP_CATALOGUE: McpToolCatalogue = {
|
||||||
|
readOnlyTools: [],
|
||||||
|
writeActionTools: [],
|
||||||
|
};
|
||||||
|
|
||||||
/** Editor open-state: "create" or edit-mode for a specific template. */
|
/** Editor open-state: "create" or edit-mode for a specific template. */
|
||||||
type EditorState =
|
type EditorState =
|
||||||
| { mode: "create" }
|
| { mode: "create" }
|
||||||
@ -40,11 +45,12 @@ type EditorState =
|
|||||||
|
|
||||||
export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
|
export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
|
||||||
const vm = useTemplates();
|
const vm = useTemplates();
|
||||||
const { profile } = useGateways();
|
const { permission, profile } = useGateways();
|
||||||
|
|
||||||
// Profiles list for the editor's default-profile selector
|
// Profiles list for the editor's default-profile selector
|
||||||
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||||
const [profilesLoaded, setProfilesLoaded] = useState(false);
|
const [profilesLoaded, setProfilesLoaded] = useState(false);
|
||||||
|
const [mcpCatalogue, setMcpCatalogue] = useState<McpToolCatalogue | null>(null);
|
||||||
|
|
||||||
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
||||||
const [editorBusy, setEditorBusy] = useState(false);
|
const [editorBusy, setEditorBusy] = useState(false);
|
||||||
@ -69,6 +75,25 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
|
|||||||
};
|
};
|
||||||
}, [profile, profilesLoaded]);
|
}, [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. */
|
/** Lazily load profiles when the editor is first opened. */
|
||||||
async function openEditor(state: EditorState) {
|
async function openEditor(state: EditorState) {
|
||||||
if (!profilesLoaded) {
|
if (!profilesLoaded) {
|
||||||
@ -105,6 +130,7 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
|
|||||||
<TemplateEditor
|
<TemplateEditor
|
||||||
template={editorState.mode === "edit" ? editorState.template : null}
|
template={editorState.mode === "edit" ? editorState.template : null}
|
||||||
profiles={profiles}
|
profiles={profiles}
|
||||||
|
mcpCatalogue={mcpCatalogue ?? EMPTY_MCP_CATALOGUE}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onClose={() => setEditorState(null)}
|
onClose={() => setEditorState(null)}
|
||||||
busy={editorBusy}
|
busy={editorBusy}
|
||||||
|
|||||||
@ -17,7 +17,12 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
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 type { Gateways } from "@/ports";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { TemplatesPanel } from "./TemplatesPanel";
|
import { TemplatesPanel } from "./TemplatesPanel";
|
||||||
@ -33,6 +38,7 @@ interface RenderOpts {
|
|||||||
agent?: MockAgentGateway;
|
agent?: MockAgentGateway;
|
||||||
template?: MockTemplateGateway;
|
template?: MockTemplateGateway;
|
||||||
profile?: MockProfileGateway;
|
profile?: MockProfileGateway;
|
||||||
|
permission?: MockPermissionGateway;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,11 +49,13 @@ function renderTemplatesPanel(opts: RenderOpts = {}) {
|
|||||||
const agent = opts.agent ?? new MockAgentGateway();
|
const agent = opts.agent ?? new MockAgentGateway();
|
||||||
const tmpl = opts.template ?? new MockTemplateGateway(agent);
|
const tmpl = opts.template ?? new MockTemplateGateway(agent);
|
||||||
const profile = opts.profile ?? new MockProfileGateway();
|
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 {
|
return {
|
||||||
agent,
|
agent,
|
||||||
tmpl,
|
tmpl,
|
||||||
profile,
|
profile,
|
||||||
|
permission,
|
||||||
...render(
|
...render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<TemplatesPanel projectId={PROJECT_ID} />
|
<TemplatesPanel projectId={PROJECT_ID} />
|
||||||
@ -187,11 +195,40 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
|||||||
expect(templates[0]).toMatchObject({
|
expect(templates[0]).toMatchObject({
|
||||||
defaultAgentName: "Main",
|
defaultAgentName: "Main",
|
||||||
defaultProfileId: "profile-main",
|
defaultProfileId: "profile-main",
|
||||||
systemPermissions: { network: "allow", crossProjectContactable: true },
|
defaultContactable: true,
|
||||||
|
systemPermissions: { network: "allow" },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
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 () => {
|
it("defaults template cross-project contactability off when the field is absent", async () => {
|
||||||
renderTemplatesPanel();
|
renderTemplatesPanel();
|
||||||
await waitForTemplatesIdle();
|
await waitForTemplatesIdle();
|
||||||
@ -211,6 +248,28 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
|||||||
).toBeTruthy();
|
).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 () => {
|
it("the Create button is disabled when the name is empty", async () => {
|
||||||
renderTemplatesPanel();
|
renderTemplatesPanel();
|
||||||
await waitForTemplatesIdle();
|
await waitForTemplatesIdle();
|
||||||
|
|||||||
@ -271,6 +271,12 @@ export interface AgentGateway {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
effort: EffortSelection | null,
|
effort: EffortSelection | null,
|
||||||
): Promise<Agent>;
|
): Promise<Agent>;
|
||||||
|
/** Changes whether this agent may be contacted from another project. */
|
||||||
|
updateAgentContactable(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
contactable: boolean,
|
||||||
|
): Promise<Agent>;
|
||||||
/** 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. */
|
||||||
@ -630,6 +636,7 @@ export interface CreateTemplateInput {
|
|||||||
content: string;
|
content: string;
|
||||||
defaultProfileId: string;
|
defaultProfileId: string;
|
||||||
defaultAgentName?: string | null;
|
defaultAgentName?: string | null;
|
||||||
|
defaultContactable?: boolean;
|
||||||
systemPermissions?: SystemPermissionSet | null;
|
systemPermissions?: SystemPermissionSet | null;
|
||||||
mcpToolPermissions?: McpToolPolicy | null;
|
mcpToolPermissions?: McpToolPolicy | null;
|
||||||
}
|
}
|
||||||
@ -640,6 +647,7 @@ export interface UpdateTemplateInput {
|
|||||||
content: string;
|
content: string;
|
||||||
defaultProfileId: string;
|
defaultProfileId: string;
|
||||||
defaultAgentName?: string | null;
|
defaultAgentName?: string | null;
|
||||||
|
defaultContactable?: boolean;
|
||||||
systemPermissions?: SystemPermissionSet | null;
|
systemPermissions?: SystemPermissionSet | null;
|
||||||
mcpToolPermissions?: McpToolPolicy | null;
|
mcpToolPermissions?: McpToolPolicy | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user