diff --git a/.ideai/agents/git.md b/.ideai/agents/git.md index f27b4041..9b132386 100644 --- a/.ideai/agents/git.md +++ b/.ideai/agents/git.md @@ -94,9 +94,10 @@ tu le dis. ## 4. Conventions -- **Messages de commit** : en **français**, style Conventional Commits cohérent avec +- **Messages de commit** : en **anglais**, style Conventional Commits cohérent avec l'historique : `feat(scope): …`, `fix(scope): …`, `chore(scope): …`, `docs(scope): …`, - `refactor(scope): …`. Corps multi-ligne expliquant le **pourquoi** quand utile. + `refactor(scope): …`. Le ou les tickets sur lesquels portent le commit. + Corps multi-ligne expliquant le **pourquoi** quand utile. - **Atomicité** : un commit = une intention cohérente. Tu sépares le code de feature de l'état runtime (`.ideai/` conversations, layouts, manifestes) et des docs. - **Co-author** : termine les messages de commit par diff --git a/.ideai/agents/main.md b/.ideai/agents/main.md index b6cdd443..4043b02d 100644 --- a/.ideai/agents/main.md +++ b/.ideai/agents/main.md @@ -104,9 +104,4 @@ Si une réflexion est récurrente et aboutit toujours à la même solution, cré Si aucun ticket n'existe pour la demande de l'utilisateur, créé le. S'il existe, réouvre le. Mets à jour le status des tickets que tu traites: in progress, closed, QA. Mets a jour le carnet des tickets que tu traites en cas de futur reprise de ce ticket pour éviter -de boucler plusieurs fois sur les memes soucis. - -A chaque build de l'appimage, j'aimerais que tu demandes à Git d'incrémenter la version, par le tag, -du commit sur lequel à été faite l'appimage sous cette forme: vx.y.z (donc par exemple v1.0.0). -Incrémente x pour chaque build faite sur la branche main,y pour chaque build faite sur la branche develop -et z pour chaque build faite sur une autre branche. \ No newline at end of file +de boucler plusieurs fois sur les memes soucis. \ No newline at end of file diff --git a/.ideai/agents/ux.md b/.ideai/agents/ux.md index a9a565f3..fb602d30 100644 --- a/.ideai/agents/ux.md +++ b/.ideai/agents/ux.md @@ -101,5 +101,3 @@ Ta place dans le cycle dépend de l'enjeu : - Avec **Architect** : dis explicitement ce que la surface exige de la donnée. Si sa contrainte technique borne ta conception, propose une alternative plutôt que de concéder en silence. -- En fonction des modifications à apporter, tu soulignes la necessité de répliquer les modifications - UI sur les interfaces Web PC et Web téléphone diff --git a/.ideai/tickets/117/carnet.md b/.ideai/tickets/117/carnet.md index 67f9ab6f..c03d4864 100644 --- a/.ideai/tickets/117/carnet.md +++ b/.ideai/tickets/117/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#117" -version: 12 -updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1787336067149 +version: 13 +updatedBy: {"kind":"user"} +updatedAt: 1788789622644 --- 2026-08-21 Main - Ticket rouvert pour livraison effective sur la branche backend dédiée `feature/117-ask-agent-sync-background-tasks` depuis `develop` @ `c1d7f6d`. diff --git a/.ideai/tickets/117/issue.md b/.ideai/tickets/117/issue.md index 5ac49ae9..f8f779f3 100644 --- a/.ideai/tickets/117/issue.md +++ b/.ideai/tickets/117/issue.md @@ -2,17 +2,17 @@ id: "66e80f94-780b-4856-806d-ba4b96673630" number: 117 title: "Garantir la synchronie métier de idea_ask_agent malgré les background tasks" -status: "inProgress" +status: "closed" priority: "critical" sprint: null links: [] agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}] attachments: [] createdBy: {"kind":"user"} -updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"user"} createdAt: 1785500280516 -updatedAt: 1787336067149 -version: 12 +updatedAt: 1788789622644 +version: 13 --- Constat : quand un agent `A` délègue une demande à un agent `B` via `idea_ask_agent`, `B` peut lancer une background task puis terminer son tour avant que cette tâche ne finisse. Dans ce cas, `A` peut recevoir une pseudo-réponse terminale trop tôt, alors que le travail réel n'est pas terminé. diff --git a/.ideai/tickets/155/carnet.md b/.ideai/tickets/155/carnet.md index 389abe3c..33fff73f 100644 --- a/.ideai/tickets/155/carnet.md +++ b/.ideai/tickets/155/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#155" -version: 13 -updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1786346556578 +version: 14 +updatedBy: {"kind":"user"} +updatedAt: 1788789622554 --- ## 2026-08-06 — Reopened from user validation on AppImage - User reports that clipboard paste in the custom CLI chat still does not work in practice. diff --git a/.ideai/tickets/155/issue.md b/.ideai/tickets/155/issue.md index de487567..aa8a2a98 100644 --- a/.ideai/tickets/155/issue.md +++ b/.ideai/tickets/155/issue.md @@ -2,16 +2,16 @@ id: "620bc08f-835c-483c-a9f1-59690ebf8ab5" number: 155 title: "Custom chat: paste image/fichier depuis le clipboard dans le composer" -status: "inProgress" +status: "closed" priority: "medium" sprint: null links: [{"target":"#154","kind":"dependsOn"}] agentRefs: [{"agentId":"8f7da528-58df-4315-97e9-0562230ecc19","role":"assigned"},{"agentId":"fe887179-933f-47d4-960f-c3b06827f86c","role":"assigned"}] attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"user"} createdAt: 1785964740363 -updatedAt: 1786346556578 -version: 13 +updatedAt: 1788789622554 +version: 14 --- Demande utilisateur requalifiée le 2026-08-06: la custom CLI ne permet toujours pas de coller un fichier dans la barre de chat, comme dans une TUI native Claude Code ou Codex. Le scope ne doit pas rester limité au seul collage d'image: il faut couvrir au minimum les fichiers/images exposés par le clipboard et les convertir en attachments utilisables dans le composer custom, via le pipeline d'attachments existant. \ No newline at end of file diff --git a/.ideai/tickets/185/carnet.md b/.ideai/tickets/185/carnet.md index 15d2d5a4..3a655f70 100644 --- a/.ideai/tickets/185/carnet.md +++ b/.ideai/tickets/185/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#185" -version: 13 -updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1786709806993 +version: 14 +updatedBy: {"kind":"user"} +updatedAt: 1788789622659 --- 2026-08-13 - Constaté depuis Main que `.ideai/permissions.json` est déjà en `allow` global sur read/write/delete/executeBash. diff --git a/.ideai/tickets/185/issue.md b/.ideai/tickets/185/issue.md index db60a7a7..407e06e9 100644 --- a/.ideai/tickets/185/issue.md +++ b/.ideai/tickets/185/issue.md @@ -2,16 +2,16 @@ id: "3eea8ad2-e8c5-4a1a-881e-dbf129aa60b0" number: 185 title: "Codex structured sandbox: rétablir l'écriture du project root pour les agents Dev sans perdre l'accès à .git" -status: "inProgress" +status: "closed" priority: "critical" sprint: null links: [] agentRefs: [{"agentId":"b4730d7f-c54d-4736-8a04-c6203aa2fd49","role":"assigned"},{"agentId":"fe887179-933f-47d4-960f-c3b06827f86c","role":"assigned"}] attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"user"} createdAt: 1786606428812 -updatedAt: 1786709806993 -version: 13 +updatedAt: 1788789622659 +version: 14 --- L'UI expose une policy Allow/Write globale, mais les sessions structurées Codex semblent recevoir un runtime dont les writable_roots sont limités aux racines Git enrichies. Les tests backend actuels verrouillent même que le project root ne soit pas ajouté comme writable root. Impact: Main et potentiellement DevBackend/DevFrontend ne peuvent plus modifier le code applicatif malgré une UI permissive. Objectif: réaligner la projection structured Codex et/ou le plan OS sandbox avec la policy UI voulue, en conservant l'accès Git ajouté récemment. \ No newline at end of file diff --git a/.ideai/tickets/196/carnet.md b/.ideai/tickets/196/carnet.md index edd34aea..90a109de 100644 --- a/.ideai/tickets/196/carnet.md +++ b/.ideai/tickets/196/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#196" -version: 6 -updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1786709807009 +version: 7 +updatedBy: {"kind":"user"} +updatedAt: 1788789622674 --- 2026-08-14 - Bug utilisateur: les grants accordes dans la page Permissions ne donnent toujours pas l'ecriture au root du projet dans la nouvelle AppImage. diff --git a/.ideai/tickets/196/issue.md b/.ideai/tickets/196/issue.md index dcfabb22..4090e3eb 100644 --- a/.ideai/tickets/196/issue.md +++ b/.ideai/tickets/196/issue.md @@ -2,16 +2,16 @@ id: "c8ee4aa6-e301-4002-af26-7ca4e537d77a" number: 196 title: "Permissions page grants do not allow agent write access to project root" -status: "inProgress" +status: "closed" priority: "high" sprint: null links: [{"target":"#185","kind":"relatesTo"}] agentRefs: [] attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"user"} createdAt: 1786704189602 -updatedAt: 1786709807009 -version: 6 +updatedAt: 1788789622674 +version: 7 --- Malgre le fix precedent et une nouvelle AppImage, accorder les permissions dans la page Permissions ne donne toujours pas aux agents l'acces en ecriture au root du projet. Attendu: quand les permissions correspondantes sont accordees dans la page Permissions, les agents doivent pouvoir ecrire dans le root du projet et ses chemins pertinents selon le grant. \ No newline at end of file diff --git a/.ideai/tickets/243/carnet.md b/.ideai/tickets/243/carnet.md index d638738f..7e23cc31 100644 --- a/.ideai/tickets/243/carnet.md +++ b/.ideai/tickets/243/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#243" -version: 3 +version: 7 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1787476334103 +updatedAt: 1788439323308 --- 2026-08-23 - Rebuild AppImage demandé par l’utilisateur. @@ -11,4 +11,14 @@ updatedAt: 1787476334103 - Bundle Tauri AppImage OK depuis `crates/app-tauri` avec `CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage`. - Artefact produit: `/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage` (114M constatés à 11:10 le 2026-08-23). - Git a décidé puis posé le tag local `v0.27.0` sur le commit `24c237b` de `develop` après build réussi, conformément à la règle de bump mineur sur `develop`. -- Point d’attention Git: après le build/tag, deux commits `chore` sans impact code sur l’AppImage buildée ont été ajoutés sur `develop`; le tag reste volontairement sur le commit buildé `24c237b`. \ No newline at end of file +- Point d’attention Git: après le build/tag, deux commits `chore` sans impact code sur l’AppImage buildée ont été ajoutés sur `develop`; le tag reste volontairement sur le commit buildé `24c237b`. + +2026-09-03 +- Rebuild AppImage redemandé par l’utilisateur. +- Skill `build-appimage` relu avant exécution. +- Git a inspecté `develop` HEAD `d7b24ad` et préparé le prochain tag local `v0.39.0` à poser sur ce commit si le build réussit. +- Build relancé avec shell propre (pollution AppImage évitée via unset `PYTHONHOME`/`PYTHONPATH`). +- Frontend build OK: `env -u PYTHONHOME -u PYTHONPATH npm --prefix frontend run build`. +- Bundle Tauri AppImage OK: `env -u PYTHONHOME -u PYTHONPATH bash -lc 'mkdir -p /tmp/idea-cargo-home && cd crates/app-tauri && CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage'`. +- Artefact produit: `/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage` (114M constatés à 14:41 le 2026-09-03). +- Git a posé le tag local annoté `v0.39.0` sur `d7b24ad`; `git tag --points-at d7b24ad` retourne `v0.39.0`. Rien poussé. diff --git a/.ideai/tickets/243/issue.md b/.ideai/tickets/243/issue.md index c2b07f53..88389dba 100644 --- a/.ideai/tickets/243/issue.md +++ b/.ideai/tickets/243/issue.md @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1787475935659 -updatedAt: 1787476334103 -version: 3 +updatedAt: 1788439323308 +version: 7 --- Reconstruction demandée de l’AppImage Linux IdeA à partir de l’état courant du dépôt, en suivant le workflow validé projet. Le tag Git local de version du commit buildé doit être incrémenté selon la règle projet: x sur main, y sur develop, z sur toute autre branche. \ No newline at end of file diff --git a/.ideai/tickets/259/carnet.md b/.ideai/tickets/259/carnet.md index 0a43d925..33b0d391 100644 --- a/.ideai/tickets/259/carnet.md +++ b/.ideai/tickets/259/carnet.md @@ -1,13 +1,15 @@ --- issueRef: "#259" -version: 3 +version: 5 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1788438656514 +updatedAt: 1788438757480 --- 2026-09-03 Main - Bug reproduit/qualifié: les permissions MCP d’un template semblent perdues après save puis réouverture. - UX: pas de redesign requis; l’éditeur doit refléter l’état réellement sauvegardé. - Architect: pas de changement de contrat large; vérifier le mapping wire alias/DTO. -- Git: a prescrit `fix/259-template-permissions-reopen` depuis `develop`, mais DevFrontend n’a pas pu créer la branche ici (`.git/index.lock: Read-only file system`). Modifs laissées localement, non pushées/non commit. -- DevFrontend: cause réelle = désalignement de normalisation frontend sur les réponses template (`defaultMcpToolPermissions`/`defaultSystemPermissions` backend non remappés vers `mcpToolPermissions`/`systemPermissions` consommés par l’UI à la réouverture). Correctif local livré avec test de régression adapter + vérifications ciblées vertes. -- En attente: verdict QA réel sur l’état local actuel, puis Git pour commit/merge si vert. +- Git: a d’abord prescrit `fix/259-template-permissions-reopen` depuis `develop`. DevFrontend n’a pas pu créer la branche dans son sandbox (`.git/index.lock: Read-only file system`), mais Git a ensuite exécuté la topologie git locale depuis sa propre session. +- DevFrontend: cause réelle = désalignement de normalisation frontend sur les réponses template (`defaultMcpToolPermissions`/`defaultSystemPermissions` backend non remappés vers `mcpToolPermissions`/`systemPermissions` consommés par l’UI à la réouverture). Correctif livré avec test de régression adapter. +- QA: vert sur l’état local actuel avec `npm test -- --run src/adapters/http/httpInvoker.test.ts src/features/templates/templates.test.tsx` et `npm run typecheck`. +- Git: a créé `fix/259-template-permissions-reopen`, commit de fix `4d34ade`, merge local `cd8bca9` dans `develop`, puis commit séparé du store tickets `d7b24ad`. Rien poussé. +- Résultat: ticket corrigé, validé QA, mergé localement dans `develop`. diff --git a/.ideai/tickets/259/issue.md b/.ideai/tickets/259/issue.md index d21099d8..d53825dc 100644 --- a/.ideai/tickets/259/issue.md +++ b/.ideai/tickets/259/issue.md @@ -2,7 +2,7 @@ id: "0c7d8e1b-bf25-4620-9eaa-36e545388056" number: 259 title: "Template permissions do not persist after save" -status: "qa" +status: "closed" priority: "high" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1788438150527 -updatedAt: 1788438656514 -version: 3 +updatedAt: 1788438757480 +version: 5 --- Dans l’éditeur de template, les permissions ajoutées semblent disparaître après sauvegarde : en revenant sur le template, les cases sont vides. Corriger la persistance/relance de l’état des permissions des templates. \ No newline at end of file diff --git a/.ideai/tickets/260/carnet.md b/.ideai/tickets/260/carnet.md new file mode 100644 index 00000000..21c26856 --- /dev/null +++ b/.ideai/tickets/260/carnet.md @@ -0,0 +1,17 @@ +--- +issueRef: "#260" +version: 8 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1788791628015 +--- +# Carnet #260 + +- Ticket principal cree pour la feature agents cross-project. +- Sous-tickets: #261 UX closed, #262 Architecture closed, #263 Backend QA, #264 Frontend QA, #265 QA/AppImage inProgress. +- UX #261 rendu: checkbox `Contactable depuis d’autres projets` dans Permissions agent et template, defaut decoche, texte d'aide distinct agent/template, erreur sauvegarde `La permission n’a pas pu être enregistrée. Réessayez.` +- Architect #262 rendu: identite canonique `projectId + agentId`, DTO MCP `idea_project_list`, `idea_project_read_context`, `idea_project_list_agents`, `idea_project_ask_agent`, `idea_project_cancel_agent`; runtime minimal projet ferme; reconciliation ouverture projet; queue/busy/cancel indexes par `RuntimeAgentKey(projectId, agentId)`; routage par correlationId requester/target project-scoped. +- Git: branche root `feature/260-cross-project-agents`; worktrees devs realignes; commits de lot faits puis merges locaux root sans conflit. +- Backend: commit `9895498` sur `feature/260-backend`, merge root `530ce01`; tests Main verts: `cargo fmt --check`, `cargo check -p application -p infrastructure -p backend -p app-tauri --lib`, `cargo test -p application cross_project --test orchestrator_service`, `cargo test -p infrastructure --test mcp_server`, `cargo test -p application --test template_usecases`, `cargo test -p infrastructure --test template_store`. +- Frontend: commit `8496507` sur `feature/260-frontend`, merge root `b0f8fe4`; DevFrontend annonce verts: `npm run typecheck`, `npm test -- src/features/permissions/permissions.test.tsx src/features/templates/templates.test.tsx src/adapters/http/httpInvoker.test.ts`, 48 passed. +- Git verification root apres merge: `npm run typecheck` vert + `cargo +stable check -p application -p infrastructure -p backend -p app-tauri --lib` vert. +- Etat actuel: pret pour QA complete sur branche integree; aucun push, aucun merge develop/main. Dirty restant attendu: store `.ideai/`/tickets/scripts non lies au code produit. \ No newline at end of file diff --git a/.ideai/tickets/260/issue.md b/.ideai/tickets/260/issue.md new file mode 100644 index 00000000..672a233a --- /dev/null +++ b/.ideai/tickets/260/issue.md @@ -0,0 +1,17 @@ +--- +id: "1f6e430c-d7ca-49cb-8eac-dc885aa593ce" +number: 260 +title: "Agents cross-project: MCP discovery, permissions et contact inter-projets" +status: "inProgress" +priority: "critical" +sprint: null +links: [] +agentRefs: [{"agentId":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d","role":"assigned"}] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1788773711834 +updatedAt: 1788791628015 +version: 8 +--- +Feature demandée: permettre à un agent IdeA de découvrir les projets IdeA, lire leur contexte, lister les agents explicitement contactables d’un projet avec leur contexte, puis contacter un agent d’un autre projet via MCP avec routage fiable de la réponse vers le bon agent et le bon projet. Contraintes clés: lancer un agent même si son projet n’est pas ouvert; si le projet est ouvert ensuite, l’agent doit être considéré comme déjà lancé; gérer busy/cancel/rendez-vous comme idea_ask_agent; désambiguïser les agents homonymes par projectId + agentId; exposer une permission par agent persistée aussi dans les templates. \ No newline at end of file diff --git a/.ideai/tickets/261/carnet.md b/.ideai/tickets/261/carnet.md new file mode 100644 index 00000000..9c6e45db --- /dev/null +++ b/.ideai/tickets/261/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#261" +version: 2 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1788774465904 +--- diff --git a/.ideai/tickets/261/issue.md b/.ideai/tickets/261/issue.md new file mode 100644 index 00000000..a37f541f --- /dev/null +++ b/.ideai/tickets/261/issue.md @@ -0,0 +1,17 @@ +--- +id: "5bbf6483-1dfa-4150-90c9-49f8e03f16e8" +number: 261 +title: "Cross-project agents: cadrage UX permission agent/template" +status: "closed" +priority: "high" +sprint: null +links: [{"target":"#260","kind":"dependsOn"}] +agentRefs: [{"agentId":"757430bb-014a-40fa-b82b-f2429dab9766","role":"assigned"}] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1788773730333 +updatedAt: 1788774465904 +version: 2 +--- +Définir la surface UI pour rendre un agent contactable depuis d’autres projets IdeA. Probable ajout d’une checkbox dans les permissions d’agent, avec libellés, comportement dans les templates, indication dans les listes d’agents contactables si nécessaire. Doit rester cohérent avec les permissions existantes et persister dans les templates. \ No newline at end of file diff --git a/.ideai/tickets/262/carnet.md b/.ideai/tickets/262/carnet.md new file mode 100644 index 00000000..3b2b88bf --- /dev/null +++ b/.ideai/tickets/262/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#262" +version: 2 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1788774465928 +--- diff --git a/.ideai/tickets/262/issue.md b/.ideai/tickets/262/issue.md new file mode 100644 index 00000000..2aaa73fc --- /dev/null +++ b/.ideai/tickets/262/issue.md @@ -0,0 +1,17 @@ +--- +id: "94c6e01a-4efc-479a-9b92-d75153160390" +number: 262 +title: "Cross-project agents: architecture MCP, registry projets et rendez-vous" +status: "closed" +priority: "critical" +sprint: null +links: [{"target":"#260","kind":"dependsOn"}] +agentRefs: [{"agentId":"b4730d7f-c54d-4736-8a04-c6203aa2fd49","role":"assigned"}] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1788773730351 +updatedAt: 1788774465928 +version: 2 +--- +Cadrer les contrats backend et runtime pour: lister les projets IdeA avec contexte; lister les agents contactables d’un projet avec contexte; contacter un agent via projectId + agentId + message; lancer/reattacher un agent dans un projet fermé; réconcilier l’état lorsque le projet est ouvert ensuite; gérer busy/cancel/rendez-vous et routage de réponse vers le bon demandeur; éviter toute ambiguïté d’agents homonymes. \ No newline at end of file diff --git a/.ideai/tickets/263/carnet.md b/.ideai/tickets/263/carnet.md new file mode 100644 index 00000000..f87b5b9d --- /dev/null +++ b/.ideai/tickets/263/carnet.md @@ -0,0 +1,29 @@ +--- +issueRef: "#263" +version: 14 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1788792738864 +--- +# Carnet #263 + +Contexte implementation backend issu de #262: +- Identite canonique cross-project: `CrossProjectAgentRef { projectId, agentId }`; ne jamais router par nom. +- Tools MCP additifs: `idea_project_list`, `idea_project_read_context`, `idea_project_list_agents`, `idea_project_ask_agent`, `idea_project_cancel_agent`. +- Runtime target et busy/cancel indexes par `RuntimeAgentKey(projectId, agentId)`. +- Permissions: agent contactable cross-project par projet, templates avec default materialise a la creation d'agent. + +Git: +- Lot initial committe par Git: `9895498` sur `feature/260-backend`, merge root `530ce01` dans `feature/260-cross-project-agents`. + +Corrections QA: +- Homonymes ask/cancel: `crates/application/src/orchestrator/service.rs` route via l'Agent resolu par `agentId`, sans repasser par nom; `idea_project_cancel_agent` stoppe via `agentId`. +- App-tauri fixture: `crates/app-tauri/src/templates.rs` corrige `with_metadata(...)` avec `default_contactable`. +- Provider template update: `crates/domain/src/template.rs` serialise toujours `default_contactable`, y compris `false`, pour renvoyer `defaultContactable: false` au lieu de Null. + +Tests verts connus: +- `cargo test -p application cross_project_ --test orchestrator_service -- --nocapture` (5 passed) +- `cargo test -p app-tauri --test dto_templates -- --nocapture` (22 passed) +- `cargo test -p app-tauri template_provider_crud_round_trips_and_update_publishes_event -- --nocapture` (1 passed) +- `cargo fmt --check` +- `cargo check -p application -p infrastructure -p backend -p app-tauri --lib` +- `cargo check -p domain -p app-tauri --lib` \ No newline at end of file diff --git a/.ideai/tickets/263/issue.md b/.ideai/tickets/263/issue.md new file mode 100644 index 00000000..bfd03124 --- /dev/null +++ b/.ideai/tickets/263/issue.md @@ -0,0 +1,17 @@ +--- +id: "b31676b7-ca52-4273-b00d-c0c46cb6c8ba" +number: 263 +title: "Cross-project agents: implémentation backend MCP et runtime" +status: "closed" +priority: "critical" +sprint: null +links: [{"target":"#260","kind":"dependsOn"}] +agentRefs: [{"agentId":"fe887179-933f-47d4-960f-c3b06827f86c","role":"assigned"}] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1788773730400 +updatedAt: 1788792738864 +version: 14 +--- +Implémenter les tools MCP cross-project et la mécanique runtime validée par Architect: discovery projets/contextes, discovery agents contactables/contextes, contact agent cross-project, permission persistée, launch projet fermé, reconciliation à l’ouverture, routage project-scopé, busy/cancel. Inclure tests backend ciblés. \ No newline at end of file diff --git a/.ideai/tickets/264/carnet.md b/.ideai/tickets/264/carnet.md new file mode 100644 index 00000000..54cbd4b0 --- /dev/null +++ b/.ideai/tickets/264/carnet.md @@ -0,0 +1,25 @@ +--- +issueRef: "#264" +version: 8 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1788792738961 +--- +# Carnet #264 + +Contexte UI issu de #261: + +- Ajouter dans section `Permissions` agent une checkbox `Contactable depuis d’autres projets`. +- Texte aide agent: `Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte.` +- Dans editeur de template agent, meme checkbox; texte aide template: `Les agents créés avec ce template reprendront cette permission.` +- Defaut decoche pour valeurs absentes/anciens templates/nouveaux agents. +- Creation depuis template: copier la valeur du template; modifier un template ne change pas retroactivement les agents existants. +- Erreur sauvegarde: `La permission n’a pas pu être enregistrée. Réessayez.` +- Lecture impossible: permission decochee visuellement desactivee avec `Permission indisponible pour le moment.` + +Git: +- Worktree frontend dedie: `/home/anthony/Documents/Projects/IdeA/.ideai/run/8f7da528-58df-4315-97e9-0562230ecc19/worktree` sur branche `feature/260-frontend`, propre apres stash Git `pre-260 leftovers frontend`. + +Blocage: +- Les tours DevFrontend echouent avant generation avec erreur Codex 400: `gpt-5.6` non supporte. +- Cause source: profil global `e9c0b04e-6506-498e-8a4a-f3713b87583d` dans `/home/anthony/.local/share/app.idea.ide/profiles.json` reste `model: gpt-5.6`. +- Pas de diff frontend produit pour #264. \ No newline at end of file diff --git a/.ideai/tickets/264/issue.md b/.ideai/tickets/264/issue.md new file mode 100644 index 00000000..8ff095c4 --- /dev/null +++ b/.ideai/tickets/264/issue.md @@ -0,0 +1,17 @@ +--- +id: "f9559bb0-47d4-43ef-99ce-1d81b9b8f5de" +number: 264 +title: "Cross-project agents: UI permissions agents et templates" +status: "closed" +priority: "high" +sprint: null +links: [{"target":"#260","kind":"dependsOn"}] +agentRefs: [{"agentId":"8f7da528-58df-4315-97e9-0562230ecc19","role":"assigned"}] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1788773730439 +updatedAt: 1788792738961 +version: 8 +--- +Implémenter la surface frontend validée par UX et les contrats fournis par Architect/DevBackend pour la permission contactable depuis d’autres projets, sur agents et templates. Inclure tests frontend ciblés. \ No newline at end of file diff --git a/.ideai/tickets/265/carnet.md b/.ideai/tickets/265/carnet.md new file mode 100644 index 00000000..c46efe32 --- /dev/null +++ b/.ideai/tickets/265/carnet.md @@ -0,0 +1,28 @@ +--- +issueRef: "#265" +version: 5 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1788792738991 +--- +# Carnet #265 + +QA finale sur `feature/260-cross-project-agents`: VERT. + +Commandes QA vertes: +- `cargo test -p application cross_project_ --test orchestrator_service -- --nocapture`: 5 passed, incluant homonymes ask/cancel et filtre contactable. +- `cargo test -p app-tauri --test dto_templates -- --nocapture`: 22 passed. +- `cargo test -p app-tauri template_provider_crud_round_trips_and_update_publishes_event -- --nocapture`: 1 passed. +- `cargo test -p infrastructure project_tool -- --nocapture`: 4 passed. +- `cargo test -p infrastructure --test mcp_server tools_list_advertises_the_idea_tools_with_schemas -- --nocapture`: 1 passed. +- `npm run typecheck`: vert. +- `npm test -- src/features/permissions/permissions.test.tsx src/features/templates/templates.test.tsx src/features/workstate/workstate.test.tsx src/adapters/http/httpInvoker.test.ts`: 4 files passed, 71 tests passed. +- `cargo fmt --check`: exit 0. + +Rouges corriges: +- Homonymes `idea_project_ask_agent` / `idea_project_cancel_agent` ne reroutent plus par nom apres resolution UUID. +- `dto_templates` app-tauri adapte a `default_contactable`. +- `idea_template_update` provider retourne `defaultContactable: false` au lieu de Null. + +Gap/risque accepte: pas de test reel multi-process/projet ferme/reconciliation; validation par tests Rust cibles, fakes applicatifs, catalogue MCP, DTO/provider template et UI frontend. + +Etape suivante: Git commit/merge local puis build AppImage. \ No newline at end of file diff --git a/.ideai/tickets/265/issue.md b/.ideai/tickets/265/issue.md new file mode 100644 index 00000000..658ea415 --- /dev/null +++ b/.ideai/tickets/265/issue.md @@ -0,0 +1,17 @@ +--- +id: "e7e283b0-e774-415f-8260-ac649dc38a32" +number: 265 +title: "Cross-project agents: validation QA complète et AppImage" +status: "inProgress" +priority: "critical" +sprint: null +links: [{"target":"#260","kind":"dependsOn"}] +agentRefs: [{"agentId":"ab328d90-c307-4771-a3b6-6c56089c8506","role":"assigned"}] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1788773730479 +updatedAt: 1788792738991 +version: 5 +--- +Valider la feature cross-project de bout en bout: tools MCP de discovery, agents contactables, contact cross-project avec projets fermés/ouverts, homonymes, busy/cancel/routage réponse, persistence permissions agents/templates, réconciliation à l’ouverture d’un projet. Puis valider le build AppImage produit. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index b671ae42..78649f54 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 260 + "nextNumber": 266 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 95fe0eb7..f15bf712 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -1487,7 +1487,7 @@ "issueRef": "#117", "path": "117", "title": "Garantir la synchronie métier de idea_ask_agent malgré les background tasks", - "status": "inProgress", + "status": "closed", "priority": "critical", "sprint": null, "assignedAgentIds": [ @@ -1496,7 +1496,7 @@ "createdBy": { "kind": "user" }, - "updatedAt": 1787336067149 + "updatedAt": 1788789622644 }, { "issueRef": "#119", @@ -2025,7 +2025,7 @@ "issueRef": "#155", "path": "155", "title": "Custom chat: paste image/fichier depuis le clipboard dans le composer", - "status": "inProgress", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ @@ -2036,7 +2036,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1786346556578 + "updatedAt": 1788789622554 }, { "issueRef": "#156", @@ -2483,7 +2483,7 @@ "issueRef": "#185", "path": "185", "title": "Codex structured sandbox: rétablir l'écriture du project root pour les agents Dev sans perdre l'accès à .git", - "status": "inProgress", + "status": "closed", "priority": "critical", "sprint": null, "assignedAgentIds": [ @@ -2494,7 +2494,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1786709806993 + "updatedAt": 1788789622659 }, { "issueRef": "#186", @@ -2632,7 +2632,7 @@ "issueRef": "#196", "path": "196", "title": "Permissions page grants do not allow agent write access to project root", - "status": "inProgress", + "status": "closed", "priority": "high", "sprint": null, "assignedAgentIds": [], @@ -2640,7 +2640,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1786709807009 + "updatedAt": 1788789622674 }, { "issueRef": "#197", @@ -3279,7 +3279,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1787476334103 + "updatedAt": 1788439323308 }, { "issueRef": "#244", @@ -3497,7 +3497,7 @@ "issueRef": "#259", "path": "259", "title": "Template permissions do not persist after save", - "status": "qa", + "status": "closed", "priority": "high", "sprint": null, "assignedAgentIds": [], @@ -3505,7 +3505,103 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1788438656514 + "updatedAt": 1788438757480 + }, + { + "issueRef": "#260", + "path": "260", + "title": "Agents cross-project: MCP discovery, permissions et contact inter-projets", + "status": "inProgress", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [ + "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + ], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1788791628015 + }, + { + "issueRef": "#261", + "path": "261", + "title": "Cross-project agents: cadrage UX permission agent/template", + "status": "closed", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "757430bb-014a-40fa-b82b-f2429dab9766" + ], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1788774465904 + }, + { + "issueRef": "#262", + "path": "262", + "title": "Cross-project agents: architecture MCP, registry projets et rendez-vous", + "status": "closed", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [ + "b4730d7f-c54d-4736-8a04-c6203aa2fd49" + ], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1788774465928 + }, + { + "issueRef": "#263", + "path": "263", + "title": "Cross-project agents: implémentation backend MCP et runtime", + "status": "closed", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [ + "fe887179-933f-47d4-960f-c3b06827f86c" + ], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1788792738864 + }, + { + "issueRef": "#264", + "path": "264", + "title": "Cross-project agents: UI permissions agents et templates", + "status": "closed", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "8f7da528-58df-4315-97e9-0562230ecc19" + ], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1788792738961 + }, + { + "issueRef": "#265", + "path": "265", + "title": "Cross-project agents: validation QA complète et AppImage", + "status": "inProgress", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [ + "ab328d90-c307-4771-a3b6-6c56089c8506" + ], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1788792738991 } ] } \ No newline at end of file diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 486dfd52..f805fb0c 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -540,6 +540,7 @@ mod open_project_template_tests { default_agent_name: None, default_system_permissions: None, default_mcp_tool_policy: None, + default_contactable: false, }) .await .expect("template is created") diff --git a/crates/app-tauri/src/templates.rs b/crates/app-tauri/src/templates.rs index fbd18023..94229664 100644 --- a/crates/app-tauri/src/templates.rs +++ b/crates/app-tauri/src/templates.rs @@ -78,6 +78,8 @@ impl TemplateToolProvider for AppTemplateToolProvider { "defaultMcpToolPermissions", "defaultMcpToolPolicy", )?, + default_contactable: optional_bool(&arguments, "defaultContactable")? + .unwrap_or(false), }) .await .map_err(template_error)?; @@ -160,6 +162,17 @@ fn optional_string<'a>( } } +fn optional_bool(value: &Value, key: &str) -> Result, TemplateToolError> { + match value.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(value)) => Ok(Some(*value)), + Some(_) => Err(TemplateToolError::new( + "invalid", + format!("field `{key}` must be a boolean"), + )), + } +} + fn optional_json_value(arguments: &Value, key: &str) -> Result, TemplateToolError> where T: serde::de::DeserializeOwned, @@ -317,6 +330,7 @@ mod tests { "defaultAgentName": "Base Agent", "defaultSystemPermissions": { "network": "allow" }, "defaultMcpToolPermissions": { "allowedTools": ["idea_template_read"] }, + "defaultContactable": true, }), ) .await @@ -332,6 +346,7 @@ mod tests { created["defaultMcpToolPermissions"], json!({ "allowedTools": ["idea_template_read"] }) ); + assert_eq!(created["defaultContactable"], true); let read = provider .handle_template_tool( @@ -353,6 +368,7 @@ mod tests { read["defaultMcpToolPermissions"], json!({ "allowedTools": ["idea_template_read"] }) ); + assert_eq!(read["defaultContactable"], true); let listed = provider .handle_template_tool(&project(), "agent", "idea_template_list", json!({})) @@ -370,6 +386,7 @@ mod tests { "content": "v2", "defaultSystemPermissions": { "network": "deny" }, "defaultMcpToolPermissions": { "allowedTools": ["idea_template_update"] }, + "defaultContactable": false, }), ) .await @@ -385,6 +402,7 @@ mod tests { updated["defaultMcpToolPermissions"], json!({ "allowedTools": ["idea_template_update"] }) ); + assert_eq!(updated["defaultContactable"], false); assert_eq!( store .get(TemplateId::from_uuid( @@ -471,6 +489,7 @@ mod tests { Some("Agent".to_owned()), None, None, + false, ) .unwrap(); diff --git a/crates/application/src/orchestrator/context_guard.rs b/crates/application/src/orchestrator/context_guard.rs index 03213f35..099c0ae2 100644 --- a/crates/application/src/orchestrator/context_guard.rs +++ b/crates/application/src/orchestrator/context_guard.rs @@ -790,6 +790,7 @@ mod tests { synced_template_version: None, skills: Vec::new(), effort: None, + contactable: false, }], }, contexts: Mutex::new(contexts), diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 68132af6..f3ddb14a 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -25,7 +25,8 @@ use domain::conversation_log::{ConversationTurn, TurnId, TurnRole}; use domain::input::{InputMediator, InputSource, SubmitConfig}; use domain::mailbox::{Ticket, TicketId}; use domain::ports::{ - BackgroundTaskRunner, BackgroundTaskStore, Clock, EventBus, ProfileStore, PtyHandle, + BackgroundTaskRunner, BackgroundTaskStore, Clock, EventBus, ProfileStore, ProjectStore, + PtyHandle, }; use domain::project::ProjectPath; use domain::{ @@ -407,6 +408,7 @@ pub struct OrchestratorService { update_context: Arc, create_skill: Arc, profiles: Arc, + projects: Option>, sessions: Arc, /// Médiateur d'entrée (cadrage C3 §5.2) — point de convergence unique de l'entrée /// d'un agent. La cible d'un `agent.message`/`idea_ask_agent` y reçoit un ticket @@ -460,7 +462,7 @@ pub struct OrchestratorService { /// /// Croissance bornée en pratique au nombre d'agents du projet ; une entrée /// morte ne coûte qu'un `Arc>` vide (pas de session, pas de process). - ask_locks: StdMutex>>>, + ask_locks: StdMutex>>>, /// Fournisseur des faits OS/runtime (exe + endpoint) pour écrire la déclaration /// MCP **réelle** quand `ensure_live_pty` (re)lance une cible sur le chemin `ask` /// (B-3). Injecté au câblage via [`Self::with_mcp_runtime_provider`] depuis @@ -600,6 +602,7 @@ impl OrchestratorService { update_context, create_skill, profiles, + projects: None, sessions, input: None, mailbox: None, @@ -634,6 +637,13 @@ impl OrchestratorService { self } + /// Branche le registre des projets connus pour les outils MCP cross-project. + #[must_use] + pub fn with_project_store(mut self, projects: Arc) -> Self { + self.projects = Some(projects); + self + } + /// Branche le producteur B8 command-backed pour `idea_run_in_background`. #[must_use] pub fn with_spawn_background_command(mut self, spawn: Arc) -> Self { @@ -719,12 +729,12 @@ impl OrchestratorService { /// never across an `.await`); the returned `Arc>` is the lock the /// caller acquires (and holds across `send_blocking`) to serialise turns for /// `agent_id`. Two different agents get two distinct `Arc`s ⇒ no contention. - fn ask_lock_for(&self, agent_id: &AgentId) -> Arc> { + fn ask_lock_for(&self, project: &Project, agent_id: AgentId) -> Arc> { let mut locks = self .ask_locks .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - Arc::clone(locks.entry(*agent_id).or_default()) + Arc::clone(locks.entry(runtime_key(project, agent_id)).or_default()) } fn active_rendezvous_link( @@ -1301,6 +1311,126 @@ impl OrchestratorService { } } + fn require_project_store(&self) -> Result<&Arc, AppError> { + self.projects.as_ref().ok_or_else(|| { + AppError::Invalid("cross-project MCP tools are not configured".to_owned()) + }) + } + + async fn load_project_for_cross_tool( + &self, + project_id: domain::ProjectId, + ) -> Result { + self.require_project_store()? + .load_project(project_id) + .await + .map_err(AppError::from) + } + + /// Lists every project known to this IdeA instance for cross-project MCP discovery. + pub async fn list_projects_for_mcp(&self) -> Result { + let projects = self + .require_project_store()? + .list_projects() + .await + .map_err(AppError::from)?; + let reply = serde_json::to_string(&projects) + .map_err(|e| AppError::Invalid(format!("failed to serialise project list: {e}")))?; + Ok(OrchestratorOutcome { + detail: format!("listed {} project(s)", projects.len()), + reply: Some(reply), + }) + } + + /// Reads the target project's global context, even when it is not currently open. + pub async fn read_project_context_for_mcp( + &self, + project_id: domain::ProjectId, + requester: ConversationParty, + ) -> Result { + let project = self.load_project_for_cross_tool(project_id).await?; + self.read_context(&project, None, requester).await + } + + /// Lists contactable agents in the target project, even when it is not currently open. + pub async fn list_project_agents_for_mcp( + &self, + project_id: domain::ProjectId, + ) -> Result { + let project = self.load_project_for_cross_tool(project_id).await?; + let listed = self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await?; + let entries: Vec<_> = listed + .discovery_entries() + .into_iter() + .filter(|entry| entry.agent.contactable) + .collect(); + let reply = serde_json::to_string(&entries) + .map_err(|e| AppError::Invalid(format!("failed to serialise agent list: {e}")))?; + Ok(OrchestratorOutcome { + detail: format!("listed {} contactable agent(s)", entries.len()), + reply: Some(reply), + }) + } + + /// Asks one contactable agent in another project by stable ids. + pub async fn ask_project_agent_for_mcp( + &self, + target_project_id: domain::ProjectId, + target_agent_id: AgentId, + task: String, + requester_project_id: domain::ProjectId, + requester_agent_id: AgentId, + correlation_id: String, + ) -> Result { + let project = self.load_project_for_cross_tool(target_project_id).await?; + let target = self + .find_agent_by_id(&project, target_agent_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("agent {target_agent_id}")))?; + if !target.contactable { + return Err(AppError::Invalid(format!( + "agent {target_agent_id} is not contactable cross-project" + ))); + } + crate::diag!( + "[cross-project] ask correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}" + ); + self.ask_resolved_agent(&project, target, task, Some(requester_agent_id)) + .await + } + + /// Cancels/stops one contactable agent in another project by stable ids. + pub async fn cancel_project_agent_for_mcp( + &self, + target_project_id: domain::ProjectId, + target_agent_id: AgentId, + requester_project_id: domain::ProjectId, + requester_agent_id: AgentId, + correlation_id: String, + ) -> Result { + let project = self.load_project_for_cross_tool(target_project_id).await?; + let target = self + .find_agent_by_id(&project, target_agent_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("agent {target_agent_id}")))?; + if !target.contactable { + return Err(AppError::Invalid(format!( + "agent {target_agent_id} is not contactable cross-project" + ))); + } + crate::diag!( + "[cross-project] cancel correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}" + ); + let _ = requester_agent_id; + self.stop_agent_by_id(&project, target.id, target.name, None) + .await + } + async fn run_in_background( &self, project: &Project, @@ -1787,6 +1917,18 @@ impl OrchestratorService { .find_agent_by_name(project, &target) .await? .ok_or_else(|| AppError::NotFound(format!("agent {target}")))?; + self.ask_resolved_agent(project, agent, task, requester) + .await + } + + async fn ask_resolved_agent( + &self, + project: &Project, + agent: domain::Agent, + task: String, + requester: Option, + ) -> Result { + let target = agent.name.clone(); let agent_id = agent.id; // Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la // cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle @@ -1818,7 +1960,7 @@ impl OrchestratorService { // Sérialisation FIFO **par agent** (A0) : verrou de tour de la **cible**, tenu // pour TOUT le tour (enqueue → réponse). RAII : tombe sur chaque early-return. - let lock = self.ask_lock_for(&agent_id); + let lock = self.ask_lock_for(project, agent_id); // Diagnostics : tracer l'attente du verrou de tour. Un « turn-lock waiting » sans // « turn-lock acquired » qui suit signe un tour précédent jamais relâché (la cible // reste Busy) — le verrou n'est libéré qu'à la fin du tour courant. @@ -2726,7 +2868,17 @@ impl OrchestratorService { .find_agent_id_by_name(project, &name) .await? .ok_or_else(|| AppError::NotFound(format!("agent {name}")))?; + self.stop_agent_by_id(project, agent_id, name, requester) + .await + } + async fn stop_agent_by_id( + &self, + project: &Project, + agent_id: AgentId, + label: String, + requester: Option, + ) -> Result { if let Some(requester) = requester { let allowed = requester == agent_id || self @@ -2769,7 +2921,7 @@ impl OrchestratorService { .await .map_err(|err| AppError::Process(err.to_string()))?; return Ok(OrchestratorOutcome { - detail: format!("cancelled current work for agent {name}"), + detail: format!("cancelled current work for agent {label}"), reply: None, }); } @@ -2780,12 +2932,12 @@ impl OrchestratorService { .is_empty() { return Err(AppError::NotFound(format!( - "running session for agent {name}" + "running session for agent {label}" ))); } Ok(OrchestratorOutcome { - detail: format!("stopped agent {name}"), + detail: format!("stopped agent {label}"), reply: None, }) } @@ -2886,6 +3038,21 @@ impl OrchestratorService { .find(|a| a.name.eq_ignore_ascii_case(name))) } + /// Finds the full [`domain::Agent`] by stable id in the project manifest. + async fn find_agent_by_id( + &self, + project: &Project, + agent_id: AgentId, + ) -> Result, AppError> { + let listed = self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await?; + Ok(listed.agents.into_iter().find(|a| a.id == agent_id)) + } + /// Resolves the target agent profile's **submit config** /// (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) **and** whether it /// declares an **MCP bridge**, in a single profile lookup. The submit config is diff --git a/crates/application/src/template/usecases.rs b/crates/application/src/template/usecases.rs index 62e2b6ea..d96478ba 100644 --- a/crates/application/src/template/usecases.rs +++ b/crates/application/src/template/usecases.rs @@ -45,6 +45,8 @@ pub struct CreateTemplateInput { pub default_system_permissions: Option, /// Optional agent-level MCP tool permission override applied at creation time. pub default_mcp_tool_policy: Option, + /// Whether agents created from this template are cross-project contactable. + pub default_contactable: bool, } /// Output of [`CreateTemplate::execute`]. @@ -90,6 +92,7 @@ impl CreateTemplate { input.default_agent_name, input.default_system_permissions, input.default_mcp_tool_policy, + input.default_contactable, ) }) .map_err(|e| AppError::Invalid(e.to_string()))?; @@ -122,6 +125,8 @@ pub struct UpdateTemplateInput { /// New default MCP tool policy. Outer `None` leaves the current value; inner /// `None` preserves project inheritance for future agents. pub default_mcp_tool_policy: Option>, + /// New default contactable flag. `None` leaves the current value. + pub default_contactable: Option, } /// Output of [`UpdateTemplate::execute`]. @@ -175,6 +180,9 @@ impl UpdateTemplate { input .default_mcp_tool_policy .unwrap_or_else(|| updated.default_mcp_tool_policy.clone()), + input + .default_contactable + .unwrap_or(updated.default_contactable), ) .map_err(|e| AppError::Invalid(e.to_string()))?; self.templates.save(&updated).await?; @@ -397,7 +405,8 @@ impl CreateAgentFromTemplate { origin, input.synchronized, ) - .map_err(|e| AppError::Invalid(e.to_string()))?; + .map_err(|e| AppError::Invalid(e.to_string()))? + .with_contactable(template.default_contactable); let mut entries = manifest.entries; entries.push(ManifestEntry::from_agent(&agent)); diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index f887c30c..4c8191b1 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -24,8 +24,9 @@ use domain::ports::{ AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, - IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore, PtyError, - PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, + IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore, + ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, + SpawnSpec, StoreError, }; use domain::profile::{ AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, @@ -105,6 +106,16 @@ impl FakeContexts { .find(|e| &e.agent_id == agent) .map(|e| e.md_path.clone()) } + fn add_agent(&self, agent: &Agent, content: &str) { + let mut inner = self.0.lock().unwrap(); + inner + .manifest + .entries + .push(ManifestEntry::from_agent(agent)); + inner + .contents + .insert(agent.context_path.clone(), content.to_owned()); + } } #[async_trait] @@ -172,6 +183,45 @@ impl ProfileStore for FakeProfiles { } } +#[derive(Clone)] +struct FakeProjects(Arc>); + +impl FakeProjects { + fn new(projects: Vec) -> Self { + Self(Arc::new(projects)) + } +} + +#[async_trait] +impl ProjectStore for FakeProjects { + async fn list_projects(&self) -> Result, StoreError> { + Ok((*self.0).clone()) + } + + async fn load_project(&self, id: ProjectId) -> Result { + self.0 + .iter() + .find(|project| project.id == id) + .cloned() + .ok_or(StoreError::NotFound) + } + + async fn save_project(&self, _project: &Project) -> Result<(), StoreError> { + Ok(()) + } + + async fn save_workspace( + &self, + _workspace: &domain::layout::Workspace, + ) -> Result<(), StoreError> { + Ok(()) + } + + async fn load_workspace(&self) -> Result { + Ok(domain::layout::Workspace::default()) + } +} + // Empty skill store: the orchestrator tests spawn agents with no assigned skills. #[derive(Default)] struct FakeSkills; @@ -795,10 +845,191 @@ fn cmd(json: &str) -> OrchestratorCommand { .unwrap() } +fn other_project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(2000)), + "other", + ProjectPath::new("/home/me/other").unwrap(), + RemoteRef::local(), + 1_700_000_000_001, + ) + .unwrap() +} + // --------------------------------------------------------------------------- // Basic dispatch coverage (spawn / stop / update / skill) — non-ask commands. // --------------------------------------------------------------------------- +#[tokio::test] +async fn cross_project_list_projects_reads_known_project_registry() { + let fx = fixture(FakeContexts::new()); + let svc = fx + .service + .with_project_store(Arc::new(FakeProjects::new(vec![ + project(), + other_project(), + ]))); + + let out = svc + .list_projects_for_mcp() + .await + .expect("project registry is readable"); + let rows: serde_json::Value = serde_json::from_str(&out.reply.unwrap()).unwrap(); + + assert_eq!(rows.as_array().unwrap().len(), 2); + assert_eq!(rows[0]["id"], project_id().to_string()); + assert_eq!(rows[1]["id"], other_project().id.to_string()); +} + +#[tokio::test] +async fn cross_project_list_agents_filters_to_contactable_agents() { + let visible = scratch_agent(aid(1), "Visible", "agents/visible.md").with_contactable(true); + let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md"); + let contexts = { + let me = FakeContexts::with_agent(&visible, "# visible"); + let mut inner = me.0.lock().unwrap(); + inner + .manifest + .entries + .push(ManifestEntry::from_agent(&hidden)); + inner + .contents + .insert(hidden.context_path.clone(), "# hidden".to_owned()); + drop(inner); + me + }; + let fx = fixture(contexts); + let target_project = other_project(); + let svc = fx + .service + .with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()]))); + + let out = svc + .list_project_agents_for_mcp(target_project.id) + .await + .expect("agent manifest is readable"); + let rows: serde_json::Value = serde_json::from_str(&out.reply.unwrap()).unwrap(); + + assert_eq!(rows.as_array().unwrap().len(), 1); + assert_eq!(rows[0]["id"], visible.id.to_string()); + assert_eq!(rows[0]["name"], "Visible"); + assert_eq!(rows[0]["contactable"], true); +} + +#[tokio::test] +async fn cross_project_ask_rejects_non_contactable_agent_before_launch() { + let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md"); + let fx = fixture(FakeContexts::with_agent(&hidden, "# hidden")); + let target_project = other_project(); + let svc = fx + .service + .with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()]))); + + let err = svc + .ask_project_agent_for_mcp( + target_project.id, + hidden.id, + "work".to_owned(), + project().id, + aid(99), + "corr-1".to_owned(), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + assert!(err.to_string().contains("not contactable")); + assert!(fx.pty.spawns().is_empty(), "agent must not be launched"); +} + +#[tokio::test] +async fn cross_project_ask_routes_by_agent_id_when_names_collide() { + let first = + scratch_agent(aid(1), "Architect", "agents/architect-first.md").with_contactable(true); + let second = + scratch_agent(aid(2), "Architect", "agents/architect-second.md").with_contactable(true); + let contexts = FakeContexts::with_agent(&first, "# first"); + contexts.add_agent(&second, "# second"); + let fx = ask_fixture(contexts); + let target_project = other_project(); + + fx.mailbox + .completions() + .push(first.id, TestCompletion::Replied("wrong first".to_owned())); + fx.mailbox.completions().push( + second.id, + TestCompletion::Replied("right second".to_owned()), + ); + + let out = timeout( + TEST_GUARD, + fx.service.ask_project_agent_for_mcp( + target_project.id, + second.id, + "work".to_owned(), + project().id, + aid(99), + "corr-homonym-ask".to_owned(), + ), + ) + .await + .expect("cross-project ask must not hang") + .expect("cross-project ask ok"); + + assert_eq!(out.reply.as_deref(), Some("right second")); + assert_eq!(fx.mailbox.pending(&first.id), 0); + assert_eq!(fx.mailbox.pending(&second.id), 0); +} + +#[tokio::test] +async fn cross_project_cancel_routes_by_agent_id_when_names_collide() { + let first = + scratch_agent(aid(1), "Architect", "agents/architect-first.md").with_contactable(true); + let second = + scratch_agent(aid(2), "Architect", "agents/architect-second.md").with_contactable(true); + let contexts = FakeContexts::with_agent(&first, "# first"); + contexts.add_agent(&second, "# second"); + let fx = ask_fixture(contexts); + let target_project = other_project(); + let first_cancelled = Arc::new(Mutex::new(0_u32)); + let second_cancelled = Arc::new(Mutex::new(0_u32)); + let first_session = Arc::new(CompletionSession { + id: sid(9101), + agent: first.id, + completions: fx.mailbox.completions(), + cancel_called: Some(Arc::clone(&first_cancelled)), + shutdown_called: None, + }) as Arc; + let second_session = Arc::new(CompletionSession { + id: sid(9102), + agent: second.id, + completions: fx.mailbox.completions(), + cancel_called: Some(Arc::clone(&second_cancelled)), + shutdown_called: None, + }) as Arc; + fx.structured + .insert_in_project(target_project.id, first_session, first.id, nid(9101)); + fx.structured + .insert_in_project(target_project.id, second_session, second.id, nid(9102)); + + timeout( + TEST_GUARD, + fx.service.cancel_project_agent_for_mcp( + target_project.id, + second.id, + project().id, + aid(99), + "corr-homonym-cancel".to_owned(), + ), + ) + .await + .expect("cross-project cancel must not hang") + .expect("cross-project cancel ok"); + + assert_eq!(*first_cancelled.lock().unwrap(), 0); + assert_eq!(*second_cancelled.lock().unwrap(), 1); +} + #[tokio::test] async fn spawn_unknown_agent_creates_then_launches() { let fx = fixture(FakeContexts::new()); @@ -1908,6 +2139,10 @@ fn ask_fixture_full( Arc::clone(&mediator) as Arc, Arc::clone(&mailbox) as Arc, ) + .with_project_store(Arc::new(FakeProjects::new(vec![ + project(), + other_project(), + ]))) .with_conversations(conversations) .with_events(Arc::new(bus.clone())) .with_structured(Arc::clone(&structured)) diff --git a/crates/application/tests/template_usecases.rs b/crates/application/tests/template_usecases.rs index 78ecf5f4..978ac828 100644 --- a/crates/application/tests/template_usecases.rs +++ b/crates/application/tests/template_usecases.rs @@ -282,6 +282,7 @@ async fn create_template_starts_at_initial_version() { default_mcp_tool_policy: Some(McpToolPolicy { allowed_tools: vec!["idea_template_read".to_owned()], }), + default_contactable: true, }) .await .unwrap(); @@ -341,6 +342,7 @@ async fn update_template_bumps_version_and_publishes_event() { default_agent_name: None, default_system_permissions: None, default_mcp_tool_policy: None, + default_contactable: None, }) .await .unwrap(); @@ -370,6 +372,7 @@ async fn update_unknown_template_is_not_found() { default_agent_name: None, default_system_permissions: None, default_mcp_tool_policy: None, + default_contactable: None, }) .await .unwrap_err(); @@ -393,6 +396,7 @@ async fn update_template_updates_default_profile_for_future_agents() { default_mcp_tool_policy: Some(Some(McpToolPolicy { allowed_tools: vec!["idea_template_read".to_owned()], })), + default_contactable: Some(true), }) .await .unwrap() @@ -411,6 +415,7 @@ async fn update_template_updates_default_profile_for_future_agents() { allowed_tools: vec!["idea_template_read".to_owned()] }) ); + assert!(updated.default_contactable); let contexts = FakeContexts::new(vec![]); let out = CreateAgentFromTemplate::new( @@ -427,6 +432,7 @@ async fn update_template_updates_default_profile_for_future_agents() { }) .await .unwrap(); + assert!(out.agent.contactable); assert_eq!(out.agent.name, "Agent Name"); assert_eq!(out.agent.profile_id, pid(42)); @@ -486,6 +492,7 @@ async fn create_agent_from_template_projects_template_permission_overrides() { Some(McpToolPolicy { allowed_tools: vec!["idea_template_read".to_owned()], }), + true, ) .unwrap(); let store = FakeTemplates::with(vec![template]); @@ -512,6 +519,7 @@ async fn create_agent_from_template_projects_template_permission_overrides() { assert_eq!(out.agent.name, "Backend"); assert_eq!(out.agent.profile_id, pid(7)); + assert!(out.agent.contactable); assert_eq!( system_permissions.0.lock().unwrap().agents, vec![AgentSystemPermissionOverride::new( diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index f0f49250..e31fdb9a 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -4295,6 +4295,8 @@ pub struct CreateTemplateRequestDto { pub default_system_permissions: Option, /// Optional agent-level MCP tool policy override for created agents. pub default_mcp_tool_policy: Option, + /// Whether agents created from this template are cross-project contactable. + pub default_contactable: bool, } impl<'de> Deserialize<'de> for CreateTemplateRequestDto { @@ -4327,6 +4329,10 @@ impl<'de> Deserialize<'de> for CreateTemplateRequestDto { ) .map_err(serde::de::Error::custom)? .flatten(); + let default_contactable = take_optional_value::(object, "defaultContactable") + .map_err(serde::de::Error::custom)? + .flatten() + .unwrap_or(false); Ok(Self { name, content, @@ -4334,6 +4340,7 @@ impl<'de> Deserialize<'de> for CreateTemplateRequestDto { default_agent_name, default_system_permissions, default_mcp_tool_policy, + default_contactable, }) } } @@ -4351,6 +4358,7 @@ impl CreateTemplateRequestDto { default_agent_name: self.default_agent_name, default_system_permissions: self.default_system_permissions, default_mcp_tool_policy: self.default_mcp_tool_policy, + default_contactable: self.default_contactable, }) } } @@ -4372,6 +4380,8 @@ pub struct UpdateTemplateRequestDto { pub default_system_permissions: Option>, /// New default MCP tool policy. Missing leaves it unchanged; null clears it. pub default_mcp_tool_policy: Option>, + /// New default contactable flag. Missing leaves it unchanged. + pub default_contactable: Option, } impl<'de> Deserialize<'de> for UpdateTemplateRequestDto { @@ -4408,6 +4418,9 @@ impl<'de> Deserialize<'de> for UpdateTemplateRequestDto { ], ) .map_err(serde::de::Error::custom)?; + let default_contactable = take_optional_value::(object, "defaultContactable") + .map_err(serde::de::Error::custom)? + .flatten(); Ok(Self { template_id, content, @@ -4416,6 +4429,7 @@ impl<'de> Deserialize<'de> for UpdateTemplateRequestDto { default_agent_name, default_system_permissions, default_mcp_tool_policy, + default_contactable, }) } } @@ -4438,6 +4452,7 @@ impl UpdateTemplateRequestDto { default_agent_name: self.default_agent_name, default_system_permissions: self.default_system_permissions, default_mcp_tool_policy: self.default_mcp_tool_policy, + default_contactable: self.default_contactable, }) } } diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index a472e8d0..6b0bdae5 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -3033,6 +3033,7 @@ impl BackendCore { // Messagerie inter-agents (cadrage C3) : médiateur d'entrée (file + livraison // PTV sérialisée) + registre de conversations + bus pour AgentReplied. .with_input_mediator(Arc::clone(&input_mediator), Arc::clone(&mailbox)) + .with_project_store(Arc::clone(&store_port)) .with_conversations(Arc::clone(&conversation_registry)) .with_events(Arc::clone(&events_port)) // Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour @@ -4973,6 +4974,7 @@ mod mcp_serve_peer_tests { synced_template_version: None, skills: Vec::new(), effort: None, + contactable: false, }); id } @@ -6310,6 +6312,7 @@ mod mcp_e2e_loopback_tests { synced_template_version: None, skills: Vec::new(), effort: None, + contactable: false, }); id } diff --git a/crates/domain/src/agent.rs b/crates/domain/src/agent.rs index 278551d9..f50099b1 100644 --- a/crates/domain/src/agent.rs +++ b/crates/domain/src/agent.rs @@ -75,6 +75,9 @@ pub struct Agent { /// `model_reasoning_effort` at launch. #[serde(default, skip_serializing_if = "Option::is_none")] pub effort: Option, + /// Whether this agent may be discovered/contacted from another project. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub contactable: bool, } impl Agent { @@ -110,6 +113,7 @@ impl Agent { synchronized, skills: Vec::new(), effort: None, + contactable: false, }) } @@ -141,6 +145,13 @@ impl Agent { self } + /// Returns a copy with the cross-project contact permission changed. + #[must_use] + pub const fn with_contactable(mut self, contactable: bool) -> Self { + self.contactable = contactable; + self + } + /// Assigns a skill to this agent. Idempotent: re-assigning the same /// `skill_id` is a no-op (returns `false`); a new assignment returns `true`. pub fn assign_skill(&mut self, skill: SkillRef) -> bool { @@ -199,6 +210,9 @@ pub struct ManifestEntry { /// Per-agent effort selection. Missing in older manifests means no override. #[serde(default, skip_serializing_if = "Option::is_none")] pub effort: Option, + /// Whether this agent may be discovered/contacted from another project. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub contactable: bool, } impl ManifestEntry { @@ -238,6 +252,7 @@ impl ManifestEntry { synced_template_version, skills: Vec::new(), effort: None, + contactable: false, }) } @@ -264,6 +279,7 @@ impl ManifestEntry { synced_template_version, skills: agent.skills.clone(), effort: agent.effort.clone(), + contactable: agent.contactable, } } @@ -290,7 +306,8 @@ impl ManifestEntry { self.synchronized, )? .with_skills(self.skills.clone()) - .with_effort(self.effort.clone())) + .with_effort(self.effort.clone()) + .with_contactable(self.contactable)) } } diff --git a/crates/domain/src/template.rs b/crates/domain/src/template.rs index 0b9ea5c9..3e562a6a 100644 --- a/crates/domain/src/template.rs +++ b/crates/domain/src/template.rs @@ -74,6 +74,9 @@ pub struct AgentTemplate { skip_serializing_if = "Option::is_none" )] pub default_mcp_tool_policy: Option, + /// Whether agents created from this template are cross-project contactable. + #[serde(default)] + pub default_contactable: bool, } impl AgentTemplate { @@ -98,6 +101,7 @@ impl AgentTemplate { default_agent_name: None, default_system_permissions: None, default_mcp_tool_policy: None, + default_contactable: false, }) } @@ -125,6 +129,7 @@ impl AgentTemplate { default_agent_name: Option, default_system_permissions: Option, default_mcp_tool_policy: Option, + default_contactable: bool, ) -> Result { crate::validation::non_empty(&name, "template.name")?; if let Some(default_agent_name) = &default_agent_name { @@ -136,6 +141,7 @@ impl AgentTemplate { default_agent_name, default_system_permissions, default_mcp_tool_policy, + default_contactable, ..self.clone() }) } diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 368da9f9..8ba9424e 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -24,7 +24,7 @@ use application::OrchestratorService; use domain::ports::McpToolPermissionStore; use domain::{ AgentId, AgentToolPolicy, DomainEvent, IssueRef, McpToolPolicy, OrchestrationSource, - OrchestratorCommand, Project, + OrchestratorCommand, Project, ProjectId, }; use futures_util::future::join_all; use serde_json::{json, Value}; @@ -483,6 +483,33 @@ impl McpServer { .await; } + if is_project_tool(&name) { + let result = self.tools_call_project(&name, arguments).await; + self.publish_processed(&name, result.is_ok()); + return match result { + Ok(outcome) => { + let text = outcome.reply.unwrap_or(outcome.detail); + application::diag!( + "[mcp] tools_call end tool={name} requester={requester_label} \ + target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}", + text.len(), + started.elapsed().as_millis(), + ); + Ok(tool_result_text(&text, false)) + } + Err(err) => { + let detail = err.to_string(); + application::diag!( + "[mcp] tools_call end tool={name} requester={requester_label} \ + target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}", + detail.len(), + started.elapsed().as_millis(), + ); + Ok(tool_result_text(&detail, true)) + } + }; + } + if tools::is_ticket_tool(&name) { let result = match &self.ticket_tools { Some(provider) => { @@ -709,6 +736,78 @@ impl McpServer { Ok(tool_result_text(&text, false)) } + async fn tools_call_project( + &self, + name: &str, + arguments: Value, + ) -> Result { + let args = arguments.as_object().ok_or_else(|| { + application::AppError::Invalid(format!("tool `{name}` arguments must be a JSON object")) + })?; + let project_id = || parse_project_id(required_str_arg(args, "projectId")?); + let agent_id = || parse_agent_id(required_str_arg(args, "agentId")?); + match name { + "idea_project_list" => { + reject_extra_project_args(args, &[])?; + self.service.list_projects_for_mcp().await + } + "idea_project_read_context" => { + reject_extra_project_args(args, &["projectId"])?; + self.service + .read_project_context_for_mcp(project_id()?, requester_party(&self.requester)) + .await + } + "idea_project_list_agents" => { + reject_extra_project_args(args, &["projectId"])?; + self.service + .list_project_agents_for_mcp(project_id()?) + .await + } + "idea_project_ask_agent" => { + reject_extra_project_args( + args, + &["projectId", "agentId", "correlationId", "task"], + )?; + let requester = parse_agent_id(&self.requester).map_err(|_| { + application::AppError::Invalid( + "idea_project_ask_agent requires an agent requester handshake".to_owned(), + ) + })?; + self.service + .ask_project_agent_for_mcp( + project_id()?, + agent_id()?, + required_str_arg(args, "task")?.to_owned(), + self.project.id, + requester, + required_str_arg(args, "correlationId")?.to_owned(), + ) + .await + } + "idea_project_cancel_agent" => { + reject_extra_project_args(args, &["projectId", "agentId", "correlationId"])?; + let requester = parse_agent_id(&self.requester).map_err(|_| { + application::AppError::Invalid( + "idea_project_cancel_agent requires an agent requester handshake" + .to_owned(), + ) + })?; + self.service + .cancel_project_agent_for_mcp( + project_id()?, + agent_id()?, + self.project.id, + requester, + required_str_arg(args, "correlationId")?.to_owned(), + ) + .await + } + other => Err(application::AppError::Invalid(format!( + "unknown project tool {other}" + ))), + } + } + fn enforce_tool_policy( &self, policy: &AgentToolPolicy, @@ -935,6 +1034,60 @@ fn parse_ask_agents_requests( Ok(out) } +fn is_project_tool(name: &str) -> bool { + matches!( + name, + "idea_project_list" + | "idea_project_read_context" + | "idea_project_list_agents" + | "idea_project_ask_agent" + | "idea_project_cancel_agent" + ) +} + +fn required_str_arg<'a>( + args: &'a serde_json::Map, + key: &str, +) -> Result<&'a str, application::AppError> { + args.get(key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| application::AppError::Invalid(format!("missing required field `{key}`"))) +} + +fn reject_extra_project_args( + args: &serde_json::Map, + allowed: &[&str], +) -> Result<(), application::AppError> { + if args + .keys() + .any(|key| !allowed.iter().any(|allowed| allowed == key)) + { + return Err(application::AppError::Invalid( + "unexpected argument for project tool".to_owned(), + )); + } + Ok(()) +} + +fn parse_project_id(value: &str) -> Result { + uuid::Uuid::parse_str(value) + .map(ProjectId::from_uuid) + .map_err(|err| application::AppError::Invalid(format!("invalid projectId: {err}"))) +} + +fn parse_agent_id(value: &str) -> Result { + uuid::Uuid::parse_str(value) + .map(AgentId::from_uuid) + .map_err(|err| application::AppError::Invalid(format!("invalid agentId: {err}"))) +} + +fn requester_party(requester: &str) -> domain::ConversationParty { + parse_agent_id(requester) + .map(domain::ConversationParty::agent) + .unwrap_or(domain::ConversationParty::User) +} + fn is_ticket_policy_mutation_tool(name: &str) -> bool { matches!( name, @@ -969,3 +1122,52 @@ fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError { } } } + +#[cfg(test)] +mod project_tool_tests { + use super::*; + use serde_json::json; + + fn object(value: Value) -> serde_json::Map { + value.as_object().unwrap().clone() + } + + #[test] + fn project_tool_helpers_parse_project_and_agent_ids() { + let project = "11111111-1111-1111-1111-111111111111"; + let agent = "22222222-2222-2222-2222-222222222222"; + + assert_eq!( + parse_project_id(project).unwrap(), + ProjectId::from_uuid(uuid::Uuid::parse_str(project).unwrap()) + ); + assert_eq!( + parse_agent_id(agent).unwrap(), + AgentId::from_uuid(uuid::Uuid::parse_str(agent).unwrap()) + ); + } + + #[test] + fn project_tool_helpers_require_correlation_and_reject_extra_fields() { + let args = object(json!({ + "projectId": "11111111-1111-1111-1111-111111111111", + "agentId": "22222222-2222-2222-2222-222222222222", + "task": "work" + })); + + let err = required_str_arg(&args, "correlationId").unwrap_err(); + assert_eq!(err.code(), "INVALID"); + + let args = object(json!({ + "projectId": "11111111-1111-1111-1111-111111111111", + "agentId": "22222222-2222-2222-2222-222222222222", + "correlationId": "corr-1", + "task": "work", + "target": "ambiguous name" + })); + let err = + reject_extra_project_args(&args, &["projectId", "agentId", "correlationId", "task"]) + .unwrap_err(); + assert_eq!(err.code(), "INVALID"); + } +} diff --git a/crates/infrastructure/src/orchestrator/mcp/templates.rs b/crates/infrastructure/src/orchestrator/mcp/templates.rs index bc95c974..c339f7b9 100644 --- a/crates/infrastructure/src/orchestrator/mcp/templates.rs +++ b/crates/infrastructure/src/orchestrator/mcp/templates.rs @@ -117,7 +117,8 @@ pub fn catalogue() -> Vec { }, "required": ["allowedTools"], "additionalProperties": false - } + }, + "defaultContactable": { "type": "boolean" } }, "required": ["name", "content", "defaultProfileId"], "additionalProperties": false @@ -151,7 +152,8 @@ pub fn catalogue() -> Vec { }, "required": ["allowedTools"], "additionalProperties": false - } + }, + "defaultContactable": { "type": "boolean" } }, "required": ["templateId"], "additionalProperties": false diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 1a226fa3..a0f37d55 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -45,6 +45,9 @@ pub enum McpToolAccess { /// Canonical read-only MCP tools allowed by default. pub const READ_ONLY_TOOLS: &[&str] = &[ + "idea_project_list", + "idea_project_read_context", + "idea_project_list_agents", "idea_list_agents", "idea_context_read", "idea_memory_read", @@ -62,6 +65,8 @@ pub const READ_ONLY_TOOLS: &[&str] = &[ /// Canonical write/action MCP tools denied by default. pub const WRITE_ACTION_TOOLS: &[&str] = &[ + "idea_project_ask_agent", + "idea_project_cancel_agent", "idea_ask_agent", "idea_ask_agents", "idea_run_in_background", @@ -136,7 +141,12 @@ pub enum ToolMapError { pub fn tool_returns_reply(tool: &str) -> bool { matches!( tool, - "idea_ask_agent" + "idea_project_list" + | "idea_project_read_context" + | "idea_project_list_agents" + | "idea_project_ask_agent" + | "idea_project_cancel_agent" + | "idea_ask_agent" | "idea_ask_agents" | "idea_list_agents" | "idea_context_read" @@ -174,6 +184,68 @@ pub fn is_managed_temp_tool(tool: &str) -> bool { #[must_use] pub fn catalogue() -> Vec { let mut tools = vec![ + ToolDef { + name: "idea_project_list", + description: "List known IdeA projects for cross-project agent discovery.", + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_project_read_context", + description: "Read a project's global context by stable project id.", + input_schema: json!({ + "type": "object", + "properties": { + "projectId": { "type": "string", "format": "uuid" } + }, + "required": ["projectId"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_project_list_agents", + description: "List cross-project contactable agents for a project by stable project id.", + input_schema: json!({ + "type": "object", + "properties": { + "projectId": { "type": "string", "format": "uuid" } + }, + "required": ["projectId"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_project_ask_agent", + description: "Ask a cross-project contactable agent by projectId and agentId and wait for its final answer.", + input_schema: json!({ + "type": "object", + "properties": { + "projectId": { "type": "string", "format": "uuid" }, + "agentId": { "type": "string", "format": "uuid" }, + "correlationId": { "type": "string" }, + "task": { "type": "string" } + }, + "required": ["projectId", "agentId", "correlationId", "task"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_project_cancel_agent", + description: "Cancel/stop a cross-project contactable agent by projectId, agentId and correlationId.", + input_schema: json!({ + "type": "object", + "properties": { + "projectId": { "type": "string", "format": "uuid" }, + "agentId": { "type": "string", "format": "uuid" }, + "correlationId": { "type": "string" } + }, + "required": ["projectId", "agentId", "correlationId"], + "additionalProperties": false + }), + }, ToolDef { name: "idea_list_agents", description: "List the IdeA agents declared in the project's manifest. Returns the \ @@ -762,6 +834,63 @@ mod tests { assert!(tool_returns_reply("idea_ask_agents")); } + #[test] + fn project_tools_are_classified_and_return_inline_replies() { + for tool in [ + "idea_project_list", + "idea_project_read_context", + "idea_project_list_agents", + ] { + assert_eq!(tool_access(tool), Some(McpToolAccess::Read)); + assert!( + tool_returns_reply(tool), + "{tool} must return inline JSON/text" + ); + } + for tool in ["idea_project_ask_agent", "idea_project_cancel_agent"] { + assert_eq!(tool_access(tool), Some(McpToolAccess::WriteAction)); + assert!( + tool_returns_reply(tool), + "{tool} must return an inline result" + ); + } + } + + #[test] + fn catalogue_advertises_project_tools_with_stable_identity_fields() { + let cat = catalogue(); + let by_name = |name: &str| { + cat.iter() + .find(|tool| tool.name == name) + .unwrap_or_else(|| panic!("catalogue must contain {name}")) + }; + + assert_eq!( + by_name("idea_project_read_context").input_schema["required"], + json!(["projectId"]) + ); + assert_eq!( + by_name("idea_project_list_agents").input_schema["required"], + json!(["projectId"]) + ); + assert_eq!( + by_name("idea_project_ask_agent").input_schema["required"], + json!(["projectId", "agentId", "correlationId", "task"]) + ); + assert_eq!( + by_name("idea_project_cancel_agent").input_schema["required"], + json!(["projectId", "agentId", "correlationId"]) + ); + assert_eq!( + by_name("idea_project_ask_agent").input_schema["properties"]["projectId"]["format"], + json!("uuid") + ); + assert_eq!( + by_name("idea_project_ask_agent").input_schema["properties"]["agentId"]["format"], + json!("uuid") + ); + } + #[test] fn run_in_background_maps_to_background_command_with_handshake_owner() { let cmd = map_tool_call( diff --git a/crates/infrastructure/src/store/template.rs b/crates/infrastructure/src/store/template.rs index 0f572aa1..b9847430 100644 --- a/crates/infrastructure/src/store/template.rs +++ b/crates/infrastructure/src/store/template.rs @@ -59,6 +59,8 @@ struct IndexEntry { skip_serializing_if = "Option::is_none" )] default_mcp_tool_policy: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + default_contactable: bool, } /// On-disk shape of `templates/index.json`. @@ -170,6 +172,7 @@ impl FsTemplateStore { default_agent_name: entry.default_agent_name.clone(), default_system_permissions: entry.default_system_permissions.clone(), default_mcp_tool_policy: entry.default_mcp_tool_policy.clone(), + default_contactable: entry.default_contactable, }) } } @@ -220,6 +223,7 @@ impl TemplateStore for FsTemplateStore { default_agent_name: template.default_agent_name.clone(), default_system_permissions: template.default_system_permissions.clone(), default_mcp_tool_policy: template.default_mcp_tool_policy.clone(), + default_contactable: template.default_contactable, }; if let Some(slot) = index.templates.iter_mut().find(|e| e.id == template.id) { *slot = row; diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 29f54669..aa30ee3b 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -109,6 +109,7 @@ impl FakeContexts { synced_template_version: None, skills: Vec::new(), effort: None, + contactable: false, }); id } @@ -725,6 +726,12 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); for expected in [ + // Cross-project agent control tools (#263). + "idea_project_list", + "idea_project_read_context", + "idea_project_list_agents", + "idea_project_ask_agent", + "idea_project_cancel_agent", "idea_list_agents", "idea_ask_agent", "idea_ask_agents", @@ -776,8 +783,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 37, - "exactly the thirty-seven exposed idea_* tools; got {names:?}" + 47, + "exactly the forty-seven exposed idea_* tools; got {names:?}" ); // Every tool advertises an object input schema. diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index a28026fb..1690e364 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -98,6 +98,7 @@ impl FakeContexts { synced_template_version: None, skills: Vec::new(), effort: None, + contactable: false, }); id } diff --git a/crates/infrastructure/tests/template_store.rs b/crates/infrastructure/tests/template_store.rs index 08010676..90528844 100644 --- a/crates/infrastructure/tests/template_store.rs +++ b/crates/infrastructure/tests/template_store.rs @@ -70,6 +70,7 @@ async fn save_then_get_and_list_roundtrip() { Some(McpToolPolicy { allowed_tools: vec!["idea_template_read".to_owned()], }), + true, ) .unwrap(); store.save(&t).await.unwrap(); @@ -137,6 +138,7 @@ async fn index_is_camelcase_with_content_hash() { Some(McpToolPolicy { allowed_tools: vec!["idea_template_read".to_owned()], }), + true, ) .unwrap(); store.save(&template).await.unwrap(); @@ -171,6 +173,10 @@ async fn index_is_camelcase_with_content_hash() { .and_then(|v| v.as_str()), Some("idea_template_read") ); + assert_eq!( + entry.get("defaultContactable").and_then(|v| v.as_bool()), + Some(true) + ); assert!(entry.get("content_hash").is_none(), "no snake_case leak"); } diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts index c58fe6b7..298c5e9f 100644 --- a/frontend/src/adapters/http/httpInvoker.test.ts +++ b/frontend/src/adapters/http/httpInvoker.test.ts @@ -264,7 +264,7 @@ describe("request/response gateways preserve the Tauri command contract", () => contentMd: "# Body", version: 1, defaultProfileId: "p1", - defaultSystemPermissions: { network: "allow" }, + defaultSystemPermissions: { network: "allow", crossProjectContactable: true }, defaultMcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }; const spy = vi @@ -276,7 +276,7 @@ describe("request/response gateways preserve the Tauri command contract", () => await expect(gw.listTemplates()).resolves.toMatchObject([ { - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }, ]); @@ -285,11 +285,11 @@ describe("request/response gateways preserve the Tauri command contract", () => name: "Permissioned", content: "# Body", defaultProfileId: "p1", - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }), ).resolves.toMatchObject({ - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }); await expect( @@ -297,11 +297,11 @@ describe("request/response gateways preserve the Tauri command contract", () => name: "Permissioned", content: "# Body", defaultProfileId: "p1", - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }), ).resolves.toMatchObject({ - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }); @@ -311,7 +311,7 @@ describe("request/response gateways preserve the Tauri command contract", () => content: "# Body", defaultProfileId: "p1", defaultAgentName: null, - defaultSystemPermissions: { network: "allow" }, + defaultSystemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }, }); @@ -322,7 +322,7 @@ describe("request/response gateways preserve the Tauri command contract", () => content: "# Body", defaultProfileId: "p1", defaultAgentName: null, - defaultSystemPermissions: { network: "allow" }, + defaultSystemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }, }); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 5057612e..146104c6 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -820,6 +820,7 @@ export type NetworkPolicy = "allow" | "deny" | "ask"; /** Optional system permission bundle, distinct from file/bash permissions. */ export interface SystemPermissionSet { network?: NetworkPolicy; + crossProjectContactable?: boolean; } /** One sparse agent system-permission override. */ diff --git a/frontend/src/features/permissions/PermissionsPanel.tsx b/frontend/src/features/permissions/PermissionsPanel.tsx index b8e8f79d..ee06eef7 100644 --- a/frontend/src/features/permissions/PermissionsPanel.tsx +++ b/frontend/src/features/permissions/PermissionsPanel.tsx @@ -54,6 +54,19 @@ const NETWORK_LABELS: Record = { ask: "Demande", }; +const CROSS_PROJECT_HELP = + "Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte."; + +function systemPermissionsFromDraft( + network: NetworkPolicy | "", + crossProjectContactable: boolean, +): SystemPermissionSet | null { + const permissions: SystemPermissionSet = {}; + if (network) permissions.network = network; + if (crossProjectContactable) permissions.crossProjectContactable = true; + return Object.keys(permissions).length > 0 ? permissions : null; +} + export function PermissionsPanel({ projectId }: PermissionsPanelProps) { const vm = usePermissions(projectId); const [target, setTarget] = useState({ type: "project" }); @@ -247,6 +260,7 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) { ? vm.systemDocument?.projectDefault ?? null : null } + showCrossProjectContactable={target.type === "agent"} busy={vm.busy} onSave={(permissions) => void handleSaveNetwork(permissions)} /> @@ -320,6 +334,7 @@ interface NetworkPermissionEditorProps { source: SystemPermissionSet | null; inherited: SystemPermissionSet | null; resolved: ResolvedAgentSystemPermissions | null; + showCrossProjectContactable: boolean; busy: boolean; onSave: (permissions: SystemPermissionSet | null) => void; } @@ -329,18 +344,27 @@ function NetworkPermissionEditor({ source, inherited, resolved, + showCrossProjectContactable, busy, onSave, }: NetworkPermissionEditorProps) { const current = source?.network ?? ""; + const currentCrossProjectContactable = + source?.crossProjectContactable ?? false; const [local, setLocal] = useState(current); + const [localCrossProjectContactable, setLocalCrossProjectContactable] = + useState(currentCrossProjectContactable); useEffect(() => { setLocal(current); - }, [current]); + setLocalCrossProjectContactable(currentCrossProjectContactable); + }, [current, currentCrossProjectContactable]); const readOnly = resolved?.control.mode === "readOnly"; - const changed = local !== current; + const changed = + local !== current || + (showCrossProjectContactable && + localCrossProjectContactable !== currentCrossProjectContactable); const wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null; const effective = resolved?.effective ?? null; const runtimeLocked = resolved?.runtimeLock.state === "locked"; @@ -432,12 +456,36 @@ function NetworkPermissionEditor({

)} + {showCrossProjectContactable && ( + + )} +
diff --git a/frontend/src/features/permissions/permissions.test.tsx b/frontend/src/features/permissions/permissions.test.tsx index b8239812..3c66bbc0 100644 --- a/frontend/src/features/permissions/permissions.test.tsx +++ b/frontend/src/features/permissions/permissions.test.tsx @@ -102,6 +102,33 @@ describe("PermissionsPanel", () => { expect(screen.getAllByText("Réseau autorisé").length).toBeGreaterThan(0); }); + it("defaults cross-project contactability off and persists the agent checkbox", async () => { + const { permission } = await renderPanel(); + + fireEvent.click(screen.getByText("Builder")); + + const checkbox = screen.getByLabelText( + "Contactable depuis d’autres projets", + ) as HTMLInputElement; + expect(checkbox.checked).toBe(false); + expect( + screen.getByText( + "Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte.", + ), + ).toBeTruthy(); + + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole("button", { name: "Save network" })); + + await waitFor(async () => { + const doc = await permission.getProjectSystemPermissions(PROJECT_ID); + expect(doc.agents?.[0]).toEqual({ + agentId: expect.any(String), + permissions: { crossProjectContactable: true }, + }); + }); + }); + it("renders the network control read-only when persisted control says so", async () => { const agent = new MockAgentGateway(); const permission = new MockPermissionGateway(); diff --git a/frontend/src/features/templates/TemplateEditor.tsx b/frontend/src/features/templates/TemplateEditor.tsx index bd84a66f..8f009b49 100644 --- a/frontend/src/features/templates/TemplateEditor.tsx +++ b/frontend/src/features/templates/TemplateEditor.tsx @@ -39,6 +39,9 @@ const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [ { value: "deny", label: "Réseau refusé" }, ]; +const TEMPLATE_CROSS_PROJECT_HELP = + "Les agents créés avec ce template reprendront cette permission."; + const ALL_MCP_TOOLS = Array.from( new Set(MCP_TOOL_GROUPS.flatMap((group) => group.tools)), ); @@ -61,6 +64,9 @@ export function TemplateEditor({ const [network, setNetwork] = useState<"" | NetworkPolicy>( template?.systemPermissions?.network ?? "", ); + const [crossProjectContactable, setCrossProjectContactable] = useState( + template?.systemPermissions?.crossProjectContactable ?? false, + ); const [allowedTools, setAllowedTools] = useState( template?.mcpToolPermissions?.allowedTools ?? [], ); @@ -85,7 +91,15 @@ export function TemplateEditor({ content, defaultProfileId, defaultAgentName: defaultAgentName.trim() || null, - systemPermissions: network ? { network } : null, + systemPermissions: + network || crossProjectContactable + ? { + ...(network ? { network } : {}), + ...(crossProjectContactable + ? { crossProjectContactable: true } + : {}), + } + : null, mcpToolPermissions: allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null, }); @@ -256,6 +270,25 @@ export function TemplateEditor({ options={NETWORK_OPTIONS} />
+ +
diff --git a/frontend/src/features/templates/templates.test.tsx b/frontend/src/features/templates/templates.test.tsx index ea3cc561..36a39373 100644 --- a/frontend/src/features/templates/templates.test.tsx +++ b/frontend/src/features/templates/templates.test.tsx @@ -173,6 +173,7 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => { target: { value: "profile-main" }, }); chooseDropdownOption("template system network permission", "Réseau autorisé"); + fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets")); fireEvent.click(screen.getByLabelText("template MCP tool idea_ticket_read")); fireEvent.click(screen.getByRole("button", { name: "Save template" })); @@ -186,11 +187,30 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => { expect(templates[0]).toMatchObject({ defaultAgentName: "Main", defaultProfileId: "profile-main", - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }); }); + it("defaults template cross-project contactability off when the field is absent", async () => { + renderTemplatesPanel(); + await waitForTemplatesIdle(); + + fireEvent.click(screen.getByRole("button", { name: "create template" })); + await waitFor(() => expect(screen.getByLabelText("template name")).toBeTruthy()); + + const checkbox = screen.getByLabelText( + "Contactable depuis d’autres projets", + ) as HTMLInputElement; + + expect(checkbox.checked).toBe(false); + expect( + screen.getByText( + "Les agents créés avec ce template reprendront cette permission.", + ), + ).toBeTruthy(); + }); + it("the Create button is disabled when the name is empty", async () => { renderTemplatesPanel(); await waitForTemplatesIdle();