diff --git a/.ideai/briefs/orchestration-v3-cadrage.md b/.ideai/briefs/orchestration-v3-cadrage.md new file mode 100644 index 0000000..1c67454 --- /dev/null +++ b/.ideai/briefs/orchestration-v3-cadrage.md @@ -0,0 +1,280 @@ +# Cadrage Architecture — Orchestration v3 : invocation native d'agents (surface MCP) + +> Produit par **Architect** en réponse au brief `orchestration-v3-invocation-native.md`. +> Cadrage **avant tout code** (méthode §3). Livrable : ce document + mise à jour `ARCHITECTURE.md` §14.3. +> Aucun code de production ici. + +--- + +## 0. État réel du terrain — ce qui est DÉJÀ résolu (lu dans le code, pas présumé) + +Le brief décrit trois faiblesses (conscience = prose, pas de discussion inter-agents, +fire-and-forget). **Deux des trois sont déjà comblées par le pivot §17** (livré, lots D0→D7). +Il faut le constater honnêtement pour ne **pas re-cadrer** ce qui existe : + +| Faiblesse du brief | Statut réel | Référence code | +|---|---|---| +| Pas de discussion inter-agents (`agent.message` « future », `task` ignoré pour agent vivant) | ✅ **RÉSOLU** | `OrchestratorCommand::AskAgent` (`domain/src/orchestrator.rs`), `OrchestratorService::ask_agent` (`application/src/orchestrator/service.rs`) | +| Fire-and-forget, pas de corrélation requête↔réponse | ✅ **RÉSOLU sans outbox** : le rendez-vous synchrone est **intrinsèque** à `AgentSession::send()` (flux → `Final` déterministe). Le `Final` *est* la fin de tour. | `application/src/agent/structured.rs` (`send_blocking`), `domain/src/ports.rs` (`ReplyEvent::Final`) | +| Pas de réveil du demandeur / event de réponse | ✅ **RÉSOLU** | `DomainEvent::AgentReplied`, `OrchestratorResponse.reply` (`infrastructure/src/orchestrator/mod.rs`) | +| Conscience = soft prompt (l'agent doit deviner le schéma JSON) | ⚠️ **PARTIEL** : la prose `# Orchestration IdeA` est injectée (`compose_convention_file`), mais **aucun outil typé natif** n'est exposé. | `application/src/agent/lifecycle.rs` | +| Interdiction des subagents natifs **+** alternative native | ⚠️ **PARTIEL** : interdiction présente (prose) ; l'alternative native (outils `idea_*`) **manque encore**. | idem | +| Capacité MCP sur le profil | ❌ **ABSENT** | — | +| Serveur MCP / config MCP par CLI | ❌ **ABSENT** | — | + +**Conclusion de cadrage** : l'orchestration v3 **n'est plus** « combler la messagerie inter-agents » +(c'est fait). Elle se réduit à **un seul chantier net** : **exposer l'orchestration IdeA comme +serveur MCP model-agnostic**, en tant qu'**adapter entrant supplémentaire** par-dessus le **même** +`OrchestratorService::dispatch`, avec **repli homogène** sur le protocole fichier `.ideai/requests` +(§14.3) + prose (`compose_convention_file`) pour les CLI sans MCP. C'est ce que cadre la suite. + +> **Principe directeur (zéro régression, §9/§17.3)** : MCP est un **confort de conscience native** +> (outils typés, plus de schéma à deviner). Il **n'invente aucune sémantique** : tout outil MCP se +> ramène à un `OrchestratorCommand` déjà existant. La voie principale du *retour de valeur* reste +> §17 (`send_blocking`) ; MCP ne fait que **déclencher** `dispatch`, jamais re-router la réponse. + +--- + +## 1. Décisions tranchées (les 4 points durs du brief) + +### Décision 1 — Capacité MCP par runtime = champ optionnel `mcp` sur `AgentProfile` (Open/Closed) + +**Tranché** : on ajoute un champ **optionnel** `mcp: Option` sur `AgentProfile`, +exactement comme `session: Option` et `structured_adapter: Option` +le sont déjà. `None` (défaut) ⇒ **repli fichier + prose** (comportement actuel, zéro régression). +`Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et l'agent voit les outils `idea_*`. + +```rust +// domain/src/profile.rs — capacité MCP déclarative (pur, validé par constructeur, comme SessionStrategy) + +/// Stratégie de matérialisation de la config MCP propre à UNE CLI : chaque CLI +/// déclare son serveur MCP différemment (fichier `.mcp.json` pour Claude Code, +/// flag de lancement, ou variable d'env). Déclaratif = donnée, pas code (§9). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "strategy")] +pub enum McpConfigStrategy { + /// Écrire un fichier de conf MCP au chemin (relatif au run dir isolé §14.1) + /// attendu par la CLI, au format JSON propre à cette CLI (ex. `.mcp.json`). + ConfigFile { target: String }, // relative_safe(target) — pas de `..`, pas d'absolu + /// Passer le serveur via un flag de lancement (ex. `--mcp-config {path}`). + Flag { flag: String }, // non_empty(flag) + /// Passer via une variable d'environnement. + Env { var: String }, // valid_env_var(var) +} + +/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI, +/// et QUEL transport. `None` sur le profil ⇒ repli fichier `.ideai/requests` + prose. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpCapability { + /// Comment matérialiser la config MCP au lancement (relatif au run dir). + pub config: McpConfigStrategy, + /// Transport du serveur MCP IdeA (détail invisible au domaine ; voir D3). + /// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert). + #[serde(default)] + pub transport: McpTransport, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum McpTransport { #[default] Stdio, Socket } +``` + +Sur `AgentProfile`, additif et **non cassant** (sérialisation inchangée pour les profils sans MCP) : +```rust +#[serde(default, skip_serializing_if = "Option::is_none")] +pub mcp: Option, +``` +Builder additif (comme `with_structured_adapter`) : `AgentProfile::new(...).with_mcp(cap)` ; la +signature de `AgentProfile::new` reste **inchangée** ⇒ tous les appels du catalogue/tests restent verts. + +**Justification** : cohérence §9 (« ajouter une IA = donnée, pas code »), symétrie avec les deux autres +capacités optionnelles déjà sur le profil, `skip_serializing_if = None` ⇒ **zéro régression** de +sérialisation. Le **prédicat de surface** est `profile.mcp.is_some()` — un seul point de vérité. + +> **Modèle en couches (exigé par le brief §4.1)** : la surface effective d'un agent est +> `surface(agent) = if profile.mcp.is_some() { Mcp } else { FileProtocol }`. Les deux couches +> produisent le **même** `OrchestratorCommand`. Aucun agent n'est jamais bloqué : sans MCP, la prose +> `# Orchestration IdeA` + `.ideai/requests` reste pleinement fonctionnelle. + +--- + +### Décision 2 — Retour synchrone d'`ask` = AUCUN nouveau modèle de corrélation : on réutilise `send_blocking` + +**Tranché (et c'est la décision la plus importante)** : l'outil MCP `idea_ask_agent` **ne crée +aucune corrélation requête↔réponse, aucun outbox, aucun event de réveil neufs**. Il appelle le +**même** `OrchestratorService::dispatch(AskAgent { target, task })` que le watcher fichier, qui +**retourne déjà** `OrchestratorOutcome { reply: Some(content) }` via `send_blocking`. L'adapter MCP +**renvoie ce `content` inline** comme valeur de retour de l'outil. Fin. + +Le brief (rédigé avant le pivot §17) supposait qu'`ask` était un point dur à résoudre via outbox + +corrélation fichier. **Le pivot §17 l'a déjà tranché autrement** : le `Final` du `ReplyStream` *est* +la fin de tour déterministe ; pas besoin de deviner, pas d'outbox, pas de `request_id`. On **n'y +revient pas**. Le tableau ci-dessous fige la sémantique, déjà implémentée : + +| Aspect | Décision (déjà en place) | Code | +|---|---|---| +| **Corrélation** | Aucune : `dispatch` est un appel synchrone `async` ; la réponse est la valeur de retour. Le transport MCP (JSON-RPC) porte nativement la corrélation requête/réponse. | `service.rs::ask_agent` | +| **Outbox** | **Supprimé de la voie principale** (§17.4). Pas réintroduit. | — | +| **Event `AgentReplied`** | **Observabilité UI uniquement** (« Architect a répondu à Main »), best-effort, ne porte pas la valeur. | `reply_outcome` | +| **Timeout** | Borné (`ASK_AGENT_TIMEOUT = 300 s`). À l'expiration : `AgentSessionError::Timeout` → la cible **reste vivante** (non tuée), l'outil MCP renvoie une **erreur typée** ; l'appelant décide. | `send_blocking` | +| **Cible a déjà une session vivante** (one-live-session-per-agent) | `ask` **réutilise** la session structurée vivante (`session_for_agent`) — rendez-vous direct, pas de respawn. Si la cible est vivante en **PTY brut** (profil sans `structured_adapter`) ⇒ erreur typée explicite (**jamais** un ACK trompeur). | `service.rs::ask_agent` étapes 1→3 | +| **Cible morte** | `LaunchAgent` en mode structuré (background) puis `send_blocking`. Garde d'unicité sur **les deux** registres. | idem | + +**Justification** : DRY radical (une seule logique de rendez-vous, partagée par UI chat, watcher +fichier et MCP) ; frontière nette (le domaine ne connaît qu'un `prompt` et un `Final`, jamais un +transcript ni un id de corrélation) ; universalité (marche pour toute CLI structurée Claude/Codex). +**Le seul travail v3 ici est de brancher l'outil MCP sur `dispatch` — pas de re-cadrer le rendez-vous.** + +> **Conséquence produit** : `idea_ask_agent` cible **toujours un agent structuré** (Claude/Codex), +> cohérent avec le menu restreint §17.3/§17.6. Un agent **demandeur** peut être n'importe quelle CLI +> MCP (Claude, Codex, Gemini…) ; un agent **cible** d'un `ask` doit être structuré. C'est déjà +> l'invariant en vigueur — MCP ne le change pas. + +--- + +### Décision 3 — MCP vs subagents natifs : on garde l'interdiction ET on offre l'alternative native, config injectée par CLI au lancement + +**Tranché** : l'interdiction des subagents natifs (prose `# Orchestration IdeA`) **reste** — elle +protège l'identité/mémoire/observabilité IdeA. Mais on offre désormais la **vraie alternative +native** : les outils `idea_*` apparaissent dans la liste d'outils de la CLI. La prose est **adaptée +selon la surface** : +- agent **MCP** (`profile.mcp.is_some()`) : la prose pointe vers les outils `idea_ask_agent` / + `idea_launch_agent` / `idea_list_agents` (au lieu d'« écris un JSON dans `.ideai/requests` »). +- agent **fichier** (`mcp == None`) : prose `.ideai/requests` actuelle, **inchangée**. + +**Injection de la config MCP par CLI = au `LaunchAgent`, dans le run dir isolé (§14.1), via la +`McpConfigStrategy`** — exactement le même point et la même mécanique que le convention file : +``` +LaunchAgent::execute (après apply_injection, avant spawn/factory.start) : + if let Some(mcp) = &profile.mcp: + // IdeA matérialise SA config MCP au format de CETTE CLI dans /... + apply_mcp_config(mcp, &run_dir, &spec) // ConfigFile→write ; Flag→spec.args ; Env→spec.env +``` +- `ConfigFile { target }` : écrit `/` (ex. `.mcp.json`) avec la déclaration du + serveur MCP IdeA (commande/transport). Non-clobbering, best-effort, **comme le seed de permissions**. +- `Flag { flag }` : ajoute le flag + chemin au `SpawnSpec.args`. +- `Env { var }` : ajoute la variable au `SpawnSpec.env`. + +Le **serveur MCP lui-même** est démarré **par projet ouvert**, à côté du `FsOrchestratorWatcher`, +dans le **même hook** `ensure_orchestrator_watch` (`app-tauri/src/state.rs`). Une CLI qui se lance +avec la config injectée se connecte à ce serveur (stdio : IdeA spawn un pont par session ; socket : +adresse partagée — détail d'adapter, point ouvert S-MCP). + +**Justification** : symétrie totale avec le convention file et le seed de permissions (même run dir, +même best-effort non-clobbering, même moment) ⇒ aucune nouvelle plomberie de cycle de vie. La config +MCP est **donnée déclarative par profil**, donc « ajouter une CLI MCP = donnée, pas code ». + +--- + +### Décision 4 — Frontières hexagonales : le serveur MCP est un adapter entrant d'infrastructure ; AUCUN nouveau port applicatif + +**Tranché** : le serveur MCP est un **driving adapter d'infrastructure** +(`infrastructure/src/orchestrator/mcp/`), **pair** du `FsOrchestratorWatcher`. Il appelle le **même** +`OrchestratorService::dispatch` (application) et **ne duplique rien**. Trois portes d'entrée +substituables se ramènent au même `OrchestratorCommand` : + +``` + ┌─────────────────────────────────────────────┐ + Agent MCP ───▶│ Serveur MCP (infra/orchestrator/mcp) │──┐ + └─────────────────────────────────────────────┘ │ + ┌─────────────────────────────────────────────┐ │ OrchestratorCommand + Fichier ───▶│ FsOrchestratorWatcher (infra/orchestrator) │──┼──▶ OrchestratorService::dispatch + (.ideai/ └─────────────────────────────────────────────┘ │ (application — INCHANGÉ) + requests) ┌─────────────────────────────────────────────┐ │ │ + UI ───▶│ Commandes Tauri (app-tauri) │──┘ ▼ + └─────────────────────────────────────────────┘ use cases agent/terminal +``` + +- **Où vit le serveur MCP** : `infrastructure/src/orchestrator/mcp/`. Il **traduit** un appel d'outil + MCP (`idea_ask_agent`, `idea_launch_agent`, `idea_list_agents`, et par parité `idea_update_context`, + `idea_create_skill`, `idea_stop_agent`) en `OrchestratorCommand`, appelle `dispatch`, et renvoie + `OrchestratorOutcome` (`reply`/`detail`) inline comme résultat d'outil. JSON-RPC, stdio/socket, + le crate MCP : **tout reste dans cet adapter**. Le domaine/application ignorent MCP. +- **Quel port côté domaine/application** : **aucun nouveau**. `OrchestratorService::dispatch` + (application) est déjà l'unique seam. `idea_list_agents` réutilise `ListAgents`. La validation + (`OrchestratorRequest::validate`) reste le point unique « parse, don't validate » — l'adapter MCP + construit un `OrchestratorCommand` (directement, ou via `OrchestratorRequest` pour réutiliser la + validation, au choix d'implémentation). +- **Réutilisation de `OrchestratorService` plutôt que duplication** : le serveur MCP reçoit + `Arc` au composition root (`state.rs`), exactement comme le watcher. Une seule + logique applicative ; les adapters ne portent que leur techno d'entrée. + +**Justification** : DRY + règle de dépendance hexagonale. Cible, identité, mémoire, observabilité UI +passent **toujours** par le seul chemin applicatif. Les spikes MCP (transport, crate) sont **confinés** +à l'adapter infra et ne touchent ni le domaine ni l'application. + +--- + +## 2. Modèle de messages corrélés — état figé (rien de neuf) + +La « corrélation requête↔réponse » du brief est portée **nativement par le transport** : +- **MCP** : JSON-RPC corrèle requête/réponse par `id` de message ⇒ rien à modéliser côté IdeA. +- **Fichier** : `.json` → `.json.response.json` (sibling), déjà en place. +- **Valeur de retour** : `OrchestratorOutcome { detail, reply }` (application) → `OrchestratorResponse + { ok, action, detail, error, reply }` (infra fichier) **ou** résultat d'outil MCP. **Structs déjà + définies**, réutilisées telles quelles. + +Aucun `CorrelationId`, aucun `AgentReply`, aucun port `AgentReplyChannel`, aucun outbox : **abandonnés +par le pivot §17** et **non réintroduits** par v3. C'est la simplification clé. + +--- + +## 3. Découpage en LOTS testables (méthode §3) — MCP uniquement + +> Chaque lot = binôme dev+test, vert avant le suivant. Backend/frontend séparés. +> Les terminaux non-IA et le chemin fichier `.ideai/requests` restent verts à chaque lot. +> **Spike S-MCP** (crate MCP Rust + transport stdio/socket + format de conf par CLI) est **confiné au +> lot M2** et n'invalide pas l'ossature (le contrat d'entrée reste `OrchestratorCommand`). + +| Lot | Côté | Périmètre | Crates/dossiers | Contrats | Tests attendus | +|---|---|---|---|---|---| +| **M0 (capacité profil)** | back | `McpCapability` + `McpConfigStrategy` + `McpTransport` (domaine, validés) ; champ `AgentProfile.mcp: Option` (+ builder `with_mcp`, `new` inchangé) ; catalogue Claude/Codex annotés (ex. `ConfigFile { target: ".mcp.json" }`). | `domain/src/profile.rs`, `application/src/agent/catalogue.rs` | enum + struct + champ optionnel sérialisé | unit purs : `mcp = None` round-trip **identique à avant** (zéro régression sérialisation) ; `Some(_)` round-trip ; constructeurs valident (`relative_safe` target, `non_empty` flag, `valid_env_var`) ; catalogue annoté. | +| **M1 (injection conf MCP au lancement)** | back | `LaunchAgent` matérialise la conf MCP selon `McpConfigStrategy` dans le run dir isolé (après `apply_injection`, avant spawn/`factory.start`) : `ConfigFile`→write non-clobbering, `Flag`→`args`, `Env`→`env`. Prose `compose_convention_file` adaptée selon `mcp.is_some()`. | `application/src/agent/lifecycle.rs` | `LaunchAgent` (chemin MCP additif) | unit (fakes) : profil `mcp=None` ⇒ **aucun** write/flag/env MCP (chemin actuel inchangé) ; `ConfigFile` ⇒ fichier écrit au bon chemin, non-clobbering ; `Flag`/`Env` ⇒ `spec` enrichi ; prose contient les outils `idea_*` si MCP, sinon `.ideai/requests`. | +| **M2 (serveur/adapter MCP)** | back | `infrastructure/src/orchestrator/mcp/` : serveur MCP exposant `idea_ask_agent`/`idea_launch_agent`/`idea_list_agents` (+ parité `idea_update_context`/`idea_create_skill`/`idea_stop_agent`) → `OrchestratorCommand` → `dispatch` → résultat inline. **Spike S-MCP** (crate, transport) isolé ici. | `infrastructure/src/orchestrator/mcp/` | mapping outil→commande ; `Arc` injecté | unit (fakes + `OrchestratorService` à use cases fakes) : chaque outil mappe la bonne commande ; `idea_ask_agent` renvoie `reply` inline ; timeout → erreur typée, cible non tuée ; `idea_list_agents` liste ; JSON-RPC malformé → erreur, jamais panic. Hors-réseau (transport en mémoire/pipe scriptable). | +| **M3 (câblage par projet)** | back | Démarrer le serveur MCP par projet ouvert dans `ensure_orchestrator_watch` (à côté du watcher) ; registre `mcp_servers` jumeau de `orchestrator_watchers` ; arrêt à la fermeture du projet. | `app-tauri/src/state.rs`, `commands.rs` | hook `ensure_orchestrator_watch` étendu | app-tauri : un serveur MCP par projet, idempotent ; fermeture du projet ⇒ arrêt ; coexiste avec le watcher fichier (les deux portes vivantes). | +| **M4 (observabilité UI — optionnel)** | front | Surfacer dans l'UI Agents qu'une délégation est passée par MCP vs fichier (badge/source sur l'event `OrchestratorRequestProcessed` / `AgentReplied`). Non bloquant. | `frontend/src/features/agents` | DTO d'event enrichi (`source: "mcp"|"file"`) | Vitest : badge source affiché ; absence d'event ⇒ pas de régression. | + +**Ordre conseillé** : **M0 → M1 → M2 → M3** (→ M4 optionnel). M0 débloque tout (donnée pure) ; +M1 injecte la conf (testable sans serveur) ; M2 livre l'adapter derrière un transport scriptable +(spike confiné) ; M3 le câble par projet. M4 est du confort d'observabilité. + +--- + +## 4. Conformité hexagonale & SOLID (rappel) + +- **Règle de dépendance** : `McpCapability`/`McpConfigStrategy` sont **domaine** (purs, validés). + Le serveur MCP, JSON-RPC, stdio/socket, le crate MCP sont **exclusivement** infra. Le domaine et + l'application **ignorent** MCP (l'application ne voit que `OrchestratorCommand`/`dispatch`). +- **S** : le serveur MCP = une seule techno d'entrée (MCP→commande). `OrchestratorService` garde sa + responsabilité (commande→use cases). `LaunchAgent` gagne une étape d'injection homogène, pas une + responsabilité nouvelle. +- **O** : ajouter une CLI MCP = un bloc `mcp` sur le profil (**donnée**). Aucun cœur touché. +- **L** : les trois portes d'entrée (fichier, MCP, UI) sont substituables — même `dispatch`, même + résultat. Repli fichier ≡ MCP du point de vue de la réponse. +- **I** : le serveur MCP ne reçoit que `Arc` (pas les use cases en détail). +- **D** : tout injecté au composition root (`state.rs`) ; aucun `new` d'adapter MCP ailleurs. + +--- + +## 5. Chantiers adjacents (situés, NON cadrés ici) + +- **Hot-swap de l'AI profile** (chantier A, §15.1) : **LIVRÉ** (`ChangeAgentProfile`). Interaction + avec v3 : un swap vers/depuis un profil MCP change la surface (`mcp.is_some()`) ⇒ au relance, + `LaunchAgent` (ré)injecte ou retire la conf MCP automatiquement. **Rien à cadrer** : la surface suit + le profil courant, point de vérité unique. +- **Reprise auto des sessions au redémarrage** (chantier B, §15.2) : **LIVRÉ** (`ListResumableAgents`, + `conversation_id` persisté sur la cellule). Interaction avec v3 : à la reprise, `LaunchAgent` + ré-matérialise la conf MCP comme à tout lancement (M1). **Rien à cadrer**. + +Ces deux chantiers **ne sont pas un prérequis** de v3/MCP et n'en bloquent aucun lot. + +--- + +## 6. Synthèse des décisions + +1. **Capacité MCP = `Option` sur le profil** (Open/Closed, `None` ⇒ repli fichier+prose, zéro régression sérialisation). +2. **Retour synchrone d'`ask` : RIEN de neuf** — réutilise `send_blocking`/`AskAgent`/`AgentReplied`/`OrchestratorOutcome.reply` déjà livrés (§17). Pas d'outbox, pas de corrélation fichier, pas de `CorrelationId`. Le transport MCP corrèle nativement. +3. **Interdiction subagents natifs conservée + alternative native** : prose adaptée selon surface ; conf MCP injectée **par CLI** au `LaunchAgent` dans le run dir isolé (`McpConfigStrategy` : ConfigFile/Flag/Env), symétrique au convention file et au seed permissions. +4. **Frontières** : serveur MCP = **adapter entrant infra** (`infrastructure/src/orchestrator/mcp/`), pair du watcher fichier, appelant le **même** `OrchestratorService::dispatch`. **Aucun nouveau port** applicatif/domaine. +5. **Lots** : M0 (capacité profil) → M1 (injection conf) → M2 (adapter/serveur MCP, spike confiné) → M3 (câblage par projet) → M4 (observabilité, optionnel). diff --git a/.ideai/briefs/orchestration-v3-invocation-native.md b/.ideai/briefs/orchestration-v3-invocation-native.md new file mode 100644 index 0000000..663208e --- /dev/null +++ b/.ideai/briefs/orchestration-v3-invocation-native.md @@ -0,0 +1,85 @@ +# Brief Architecture — Orchestration v3 : invocation native d'agents (surface MCP + repli fichier) + +> Demandé par **Main** à **Architect**. Cadrage attendu **avant tout code** (méthode §3). +> Ce brief ne prescrit pas l'implémentation : il pose le problème, les contraintes et les +> décisions à trancher. À toi de produire la cartographie (ports, adapters, modèles, lots). + +## 1. Contexte & problème + +Aujourd'hui, un agent apprend qu'il doit déléguer via IdeA **uniquement par une instruction +en prose** injectée en tête de son convention file (`compose_convention_file`, +`crates/application/src/agent/lifecycle.rs` → bloc « # Orchestration IdeA »). Il écrit alors +un JSON dans `.ideai/requests//*.json`, capté par l'`OrchestratorWatcher` +(`crates/infrastructure/src/orchestrator/mod.rs`) → validé par le modèle domaine pur +(`crates/domain/src/orchestrator.rs`) → exécuté par `OrchestratorService`. + +**Trois faiblesses constatées dans le code :** + +1. **Conscience = soft prompt.** Rien ne contraint l'agent ; rien ne l'empêche d'utiliser + le subagent natif du fournisseur ; le schéma JSON n'est même pas fourni dans l'instruction + (l'agent doit le deviner). +2. **Pas de discussion inter-agents.** `agent.message` est marqué « future ». Le champ `task` + d'`agent.run` est replié dans `context`, mais `OrchestratorService` n'utilise `context` + que pour un agent **neuf** (initial `.md`) : pour un agent **déjà existant**, le `task` est + **silencieusement ignoré**. La réponse (`*.response.json`) ne porte qu'un ACK de cycle de + vie (`detail: "launched agent X"`), jamais la sortie produite par la cible. +3. **Fire-and-forget.** Aucune corrélation requête↔réponse de contenu, aucun réveil du + demandeur. + +## 2. Objectif produit (vision Anthony) + +Rendre l'invocation d'un agent par un autre **aussi native que l'invocation de subagents dans +Claude CLI** (l'outil `Task` : le modèle voit un outil typé, l'appelle, et **le résultat +revient inline** dans sa conversation) — mais de façon **model-agnostic** (Claude, Codex, +Gemini, custom) et **toujours médiée par IdeA** (qui garde identité, contexte, mémoire, +observabilité UI). + +## 3. Direction pressentie (à valider/affiner par l'Architecte) + +**Exposer l'orchestration IdeA comme un serveur MCP** que IdeA branche sur chaque CLI qui le +supporte (Claude Code, Codex, Gemini CLI supportent MCP). Outils pressentis : + +| Outil MCP | Effet | +|---|---| +| `idea_ask_agent(target, task) → reply` | Lance/réveille la cible, transmet la tâche, **attend et renvoie sa réponse** inline | +| `idea_launch_agent(target, visibility)` | Lancement fire-and-forget (équiv. `agent.run` actuel) | +| `idea_list_agents() → […]` | Découverte des agents du projet | + +Bénéfices : conscience native (l'outil apparaît dans la liste d'outils, plus de prose à +« se rappeler »), arguments typés/validés (fini le JSON deviné), et surtout `ask_agent` +**renvoie le contenu** → comble la messagerie inter-agents manquante. + +## 4. Points durs à trancher (cœur du cadrage) + +1. **Capacité par runtime.** Tous les profils ne supportent pas MCP/outils (custom CLI). + → Modèle **en couches** : surface MCP quand le profil le déclare ; **repli sur le protocole + fichier `.ideai/requests` + prose** sinon. Le port `AgentRuntime` gagne une capacité + déclarative (`supportsMcp` ou descripteur de capacités). Comment exprimer ça dans le profil + déclaratif (§9) sans casser l'existant ? +2. **Retour synchrone d'`ask_agent`.** C'est le vrai défi : « attendre que la cible ait fini + son tour et capturer sa sortie » pour un fournisseur arbitraire = même problème que + l'inspecteur de session (cf. mémoire `conversation-resume-architecture`). Piste : la cible + écrit sa réponse dans un **outbox** `.ideai/`, l'outil MCP attend/poll avec corrélation + requête↔réponse + timeout. Définir : modèle de corrélation, event `AgentReplied`, sémantique + de timeout/erreur, et que faire si la cible tourne déjà (one-live-session-per-agent). +3. **MCP vs subagents natifs.** On garde l'interdiction des subagents natifs (sinon + court-circuit d'IdeA = perte identité/mémoire/observabilité), mais on offre désormais une + **vraie alternative native**, pas qu'une interdiction. Comment configurer/injecter le serveur + MCP par CLI (chaque CLI a sa propre conf MCP) depuis le lancement IdeA ? +4. **Frontières hexagonales.** Où vit le serveur MCP (nouvel adapter d'infrastructure ?), quel + port côté domaine/application, comment il réutilise `OrchestratorService` existant plutôt que + de le dupliquer. + +## 5. Chantiers adjacents (à seulement situer, pas à cadrer ici) + +Garder en tête la cohérence avec deux autres chantiers du même fil « agent = entité » : +- **Hot-swap de l'AI profile** d'un agent existant (absent à toutes les couches aujourd'hui). +- **Reprise auto des sessions au redémarrage** (terrain T5/T7 + `conversation_id` prêt mais + non câblé : rien ne relance les agents `agent_was_running` à l'ouverture du projet). + +## 6. Livrable attendu + +Une cartographie d'architecture pour l'**orchestration v3** : ports & adapters, modèle de +messages (requête/réponse corrélées), capacité runtime MCP, stratégie de repli, découpage en +**lots** testables (méthode §3), et la liste des décisions tranchées avec leur justification. +Mets à jour `ARCHITECTURE.md` (§14.3) en conséquence. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d62de68..a7537fc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -633,7 +633,7 @@ IdeA/ | L13 | **OrchestratorApi** | File-watcher `.ideai/requests/`, port `OrchestratorApi`, adapter `FsOrchestratorAdapter`, protocole `agent.run`/`agent.stop`/`agent.attach`/`agent.detach`/`agent.message`, sessions agent visibles ou arrière-plan. | `domain/agent`, `application/agent`, `infrastructure/orchestrator`, `app-tauri`, `frontend/features/agents` | | L14 | **Mémoire** | Base de connaissance projet model-agnostic. Modèle 2 étages : `.md` source de vérité (port `MemoryStore`), rappel adaptatif (port `MemoryRecall`), embeddings déclaratifs (port `Embedder`), bascule auto sur seuil. Découpé en sous-lots **A/B/C** (voir §14.5). | `domain/memory`, `application/memory`, `infrastructure/store`, `frontend/features/memory` | | L15 | **Agent = entité à session persistante** | Hot-swap de l'AI profile d'un agent existant (chantier A) + reprise des sessions au redémarrage d'IdeA / réouverture projet (chantier B). Fondation commune « l'agent porte un cycle de vie de session ». Découpé en sous-lots **A0/A1/A2 + B0/B1/B2** (voir §15). | `domain/agent`, `application/agent`, `application/layout`, `infrastructure/store`, `app-tauri`, `frontend/features/agents`, `frontend/features/layout` | -| L16 | **Orchestration v3 — invocation native (CLI `idea` universelle + MCP optionnel)** | **Voie principale (cross-model)** : binaire `idea` (`ask` synchrone, `launch`, `reply`, `list-agents`, `next`) + skill built-in « Orchestration IdeA » auto-assigné, branchés sur le **même** `OrchestratorService` ; messagerie inter-agents synchrone via port `AgentReplyChannel` + outbox `.ideai/outbox/` ; tâche à un agent vivant via **inbox** `.ideai/inbox/`. **Optionnel/postérieur** : surface MCP (`idea_*`) + capacité `mcp` déclarative sur le profil, par-dessus le même backend. Découpé en bloc **universel (C0, C1, C-univ-1/2)** puis **MCP (C-mcp-0/1, C4)** — voir §16. | `crates/idea-cli`, `domain/{orchestrator,skill,events,ports,profile}`, `application/orchestrator`, `infrastructure/orchestrator` (+`mcp` optionnel), `app-tauri`, `frontend/features/agents` | +| L16 | **Orchestration v3 — invocation native** | **Voie principale = §17 (LIVRÉE)** : messagerie inter-agents synchrone intrinsèque à `AgentSession::send_blocking` (`AskAgent`, `AgentReplied`), **sans** binaire `idea`/outbox/inbox/`AgentReplyChannel` (abandonnés). **Reste à livrer = surface MCP optionnelle** (§14.3.1) : capacité `mcp` déclarative sur le profil + adapter entrant d'infra (outils `idea_*`) par-dessus le **même** `OrchestratorService::dispatch`, repli homogène fichier+prose sinon. Lots **M0→M3** (+M4 optionnel) — voir §14.3.1 et `.ideai/briefs/orchestration-v3-cadrage.md`. | `domain/profile`, `application/agent`, `infrastructure/orchestrator/mcp`, `app-tauri` | --- @@ -740,7 +740,35 @@ Exemple `skill.create` : **Impact UI/UX** : le layout n'est pas la source de vérité du travail agent. La grille affiche des vues attachées aux sessions. L'UI doit exposer un registre des sessions visibles et arrière-plan, avec actions ouvrir dans une cellule, détacher, déplacer, arrêter, et afficher la relation `requestedBy`/`targetAgent` quand elle existe. -> **Évolution v3 (§16)** : ce protocole fichier reste le **contrat partagé**. L'**orchestration v3** en fait la **voie principale et universelle** via un **binaire `idea`** (client mince sur le PATH de l'agent) + un **skill built-in** auto-assigné, qui écrivent ces fichiers à la place de l'agent — et comble le manque de §14.3 : la **messagerie inter-agents synchrone** (`agent.ask` : transmet une tâche et **renvoie la réponse de contenu** inline via l'outbox, alors qu'ici `task` est ignoré pour un agent déjà vivant et la réponse n'est qu'un ACK). Une tâche à un agent **déjà vivant** passe par une **inbox** qu'il relit (pas un write dans son TUI). Une **surface MCP** (outils `idea_*`) est ajoutée **optionnellement** par-dessus le même `OrchestratorService`. Voir §16. +> **Évolution v3 — état réel (révisé 2026-06-10, cf. `.ideai/briefs/orchestration-v3-cadrage.md`)** : ce protocole fichier reste le **contrat partagé / repli universel**. Le manque historique de §14.3 — la **messagerie inter-agents synchrone** (`task` ignoré pour un agent vivant, réponse = simple ACK) — est désormais **comblé par §17** (pivot livré) : `OrchestratorCommand::AskAgent` + `OrchestratorService::ask_agent` transmettent la tâche et **renvoient la réponse de contenu inline** via `AgentSession::send_blocking` (le `Final` du flux *est* la fin de tour déterministe), event `AgentReplied` pour l'observabilité, **sans outbox ni corrélation fichier** (abandonnés). La cible réutilise sa session structurée vivante (invariant « 1 session/agent » §17.4) ; un timeout laisse la cible vivante (erreur typée). +> +> **Ce qui reste pour v3 = la seule surface MCP optionnelle** (§14.3.1) : un **adapter entrant d'infrastructure** (outils typés `idea_*`) qui appelle le **même** `OrchestratorService::dispatch`, en repli homogène sur ce protocole fichier + prose quand le profil ne déclare pas MCP. **Abandonnés** (ne pas implémenter) : binaire `idea` (`ask/reply/next`), skill built-in auto-rapporté, inbox `.ideai/inbox/`, outbox `.ideai/outbox/`, port `AgentReplyChannel`/`OutboxReplyChannel`, `CorrelationId` (le rendez-vous synchrone est intrinsèque à `send_blocking`, §17.1). §16 est conservé pour mémoire historique uniquement. + +#### 14.3.1 Surface MCP — invocation native d'agents (adapter entrant OPTIONNEL, par-dessus le même `OrchestratorService`) + +> Cadrage complet : `.ideai/briefs/orchestration-v3-cadrage.md`. Cette sous-section fige les 4 décisions et le découpage en lots. Elle **ne réécrit ni le domaine ni l'application** : elle ajoute une **capacité déclarative de profil** + un **adapter entrant d'infra**. + +**Objectif** : rendre l'invocation d'un agent par un autre **aussi native qu'un subagent** (outil typé visible dans la liste d'outils, résultat **inline**), de façon **model-agnostic**, **toujours médiée par IdeA**. On **garde l'interdiction des subagents natifs** (prose) et on offre **la vraie alternative native** (outils `idea_*`). + +**Décision 1 — Capacité MCP = champ optionnel `mcp: Option` sur `AgentProfile`** (Open/Closed, comme `session`/`structured_adapter` ; `skip_serializing_if = None` ⇒ zéro régression sérialisation). `None` ⇒ **repli fichier `.ideai/requests` + prose** (comportement actuel). `Some(_)` ⇒ IdeA matérialise la conf MCP de cette CLI au lancement et l'agent voit les outils. `McpCapability { config: McpConfigStrategy::{ConfigFile{target}|Flag{flag}|Env{var}}, transport: {Stdio|Socket} }`. Modèle **en couches** : `surface(agent) = if profile.mcp.is_some() { Mcp } else { FileProtocol }` — les deux produisent le **même** `OrchestratorCommand` ; aucun agent n'est jamais bloqué. + +**Décision 2 — Retour synchrone d'`ask` : RIEN de neuf.** L'outil `idea_ask_agent` appelle le **même** `dispatch(AskAgent{target,task})` qui **renvoie déjà** `OrchestratorOutcome.reply` via `send_blocking` (§17.4) ; l'adapter MCP renvoie ce contenu **inline**. La corrélation requête↔réponse est portée **nativement par JSON-RPC** (côté MCP) et par le sibling `*.response.json` (côté fichier). **Pas** de `CorrelationId`, pas d'outbox. Timeout borné (300 s) ⇒ cible **vivante**, erreur typée. Cible PTY brut ⇒ erreur explicite (jamais d'ACK trompeur). + +**Décision 3 — Interdiction conservée + injection de la conf MCP par CLI au `LaunchAgent`.** La conf MCP est matérialisée dans le **run dir isolé** (§14.1), **après `apply_injection`, avant le spawn/`factory.start`**, selon `McpConfigStrategy` (`ConfigFile`→write non-clobbering ; `Flag`→`SpawnSpec.args` ; `Env`→`SpawnSpec.env`) — **symétrique** au convention file et au seed de permissions. La prose `compose_convention_file` est **adaptée selon la surface** (outils `idea_*` si MCP, sinon `.ideai/requests`). Le **serveur MCP** est démarré **par projet ouvert**, dans le **même hook** `ensure_orchestrator_watch`, à côté du `FsOrchestratorWatcher`. + +**Décision 4 — Frontières.** Le serveur MCP est un **driving adapter d'infra** (`infrastructure/src/orchestrator/mcp/`), **pair** du `FsOrchestratorWatcher`, qui traduit un appel d'outil (`idea_ask_agent`/`idea_launch_agent`/`idea_list_agents` + parité `idea_update_context`/`idea_create_skill`/`idea_stop_agent`) en `OrchestratorCommand` et appelle le **même** `OrchestratorService::dispatch`. **Aucun nouveau port** domaine/application. Trois portes d'entrée substituables (fichier, MCP, UI) ⇒ une seule logique applicative. JSON-RPC, stdio/socket, le crate MCP : **confinés à l'adapter** ; le domaine/application ignorent MCP. + +**Découpage en LOTS (méthode §3)** — MCP uniquement ; le chemin fichier reste vert à chaque lot ; spike S-MCP (crate + transport + format de conf par CLI) **confiné au lot M2** : + +| Lot | Côté | Périmètre | Tests attendus | +|---|---|---|---| +| **M0** | back | `McpCapability`/`McpConfigStrategy`/`McpTransport` (domaine validés) + champ `AgentProfile.mcp` (builder `with_mcp`, `new` inchangé) ; catalogue Claude/Codex annotés. | round-trip `mcp=None` **identique à avant** ; `Some(_)` round-trip ; constructeurs valident ; catalogue annoté. | +| **M1** | back | `LaunchAgent` injecte la conf MCP (run dir, après `apply_injection`) ; prose adaptée selon `mcp.is_some()`. | `mcp=None` ⇒ aucun write/flag/env MCP (chemin inchangé) ; `ConfigFile` non-clobbering ; `Flag`/`Env` enrichissent `spec` ; prose correcte selon surface. | +| **M2** | back | `infrastructure/src/orchestrator/mcp/` : serveur MCP, outils `idea_*`→`OrchestratorCommand`→`dispatch`→résultat inline. Spike S-MCP isolé. | chaque outil mappe la bonne commande ; `idea_ask_agent` renvoie `reply` inline ; timeout typé, cible vivante ; JSON-RPC malformé → erreur, jamais panic ; hors-réseau. | +| **M3** | back | Démarrer le serveur MCP par projet dans `ensure_orchestrator_watch` (registre `mcp_servers` jumeau de `orchestrator_watchers`) ; arrêt à la fermeture. | un serveur/projet, idempotent ; arrêt à la fermeture ; coexiste avec le watcher fichier. | +| **M4** *(optionnel)* | front | Surfacer la source (`mcp`/`file`) d'une délégation dans l'UI Agents. | badge source ; pas de régression sans event. | + +**Ordre** : **M0 → M1 → M2 → M3** (→ M4 optionnel). Chantiers adjacents **déjà livrés** (non prérequis) : hot-swap profil (A, §15.1) et reprise auto (B, §15.2) — au relance, `LaunchAgent` (ré)injecte/retire la conf MCP automatiquement puisque la surface suit le profil courant. --- diff --git a/Cargo.lock b/Cargo.lock index 5dc05a3..679ca5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1921,6 +1921,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "thiserror 2.0.18", "tokio", "uuid", ] diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index ee17c74..7910306 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -12,7 +12,7 @@ use serde::Serialize; use tauri::{AppHandle, Emitter}; -use domain::events::DomainEvent; +use domain::events::{DomainEvent, OrchestrationSource}; use infrastructure::TokioBroadcastEventBus; /// Name of the Tauri event carrying relayed [`DomainEvent`]s. @@ -126,6 +126,10 @@ pub enum DomainEventDto { action: String, /// Whether IdeA handled it successfully. ok: bool, + /// Which entry door the request arrived through (`"file"` watcher vs + /// `"mcp"` server). Serialised as a lowercase string so the frontend can + /// badge the source. + source: OrchestrationSourceDto, }, /// A memory note was created or updated. #[serde(rename_all = "camelCase")] @@ -170,6 +174,27 @@ pub enum DomainEventDto { }, } +/// Wire mirror of [`OrchestrationSource`]: which entry door a processed +/// orchestration request arrived through. Serialised as a lowercase string +/// (`"file"` / `"mcp"`) the frontend badges on the event. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OrchestrationSourceDto { + /// A `.ideai/requests` JSON file (filesystem watcher). + File, + /// A `tools/call` on the MCP server. + Mcp, +} + +impl From for OrchestrationSourceDto { + fn from(source: OrchestrationSource) -> Self { + match source { + OrchestrationSource::File => Self::File, + OrchestrationSource::Mcp => Self::Mcp, + } + } +} + impl From<&DomainEvent> for DomainEventDto { fn from(e: &DomainEvent) -> Self { match e { @@ -239,10 +264,12 @@ impl From<&DomainEvent> for DomainEventDto { requester_id, action, ok, + source, } => Self::OrchestratorRequestProcessed { requester_id: requester_id.clone(), action: action.clone(), ok: *ok, + source: (*source).into(), }, DomainEvent::MemorySaved { slug } => Self::MemorySaved { slug: slug.as_str().to_string(), diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 57a9e64..c3ffaa4 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -41,9 +41,9 @@ use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, - Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall, - OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock, - TokioBroadcastEventBus, + Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, McpServer, + NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, + SystemClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; @@ -245,6 +245,13 @@ pub struct AppState { /// Guarded by a `Mutex` so the open/close commands can register/unregister /// watchers concurrently. pub orchestrator_watchers: Mutex>, + /// Live IdeA MCP servers, keyed by project — the **twin** of + /// [`orchestrator_watchers`](Self::orchestrator_watchers). One server per open + /// project is the MCP entry door onto the *same* [`OrchestratorService`] the + /// file watcher feeds; both coexist (Décision 4). Started on open/create and + /// stopped on close, exactly like the watcher, via the same `Mutex`-guarded + /// per-project registry. + pub mcp_servers: Mutex>, } impl AppState { @@ -814,6 +821,7 @@ impl AppState { dismiss_embedder_suggestion, orchestrator_service, orchestrator_watchers: Mutex::new(HashMap::new()), + mcp_servers: Mutex::new(HashMap::new()), move_tab, } } @@ -844,6 +852,46 @@ impl AppState { events, ); watchers.insert(project.id, handle); + drop(watchers); + + // Start the MCP server for this project, beside (and in parallel with) the + // file watcher — both are entry doors onto the *same* OrchestratorService + // (Décision 4). Idempotent like the watcher above. + self.ensure_mcp_server(project); + } + + /// Starts an [`McpServer`] for `project` if one is not already registered + /// (idempotent), the **twin** of [`ensure_orchestrator_watch`]. Registers its + /// lifecycle handle in [`mcp_servers`](Self::mcp_servers); dropping/stopping the + /// handle tears down the supervision task. + /// + /// ## Transport decision (point ouvert S-MCP) + /// + /// At project-open time there is **no CLI connected yet**: a real `stdio` + /// transport only has a peer once an MCP-capable agent is launched with the + /// injected MCP config (M1), and the exact transport↔session bind is the open + /// **S-MCP** point of the cadrage. We therefore deliberately do **not** run a + /// blocking `McpServer::serve` here (that would have no peer to talk to and must + /// never figer the open/close of a project). Instead this hook owns the server's + /// **lifecycle** per project — creation now, clean stop on close — parked on a + /// stop signal exactly like the watcher. The per-session transport binding is + /// finalised later (S-MCP) without touching this wiring. + fn ensure_mcp_server(&self, project: &Project) { + let mut servers = self + .mcp_servers + .lock() + .expect("mcp server registry poisoned"); + if servers.contains_key(&project.id) { + return; + } + let bus = Arc::clone(&self.event_bus); + let events: Arc = + Arc::new(move |event| bus.publish(event)); + let handle = McpServerHandle::start( + McpServer::new(Arc::clone(&self.orchestrator_service), project.clone()) + .with_events(events), + ); + servers.insert(project.id, handle); } /// Returns the ids of every currently-open project. @@ -862,6 +910,8 @@ impl AppState { /// Stops and removes the orchestrator watcher for `project_id`, if any. /// Called from `close_project` so a closed project stops consuming requests. + /// Symmetrically stops the project's MCP server (its twin) so both entry doors + /// are torn down together. pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) { if let Some(handle) = self .orchestrator_watchers @@ -871,6 +921,50 @@ impl AppState { { handle.stop(); } + if let Some(handle) = self + .mcp_servers + .lock() + .expect("mcp server registry poisoned") + .remove(project_id) + { + handle.stop(); + } + } +} + +/// Lifecycle handle for a per-project [`McpServer`] supervision task — the **twin** +/// of [`OrchestratorWatchHandle`](infrastructure::OrchestratorWatchHandle). +/// +/// It carries the **same** stop mechanism as the watcher (a one-shot +/// `mpsc::Sender<()>`): [`stop`](Self::stop) signals the task to exit, and dropping +/// the handle stops it too (the channel closes). The supervision task keeps the +/// `McpServer` alive and ready, parked on the stop signal; it spawns **no blocking +/// loop**, so it never figes the open/close of a project (see the transport +/// decision on [`AppState::ensure_mcp_server`]). +pub struct McpServerHandle { + stop: tokio::sync::mpsc::Sender<()>, +} + +impl McpServerHandle { + /// Spawns the supervision task that owns `server` until stopped. Must run inside + /// the ambient Tokio runtime (Tauri async commands satisfy this), exactly like + /// [`FsOrchestratorWatcher::start`](infrastructure::FsOrchestratorWatcher). + #[must_use] + fn start(server: McpServer) -> Self { + let (stop_tx, mut stop_rx) = tokio::sync::mpsc::channel::<()>(1); + tokio::spawn(async move { + // Hold the server for this project's lifetime; park until stopped. When a + // per-session transport bind lands (S-MCP), it attaches to this `server`. + let _server = server; + let _ = stop_rx.recv().await; + }); + Self { stop: stop_tx } + } + + /// Signals the supervision task to stop (best-effort; dropping the handle also + /// stops it). Mirrors [`OrchestratorWatchHandle::stop`](infrastructure::OrchestratorWatchHandle::stop). + pub fn stop(&self) { + let _ = self.stop.try_send(()); } } diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs index 5b21614..9c4bfb2 100644 --- a/crates/app-tauri/tests/dto.rs +++ b/crates/app-tauri/tests/dto.rs @@ -138,6 +138,64 @@ fn domain_event_dto_maps_pty_output_bytes() { assert_eq!(v["bytes"], json!([1, 2, 3])); } +// --------------------------------------------------------------------------- +// M4 — orchestration source on `orchestratorRequestProcessed` +// --------------------------------------------------------------------------- + +#[test] +fn orchestration_source_dto_serialises_lowercase() { + use app_tauri_lib::events::OrchestrationSourceDto; + use domain::events::OrchestrationSource; + + // File → "file"; Mcp → "mcp" (camelCase rename on a unit enum = lowercase). + let file = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap(); + assert_eq!(file, json!("file")); + + let mcp = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::Mcp)).unwrap(); + assert_eq!(mcp, json!("mcp")); +} + +#[test] +fn domain_event_dto_maps_orchestrator_request_processed_with_file_source() { + use domain::events::OrchestrationSource; + + let dto = DomainEventDto::from(&DomainEvent::OrchestratorRequestProcessed { + requester_id: "Main".into(), + action: "spawn_agent".into(), + ok: true, + source: OrchestrationSource::File, + }); + let v = serde_json::to_value(&dto).unwrap(); + assert_eq!( + v, + json!({ + "type": "orchestratorRequestProcessed", + "requesterId": "Main", + "action": "spawn_agent", + "ok": true, + "source": "file", + }) + ); +} + +#[test] +fn domain_event_dto_maps_orchestrator_request_processed_with_mcp_source() { + use domain::events::OrchestrationSource; + + let dto = DomainEventDto::from(&DomainEvent::OrchestratorRequestProcessed { + requester_id: "mcp".into(), + action: "idea_ask_agent".into(), + ok: false, + source: OrchestrationSource::Mcp, + }); + let v = serde_json::to_value(&dto).unwrap(); + assert_eq!(v["type"], "orchestratorRequestProcessed"); + assert_eq!(v["requesterId"], "mcp"); + assert_eq!(v["action"], "idea_ask_agent"); + assert_eq!(v["ok"], json!(false)); + assert_eq!(v["source"], "mcp", "MCP door must badge as `mcp`"); +} + // --------------------------------------------------------------------------- // Terminal DTOs (L3) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/tests/orchestrator_wiring.rs b/crates/app-tauri/tests/orchestrator_wiring.rs index 3ed6d0d..f58dcf7 100644 --- a/crates/app-tauri/tests/orchestrator_wiring.rs +++ b/crates/app-tauri/tests/orchestrator_wiring.rs @@ -47,6 +47,14 @@ fn has_watcher(state: &AppState, id: &ProjectId) -> bool { state.orchestrator_watchers.lock().unwrap().contains_key(id) } +fn mcp_count(state: &AppState) -> usize { + state.mcp_servers.lock().unwrap().len() +} + +fn has_mcp(state: &AppState, id: &ProjectId) -> bool { + state.mcp_servers.lock().unwrap().contains_key(id) +} + #[tokio::test] async fn ensure_watch_registers_a_watcher_and_is_idempotent() { let state = AppState::build(temp_path("appdata")); @@ -101,3 +109,105 @@ async fn watchers_are_isolated_per_project() { assert!(has_watcher(&state, &b.id)); assert_eq!(watcher_count(&state), 1); } + +// --- M3: IdeA MCP server lifecycle (twin of the watcher, Décision 4) --- +// +// The MCP server registry (`mcp_servers`) is the twin of `orchestrator_watchers`: +// `ensure_orchestrator_watch` starts both side by side on open/create, and +// `stop_orchestrator_watch` tears both down on close. These tests mirror the +// watcher lifecycle tests above against the MCP registry. The per-project +// supervision task parks on a stop signal (no blocking serve loop), so open/close +// must return promptly — the `#[tokio::test]` harness itself proves no figing +// (the test completes). + +#[tokio::test] +async fn ensure_watch_starts_an_mcp_server_per_project() { + let state = AppState::build(temp_path("appdata")); + let project = make_project(); + + assert_eq!(mcp_count(&state), 0, "no MCP server before open"); + + state.ensure_orchestrator_watch(&project); + assert!( + has_mcp(&state, &project.id), + "MCP server registered alongside the watcher" + ); + assert_eq!(mcp_count(&state), 1); +} + +#[tokio::test] +async fn ensure_mcp_server_is_idempotent_per_project() { + let state = AppState::build(temp_path("appdata")); + let project = make_project(); + + state.ensure_orchestrator_watch(&project); + assert_eq!(mcp_count(&state), 1); + + // Opening the same project again must not spawn a second MCP server. + state.ensure_orchestrator_watch(&project); + assert_eq!( + mcp_count(&state), + 1, + "MCP server start is idempotent per project" + ); + assert!(has_mcp(&state, &project.id)); +} + +#[tokio::test] +async fn stop_watch_unregisters_the_mcp_server() { + let state = AppState::build(temp_path("appdata")); + let project = make_project(); + + state.ensure_orchestrator_watch(&project); + assert!(has_mcp(&state, &project.id)); + + state.stop_orchestrator_watch(&project.id); + assert!( + !has_mcp(&state, &project.id), + "MCP server removed on close" + ); + assert_eq!(mcp_count(&state), 0); + + // Stopping an unknown project is a no-op (does not panic) for the MCP twin too. + state.stop_orchestrator_watch(&project.id); +} + +#[tokio::test] +async fn watcher_and_mcp_server_coexist_and_close_together() { + let state = AppState::build(temp_path("appdata")); + let project = make_project(); + + // Open: both entry doors onto the same OrchestratorService are live. + state.ensure_orchestrator_watch(&project); + assert!(has_watcher(&state, &project.id), "watcher live on open"); + assert!(has_mcp(&state, &project.id), "MCP server live on open"); + assert_eq!(watcher_count(&state), 1); + assert_eq!(mcp_count(&state), 1); + + // Close: the symmetric teardown removes both. + state.stop_orchestrator_watch(&project.id); + assert!(!has_watcher(&state, &project.id), "watcher gone on close"); + assert!(!has_mcp(&state, &project.id), "MCP server gone on close"); + assert_eq!(watcher_count(&state), 0); + assert_eq!(mcp_count(&state), 0); +} + +#[tokio::test] +async fn mcp_servers_are_isolated_per_project() { + let state = AppState::build(temp_path("appdata")); + let a = make_project(); + let b = make_project(); + + state.ensure_orchestrator_watch(&a); + state.ensure_orchestrator_watch(&b); + + assert_eq!(mcp_count(&state), 2, "one MCP server per open project"); + assert!(has_mcp(&state, &a.id)); + assert!(has_mcp(&state, &b.id)); + + // Closing one leaves the other's MCP server running. + state.stop_orchestrator_watch(&a.id); + assert!(!has_mcp(&state, &a.id)); + assert!(has_mcp(&state, &b.id)); + assert_eq!(mcp_count(&state), 1); +} diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs index 3e2dee0..84b5e53 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -18,7 +18,9 @@ //! making the reference profiles addressable across runs without a registry. use domain::ids::ProfileId; -use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; +use domain::profile::{ + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, StructuredAdapter, +}; /// A fixed UUID namespace used to derive stable ids for reference profiles. /// (Random-looking but constant; only its stability matters.) @@ -57,7 +59,12 @@ pub fn reference_profiles() -> Vec { None, ) .expect("claude reference profile is valid") - .with_structured_adapter(StructuredAdapter::Claude), + .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json") + .expect(".mcp.json is a valid relative MCP config target"), + McpTransport::Stdio, + )), AgentProfile::new( reference_id("codex"), "OpenAI Codex CLI", @@ -70,7 +77,12 @@ pub fn reference_profiles() -> Vec { None, ) .expect("codex reference profile is valid") - .with_structured_adapter(StructuredAdapter::Codex), + .with_structured_adapter(StructuredAdapter::Codex) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json") + .expect(".mcp.json is a valid relative MCP config target"), + McpTransport::Stdio, + )), AgentProfile::new( reference_id("gemini"), "Gemini CLI", @@ -119,3 +131,50 @@ pub fn selectable_reference_profiles() -> Vec { .filter(AgentProfile::is_selectable) .collect() } + +#[cfg(test)] +mod mcp_tests { + use super::*; + + fn profile(slug: &str) -> AgentProfile { + let id = reference_id(slug); + reference_profiles() + .into_iter() + .find(|p| p.id == id) + .unwrap_or_else(|| panic!("reference profile `{slug}` exists")) + } + + #[test] + fn claude_and_codex_expose_mcp_capability() { + for slug in ["claude", "codex"] { + let p = profile(slug); + assert!( + p.mcp.is_some(), + "structured profile `{slug}` must carry an MCP capability" + ); + } + } + + #[test] + fn claude_and_codex_mcp_use_config_file_mcp_json() { + for slug in ["claude", "codex"] { + let mcp = profile(slug).mcp.expect("mcp present"); + assert_eq!( + mcp.config, + McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() }, + "profile `{slug}` should declare `.mcp.json`" + ); + assert_eq!(mcp.transport, McpTransport::Stdio); + } + } + + #[test] + fn gemini_and_aider_have_no_mcp_capability() { + for slug in ["gemini", "aider"] { + assert!( + profile(slug).mcp.is_none(), + "non-structured profile `{slug}` must NOT carry MCP (file fallback)" + ); + } + } +} diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index d0cff83..37ac57b 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -997,10 +997,19 @@ impl LaunchAgent { &project_context, &skills, &memory, + profile.mcp.is_some(), &mut spec, ) .await?; + // 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ── + // Strictement APRÈS le convention file (étape 5) et AVANT le spawn / + // `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`, + // IdeA matérialise SA config MCP au format de CETTE CLI, dans le **même** + // run dir isolé que le convention file et le seed de permissions. `None` + // ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression). + self.apply_mcp_config(&profile, &run_dir, &mut spec).await; + // 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ── // Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le // run dir (étape 5) ; la CLI structurée le lira à chaque tour @@ -1226,6 +1235,7 @@ impl LaunchAgent { /// materialising a `conventionFile` context (write the `.md` to `/target`) /// or attaching the on-disk context path to an environment variable. `Args` is /// already folded into the spec by the runtime; `Stdin` is handled post-spawn. + #[allow(clippy::too_many_arguments)] async fn apply_injection( &self, project: &Project, @@ -1234,6 +1244,7 @@ impl LaunchAgent { project_context: &str, skills: &[Skill], memory: &[MemoryIndexEntry], + mcp_enabled: bool, spec: &mut SpawnSpec, ) -> Result<(), AppError> { match spec.context_plan.clone() { @@ -1251,6 +1262,7 @@ impl LaunchAgent { content.as_str(), skills, memory, + mcp_enabled, ); let path = RemotePath::new(join(&spec.cwd, &target)); self.fs.write(&path, document.as_bytes()).await?; @@ -1267,6 +1279,92 @@ impl LaunchAgent { } Ok(()) } + + /// Materialises the IdeA MCP server config for **this** CLI when the profile + /// carries an [`McpCapability`] (cadrage v3, Décision 3). Runs **after** the + /// convention file (`apply_injection`) and **before** the spawn / `factory.start`, + /// in the **same** isolated run dir as the convention file and the permission seed. + /// + /// Dispatch by [`McpConfigStrategy`]: + /// - `ConfigFile { target }` → write `/` (e.g. `.mcp.json`) with + /// the IdeA MCP server declaration. **Non-clobbering and best-effort**, exactly + /// like [`Self::seed_cli_permissions`]: an existing file (possibly user-edited) + /// is left untouched, and any write/exists failure is swallowed so it **never** + /// fails the launch. + /// - `Flag { flag }` → append the flag + run-dir config path to [`SpawnSpec::args`]. + /// - `Env { var }` → append the variable (run-dir config path) to [`SpawnSpec::env`]. + /// + /// `profile.mcp == None` ⇒ no-op: no write, no flag, no env (current path, zero + /// regression). + /// + /// Note (M1): the embedded server declaration (command + transport) is a coherent + /// **placeholder** — the real IdeA MCP server and its exact launch command land in + /// M2/M3. The strategy plumbing here is stable; only the declaration content will + /// be finalised then. + async fn apply_mcp_config( + &self, + profile: &AgentProfile, + run_dir: &ProjectPath, + spec: &mut SpawnSpec, + ) { + let Some(mcp) = &profile.mcp else { + return; + }; + match &mcp.config { + domain::profile::McpConfigStrategy::ConfigFile { target } => { + // Non-clobbering, best-effort — mirrors `seed_cli_permissions`. + let path = RemotePath::new(join(run_dir, target)); + match self.fs.exists(&path).await { + Ok(true) => {} + Ok(false) => { + let declaration = mcp_server_declaration(mcp.transport); + // Best-effort: a write failure must not fail the launch. + let _ = self.fs.write(&path, declaration.as_bytes()).await; + } + // An exists() probe failure is swallowed too (best-effort). + Err(_) => {} + } + } + domain::profile::McpConfigStrategy::Flag { flag } => { + // Pass the server via a launch flag (e.g. `--mcp-config {path}`). The + // config path is the run dir itself (the CLI's cwd), where the server + // declaration / connection is anchored. + spec.args.push(flag.clone()); + spec.args.push(run_dir.as_str().to_owned()); + } + domain::profile::McpConfigStrategy::Env { var } => { + spec.env.push((var.clone(), run_dir.as_str().to_owned())); + } + } + } +} + +/// Builds the IdeA MCP server declaration written into a CLI's `ConfigFile` MCP +/// config (cadrage v3, Décision 3). Coherent **placeholder** for M1: it describes a +/// stdio/socket server launched by the `idea` binary — the exact command and the +/// real server land in **M2/M3**. Kept pure (no I/O) so it is unit-testable. +#[must_use] +fn mcp_server_declaration(transport: domain::profile::McpTransport) -> String { + // Common `.mcp.json`-style shape (Claude Code et CLIs apparentées). The transport + // is surfaced so a socket-based CLI can be wired in M2 without changing M1's seam. + let transport_label = match transport { + domain::profile::McpTransport::Stdio => "stdio", + domain::profile::McpTransport::Socket => "socket", + }; + format!( + r#"{{ + "mcpServers": {{ + "idea": {{ + "command": "idea", + "args": [ + "mcp-server" + ], + "transport": "{transport_label}" + }} + }} +}} +"# + ) } /// Builds an absolute path string by joining a [`ProjectPath`] with a relative @@ -1402,6 +1500,13 @@ fn json_escape(s: &str) -> String { /// entirely, so an agent with no memory gets exactly the previous document. /// /// Kept as a **pure** function (no I/O) so it is unit-testable in isolation. +/// +/// The orchestration prose adapts to the agent's **surface** (cadrage v3, Décision +/// 3): when `mcp_enabled` is `true` (`profile.mcp.is_some()`), the agent sees the +/// native `idea_*` tools, so the prose points to those instead of the file +/// protocol — while keeping the **same** ban on the provider's native subagents. +/// When `false` (`mcp == None`) the prose is the **current `.ideai/requests` +/// wording, unchanged** (zero regression). #[must_use] pub(crate) fn compose_convention_file( project_root: &str, @@ -1409,6 +1514,7 @@ pub(crate) fn compose_convention_file( agent_md: &str, skills: &[Skill], memory: &[MemoryIndexEntry], + mcp_enabled: bool, ) -> String { let mut out = String::new(); out.push_str("# Project root\n\n"); @@ -1420,12 +1526,26 @@ pub(crate) fn compose_convention_file( ); out.push_str("---\n\n"); out.push_str("# Orchestration IdeA\n\n"); - out.push_str( - "Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \ - natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \ - `.ideai/requests//` ; IdeA lancera ou réattachera l'agent cible \ - avec son propre AI Profile, son contexte et sa mémoire.\n\n", - ); + if mcp_enabled { + // Surface MCP (cadrage v3, D3) : l'alternative native aux subagents est + // exposée comme outils typés `idea_*`. L'interdiction des subagents natifs + // du fournisseur est **conservée** ; seule la voie de délégation change. + out.push_str( + "Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \ + natifs du fournisseur IA. Utilise les outils IdeA natifs : \ + `idea_ask_agent` (déléguer une tâche et recevoir la réponse), \ + `idea_launch_agent` (lancer/réattacher un agent) et `idea_list_agents` \ + (lister les agents du projet). IdeA lancera ou réattachera l'agent cible \ + avec son propre AI Profile, son contexte et sa mémoire.\n\n", + ); + } else { + out.push_str( + "Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \ + natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \ + `.ideai/requests//` ; IdeA lancera ou réattachera l'agent cible \ + avec son propre AI Profile, son contexte et sa mémoire.\n\n", + ); + } out.push_str("---\n\n"); if !project_context.trim().is_empty() { @@ -1533,8 +1653,14 @@ mod tests { #[test] fn compose_convention_file_carries_root_then_persona() { - let doc = - compose_convention_file("/abs/project/root", "", "# Persona\n\nDo things.", &[], &[]); + let doc = compose_convention_file( + "/abs/project/root", + "", + "# Persona\n\nDo things.", + &[], + &[], + false, + ); // Absolute project root present. assert!(doc.contains("/abs/project/root")); @@ -1557,6 +1683,7 @@ mod tests { "# Persona\n\nDo X.", &[], &[], + false, ); assert!(doc.contains("# Contexte projet")); @@ -1589,6 +1716,7 @@ mod tests { s(2, "review", "REVIEW_BODY"), ], &[], + false, ); // Both skill bodies present, after the persona. @@ -1619,7 +1747,8 @@ mod tests { fn compose_convention_file_empty_memory_is_identical_to_no_memory() { // An empty `memory` must yield exactly the previous document: no section, // byte-for-byte identical to the no-skills/no-memory composition. - let with_empty = compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[]); + let with_empty = + compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], false); assert!( !with_empty.contains("# Mémoire projet"), "no memory ⇒ no memory section" @@ -1628,7 +1757,7 @@ mod tests { // 4-arg call; this pins the omission contract explicitly). assert_eq!( with_empty, - compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[]) + compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], false) ); } @@ -1648,6 +1777,7 @@ mod tests { MemoryType::Reference, ), ], + false, ); // Section present, after the persona. @@ -1684,6 +1814,7 @@ mod tests { "# Persona", std::slice::from_ref(&skill), &[mem("note", "Note", "a hook", MemoryType::Project)], + false, ); // Both sections present. @@ -1698,6 +1829,52 @@ mod tests { assert!(skills_at < memory_at, "skills come before memory"); } + #[test] + fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() { + // mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping + // the ban on the provider's native subagents (cadrage v3, Décision 3). + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], true); + + // Native IdeA orchestration tools surfaced. + assert!( + doc.contains("idea_ask_agent"), + "MCP prose must mention idea_ask_agent" + ); + assert!(doc.contains("idea_launch_agent")); + assert!(doc.contains("idea_list_agents")); + // Native-subagent ban preserved. + assert!( + doc.contains("n'utilise jamais les subagents"), + "MCP prose must keep the native-subagent ban" + ); + // It does NOT fall back to the file protocol wording. + assert!( + !doc.contains(".ideai/requests"), + "MCP prose must not point to the file protocol" + ); + } + + #[test] + fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() { + // mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged, + // and crucially WITHOUT the `idea_*` tools (zero regression). + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], false); + + assert!( + doc.contains(".ideai/requests"), + "non-MCP prose must point to the file protocol" + ); + assert!( + doc.contains("n'utilise jamais les subagents"), + "non-MCP prose keeps the native-subagent ban" + ); + // No native MCP tools leaked into the file-protocol prose. + assert!( + !doc.contains("idea_ask_agent"), + "non-MCP prose must not mention the idea_* tools" + ); + } + #[test] fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() { let json = claude_settings_seed("/home/me/proj"); diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 99dc717..b07a032 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -147,6 +147,7 @@ impl OrchestratorService { OrchestratorCommand::AskAgent { target, task } => { self.ask_agent(project, target, task).await } + OrchestratorCommand::ListAgents => self.list_agents(project).await, OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await, OrchestratorCommand::UpdateAgentContext { name, context } => { self.update_agent_context(project, name, context).await @@ -355,6 +356,35 @@ impl OrchestratorService { } } + /// `list_agents`: discovery — return the project's agents exactly as the UI + /// reads them from the manifest, via the **same** [`ListAgents`] use case. + /// + /// The list is serialised as a JSON array into [`OrchestratorOutcome::reply`] + /// (the existing inline-payload channel, also used by `ask`), with a one-line + /// count in [`OrchestratorOutcome::detail`]. Each element carries the agent's + /// `id`, `name`, `contextPath`, `profileId`, `origin`, `synchronized` and + /// `skills` (camelCase, the [`domain::Agent`] serde shape). + /// + /// # Errors + /// Propagates [`AppError`] from the use case (manifest load / invariant) or a + /// serialisation failure ([`AppError::Invalid`]). + async fn list_agents(&self, project: &Project) -> Result { + let listed = self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await?; + + let reply = serde_json::to_string(&listed.agents) + .map_err(|e| AppError::Invalid(format!("failed to serialise agent list: {e}")))?; + + Ok(OrchestratorOutcome { + detail: format!("listed {} agent(s)", listed.agents.len()), + reply: Some(reply), + }) + } + /// `stop_agent`: translate the agent name → its live session → `CloseTerminal`. async fn stop_agent( &self, diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index c32a9d0..efe8331 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -29,7 +29,9 @@ use domain::ports::{ OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; -use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; +use domain::profile::{ + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, SessionStrategy, +}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; @@ -342,6 +344,14 @@ struct FakeFs { writes: WriteLog, created_dirs: Arc>>, project_context: Arc>>, + /// Paths reported as **already existing** by [`FileSystem::exists`] (the + /// default empty set keeps the historical `exists == false` behaviour). Used + /// by the MCP non-clobbering test. + existing: Arc>>, + /// Paths whose [`FileSystem::write`] must fail with an I/O error (nothing is + /// recorded for them). Used by the MCP best-effort test to prove that an MCP + /// config write failure never fails the launch. + fail_writes_for: Arc>>, } impl FakeFs { @@ -351,11 +361,23 @@ impl FakeFs { writes: Arc::new(Mutex::new(Vec::new())), created_dirs: Arc::new(Mutex::new(Vec::new())), project_context: Arc::new(Mutex::new(None)), + existing: Arc::new(Mutex::new(Vec::new())), + fail_writes_for: Arc::new(Mutex::new(Vec::new())), } } fn set_project_context(&self, content: &str) { *self.project_context.lock().unwrap() = Some(content.to_owned()); } + /// Marks a path as already existing on disk, so [`FileSystem::exists`] returns + /// `true` for it (non-clobbering scenarios). + fn mark_existing(&self, path: &str) { + self.existing.lock().unwrap().push(path.to_owned()); + } + /// Makes any [`FileSystem::write`] whose path ends with `suffix` fail + /// (best-effort scenarios — e.g. the MCP `.mcp.json` config file). + fn fail_write_suffix(&self, suffix: &str) { + self.fail_writes_for.lock().unwrap().push(suffix.to_owned()); + } fn writes(&self) -> Vec<(String, Vec)> { self.writes.lock().unwrap().clone() } @@ -401,15 +423,26 @@ impl FileSystem for FakeFs { Err(FsError::NotFound(path.as_str().to_owned())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { + let p = path.as_str(); + if self + .fail_writes_for + .lock() + .unwrap() + .iter() + .any(|suffix| p.ends_with(suffix.as_str())) + { + return Err(FsError::Io(format!("forced write failure for {p}"))); + } self.trace.lock().unwrap().push("fs.write".to_owned()); self.writes .lock() .unwrap() - .push((path.as_str().to_owned(), data.to_vec())); + .push((p.to_owned(), data.to_vec())); Ok(()) } - async fn exists(&self, _path: &RemotePath) -> Result { - Ok(false) + async fn exists(&self, path: &RemotePath) -> Result { + let p = path.as_str(); + Ok(self.existing.lock().unwrap().iter().any(|e| e == p)) } async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { self.created_dirs @@ -1448,6 +1481,200 @@ async fn launch_unknown_profile_is_not_found() { assert!(pty.spawns().is_empty(), "no spawn when profile unresolved"); } +// --------------------------------------------------------------------------- +// LaunchAgent — MCP config injection (M1, cadrage v3 Décision 3) +// --------------------------------------------------------------------------- + +/// Builds a CLAUDE.md-convention profile carrying an [`McpCapability`], so the +/// MCP injection path (`apply_mcp_config`) is exercised during a launch. +fn profile_with_mcp(id: ProfileId, config: McpConfigStrategy) -> AgentProfile { + profile(id, ContextInjection::convention_file("CLAUDE.md").unwrap()) + .with_mcp(McpCapability::new(config, McpTransport::Stdio)) +} + +/// Returns the writes whose path ends with the given suffix (e.g. `/.mcp.json`). +fn writes_ending_with(fs: &FakeFs, suffix: &str) -> Vec<(String, Vec)> { + fs.writes() + .into_iter() + .filter(|(p, _)| p.ends_with(suffix)) + .collect() +} + +#[tokio::test] +async fn launch_without_mcp_writes_no_mcp_config_and_leaves_spec_untouched() { + // Test 1 — profile.mcp = None ⇒ no MCP file written, spec.args/env unchanged. + // The profile has empty args and the runtime adds no env, so a clean launch + // must leave the spawned spec's args/env exactly empty (no MCP flag/env). + let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture( + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + + launch.execute(launch_input(agent.id)).await.unwrap(); + + // No MCP-style config file anywhere. + assert!( + writes_ending_with(&fs, "/.mcp.json").is_empty(), + "no MCP config file when profile.mcp is None" + ); + // Exactly the convention file write (+ the Claude seed), nothing MCP-related. + assert_eq!( + fs.context_writes().len(), + 1, + "only the convention file is written" + ); + + // Spec args/env carry nothing MCP-related (here: empty). + let spawns = pty.spawns(); + assert_eq!(spawns.len(), 1); + assert!(spawns[0].args.is_empty(), "no MCP flag pushed to args"); + assert!(spawns[0].env.is_empty(), "no MCP var pushed to env"); +} + +#[tokio::test] +async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() { + // Test 2 — ConfigFile { target: ".mcp.json" } ⇒ a file is written at + // /.mcp.json with a non-empty, coherent MCP server declaration. + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = + launch_fixture_with_profile( + profile_with_mcp( + pid(9), + McpConfigStrategy::config_file(".mcp.json").unwrap(), + ), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + + launch.execute(launch_input(agent.id)).await.unwrap(); + + let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); + let mcp = writes_ending_with(&fs, "/.mcp.json"); + assert_eq!(mcp.len(), 1, "exactly one MCP config file written"); + assert_eq!(mcp[0].0, format!("{run_dir}/.mcp.json"), "written at run dir"); + + let body = String::from_utf8(mcp[0].1.clone()).unwrap(); + assert!(!body.trim().is_empty(), "MCP declaration is non-empty"); + // Coherent MCP server declaration (an `mcpServers`/`idea` server entry). + assert!( + body.contains("mcpServers") && body.contains("idea"), + "MCP declaration must declare the IdeA MCP server, got: {body}" + ); + // It is valid JSON. + let _: serde_json::Value = + serde_json::from_str(&body).expect("MCP declaration is valid JSON"); +} + +#[tokio::test] +async fn launch_mcp_config_file_is_non_clobbering() { + // Test 3 — if /.mcp.json already exists, the launch must NOT overwrite + // it: no write is recorded against that path. + let profile = profile_with_mcp( + pid(9), + McpConfigStrategy::config_file(".mcp.json").unwrap(), + ); + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = + launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + // Pre-existing MCP config file on disk. + let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); + fs.mark_existing(&format!("{run_dir}/.mcp.json")); + + launch.execute(launch_input(agent.id)).await.unwrap(); + + assert!( + writes_ending_with(&fs, "/.mcp.json").is_empty(), + "an existing MCP config file must not be clobbered" + ); +} + +#[tokio::test] +async fn launch_mcp_config_file_write_failure_is_best_effort() { + // Test 4 — a write failure on the MCP config file must NOT fail the launch. + let profile = profile_with_mcp( + pid(9), + McpConfigStrategy::config_file(".mcp.json").unwrap(), + ); + let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = + launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + // Force the MCP config write to fail. + fs.fail_write_suffix("/.mcp.json"); + + // The launch still succeeds and spawns the CLI. + let out = launch + .execute(launch_input(agent.id)) + .await + .expect("MCP write failure must not fail the launch"); + assert!(out.structured.is_none()); + assert_eq!(pty.spawns().len(), 1, "the CLI is still spawned"); +} + +#[tokio::test] +async fn launch_mcp_flag_appends_flag_and_path_to_args() { + // Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed + // by the run-dir config path. + let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = + launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + + launch.execute(launch_input(agent.id)).await.unwrap(); + + let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); + let spawns = pty.spawns(); + assert_eq!(spawns.len(), 1); + let args = &spawns[0].args; + let pos = args + .iter() + .position(|a| a == "--mcp-config") + .expect("the MCP flag must be present in args"); + assert_eq!( + args.get(pos + 1), + Some(&run_dir), + "the MCP flag is followed by the run-dir config path" + ); +} + +#[tokio::test] +async fn launch_mcp_env_appends_var_and_path_to_env() { + // Test 6 — Env { var: "IDEA_MCP" } ⇒ spec.env carries (IDEA_MCP, ). + let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = + launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::env("IDEA_MCP").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + + launch.execute(launch_input(agent.id)).await.unwrap(); + + let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); + let spawns = pty.spawns(); + assert_eq!(spawns.len(), 1); + assert!( + spawns[0] + .env + .iter() + .any(|(k, v)| k == "IDEA_MCP" && v == &run_dir), + "spec.env must carry (IDEA_MCP, run_dir), got: {:?}", + spawns[0].env + ); +} + // --------------------------------------------------------------------------- // LaunchAgent — session resume (T4) // --------------------------------------------------------------------------- diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 9cc9dd9..f34d453 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -5,6 +5,21 @@ use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId}; use crate::memory::MemorySlug; use crate::template::TemplateVersion; +/// Which entry door a processed orchestration request arrived through. +/// +/// IdeA exposes the *same* [`crate::OrchestratorService::dispatch`] behind two +/// substitutable driving adapters (ARCHITECTURE §14.3 + cadrage `orchestration-v3`): +/// the `.ideai/requests` filesystem watcher and the MCP server. This tag — set by +/// the adapter that received the request, never inferred in the application — lets +/// the presentation layer surface *how* a delegation came in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrchestrationSource { + /// A JSON request file dropped under `.ideai/requests/`. + File, + /// A `tools/call` on the MCP server. + Mcp, +} + /// Events emitted by the domain/application as state changes occur. /// /// Deliberately *not* `Serialize`/`Deserialize`: events are an in-process @@ -109,6 +124,9 @@ pub enum DomainEvent { action: String, /// Whether IdeA handled it successfully. ok: bool, + /// Which entry door the request arrived through (file watcher vs MCP), + /// tagged by the receiving adapter. + source: OrchestrationSource, }, /// A memory note was created or updated (`.md` written, index upserted). MemorySaved { diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 83de6b2..bbd6744 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -87,7 +87,7 @@ pub use layout::{ SplitContainer, Tab, WeightedChild, Window, Workspace, }; -pub use events::DomainEvent; +pub use events::{DomainEvent, OrchestrationSource}; pub use orchestrator::{ OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility, diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index 0a24fb4..4b474ea 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -150,6 +150,10 @@ pub enum OrchestratorCommand { /// The task/message to send and await a reply for. task: String, }, + /// List the project's agents (discovery) — exactly the data the UI reads from + /// the manifest. Carries no argument: the dispatch resolves the agents against + /// the project it is dispatched for. + ListAgents, /// Create a reusable skill in the given scope — exactly as the UI would. CreateSkill { /// Display name (also the `.md` stem on disk). @@ -227,6 +231,7 @@ impl OrchestratorRequest { target: self.require_target_agent(action)?, task: self.require("task", action, self.task.as_deref())?, }), + "list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents), "create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill { name: self.require_name(action)?, content: self.require("context", action, self.context.as_deref())?, diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index d93b6ef..a024e09 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -134,6 +134,107 @@ pub enum StructuredAdapter { Codex, } +/// Transport du serveur MCP IdeA exposé à une CLI (détail invisible au domaine). +/// +/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert). +/// Déclaratif, Open/Closed (comme [`StructuredAdapter`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum McpTransport { + /// Pont stdio (défaut) : IdeA parle au serveur MCP via stdin/stdout. + #[default] + Stdio, + /// Socket partagé (adresse) : optimisation, point ouvert (spike S-MCP). + Socket, +} + +/// Stratégie de matérialisation de la config MCP propre à UNE CLI : chaque CLI +/// déclare son serveur MCP différemment (fichier `.mcp.json` pour Claude Code, +/// flag de lancement, ou variable d'env). Déclaratif = donnée, pas code (§9). +/// +/// Invariants (garantis par les constructeurs) : +/// - `ConfigFile.target` est un chemin relatif sûr (pas de `..`, pas d'absolu), +/// - `Flag.flag` est non vide, +/// - `Env.var` est un identifiant de variable d'environnement valide. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "strategy")] +pub enum McpConfigStrategy { + /// Écrire un fichier de conf MCP au chemin (relatif au run dir isolé §14.1) + /// attendu par la CLI, au format JSON propre à cette CLI (ex. `.mcp.json`). + ConfigFile { + /// Chemin relatif sûr du fichier de conf MCP. + target: String, + }, + /// Passer le serveur via un flag de lancement (ex. `--mcp-config {path}`). + Flag { + /// Le flag (template) non vide. + flag: String, + }, + /// Passer via une variable d'environnement. + Env { + /// Nom de la variable d'environnement. + var: String, + }, +} + +impl McpConfigStrategy { + /// Constructeur validé `ConfigFile`. + /// + /// # Errors + /// Renvoie [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou + /// contient `..`. + pub fn config_file(target: impl Into) -> Result { + let target = target.into(); + crate::validation::relative_safe(&target)?; + Ok(Self::ConfigFile { target }) + } + + /// Constructeur validé `Flag`. + /// + /// # Errors + /// Renvoie [`DomainError::EmptyField`] si `flag` est vide. + pub fn flag(flag: impl Into) -> Result { + let flag = flag.into(); + crate::validation::non_empty(&flag, "mcp.config.flag")?; + Ok(Self::Flag { flag }) + } + + /// Constructeur validé `Env`. + /// + /// # Errors + /// Renvoie [`DomainError::InvalidEnvVar`] si `var` n'est pas un identifiant + /// valide. + pub fn env(var: impl Into) -> Result { + let var = var.into(); + crate::validation::valid_env_var(&var)?; + Ok(Self::Env { var }) + } +} + +/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI, +/// et QUEL transport. `None` sur le profil ⇒ repli fichier `.ideai/requests` + +/// prose (comportement actuel, zéro régression). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpCapability { + /// Comment matérialiser la config MCP au lancement (relatif au run dir). + pub config: McpConfigStrategy, + /// Transport du serveur MCP IdeA (détail invisible au domaine). + /// `stdio` = défaut robuste cross-OS ; `socket` = optimisation. + #[serde(default)] + pub transport: McpTransport, +} + +impl McpCapability { + /// Construit une capacité MCP. La validation est portée par le constructeur + /// de [`McpConfigStrategy`] (parse, don't validate) ; `transport` n'a pas + /// d'invariant. + #[must_use] + pub const fn new(config: McpConfigStrategy, transport: McpTransport) -> Self { + Self { config, transport } + } +} + /// Declarative runtime configuration for one AI CLI. /// /// Invariants: @@ -173,6 +274,15 @@ pub struct AgentProfile { /// sérialisation : un profil sans adapter sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub structured_adapter: Option, + /// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1). + /// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel). + /// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et + /// l'agent voit les outils `idea_*`. Additif, Open/Closed. + /// + /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de + /// sérialisation : un profil sans MCP sérialise exactement comme avant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp: Option, } /// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel). @@ -307,6 +417,7 @@ impl AgentProfile { cwd_template, session, structured_adapter: None, + mcp: None, }) } @@ -319,6 +430,15 @@ impl AgentProfile { self } + /// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le + /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les + /// profils sans MCP ne l'appellent simplement pas. + #[must_use] + pub fn with_mcp(mut self, mcp: McpCapability) -> Self { + self.mcp = Some(mcp); + self + } + /// Indique si ce profil peut être **proposé à la sélection/création** d'un /// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est /// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il @@ -334,3 +454,175 @@ impl AgentProfile { self.structured_adapter.is_some() } } + +#[cfg(test)] +mod mcp_tests { + use super::*; + use crate::ids::ProfileId; + + /// A reference profile without any MCP capability (the historical shape). + fn profile_without_mcp() -> AgentProfile { + AgentProfile::new( + ProfileId::from_uuid(uuid::Uuid::nil()), + "Dev", + "claude", + vec!["-p".to_owned()], + ContextInjection::convention_file("CLAUDE.md").expect("valid target"), + Some("claude --version".to_owned()), + "{agentRunDir}", + None, + ) + .expect("valid profile") + } + + // -- Critère 1 : zéro régression de sérialisation (mcp = None) -------------- + + #[test] + fn profile_without_mcp_omits_mcp_key_in_json() { + let profile = profile_without_mcp(); + assert!(profile.mcp.is_none()); + + let json = serde_json::to_string(&profile).expect("serialise"); + assert!( + !json.contains("\"mcp\""), + "a profile without MCP must NOT serialise an `mcp` key (zero regression); got: {json}" + ); + } + + #[test] + fn profile_without_mcp_round_trips_identically() { + let profile = profile_without_mcp(); + let json = serde_json::to_string(&profile).expect("serialise"); + let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(profile, back); + assert!(back.mcp.is_none()); + } + + #[test] + fn legacy_json_without_mcp_field_deserialises_to_none() { + // JSON produced before the `mcp` field existed: no `mcp` key at all. + let legacy = r#"{ + "id": "00000000-0000-0000-0000-000000000000", + "name": "Dev", + "command": "claude", + "args": [], + "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, + "detect": null, + "cwdTemplate": "{agentRunDir}" + }"#; + let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); + assert!(profile.mcp.is_none()); + } + + // -- Critère 2 : round-trip avec MCP (les 3 stratégies) --------------------- + + #[test] + fn profile_with_mcp_config_file_round_trips() { + let cap = McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").expect("valid target"), + McpTransport::Stdio, + ); + let profile = profile_without_mcp().with_mcp(cap.clone()); + assert_eq!(profile.mcp, Some(cap)); + + let json = serde_json::to_string(&profile).expect("serialise"); + assert!(json.contains("\"mcp\""), "mcp key must be present: {json}"); + let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(profile, back); + } + + #[test] + fn mcp_capability_flag_strategy_round_trips() { + let cap = McpCapability::new( + McpConfigStrategy::flag("--mcp-config {path}").expect("valid flag"), + McpTransport::Socket, + ); + let json = serde_json::to_string(&cap).expect("serialise"); + let back: McpCapability = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(cap, back); + assert_eq!(back.transport, McpTransport::Socket); + assert!(matches!(back.config, McpConfigStrategy::Flag { .. })); + } + + #[test] + fn mcp_capability_env_strategy_round_trips() { + let cap = McpCapability::new( + McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid env"), + McpTransport::Stdio, + ); + let json = serde_json::to_string(&cap).expect("serialise"); + let back: McpCapability = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(cap, back); + assert!(matches!(back.config, McpConfigStrategy::Env { .. })); + } + + #[test] + fn mcp_config_strategy_uses_tagged_camel_case() { + // The `strategy` tag and camelCase rename are part of the wire contract. + let json = serde_json::to_string( + &McpConfigStrategy::config_file(".mcp.json").expect("valid"), + ) + .expect("serialise"); + assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}"); + } + + // -- Critère 4 : transport par défaut = Stdio ------------------------------- + + #[test] + fn transport_default_is_stdio() { + assert_eq!(McpTransport::default(), McpTransport::Stdio); + } + + #[test] + fn mcp_capability_without_transport_defaults_to_stdio() { + // `transport` is `#[serde(default)]`: an MCP capability serialised without + // it must deserialise to Stdio. + let json = r#"{ "config": { "strategy": "configFile", "target": ".mcp.json" } }"#; + let cap: McpCapability = serde_json::from_str(json).expect("deserialise"); + assert_eq!(cap.transport, McpTransport::Stdio); + } + + // -- Critère 3 : validation des constructeurs ------------------------------- + + #[test] + fn config_file_rejects_absolute_target() { + let err = McpConfigStrategy::config_file("/etc/mcp.json").unwrap_err(); + assert!(matches!(err, DomainError::PathNotRelativeSafe { .. })); + } + + #[test] + fn config_file_rejects_parent_traversal() { + let err = McpConfigStrategy::config_file("../escape/.mcp.json").unwrap_err(); + assert!(matches!(err, DomainError::PathNotRelativeSafe { .. })); + } + + #[test] + fn config_file_accepts_safe_relative_target() { + let s = McpConfigStrategy::config_file(".mcp.json").expect("safe relative"); + assert_eq!(s, McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() }); + } + + #[test] + fn flag_rejects_empty() { + let err = McpConfigStrategy::flag("").unwrap_err(); + assert!(matches!(err, DomainError::EmptyField { .. })); + } + + #[test] + fn flag_accepts_non_empty() { + let s = McpConfigStrategy::flag("--mcp-config {path}").expect("non-empty"); + assert_eq!(s, McpConfigStrategy::Flag { flag: "--mcp-config {path}".to_owned() }); + } + + #[test] + fn env_rejects_invalid_name() { + let err = McpConfigStrategy::env("1BAD-NAME").unwrap_err(); + assert!(matches!(err, DomainError::InvalidEnvVar { .. })); + } + + #[test] + fn env_accepts_valid_name() { + let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid"); + assert_eq!(s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() }); + } +} diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index f2417cb..52e05bc 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -16,6 +16,8 @@ application = { workspace = true } tokio = { workspace = true, features = ["process", "time"] } uuid = { workspace = true } async-trait = { workspace = true } +# Ergonomic error enums for the MCP adapter (tool-mapping / transport errors). +thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } portable-pty = "0.9" diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 722325b..f107e82 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -32,6 +32,7 @@ pub use fs::LocalFileSystem; pub use git::Git2Repository; pub use id::UuidGenerator; pub use inspector::ClaudeTranscriptInspector; +pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport}; pub use orchestrator::{ process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, REQUESTS_SUBDIR, diff --git a/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs b/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs new file mode 100644 index 0000000..68abb9f --- /dev/null +++ b/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs @@ -0,0 +1,181 @@ +//! Minimal **JSON-RPC 2.0** message model + transport seam for the IdeA MCP +//! adapter (spike **S-MCP**). +//! +//! ## Why hand-rolled JSON-RPC instead of an MCP crate +//! +//! MCP (Model Context Protocol) rides on JSON-RPC 2.0: a server advertises its +//! tools via `tools/list` and runs them via `tools/call`, after an `initialize` +//! handshake. The surface IdeA needs is exactly those three methods — no +//! resources, no prompts, no sampling. The Rust MCP crate ecosystem +//! (`rmcp`, `mcp-sdk`, …) is young, churny, and drags in a concrete async +//! transport/runtime stack that is awkward to drive **without a socket or child +//! process** in a unit test. The cadrage (S-MCP) explicitly permits implementing +//! "the strict JSON-RPC 2.0 minimum (`initialize`, `tools/list`, `tools/call`)" +//! ourselves when that buys **testability and zero-network**. We take that path: +//! the whole protocol lives in this adapter, behind a tiny [`Transport`] trait, so +//! tests speak it over an in-memory transport with no I/O at all. +//! +//! Everything here is JSON-RPC plumbing — it never knows what an +//! `OrchestratorCommand` is. The mapping to IdeA semantics lives in +//! [`super::server`]. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// The JSON-RPC protocol version string every message must carry. +pub const JSONRPC_VERSION: &str = "2.0"; + +/// A request id as defined by JSON-RPC 2.0: a string, a number, or null. We keep +/// it as a raw [`Value`] and echo it back verbatim on the matching response so the +/// transport-level correlation (Décision 2: corrélation portée par le transport) +/// is preserved exactly, whatever the client chose. +pub type RequestId = Value; + +/// An incoming JSON-RPC request (or notification, when `id` is absent). +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct JsonRpcRequest { + /// Must equal [`JSONRPC_VERSION`]. + pub jsonrpc: String, + /// Correlation id. Absent ⇒ the message is a *notification* (no reply owed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// The method name (`initialize`, `tools/list`, `tools/call`, …). + pub method: String, + /// Method parameters; shape depends on `method`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// An outgoing JSON-RPC response: exactly one of `result` / `error` is set. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct JsonRpcResponse { + /// Must equal [`JSONRPC_VERSION`]. + pub jsonrpc: String, + /// Echoes the request id (null for errors that could not be correlated). + pub id: RequestId, + /// Success payload (mutually exclusive with [`Self::error`]). + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + /// Failure payload (mutually exclusive with [`Self::result`]). + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl JsonRpcResponse { + /// Builds a success response echoing `id`. + #[must_use] + pub fn success(id: RequestId, result: Value) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.to_owned(), + id, + result: Some(result), + error: None, + } + } + + /// Builds an error response echoing `id`. + #[must_use] + pub fn error(id: RequestId, error: JsonRpcError) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.to_owned(), + id, + result: None, + error: Some(error), + } + } +} + +/// A JSON-RPC error object. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct JsonRpcError { + /// One of the standard JSON-RPC codes (see [`error_codes`]). + pub code: i32, + /// Short human-readable description. + pub message: String, + /// Optional structured detail. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl JsonRpcError { + /// Builds an error with no `data`. + #[must_use] + pub fn new(code: i32, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } +} + +/// The subset of standard JSON-RPC 2.0 error codes this adapter raises. +pub mod error_codes { + /// Invalid JSON was received by the server. + pub const PARSE_ERROR: i32 = -32_700; + /// The JSON sent is not a valid Request object. + pub const INVALID_REQUEST: i32 = -32_600; + /// The method does not exist / is not supported. + pub const METHOD_NOT_FOUND: i32 = -32_601; + /// Invalid method parameters. + pub const INVALID_PARAMS: i32 = -32_602; + /// Internal server error (a dispatched IdeA command failed). + pub const INTERNAL_ERROR: i32 = -32_603; +} + +/// Errors a [`Transport`] may surface. +#[derive(Debug, thiserror::Error)] +pub enum TransportError { + /// The peer closed the stream (clean EOF). The serve loop stops on this. + #[error("transport closed")] + Closed, + /// An underlying I/O failure. + #[error("transport io error: {0}")] + Io(String), +} + +/// Abstract message transport for the MCP server (the **S-MCP transport seam**). +/// +/// One framed JSON-RPC message per `recv`/`send`. Concrete transports (stdio, and +/// later a socket) live behind this trait so the server's protocol logic is +/// **transport-agnostic** and, crucially, **unit-testable over an in-memory +/// transport** with no socket and no child process. The `stdio` transport is the +/// default concrete one; `socket` is a documented TODO (cadrage S-MCP). +#[async_trait::async_trait] +pub trait Transport: Send { + /// Receives the next framed message as raw bytes. Returns + /// [`TransportError::Closed`] on clean EOF (the serve loop exits). + async fn recv(&mut self) -> Result, TransportError>; + + /// Sends one framed message (raw bytes, a complete JSON value). + async fn send(&mut self, message: &[u8]) -> Result<(), TransportError>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_without_id_is_a_notification() { + let raw = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#; + let req: JsonRpcRequest = serde_json::from_str(raw).unwrap(); + assert!(req.id.is_none()); + assert_eq!(req.method, "notifications/initialized"); + } + + #[test] + fn success_and_error_are_mutually_exclusive_on_the_wire() { + let ok = JsonRpcResponse::success(serde_json::json!(1), serde_json::json!({"k":"v"})); + let text = serde_json::to_string(&ok).unwrap(); + assert!(text.contains("\"result\"")); + assert!(!text.contains("\"error\"")); + + let err = JsonRpcResponse::error( + serde_json::json!(1), + JsonRpcError::new(error_codes::METHOD_NOT_FOUND, "nope"), + ); + let text = serde_json::to_string(&err).unwrap(); + assert!(text.contains("\"error\"")); + assert!(!text.contains("\"result\"")); + } +} diff --git a/crates/infrastructure/src/orchestrator/mcp/mod.rs b/crates/infrastructure/src/orchestrator/mcp/mod.rs new file mode 100644 index 0000000..711622a --- /dev/null +++ b/crates/infrastructure/src/orchestrator/mcp/mod.rs @@ -0,0 +1,41 @@ +//! IdeA **MCP server** — a driving adapter that exposes the orchestration of IdeA +//! as Model-Context-Protocol tools (cadrage `orchestration-v3`, lot **M2**). +//! +//! This adapter is the **pair of the filesystem watcher** +//! ([`super::FsOrchestratorWatcher`]): a second, substitutable entry door onto the +//! exact same [`application::OrchestratorService::dispatch`]. An MCP-capable CLI +//! (Claude, Codex, Gemini…) calls a typed `idea_*` tool instead of dropping a JSON +//! file under `.ideai/requests`; the resulting [`domain::OrchestratorCommand`] and +//! its [`application::OrchestratorOutcome`] are identical. The server **invents no +//! semantics** and **never re-routes** a reply (Décision 2 + principe directeur). +//! +//! ## Spike S-MCP — what is confined here +//! +//! Everything MCP/JSON-RPC/transport lives in this module and **nowhere else**; +//! the domain and application layers never see it: +//! - [`jsonrpc`] — a hand-rolled JSON-RPC 2.0 message model + a [`jsonrpc::Transport`] +//! seam (no MCP crate; chosen for zero-network testability — see its docs), +//! - [`transport`] — the `stdio` default transport + an in-memory scriptable +//! transport for tests (a `socket` transport is a documented TODO), +//! - [`tools`] — the tool catalogue and the tool → `OrchestratorCommand` mapping +//! (reusing [`domain::OrchestratorRequest::validate`] as the one validator), +//! - [`server`] — [`McpServer`], the request loop over the transport. +//! +//! ## Testability +//! +//! [`McpServer::handle_raw`] turns one raw JSON-RPC message into its response with +//! no I/O, and [`transport::MemoryTransport`] scripts a whole session in memory, so +//! the agent of Test can cover the adapter **entirely off-network** (no socket, no +//! child process). + +pub mod jsonrpc; +pub mod server; +pub mod tools; +pub mod transport; + +pub use jsonrpc::{ + JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION, +}; +pub use server::McpServer; +pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError}; +pub use transport::{MemoryTransport, StdioTransport}; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs new file mode 100644 index 0000000..8ca1f3f --- /dev/null +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -0,0 +1,246 @@ +//! [`McpServer`] — the IdeA MCP **driving adapter** (cadrage Décision 4). +//! +//! This is the **pair of [`FsOrchestratorWatcher`](super::super::FsOrchestratorWatcher)**: +//! another entry door onto the *same* [`OrchestratorService::dispatch`]. Where the +//! watcher reads a JSON file, this server reads a JSON-RPC `tools/call`; both build +//! an [`OrchestratorCommand`] and return its [`OrchestratorOutcome`] unchanged. It +//! invents **no semantics** and **never re-routes** the reply — for `idea_ask_agent` +//! it returns `outcome.reply` inline, exactly what `dispatch` produced. +//! +//! It implements the strict MCP minimum over JSON-RPC 2.0: +//! - `initialize` → advertise protocol version + tool capability, +//! - `tools/list` → the [`tools::catalogue`], +//! - `tools/call` → map → `dispatch` → MCP tool result, +//! - notifications (e.g. `notifications/initialized`) → accepted, no reply. +//! +//! It receives `Arc` and the target [`Project`] by injection +//! at the composition root (M3), exactly like the watcher — no application logic is +//! duplicated here. + +use std::sync::Arc; + +use application::OrchestratorService; +use domain::{DomainEvent, OrchestrationSource, Project}; +use serde_json::{json, Value}; + +use super::jsonrpc::{ + error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, + JSONRPC_VERSION, +}; +use super::tools::{self, ToolMapError}; + +/// The MCP protocol version this server speaks (advertised on `initialize`). +const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; + +/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`]. +/// +/// Cheap to clone the dependencies it holds; one instance serves one project's +/// agents over one transport (M3 owns one server per open project, beside the +/// watcher). +pub struct McpServer { + service: Arc, + project: Project, + /// Optional event sink (a domain [`EventBus`](domain::ports::EventBus) publish + /// closure), the twin of the file watcher's. When set, a processed `tools/call` + /// publishes [`DomainEvent::OrchestratorRequestProcessed`] tagged + /// [`OrchestrationSource::Mcp`] so the presentation layer can surface that this + /// delegation arrived through the MCP door. `None` ⇒ no publication (the server + /// still works), so existing call sites stay valid. + events: Option>, +} + +impl McpServer { + /// Builds the server from the injected application service and target project. + /// No event sink — use [`with_events`](Self::with_events) to surface processed + /// requests on the bus. + #[must_use] + pub fn new(service: Arc, project: Project) -> Self { + Self { + service, + project, + events: None, + } + } + + /// Attaches an event sink so each processed `tools/call` is republished on the + /// bus tagged [`OrchestrationSource::Mcp`] — the MCP twin of the file watcher's + /// `events` closure. Additive: callers that do not need observability keep using + /// [`new`](Self::new). + #[must_use] + pub fn with_events(mut self, events: Arc) -> Self { + self.events = Some(events); + self + } + + /// Serves JSON-RPC messages from `transport` until it closes (clean EOF). + /// + /// Every inbound line is handled in isolation: a malformed line or an unknown + /// method yields a JSON-RPC error response, **never a panic** and never a + /// dropped connection. Notifications (no `id`) are processed without a reply. + pub async fn serve(&self, transport: &mut T) { + loop { + let raw = match transport.recv().await { + Ok(bytes) => bytes, + Err(TransportError::Closed) => break, + Err(TransportError::Io(_)) => break, + }; + if let Some(response) = self.handle_raw(&raw).await { + let Ok(bytes) = serde_json::to_vec(&response) else { + continue; + }; + if transport.send(&bytes).await.is_err() { + break; + } + } + } + } + + /// Parses one raw message and produces the response to send back, or `None` + /// for a notification (no `id`) that owes no reply. + /// + /// Kept standalone (no transport) so the whole request→response behaviour is + /// unit-testable over plain bytes, with no I/O. + pub async fn handle_raw(&self, raw: &[u8]) -> Option { + let request: JsonRpcRequest = match serde_json::from_slice(raw) { + Ok(r) => r, + Err(e) => { + // Could not correlate (no id parsed) ⇒ null id per JSON-RPC. + return Some(JsonRpcResponse::error( + Value::Null, + JsonRpcError::new(error_codes::PARSE_ERROR, format!("invalid json: {e}")), + )); + } + }; + + if request.jsonrpc != JSONRPC_VERSION { + let id = request.id.unwrap_or(Value::Null); + return Some(JsonRpcResponse::error( + id, + JsonRpcError::new( + error_codes::INVALID_REQUEST, + format!("unsupported jsonrpc version: {}", request.jsonrpc), + ), + )); + } + + // Notification (no id): never reply (the `?` short-circuits to `None`). + let id = request.id.clone()?; + + let result = self.dispatch_method(&request.method, request.params).await; + Some(match result { + Ok(value) => JsonRpcResponse::success(id, value), + Err(error) => JsonRpcResponse::error(id, error), + }) + } + + /// Routes a method name to its handler. + async fn dispatch_method( + &self, + method: &str, + params: Option, + ) -> Result { + match method { + "initialize" => Ok(self.initialize_result()), + "tools/list" => Ok(self.tools_list_result()), + "tools/call" => self.tools_call(params.unwrap_or(Value::Null)).await, + other => Err(JsonRpcError::new( + error_codes::METHOD_NOT_FOUND, + format!("method not found: {other}"), + )), + } + } + + /// The `initialize` result: protocol version, server identity, and the fact + /// that we expose tools. + fn initialize_result(&self) -> Value { + json!({ + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": { "tools": {} }, + "serverInfo": { "name": "idea-orchestrator", "version": env!("CARGO_PKG_VERSION") } + }) + } + + /// The `tools/list` result: the catalogue as MCP tool descriptors. + fn tools_list_result(&self) -> Value { + let tools: Vec = tools::catalogue() + .into_iter() + .map(|t| { + json!({ + "name": t.name, + "description": t.description, + "inputSchema": t.input_schema, + }) + }) + .collect(); + json!({ "tools": tools }) + } + + /// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and + /// fold the [`OrchestratorOutcome`](application::OrchestratorOutcome) into an MCP + /// tool result. The `idea_ask_agent` reply is returned **inline** — never + /// re-routed. + async fn tools_call(&self, params: Value) -> Result { + let name = params + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| { + JsonRpcError::new(error_codes::INVALID_PARAMS, "missing tool `name`") + })? + .to_owned(); + let arguments = params.get("arguments").cloned().unwrap_or(json!({})); + + let command = tools::map_tool_call(&name, &arguments).map_err(map_err_to_jsonrpc)?; + + let result = self.service.dispatch(&self.project, command).await; + // Surface the processed delegation on the bus, tagged as the MCP door — the + // twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the + // action is the tool name. No-op when no sink is attached. + self.publish_processed(&name, result.is_ok()); + match result { + Ok(outcome) => { + // The text the agent sees: the reply for an `ask`, else the detail. + let text = outcome.reply.clone().unwrap_or_else(|| outcome.detail.clone()); + Ok(tool_result_text(&text, false)) + } + // A failed IdeA command is reported as a tool execution error + // (`isError: true`) rather than a protocol error: the agent can read + // it and decide, and the connection stays healthy. + Err(e) => Ok(tool_result_text(&e.to_string(), true)), + } + } + + /// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled + /// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the + /// stable `"mcp"` label until the per-session transport bind (open point S-MCP) + /// carries the connected agent's identity. No-op without an event sink. + fn publish_processed(&self, action: &str, ok: bool) { + if let Some(publish) = &self.events { + publish(DomainEvent::OrchestratorRequestProcessed { + requester_id: "mcp".to_owned(), + action: action.to_owned(), + ok, + source: OrchestrationSource::Mcp, + }); + } + } +} + +/// Builds an MCP `tools/call` result with a single text content block. +fn tool_result_text(text: &str, is_error: bool) -> Value { + json!({ + "content": [ { "type": "text", "text": text } ], + "isError": is_error + }) +} + +/// Maps a [`ToolMapError`] to the right JSON-RPC error code. +fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError { + match err { + ToolMapError::UnknownTool(_) => { + JsonRpcError::new(error_codes::METHOD_NOT_FOUND, err.to_string()) + } + ToolMapError::BadArguments(_) | ToolMapError::Invalid(_) => { + JsonRpcError::new(error_codes::INVALID_PARAMS, err.to_string()) + } + } +} diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs new file mode 100644 index 0000000..22ee067 --- /dev/null +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -0,0 +1,334 @@ +//! The IdeA MCP **tool catalogue** and the tool → [`OrchestratorRequest`] mapping. +//! +//! Each tool here is a thin, typed face over an **already-existing** +//! [`OrchestratorCommand`] (cadrage Décision 4, principe directeur : « le serveur +//! MCP n'invente aucune sémantique »). The mapping builds an +//! [`OrchestratorRequest`] from the tool arguments and lets +//! [`OrchestratorRequest::validate`] be the **single source of truth** for +//! validation — the very same path the filesystem watcher takes. No tool +//! validates anything itself; no tool reaches a use case directly. +//! +//! ## Agent discovery (`idea_list_agents`) +//! +//! Discovery maps to the [`OrchestratorCommand::ListAgents`] domain variant and is +//! served through the **same** `OrchestratorService::dispatch` as every other tool +//! (no use-case is reached directly). Its success carries the agent list inline as +//! a JSON array in the outcome's reply — see [`tool_returns_reply`]. + +use domain::{OrchestratorCommand, OrchestratorError, OrchestratorRequest}; +use serde_json::{json, Value}; + +/// A tool the IdeA MCP server advertises, with the JSON Schema MCP clients read +/// from `tools/list`. +pub struct ToolDef { + /// Tool name as seen by the agent (`idea_ask_agent`, …). + pub name: &'static str, + /// One-line human description shown in the client. + pub description: &'static str, + /// JSON Schema (object) for the tool's arguments. + pub input_schema: Value, +} + +/// Errors mapping a tool call into a validated [`OrchestratorCommand`]. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ToolMapError { + /// The tool name is not one this server exposes. + #[error("unknown tool: {0}")] + UnknownTool(String), + /// The `arguments` object was absent or not a JSON object. + #[error("tool `{0}` arguments must be a JSON object")] + BadArguments(String), + /// The arguments did not satisfy the underlying command's invariants. + #[error(transparent)] + Invalid(#[from] OrchestratorError), +} + +/// Whether a successful call of `tool` carries an inline reply payload back to the +/// caller: `idea_ask_agent` (the target's `Final`) and `idea_list_agents` (the +/// agent list as a JSON array). +#[must_use] +pub fn tool_returns_reply(tool: &str) -> bool { + matches!(tool, "idea_ask_agent" | "idea_list_agents") +} + +/// The full catalogue advertised on `tools/list`. +/// +/// Exactly the tools whose mapping target already exists as an +/// [`OrchestratorCommand`]. +#[must_use] +pub fn catalogue() -> Vec { + vec![ + ToolDef { + name: "idea_list_agents", + description: "List the IdeA agents declared in the project's manifest. Returns the \ + agents inline as a JSON array (id, name, profile, origin, …).", + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ask_agent", + description: "Ask another IdeA agent a task and wait for its reply (synchronous \ + inter-agent rendezvous). Returns the target agent's answer inline.", + input_schema: json!({ + "type": "object", + "properties": { + "target": { "type": "string", "description": "Target agent display name." }, + "task": { "type": "string", "description": "The task/message to send." } + }, + "required": ["target", "task"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_launch_agent", + description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \ + has started the agent, without waiting for any work.", + input_schema: json!({ + "type": "object", + "properties": { + "target": { "type": "string", "description": "Target agent display name." }, + "profile": { "type": "string", "description": "Optional profile reference for a not-yet-created agent." }, + "task": { "type": "string", "description": "Optional initial task/context for the launched agent." }, + "visibility": { "type": "string", "enum": ["background", "visible"], "description": "Where to place the session (default background)." }, + "nodeId": { "type": "string", "description": "Target layout cell id, required when visibility is \"visible\"." } + }, + "required": ["target"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_stop_agent", + description: "Stop a running IdeA agent by killing its terminal session.", + input_schema: json!({ + "type": "object", + "properties": { + "target": { "type": "string", "description": "Target agent display name." } + }, + "required": ["target"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_update_context", + description: "Overwrite an IdeA agent's `.md` context with a new Markdown body.", + input_schema: json!({ + "type": "object", + "properties": { + "target": { "type": "string", "description": "Target agent display name." }, + "context": { "type": "string", "description": "New Markdown body." } + }, + "required": ["target", "context"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_create_skill", + description: "Create a reusable IdeA skill (Markdown) in the project or global scope.", + input_schema: json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "Skill name (also the .md stem)." }, + "context": { "type": "string", "description": "Markdown body of the skill." }, + "scope": { "type": "string", "enum": ["project", "global"], "description": "Scope (default project)." } + }, + "required": ["name", "context"], + "additionalProperties": false + }), + }, + ] +} + +/// Maps an MCP `tools/call` (`name` + `arguments`) to a validated +/// [`OrchestratorCommand`], reusing [`OrchestratorRequest::validate`] as the one +/// validation authority. +/// +/// `arguments` is the raw object an MCP client passes under `params.arguments`. +/// +/// # Errors +/// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`], +/// - [`ToolMapError::BadArguments`] when `arguments` is not an object, +/// - [`ToolMapError::Invalid`] when the underlying command's invariants fail. +pub fn map_tool_call( + name: &str, + arguments: &Value, +) -> Result { + let args = arguments + .as_object() + .ok_or_else(|| ToolMapError::BadArguments(name.to_owned()))?; + + // Small helpers to pull optional string fields out of the arguments object. + let s = |key: &str| -> Option { + args.get(key).and_then(Value::as_str).map(str::to_owned) + }; + + // Translate the tool into the wire-level request shape the watcher already + // uses (v2 `type`), then let `validate` enforce the invariants once. + let request = match name { + "idea_list_agents" => OrchestratorRequest { + request_type: Some("agent.list".to_owned()), + ..base() + }, + "idea_ask_agent" => OrchestratorRequest { + request_type: Some("agent.message".to_owned()), + target_agent: s("target"), + task: s("task"), + ..base() + }, + "idea_launch_agent" => OrchestratorRequest { + request_type: Some("agent.run".to_owned()), + target_agent: s("target"), + profile: s("profile"), + task: s("task"), + visibility: s("visibility"), + node_id: parse_node_id(args.get("nodeId")), + ..base() + }, + "idea_stop_agent" => OrchestratorRequest { + request_type: Some("agent.stop".to_owned()), + target_agent: s("target"), + ..base() + }, + "idea_update_context" => OrchestratorRequest { + request_type: Some("agent.update_context".to_owned()), + target_agent: s("target"), + context: s("context"), + ..base() + }, + "idea_create_skill" => OrchestratorRequest { + request_type: Some("skill.create".to_owned()), + name: s("name"), + context: s("context"), + scope: s("scope"), + ..base() + }, + other => return Err(ToolMapError::UnknownTool(other.to_owned())), + }; + + Ok(request.validate()?) +} + +/// An all-`None` request to spread over; keeps each arm above to just its fields. +fn base() -> OrchestratorRequest { + OrchestratorRequest { + action: None, + request_type: None, + requested_by: None, + name: None, + target_agent: None, + profile: None, + context: None, + task: None, + visibility: None, + node_id: None, + attach_to_cell: None, + scope: None, + } +} + +/// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently +/// dropping a malformed/absent one (validation then rejects a `visible` launch +/// missing its node, with a precise field error). +fn parse_node_id(value: Option<&Value>) -> Option { + let raw = value?.as_str()?; + uuid::Uuid::parse_str(raw).ok().map(domain::NodeId::from_uuid) +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::OrchestratorVisibility; + + #[test] + fn ask_agent_maps_to_ask_command() { + let cmd = map_tool_call( + "idea_ask_agent", + &json!({ "target": "Architect", "task": "Analyse §17" }), + ) + .unwrap(); + assert_eq!( + cmd, + OrchestratorCommand::AskAgent { + target: "Architect".to_owned(), + task: "Analyse §17".to_owned(), + } + ); + } + + #[test] + fn launch_agent_maps_to_spawn_background_by_default() { + let cmd = map_tool_call("idea_launch_agent", &json!({ "target": "Dev" })).unwrap(); + assert_eq!( + cmd, + OrchestratorCommand::SpawnAgent { + name: "Dev".to_owned(), + profile: None, + context: None, + visibility: OrchestratorVisibility::Background, + } + ); + } + + #[test] + fn stop_update_and_skill_map_to_their_commands() { + assert_eq!( + map_tool_call("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(), + OrchestratorCommand::StopAgent { name: "Dev".to_owned() } + ); + assert_eq!( + map_tool_call( + "idea_update_context", + &json!({ "target": "Dev", "context": "# body" }) + ) + .unwrap(), + OrchestratorCommand::UpdateAgentContext { + name: "Dev".to_owned(), + context: "# body".to_owned(), + } + ); + assert_eq!( + map_tool_call( + "idea_create_skill", + &json!({ "name": "deploy", "context": "# steps" }) + ) + .unwrap(), + OrchestratorCommand::CreateSkill { + name: "deploy".to_owned(), + content: "# steps".to_owned(), + scope: domain::SkillScope::Project, + } + ); + } + + #[test] + fn missing_required_field_surfaces_validation_error() { + let err = map_tool_call("idea_ask_agent", &json!({ "target": "Architect" })); + assert!(matches!(err, Err(ToolMapError::Invalid(_)))); + } + + #[test] + fn list_agents_maps_to_list_command() { + let cmd = map_tool_call("idea_list_agents", &json!({})).unwrap(); + assert_eq!(cmd, OrchestratorCommand::ListAgents); + } + + #[test] + fn unknown_tool_is_rejected() { + let err = map_tool_call("idea_made_up_tool", &json!({})); + assert_eq!( + err, + Err(ToolMapError::UnknownTool("idea_made_up_tool".to_owned())) + ); + } + + #[test] + fn non_object_arguments_are_rejected() { + let err = map_tool_call("idea_ask_agent", &json!("nope")); + assert_eq!( + err, + Err(ToolMapError::BadArguments("idea_ask_agent".to_owned())) + ); + } +} diff --git a/crates/infrastructure/src/orchestrator/mcp/transport.rs b/crates/infrastructure/src/orchestrator/mcp/transport.rs new file mode 100644 index 0000000..fe58b3d --- /dev/null +++ b/crates/infrastructure/src/orchestrator/mcp/transport.rs @@ -0,0 +1,162 @@ +//! Concrete transports for the MCP adapter. +//! +//! - [`StdioTransport`] — the **default** transport (cadrage S-MCP): newline- +//! delimited JSON ("JSON Lines") over a pair of async byte streams. A CLI that +//! IdeA spawns with the injected MCP config speaks to the server over its +//! stdin/stdout; IdeA bridges those streams here. +//! - [`MemoryTransport`] — an in-memory, **scriptable** transport used by tests to +//! drive the server with **no socket and no child process** (S-MCP testability +//! requirement). Lives in production code (not `#[cfg(test)]`) so the test crate +//! and downstream adapters can construct it. +//! +//! A `socket` transport is a documented **TODO** (cadrage S-MCP, point ouvert): +//! the [`Transport`] seam means it can be added without touching the server. + +use std::collections::VecDeque; + +use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::mpsc; + +use super::jsonrpc::{Transport, TransportError}; + +/// Newline-delimited JSON transport over a generic async reader/writer. +/// +/// Generic over the streams so the default real wiring uses +/// `tokio::io::Stdin`/`Stdout`, while tests (or a future socket) can plug any +/// `AsyncRead`/`AsyncWrite`. Each `recv` reads one line; each `send` writes one +/// JSON value followed by `\n` and flushes. +pub struct StdioTransport { + reader: BufReader, + writer: W, +} + +impl StdioTransport +where + R: tokio::io::AsyncRead + Unpin + Send, + W: AsyncWrite + Unpin + Send, +{ + /// Wraps a reader/writer pair as a line-delimited JSON transport. + pub fn new(reader: R, writer: W) -> Self { + Self { + reader: BufReader::new(reader), + writer, + } + } +} + +#[async_trait::async_trait] +impl Transport for StdioTransport +where + R: tokio::io::AsyncRead + Unpin + Send, + W: AsyncWrite + Unpin + Send, +{ + async fn recv(&mut self) -> Result, TransportError> { + let mut line = String::new(); + let n = self + .reader + .read_line(&mut line) + .await + .map_err(|e| TransportError::Io(e.to_string()))?; + if n == 0 { + return Err(TransportError::Closed); + } + Ok(line.into_bytes()) + } + + async fn send(&mut self, message: &[u8]) -> Result<(), TransportError> { + self.writer + .write_all(message) + .await + .map_err(|e| TransportError::Io(e.to_string()))?; + self.writer + .write_all(b"\n") + .await + .map_err(|e| TransportError::Io(e.to_string()))?; + self.writer + .flush() + .await + .map_err(|e| TransportError::Io(e.to_string()))?; + Ok(()) + } +} + +/// An in-memory, scriptable transport for tests (zero I/O, zero network). +/// +/// `recv` drains a pre-loaded queue of inbound messages and then reports +/// [`TransportError::Closed`] (so the serve loop terminates deterministically); +/// every `send` is captured on an `mpsc` channel the test can drain to assert the +/// exact responses the server produced. +pub struct MemoryTransport { + inbound: VecDeque>, + outbound: mpsc::UnboundedSender>, +} + +impl MemoryTransport { + /// Builds a transport that will hand `messages` to the server in order, then + /// signal EOF. Returns the transport and a receiver of everything the server + /// `send`s back. + #[must_use] + pub fn scripted( + messages: Vec>, + ) -> (Self, mpsc::UnboundedReceiver>) { + let (tx, rx) = mpsc::unbounded_channel(); + ( + Self { + inbound: messages.into(), + outbound: tx, + }, + rx, + ) + } + + /// Convenience: script from JSON strings. + #[must_use] + pub fn scripted_str(messages: &[&str]) -> (Self, mpsc::UnboundedReceiver>) { + Self::scripted(messages.iter().map(|m| m.as_bytes().to_vec()).collect()) + } +} + +#[async_trait::async_trait] +impl Transport for MemoryTransport { + async fn recv(&mut self) -> Result, TransportError> { + self.inbound.pop_front().ok_or(TransportError::Closed) + } + + async fn send(&mut self, message: &[u8]) -> Result<(), TransportError> { + self.outbound + .send(message.to_vec()) + .map_err(|_| TransportError::Closed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn memory_transport_replays_then_closes() { + let (mut t, _rx) = MemoryTransport::scripted_str(&["a", "b"]); + assert_eq!(t.recv().await.unwrap(), b"a"); + assert_eq!(t.recv().await.unwrap(), b"b"); + assert!(matches!(t.recv().await, Err(TransportError::Closed))); + } + + #[tokio::test] + async fn memory_transport_captures_sends() { + let (mut t, mut rx) = MemoryTransport::scripted(vec![]); + t.send(b"hello").await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), b"hello"); + } + + #[tokio::test] + async fn stdio_transport_round_trips_lines() { + let input = b"{\"x\":1}\n{\"y\":2}\n".to_vec(); + let mut out: Vec = Vec::new(); + { + let mut t = StdioTransport::new(&input[..], &mut out); + assert_eq!(t.recv().await.unwrap(), b"{\"x\":1}\n"); + t.send(b"{\"ok\":true}").await.unwrap(); + } + assert_eq!(out, b"{\"ok\":true}\n"); + } +} diff --git a/crates/infrastructure/src/orchestrator/mod.rs b/crates/infrastructure/src/orchestrator/mod.rs index e613f4d..c75084e 100644 --- a/crates/infrastructure/src/orchestrator/mod.rs +++ b/crates/infrastructure/src/orchestrator/mod.rs @@ -23,11 +23,13 @@ //! notify just lowers latency. The per-file logic ([`process_request_file`]) is a //! standalone async fn, unit-tested against a temp dir independently of the watch. +pub mod mcp; + use std::path::{Path, PathBuf}; use std::sync::Arc; use application::OrchestratorService; -use domain::{DomainEvent, OrchestratorRequest, Project}; +use domain::{DomainEvent, OrchestrationSource, OrchestratorRequest, Project}; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; @@ -205,6 +207,8 @@ async fn scan_once( requester_id: requester_id.clone(), action: outcome.action.clone().unwrap_or_default(), ok: outcome.ok, + // This adapter is the filesystem entry door — tag it as such. + source: OrchestrationSource::File, }); } } diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs new file mode 100644 index 0000000..3bc4933 --- /dev/null +++ b/crates/infrastructure/tests/mcp_server.rs @@ -0,0 +1,949 @@ +//! Functional unit tests for the IdeA **MCP server** adapter (lot **M2**, +//! cadrage `orchestration-v3` §3). +//! +//! These drive [`McpServer`] **entirely off-network** — no socket, no child +//! process — over either [`McpServer::handle_raw`] (one raw JSON-RPC message → +//! its response) or [`MemoryTransport`] (a scripted in-memory session). The server +//! is wired over the **same** in-memory fakes the filesystem-watcher tests already +//! use (`orchestrator_watcher.rs`), so we assert MCP behaviour against a real +//! [`OrchestratorService`] without any I/O: +//! +//! 1. `tools/list` advertises the six `idea_*` tools, each with an input schema. +//! 2. `tools/call` maps every tool to the right `OrchestratorCommand` (observed +//! through the fakes: spawn creates an agent, stop closes the session, …). +//! 3. `idea_ask_agent` returns the target's `reply` **inline**. +//! 4. `idea_list_agents` returns the agent list **inline** as a JSON array. +//! 5. A failed IdeA command ⇒ tool result `isError: true` (not a transport error, +//! not a panic); the server stays alive for the next call. +//! 6. Malformed JSON-RPC ⇒ a typed `PARSE_ERROR`, never a panic. +//! 7. Unknown method / unknown tool ⇒ typed errors, never a panic. +//! 8. `initialize` answers the minimal handshake. +//! 9. A `tools/call` missing a required argument is rejected by the **same** +//! `OrchestratorRequest::validate` (typed error, **no** dispatch). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::agent::{AgentManifest, ManifestEntry}; +use domain::events::{DomainEvent, OrchestrationSource}; +use domain::ids::SkillId; +use domain::ids::{AgentId, ProfileId, ProjectId}; +use domain::ids::NodeId; +use domain::markdown::MarkdownDoc; +use domain::ports::{ + AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan, + DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, + PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, + ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, +}; +use domain::profile::{AgentProfile, ContextInjection}; +use domain::project::{Project, ProjectPath}; +use domain::remote::RemoteRef; +use domain::skill::{Skill, SkillScope}; +use domain::terminal::{SessionKind, TerminalSession}; +use domain::{PtySize, SessionId}; +use uuid::Uuid; + +use application::{ + CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, + OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext, +}; +use infrastructure::{McpServer, MemoryTransport}; +use infrastructure::orchestrator::mcp::jsonrpc::error_codes; + +use serde_json::{json, Value}; + +// --------------------------------------------------------------------------- +// Fakes — mirrored from `orchestrator_watcher.rs` so the MCP adapter is tested +// against the exact same OrchestratorService wiring (no use-case is re-invented). +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct ContextsInner { + manifest: AgentManifest, + contents: HashMap, +} +#[derive(Clone)] +struct FakeContexts(Arc>); +impl FakeContexts { + fn new() -> Self { + Self(Arc::new(Mutex::new(ContextsInner { + manifest: AgentManifest { + version: 1, + entries: Vec::new(), + }, + contents: HashMap::new(), + }))) + } + fn entries(&self) -> Vec { + self.0.lock().unwrap().manifest.entries.clone() + } + /// Seeds the manifest with an already-existing agent so `find_agent_id_by_name` + /// resolves it without going through `CreateAgentFromScratch`. + fn seed_agent(&self, name: &str) -> AgentId { + let id = AgentId::from_uuid(Uuid::new_v4()); + let mut inner = self.0.lock().unwrap(); + inner.manifest.entries.push(ManifestEntry { + agent_id: id, + name: name.to_owned(), + md_path: format!("agents/{name}.md"), + profile_id: ProfileId::from_uuid(Uuid::from_u128(9)), + template_id: None, + synchronized: false, + synced_template_version: None, + skills: Vec::new(), + }); + id + } + fn md_path_of(&self, agent: &AgentId) -> Option { + self.0 + .lock() + .unwrap() + .manifest + .entries + .iter() + .find(|e| &e.agent_id == agent) + .map(|e| e.md_path.clone()) + } +} +#[async_trait] +impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _project: &Project, + agent: &AgentId, + ) -> Result { + let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + Ok(MarkdownDoc::new( + self.0 + .lock() + .unwrap() + .contents + .get(&md) + .cloned() + .unwrap_or_default(), + )) + } + async fn write_context( + &self, + _project: &Project, + agent: &AgentId, + md: &MarkdownDoc, + ) -> Result<(), StoreError> { + let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + self.0 + .lock() + .unwrap() + .contents + .insert(path, md.as_str().to_owned()); + Ok(()) + } + async fn load_manifest(&self, _project: &Project) -> Result { + Ok(self.0.lock().unwrap().manifest.clone()) + } + async fn save_manifest( + &self, + _project: &Project, + manifest: &AgentManifest, + ) -> Result<(), StoreError> { + self.0.lock().unwrap().manifest = manifest.clone(); + Ok(()) + } +} + +#[derive(Clone)] +struct FakeProfiles(Arc>); +#[async_trait] +impl ProfileStore for FakeProfiles { + async fn list(&self) -> Result, StoreError> { + Ok((*self.0).clone()) + } + async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> { + Ok(()) + } + async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> { + Ok(()) + } + async fn is_configured(&self) -> Result { + Ok(true) + } + async fn mark_configured(&self) -> Result<(), StoreError> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeSkills; +#[async_trait] +impl SkillStore for FakeSkills { + async fn list( + &self, + _scope: SkillScope, + _root: &ProjectPath, + ) -> Result, StoreError> { + Ok(Vec::new()) + } + async fn get( + &self, + _scope: SkillScope, + _root: &ProjectPath, + _id: SkillId, + ) -> Result { + Err(StoreError::NotFound) + } + async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { + Ok(()) + } + async fn delete( + &self, + _scope: SkillScope, + _root: &ProjectPath, + _id: SkillId, + ) -> Result<(), StoreError> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeRecall; +#[async_trait] +impl domain::ports::MemoryRecall for FakeRecall { + async fn recall( + &self, + _root: &ProjectPath, + _query: &domain::ports::MemoryQuery, + ) -> Result, domain::ports::MemoryError> { + Ok(Vec::new()) + } +} + +struct FakeRuntime; +impl AgentRuntime for FakeRuntime { + fn detect(&self, _p: &AgentProfile) -> Result { + Ok(true) + } + fn prepare_invocation( + &self, + profile: &AgentProfile, + _ctx: &PreparedContext, + cwd: &ProjectPath, + _session: &SessionPlan, + ) -> Result { + Ok(SpawnSpec { + command: profile.command.clone(), + args: profile.args.clone(), + cwd: cwd.clone(), + env: Vec::new(), + context_plan: Some(ContextInjectionPlan::Stdin), + }) + } +} + +#[derive(Clone, Default)] +struct FakeFs; +#[async_trait] +impl FileSystem for FakeFs { + async fn read(&self, p: &RemotePath) -> Result, FsError> { + Err(FsError::NotFound(p.as_str().to_owned())) + } + async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> { + Ok(()) + } + async fn exists(&self, _p: &RemotePath) -> Result { + Ok(false) + } + async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + async fn list(&self, _p: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +#[derive(Clone)] +struct FakePty; +#[async_trait] +impl PtyPort for FakePty { + async fn spawn(&self, _s: SpawnSpec, _z: PtySize) -> Result { + Ok(PtyHandle { + session_id: SessionId::from_uuid(Uuid::from_u128(777)), + }) + } + fn write(&self, _h: &PtyHandle, _d: &[u8]) -> Result<(), PtyError> { + Ok(()) + } + fn resize(&self, _h: &PtyHandle, _z: PtySize) -> Result<(), PtyError> { + Ok(()) + } + fn subscribe_output(&self, _h: &PtyHandle) -> Result { + Ok(Box::new(std::iter::empty())) + } + fn scrollback(&self, _h: &PtyHandle) -> Result, PtyError> { + Ok(Vec::new()) + } + async fn kill(&self, _h: &PtyHandle) -> Result { + Ok(ExitStatus { code: Some(0) }) + } +} + +/// A structured session whose turn deterministically ends on a `Final` carrying a +/// fixed reply — lets us exercise `idea_ask_agent` without a real CLI. +struct FakeSession { + id: SessionId, + reply: String, +} +#[async_trait] +impl AgentSession for FakeSession { + fn id(&self) -> SessionId { + self.id + } + fn conversation_id(&self) -> Option { + None + } + async fn send(&self, _prompt: &str) -> Result { + let events = vec![ + ReplyEvent::TextDelta { + text: "thinking…".to_owned(), + }, + ReplyEvent::Final { + content: self.reply.clone(), + }, + ]; + Ok(Box::new(events.into_iter())) + } + async fn shutdown(&self) -> Result<(), AgentSessionError> { + Ok(()) + } +} + +#[derive(Default, Clone)] +struct NoopBus; +impl EventBus for NoopBus { + fn publish(&self, _e: DomainEvent) {} + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +struct SeqIds(Mutex); +impl IdGenerator for SeqIds { + fn new_uuid(&self) -> Uuid { + let mut n = self.0.lock().unwrap(); + let id = Uuid::from_u128(*n); + *n += 1; + id + } +} + +fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1000)), + "demo", + ProjectPath::new("/home/me/proj").unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +/// Builds an [`OrchestratorService`] over the in-memory fakes (no structured +/// registry). Returns the shared `TerminalSessions` so a test can pre-bind a live +/// PTY session for an agent (needed to make `stop_agent` succeed). +fn build_service(contexts: FakeContexts) -> (Arc, Arc) { + let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( + ProfileId::from_uuid(Uuid::from_u128(9)), + "Claude Code", + "claude", + Vec::new(), + ContextInjection::stdin(), + None, + "{agentRunDir}", + None, + ) + .unwrap()]))); + let sessions = Arc::new(TerminalSessions::new()); + let bus = Arc::new(NoopBus); + let create = Arc::new(CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds(Mutex::new(1))), + bus.clone(), + )); + let launch = Arc::new(LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(FakeFs), + Arc::new(FakePty), + Arc::new(FakeSkills), + Arc::clone(&sessions), + bus.clone(), + Arc::new(SeqIds(Mutex::new(1))), + Arc::new(FakeRecall), + None, + )); + let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); + let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); + let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(FakeSkills) as Arc, + Arc::new(SeqIds(Mutex::new(1))), + )); + let service = Arc::new(OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + )); + (service, sessions) +} + +/// Like [`build_service`] but with the **structured sessions** registry wired, so +/// `idea_ask_agent` can find a live structured target. Returns the service and the +/// shared registry so a test can pre-insert a replying session. +fn build_service_with_structured( + contexts: FakeContexts, +) -> (Arc, Arc) { + let structured = Arc::new(StructuredSessions::new()); + let (base, _sessions) = build_service(contexts); + let service = Arc::try_unwrap(base) + .unwrap_or_else(|_| panic!("freshly built service must be uniquely owned")) + .with_structured(Arc::clone(&structured)); + (Arc::new(service), structured) +} + +fn server(service: Arc) -> McpServer { + McpServer::new(service, project()) +} + +/// A capturing event sink (the MCP twin of the file watcher's publish closure): +/// records every [`DomainEvent`] the server emits so a test can assert the +/// `OrchestratorRequestProcessed` source tag. Returns the closure to wire via +/// `with_events` and the shared buffer to inspect afterwards. +fn capturing_events() -> ( + Arc, + Arc>>, +) { + let captured = Arc::new(Mutex::new(Vec::new())); + let sink = captured.clone(); + let publish: Arc = + Arc::new(move |e: DomainEvent| sink.lock().unwrap().push(e)); + (publish, captured) +} + +// --- JSON-RPC framing helpers --------------------------------------------- + +/// A `tools/call` request envelope for `tool` with `arguments`. +fn tools_call(id: i64, tool: &str, arguments: Value) -> Vec { + serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": tool, "arguments": arguments } + })) + .unwrap() +} + +/// Extracts the single text content block of a successful `tools/call` result. +fn result_text(result: &Value) -> &str { + result["content"][0]["text"].as_str().expect("text content block") +} + +// --------------------------------------------------------------------------- +// 1. tools/list catalogue +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn tools_list_advertises_the_six_idea_tools_with_schemas() { + let (service, _s) = build_service(FakeContexts::new()); + let server = server(service); + + let raw = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list" + })) + .unwrap(); + + let response = server.handle_raw(&raw).await.expect("tools/list owes a reply"); + assert!(response.error.is_none(), "got error: {:?}", response.error); + let result = response.result.expect("result"); + + let tools = result["tools"].as_array().expect("tools array"); + let names: Vec<&str> = tools + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + + for expected in [ + "idea_list_agents", + "idea_ask_agent", + "idea_launch_agent", + "idea_stop_agent", + "idea_update_context", + "idea_create_skill", + ] { + assert!(names.contains(&expected), "missing tool {expected}; got {names:?}"); + } + assert_eq!(tools.len(), 6, "exactly the six idea_* tools; got {names:?}"); + + // Every tool advertises an object input schema. + for t in tools { + assert_eq!( + t["inputSchema"]["type"], json!("object"), + "tool {} must declare an object input schema", + t["name"] + ); + } +} + +// --------------------------------------------------------------------------- +// 2. tools/call → the right OrchestratorCommand (observed through the fakes) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn launch_agent_call_creates_and_launches_the_agent() { + let contexts = FakeContexts::new(); + let (service, _s) = build_service(contexts.clone()); + let server = server(service); + + // Agent unknown → SpawnAgent creates it from scratch then launches it. + let raw = tools_call( + 1, + "idea_launch_agent", + json!({ "target": "dev-backend", "profile": "claude-code" }), + ); + let response = server.handle_raw(&raw).await.expect("reply owed"); + assert!(response.error.is_none(), "transport error: {:?}", response.error); + let result = response.result.expect("result"); + assert_eq!(result["isError"], json!(false), "got {result}"); + + // The command really reached the use cases: the agent now exists in the manifest. + assert_eq!(contexts.entries().len(), 1); + assert_eq!(contexts.entries()[0].name, "dev-backend"); +} + +#[tokio::test] +async fn stop_agent_call_closes_the_live_session() { + let contexts = FakeContexts::new(); + let agent_id = contexts.seed_agent("dev"); + let (service, sessions) = build_service(contexts); + // Pre-bind a live PTY session for the agent so StopAgent has something to close. + let session_id = SessionId::from_uuid(Uuid::from_u128(555)); + sessions.insert( + PtyHandle { session_id }, + TerminalSession::starting( + session_id, + NodeId::from_uuid(Uuid::from_u128(8)), + ProjectPath::new("/home/me/proj").unwrap(), + SessionKind::Agent { agent_id }, + PtySize { rows: 24, cols: 80 }, + ), + ); + assert!(sessions.session_for_agent(&agent_id).is_some()); + let server = server(service); + + let raw = tools_call(1, "idea_stop_agent", json!({ "target": "dev" })); + let response = server.handle_raw(&raw).await.expect("reply owed"); + let result = response.result.expect("result"); + assert_eq!(result["isError"], json!(false), "got {result}"); + + // CloseTerminal ran → the agent no longer has a live session. + assert!( + sessions.session_for_agent(&agent_id).is_none(), + "stop_agent should have removed the session" + ); +} + +#[tokio::test] +async fn update_context_and_create_skill_calls_succeed() { + let contexts = FakeContexts::new(); + let agent_id = contexts.seed_agent("dev"); + // Seed an existing body so write_context finds the md path. + contexts + .0 + .lock() + .unwrap() + .contents + .insert(format!("agents/dev.md"), "# old".to_owned()); + let _ = agent_id; + let (service, _s) = build_service(contexts.clone()); + let server = server(service); + + let raw = tools_call( + 1, + "idea_update_context", + json!({ "target": "dev", "context": "# new body" }), + ); + let r = server.handle_raw(&raw).await.expect("reply owed"); + assert_eq!(r.result.expect("result")["isError"], json!(false)); + + let raw = tools_call( + 2, + "idea_create_skill", + json!({ "name": "deploy", "context": "# steps" }), + ); + let r = server.handle_raw(&raw).await.expect("reply owed"); + assert_eq!(r.result.expect("result")["isError"], json!(false)); +} + +// --------------------------------------------------------------------------- +// 3. idea_ask_agent returns the reply inline +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn ask_agent_returns_target_reply_inline() { + let contexts = FakeContexts::new(); + let agent_id = contexts.seed_agent("architect"); + let (service, structured) = build_service_with_structured(contexts); + structured.insert( + Arc::new(FakeSession { + id: SessionId::from_uuid(Uuid::from_u128(4242)), + reply: "the answer is 42".to_owned(), + }), + agent_id, + NodeId::from_uuid(Uuid::from_u128(7)), + ); + let server = server(service); + + let raw = tools_call( + 7, + "idea_ask_agent", + json!({ "target": "architect", "task": "What is the answer?" }), + ); + let response = server.handle_raw(&raw).await.expect("reply owed"); + assert_eq!(response.id, json!(7), "id must be echoed"); + assert!(response.error.is_none(), "transport error: {:?}", response.error); + + let result = response.result.expect("result"); + assert_eq!(result["isError"], json!(false), "got {result}"); + assert_eq!( + result_text(&result), + "the answer is 42", + "ask reply must be returned inline; got {result}" + ); +} + +// --------------------------------------------------------------------------- +// 4. idea_list_agents returns the agent list inline (JSON array) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn list_agents_returns_the_agents_inline_as_json_array() { + let contexts = FakeContexts::new(); + contexts.seed_agent("architect"); + contexts.seed_agent("dev-backend"); + let (service, _s) = build_service(contexts); + let server = server(service); + + let raw = tools_call(3, "idea_list_agents", json!({})); + let response = server.handle_raw(&raw).await.expect("reply owed"); + let result = response.result.expect("result"); + assert_eq!(result["isError"], json!(false), "got {result}"); + + // The inline text is the JSON array of the project's agents. + let text = result_text(&result); + let agents: Value = serde_json::from_str(text).expect("reply must be a JSON array"); + let arr = agents.as_array().expect("array"); + assert_eq!(arr.len(), 2, "two seeded agents expected; got {text}"); + let names: Vec<&str> = arr.iter().map(|a| a["name"].as_str().unwrap()).collect(); + assert!(names.contains(&"architect")); + assert!(names.contains(&"dev-backend")); +} + +// --------------------------------------------------------------------------- +// 5. A failed IdeA command ⇒ isError: true (not a transport error, no panic) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn failed_command_is_tool_error_not_transport_error_and_server_survives() { + // stop_agent on a seeded-but-not-running agent → AppError::NotFound from the + // use case → the tool result is an execution error, the JSON-RPC layer is fine. + let contexts = FakeContexts::new(); + contexts.seed_agent("idle"); + let (service, _s) = build_service(contexts); + let server = server(service); + + let raw = tools_call(1, "idea_stop_agent", json!({ "target": "idle" })); + let response = server.handle_raw(&raw).await.expect("reply owed"); + // Healthy connection: no JSON-RPC error... + assert!( + response.error.is_none(), + "a failed command must NOT be a JSON-RPC transport error: {:?}", + response.error + ); + // ...but the tool result is flagged as an error. + let result = response.result.expect("result"); + assert_eq!(result["isError"], json!(true), "got {result}"); + assert!( + !result_text(&result).is_empty(), + "error text should describe the failure" + ); + + // The server is still alive: a subsequent valid call still answers. + let raw = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/list" + })) + .unwrap(); + let again = server.handle_raw(&raw).await.expect("server still serves"); + assert!(again.result.is_some(), "server must survive a failed command"); +} + +// --------------------------------------------------------------------------- +// 6. Malformed JSON-RPC ⇒ typed PARSE_ERROR, never a panic +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn malformed_json_yields_parse_error_no_panic() { + let (service, _s) = build_service(FakeContexts::new()); + let server = server(service); + + let response = server + .handle_raw(b"{ this is not json") + .await + .expect("a parse error still owes a response"); + let error = response.error.expect("parse error expected"); + assert_eq!(error.code, error_codes::PARSE_ERROR); + // JSON-RPC mandates a null id when the request could not be correlated. + assert_eq!(response.id, Value::Null); + assert!(response.result.is_none()); +} + +// --------------------------------------------------------------------------- +// 7. Unknown method / unknown tool ⇒ typed errors, no panic +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn unknown_method_yields_method_not_found() { + let (service, _s) = build_service(FakeContexts::new()); + let server = server(service); + + let raw = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "id": 9, "method": "resources/list" + })) + .unwrap(); + let response = server.handle_raw(&raw).await.expect("reply owed"); + let error = response.error.expect("method-not-found expected"); + assert_eq!(error.code, error_codes::METHOD_NOT_FOUND); + assert_eq!(response.id, json!(9), "id echoed even on error"); +} + +#[tokio::test] +async fn unknown_tool_yields_typed_error() { + let (service, _s) = build_service(FakeContexts::new()); + let server = server(service); + + let raw = tools_call(4, "idea_made_up_tool", json!({})); + let response = server.handle_raw(&raw).await.expect("reply owed"); + let error = response.error.expect("unknown-tool expected"); + // An unknown tool maps to METHOD_NOT_FOUND (see server::map_err_to_jsonrpc). + assert_eq!(error.code, error_codes::METHOD_NOT_FOUND); + assert!( + error.message.contains("unknown tool"), + "message should name the cause; got {}", + error.message + ); +} + +// --------------------------------------------------------------------------- +// 8. initialize handshake +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn initialize_answers_minimal_handshake() { + let (service, _s) = build_service(FakeContexts::new()); + let server = server(service); + + let raw = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "id": 0, "method": "initialize", + "params": { "protocolVersion": "2024-11-05" } + })) + .unwrap(); + let response = server.handle_raw(&raw).await.expect("reply owed"); + assert!(response.error.is_none()); + let result = response.result.expect("result"); + assert!( + result["protocolVersion"].is_string(), + "handshake must advertise a protocol version; got {result}" + ); + // Capability for tools is advertised, and the server identifies itself. + assert!(result["capabilities"]["tools"].is_object()); + assert_eq!(result["serverInfo"]["name"], json!("idea-orchestrator")); +} + +// --------------------------------------------------------------------------- +// 9. Shared validation: a tools/call missing a required argument is rejected by +// the SAME OrchestratorRequest::validate, with no dispatch. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn ask_agent_missing_task_is_rejected_by_shared_validation_no_dispatch() { + let contexts = FakeContexts::new(); + contexts.seed_agent("architect"); + // No structured session inserted: if validation wrongly let this through to + // dispatch, ask_agent would try to launch/contact the target and the error + // would differ. A validation rejection here is INVALID_PARAMS, before dispatch. + let (service, structured) = build_service_with_structured(contexts); + let _ = &structured; // intentionally empty + let server = server(service); + + let raw = tools_call(5, "idea_ask_agent", json!({ "target": "architect" })); + let response = server.handle_raw(&raw).await.expect("reply owed"); + + // Rejected at the mapping/validation seam → a JSON-RPC INVALID_PARAMS error, + // NOT a tool result (which would mean dispatch happened). + let error = response.error.expect("validation error expected"); + assert_eq!(error.code, error_codes::INVALID_PARAMS, "got {error:?}"); + assert!(response.result.is_none(), "no dispatch ⇒ no tool result"); +} + +// --------------------------------------------------------------------------- +// Bonus: drive a whole scripted session over MemoryTransport (zero I/O), proving +// the serve loop handles a sequence (notification + calls) and closes cleanly. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn scripted_session_over_memory_transport_round_trips() { + let contexts = FakeContexts::new(); + contexts.seed_agent("architect"); + let (service, _s) = build_service(contexts); + let server = server(service); + + let init = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize" + })) + .unwrap(); + // A notification (no id) must produce NO reply. + let note = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "method": "notifications/initialized" + })) + .unwrap(); + let list = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/list" + })) + .unwrap(); + + let (mut transport, mut rx) = MemoryTransport::scripted(vec![init, note, list]); + server.serve(&mut transport).await; + // The serve loop has returned (clean EOF), but `transport` still owns the + // outbound sender; drop it so the channel closes and `rx` can reach EOF. + // Without this, `rx.recv()` below would block forever on the single-thread + // test runtime (the sender would still be alive). + drop(transport); + + // Exactly two responses (init + list); the notification produced none. + let first = rx.recv().await.expect("init response"); + let second = rx.recv().await.expect("list response"); + assert!(rx.recv().await.is_none(), "notification must not be answered"); + + let first: Value = serde_json::from_slice(&first).unwrap(); + let second: Value = serde_json::from_slice(&second).unwrap(); + assert_eq!(first["id"], json!(1)); + assert!(first["result"]["protocolVersion"].is_string()); + assert_eq!(second["id"], json!(2)); + assert!(second["result"]["tools"].is_array()); +} + +// --------------------------------------------------------------------------- +// M4 — observability: a processed `tools/call` publishes +// OrchestratorRequestProcessed tagged source = Mcp when an event sink is wired. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn processed_tools_call_publishes_event_tagged_mcp_source() { + let contexts = FakeContexts::new(); + let (service, _s) = build_service(contexts.clone()); + let (publish, captured) = capturing_events(); + let server = McpServer::new(service, project()).with_events(publish); + + // A successful launch (creates + launches an agent from scratch). + let raw = tools_call( + 1, + "idea_launch_agent", + json!({ "target": "dev-backend", "profile": "claude-code" }), + ); + let response = server.handle_raw(&raw).await.expect("reply owed"); + assert!(response.error.is_none(), "transport error: {:?}", response.error); + assert_eq!(response.result.expect("result")["isError"], json!(false)); + + // Exactly one OrchestratorRequestProcessed event, tagged as the MCP door. + let events = captured.lock().unwrap(); + let processed: Vec<&DomainEvent> = events + .iter() + .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })) + .collect(); + assert_eq!( + processed.len(), + 1, + "expected exactly one processed event; got {events:?}" + ); + match processed[0] { + DomainEvent::OrchestratorRequestProcessed { + requester_id, + action, + ok, + source, + } => { + assert_eq!(*source, OrchestrationSource::Mcp, "MCP door must tag Mcp"); + assert_eq!(action, "idea_launch_agent", "action mirrors the tool name"); + assert!(*ok, "the launch succeeded"); + assert_eq!(requester_id, "mcp", "stable mcp label until per-session bind"); + } + other => panic!("expected OrchestratorRequestProcessed, got {other:?}"), + } +} + +#[tokio::test] +async fn failed_tools_call_still_publishes_event_with_ok_false_and_mcp_source() { + // stop_agent on a seeded-but-not-running agent → command fails (isError), yet + // the observability beacon is still emitted, tagged Mcp with ok = false. + let contexts = FakeContexts::new(); + contexts.seed_agent("idle"); + let (service, _s) = build_service(contexts); + let (publish, captured) = capturing_events(); + let server = McpServer::new(service, project()).with_events(publish); + + let raw = tools_call(1, "idea_stop_agent", json!({ "target": "idle" })); + let response = server.handle_raw(&raw).await.expect("reply owed"); + assert_eq!(response.result.expect("result")["isError"], json!(true)); + + let events = captured.lock().unwrap(); + let processed: Vec<&DomainEvent> = events + .iter() + .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })) + .collect(); + assert_eq!(processed.len(), 1, "a failed call still beacons; got {events:?}"); + match processed[0] { + DomainEvent::OrchestratorRequestProcessed { ok, source, action, .. } => { + assert!(!*ok, "the dispatch failed → ok = false"); + assert_eq!(*source, OrchestrationSource::Mcp); + assert_eq!(action, "idea_stop_agent"); + } + other => panic!("expected OrchestratorRequestProcessed, got {other:?}"), + } +} + +#[tokio::test] +async fn server_without_event_sink_emits_nothing_and_does_not_panic() { + // Non-regression: McpServer::new (no with_events) keeps working and publishes + // no event — existing call sites stay valid. + let contexts = FakeContexts::new(); + let (service, _s) = build_service(contexts.clone()); + let server = McpServer::new(service, project()); // no event sink + + let raw = tools_call( + 1, + "idea_launch_agent", + json!({ "target": "dev-backend", "profile": "claude-code" }), + ); + let response = server.handle_raw(&raw).await.expect("reply owed"); + assert!(response.error.is_none(), "transport error: {:?}", response.error); + assert_eq!(response.result.expect("result")["isError"], json!(false)); + // The command really ran (agent created) — proof the no-op sink path is live. + assert_eq!(contexts.entries().len(), 1); +} diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index c227813..1ea8022 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -15,7 +15,7 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::agent::{AgentManifest, ManifestEntry}; -use domain::events::DomainEvent; +use domain::events::{DomainEvent, OrchestrationSource}; use domain::ids::SkillId; use domain::ids::{AgentId, ProfileId, ProjectId}; use domain::markdown::MarkdownDoc; @@ -37,7 +37,7 @@ use application::{ CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext, }; -use infrastructure::{process_request_file, OrchestratorResponse}; +use infrastructure::{process_request_file, FsOrchestratorWatcher, OrchestratorResponse}; // --- temp dir (mirror local_fs.rs) --- struct TempDir(PathBuf); @@ -720,3 +720,68 @@ async fn failure_response_omits_reply_key() { "reply key must be absent on failure — got {raw}" ); } + +// --------------------------------------------------------------------------- +// M4 — observability: the filesystem watcher tags processed requests source=File. +// +// Drives the *public* `FsOrchestratorWatcher::start` against a real temp project +// root with a capturing events closure (the same sink shape the composition root +// wires), then polls (bounded) until the OrchestratorRequestProcessed beacon for +// the dropped request lands. Asserts the tag is `OrchestrationSource::File`. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn watcher_publishes_processed_event_tagged_file_source() { + use std::time::Duration; + + let tmp = TempDir::new(); + // A project rooted at the temp dir so the watcher scans /.ideai/requests/. + let project = Project::new( + ProjectId::from_uuid(Uuid::from_u128(2000)), + "demo", + ProjectPath::new(tmp.0.to_str().unwrap()).unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap(); + let service = build_service(FakeContexts::new()); + + let captured = Arc::new(Mutex::new(Vec::::new())); + let sink = captured.clone(); + let publish: Arc = + Arc::new(move |e| sink.lock().unwrap().push(e)); + + let handle = FsOrchestratorWatcher::start(project, Arc::clone(&service), publish); + + // Drop a valid request under .ideai/requests//. + let req_dir = tmp.0.join(".ideai").join("requests").join("Main"); + std::fs::create_dir_all(&req_dir).unwrap(); + std::fs::write( + req_dir.join("req-1.json"), + br#"{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code" }"#, + ) + .unwrap(); + + // Poll (bounded) until the processed beacon for our request shows up. + let mut found: Option = None; + for _ in 0..50 { + if let Some(e) = captured.lock().unwrap().iter().find(|e| { + matches!(e, DomainEvent::OrchestratorRequestProcessed { action, .. } if action == "spawn_agent") + }) { + found = Some(e.clone()); + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + handle.stop(); + + let event = found.expect("watcher must publish a processed beacon within the poll budget"); + match event { + DomainEvent::OrchestratorRequestProcessed { source, ok, requester_id, .. } => { + assert_eq!(source, OrchestrationSource::File, "fs door must tag File"); + assert!(ok, "the spawn request succeeded"); + assert_eq!(requester_id, "Main", "requester id = request subdirectory"); + } + other => panic!("expected OrchestratorRequestProcessed, got {other:?}"), + } +} diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index e4c6ea9..4126800 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -25,6 +25,18 @@ export type DomainEvent = | { type: "layoutChanged"; projectId: string } | { type: "remoteConnected"; projectId: string } | { type: "gitStateChanged"; projectId: string } + | { + type: "orchestratorRequestProcessed"; + requesterId: string; + action: string; + ok: boolean; + /** + * Which entry door the delegation arrived through: `"mcp"` (MCP server) or + * `"file"` (`.ideai/requests` watcher). Optional for backward-compatibility: + * an event relayed without it simply yields no source badge. + */ + source?: "mcp" | "file"; + } | { type: "ptyOutput"; sessionId: string; bytes: number[] }; /** Where a project physically lives (mirror of the backend `RemoteRef`). */ diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index def02a0..47dea63 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -323,6 +323,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { vm.profiles.find((p) => p.id === a.profileId)?.name ?? a.profileId; const agentDrift = drift.driftByAgentId.get(a.id); + // Source of this agent's last orchestration delegation (mcp vs + // file), if any has been observed. Absent ⇒ no badge. + const delegationSource = vm.delegationSourceByRequester[a.id]; return (
  • )} + {delegationSource && ( + + {delegationSource} + + )} {profileName} {live && ( diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx index a02c28c..4461dd5 100644 --- a/frontend/src/features/agents/agents.test.tsx +++ b/frontend/src/features/agents/agents.test.tsx @@ -582,3 +582,94 @@ describe("AgentsPanel live refresh on domain events", () => { expect(screen.getByText("No agents yet.")).toBeTruthy(); }); }); + +// --------------------------------------------------------------------------- +// M4 — orchestration source badge (mcp / file) on `orchestratorRequestProcessed` +// --------------------------------------------------------------------------- + +describe("AgentsPanel orchestration source badge (M4)", () => { + /** Mounts the panel with a live `MockSystemGateway`, returning all the pieces. */ + async function renderWithSystem() { + const agent = new MockAgentGateway(); + const profile = new MockProfileGateway(); + const system = new MockSystemGateway(); + const template = new MockTemplateGateway(agent); + const gateways = { agent, profile, system, template } as unknown as Gateways; + // The badge keys on `a.id === requesterId`, so create the agent first and use + // its id as the requester in the emitted event. + const created = await agent.createAgent(PROJECT_ID, { + name: "Requester", + profileId: "p1", + }); + render( + + + , + ); + await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy()); + return { agent, system, created }; + } + + it("shows an `mcp` badge when a delegation arrives via the MCP door", async () => { + const { system, created } = await renderWithSystem(); + + system.emit({ + type: "orchestratorRequestProcessed", + requesterId: created.id, + action: "idea_ask_agent", + ok: true, + source: "mcp", + }); + + const badge = await screen.findByLabelText("delegation source mcp"); + expect(badge.textContent?.toLowerCase()).toContain("mcp"); + }); + + it("shows a `file` badge when a delegation arrives via the .ideai/requests door", async () => { + const { system, created } = await renderWithSystem(); + + system.emit({ + type: "orchestratorRequestProcessed", + requesterId: created.id, + action: "spawn_agent", + ok: true, + source: "file", + }); + + const badge = await screen.findByLabelText("delegation source file"); + expect(badge.textContent?.toLowerCase()).toContain("file"); + }); + + it("renders NO badge when the event carries no `source` (older backend)", async () => { + const { system, created } = await renderWithSystem(); + + // Same requester, but the event omits `source` entirely. + system.emit({ + type: "orchestratorRequestProcessed", + requesterId: created.id, + action: "spawn_agent", + ok: true, + }); + + // Give the effect a tick; the map must stay empty → no badge for any source. + await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy()); + expect(screen.queryByLabelText("delegation source mcp")).toBeNull(); + expect(screen.queryByLabelText("delegation source file")).toBeNull(); + }); + + it("does not badge an agent whose id never matched a processed requester", async () => { + const { system } = await renderWithSystem(); + + // A processed event for a *different* requester id leaves our agent un-badged. + system.emit({ + type: "orchestratorRequestProcessed", + requesterId: "some-other-requester", + action: "idea_ask_agent", + ok: true, + source: "mcp", + }); + + await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy()); + expect(screen.queryByLabelText("delegation source mcp")).toBeNull(); + }); +}); diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index dce929c..256ecf7 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -30,6 +30,13 @@ export interface AgentsViewModel { profiles: AgentProfile[]; /** Agents that currently own a live PTY session, as tracked by IdeA. */ liveAgents: LiveAgent[]; + /** + * The entry door (`"mcp"` | `"file"`) the *last* orchestration delegation from + * each requester agent arrived through, keyed by requester id. Populated from + * `orchestratorRequestProcessed` events; a requester absent here (or an event + * without `source`) yields no badge. + */ + delegationSourceByRequester: Record; /** Last error message, or `null`. */ error: string | null; /** Whether a request is in flight. */ @@ -96,6 +103,9 @@ export function useAgents(projectId: string): AgentsViewModel { const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [runningAgentId, setRunningAgentId] = useState(null); + const [delegationSourceByRequester, setDelegationSourceByRequester] = useState< + Record + >({}); const refresh = useCallback(async () => { setBusy(true); @@ -150,6 +160,19 @@ export function useAgents(projectId: string): AgentsViewModel { void refresh(); void refreshLiveAgents(); } + // Record which door a delegation came through (mcp vs file). Absent + // `source` (older backend) leaves the map untouched → no badge. + if ( + event.type === "orchestratorRequestProcessed" && + event.source !== undefined + ) { + const { requesterId, source } = event; + setDelegationSourceByRequester((prev) => + prev[requesterId] === source + ? prev + : { ...prev, [requesterId]: source }, + ); + } }) .then((un) => { if (cancelled) un(); @@ -317,6 +340,7 @@ export function useAgents(projectId: string): AgentsViewModel { context, profiles, liveAgents, + delegationSourceByRequester, error, busy, runningAgentId,