feat(agent): orchestration v3 — surface MCP model-agnostic (M0→M4) — §14.3
Expose l'orchestration IdeA comme serveur MCP par-dessus le même OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking). - M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport) - M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface - M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents - M3 câblage par projet (registre mcp_servers jumeau du watcher) - M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3). Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
280
.ideai/briefs/orchestration-v3-cadrage.md
Normal file
280
.ideai/briefs/orchestration-v3-cadrage.md
Normal file
@ -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<McpCapability>` sur `AgentProfile`,
|
||||
exactement comme `session: Option<SessionStrategy>` et `structured_adapter: Option<StructuredAdapter>`
|
||||
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<McpCapability>,
|
||||
```
|
||||
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 <run_dir>/...
|
||||
apply_mcp_config(mcp, &run_dir, &spec) // ConfigFile→write ; Flag→spec.args ; Env→spec.env
|
||||
```
|
||||
- `ConfigFile { target }` : écrit `<run_dir>/<target>` (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<OrchestratorService>` 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** : `<file>.json` → `<file>.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<McpCapability>` (+ 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<OrchestratorService>` 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<OrchestratorService>` (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<McpCapability>` 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).
|
||||
85
.ideai/briefs/orchestration-v3-invocation-native.md
Normal file
85
.ideai/briefs/orchestration-v3-invocation-native.md
Normal file
@ -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/<id>/*.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.
|
||||
@ -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<McpCapability>` 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1921,6 +1921,7 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@ -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<OrchestrationSource> 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(),
|
||||
|
||||
@ -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<HashMap<ProjectId, OrchestratorWatchHandle>>,
|
||||
/// 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<HashMap<ProjectId, McpServerHandle>>,
|
||||
}
|
||||
|
||||
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<dyn Fn(DomainEvent) + Send + Sync> =
|
||||
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(());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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<AgentProfile> {
|
||||
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<AgentProfile> {
|
||||
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<AgentProfile> {
|
||||
.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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 `<cwd>/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 `<run_dir>/<target>` (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");
|
||||
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/<ton-agent>/` ; 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");
|
||||
|
||||
@ -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<OrchestratorOutcome, AppError> {
|
||||
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,
|
||||
|
||||
@ -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<String>,
|
||||
created_dirs: Arc<Mutex<Vec<String>>>,
|
||||
project_context: Arc<Mutex<Option<String>>>,
|
||||
/// 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<Mutex<Vec<String>>>,
|
||||
/// 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<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
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<u8>)> {
|
||||
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<bool, FsError> {
|
||||
Ok(false)
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
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<u8>)> {
|
||||
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
|
||||
// <run_dir>/.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 <run_dir>/.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, <run_dir>).
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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())?,
|
||||
|
||||
@ -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<String>) -> Result<Self, DomainError> {
|
||||
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<String>) -> Result<Self, DomainError> {
|
||||
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<String>) -> Result<Self, DomainError> {
|
||||
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<StructuredAdapter>,
|
||||
/// 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<McpCapability>,
|
||||
}
|
||||
|
||||
/// 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() });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
181
crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs
Normal file
181
crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs
Normal file
@ -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<RequestId>,
|
||||
/// 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<Value>,
|
||||
}
|
||||
|
||||
/// 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<Value>,
|
||||
/// Failure payload (mutually exclusive with [`Self::result`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
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<Value>,
|
||||
}
|
||||
|
||||
impl JsonRpcError {
|
||||
/// Builds an error with no `data`.
|
||||
#[must_use]
|
||||
pub fn new(code: i32, message: impl Into<String>) -> 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<Vec<u8>, 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\""));
|
||||
}
|
||||
}
|
||||
41
crates/infrastructure/src/orchestrator/mcp/mod.rs
Normal file
41
crates/infrastructure/src/orchestrator/mcp/mod.rs
Normal file
@ -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};
|
||||
246
crates/infrastructure/src/orchestrator/mcp/server.rs
Normal file
246
crates/infrastructure/src/orchestrator/mcp/server.rs
Normal file
@ -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<OrchestratorService>` 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<OrchestratorService>,
|
||||
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<Arc<dyn Fn(DomainEvent) + Send + Sync>>,
|
||||
}
|
||||
|
||||
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<OrchestratorService>, 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<dyn Fn(DomainEvent) + Send + Sync>) -> 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<T: Transport>(&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<JsonRpcResponse> {
|
||||
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<Value>,
|
||||
) -> Result<Value, JsonRpcError> {
|
||||
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<Value> = 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<Value, JsonRpcError> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
334
crates/infrastructure/src/orchestrator/mcp/tools.rs
Normal file
334
crates/infrastructure/src/orchestrator/mcp/tools.rs
Normal file
@ -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<ToolDef> {
|
||||
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<OrchestratorCommand, ToolMapError> {
|
||||
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<String> {
|
||||
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<domain::NodeId> {
|
||||
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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
162
crates/infrastructure/src/orchestrator/mcp/transport.rs
Normal file
162
crates/infrastructure/src/orchestrator/mcp/transport.rs
Normal file
@ -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<R, W> {
|
||||
reader: BufReader<R>,
|
||||
writer: W,
|
||||
}
|
||||
|
||||
impl<R, W> StdioTransport<R, W>
|
||||
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<R, W> Transport for StdioTransport<R, W>
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin + Send,
|
||||
W: AsyncWrite + Unpin + Send,
|
||||
{
|
||||
async fn recv(&mut self) -> Result<Vec<u8>, 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<Vec<u8>>,
|
||||
outbound: mpsc::UnboundedSender<Vec<u8>>,
|
||||
}
|
||||
|
||||
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<Vec<u8>>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Vec<u8>>) {
|
||||
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<Vec<u8>>) {
|
||||
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<Vec<u8>, 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<u8> = 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");
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
949
crates/infrastructure/tests/mcp_server.rs
Normal file
949
crates/infrastructure/tests/mcp_server.rs
Normal file
@ -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<String, String>,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct FakeContexts(Arc<Mutex<ContextsInner>>);
|
||||
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<ManifestEntry> {
|
||||
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<String> {
|
||||
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<MarkdownDoc, StoreError> {
|
||||
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<AgentManifest, StoreError> {
|
||||
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<Vec<AgentProfile>>);
|
||||
#[async_trait]
|
||||
impl ProfileStore for FakeProfiles {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, 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<bool, StoreError> {
|
||||
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<Vec<Skill>, StoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_scope: SkillScope,
|
||||
_root: &ProjectPath,
|
||||
_id: SkillId,
|
||||
) -> Result<Skill, StoreError> {
|
||||
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<Vec<domain::MemoryIndexEntry>, domain::ports::MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeRuntime;
|
||||
impl AgentRuntime for FakeRuntime {
|
||||
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||
Ok(true)
|
||||
}
|
||||
fn prepare_invocation(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
_session: &SessionPlan,
|
||||
) -> Result<SpawnSpec, RuntimeError> {
|
||||
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<Vec<u8>, 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<bool, FsError> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _p: &RemotePath) -> Result<Vec<DirEntry>, 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<PtyHandle, PtyError> {
|
||||
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<OutputStream, PtyError> {
|
||||
Ok(Box::new(std::iter::empty()))
|
||||
}
|
||||
fn scrollback(&self, _h: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn kill(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||
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<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
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<u128>);
|
||||
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<OrchestratorService>, Arc<TerminalSessions>) {
|
||||
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<dyn ProfileStore>,
|
||||
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<dyn SkillStore>,
|
||||
Arc::new(SeqIds(Mutex::new(1))),
|
||||
));
|
||||
let service = Arc::new(OrchestratorService::new(
|
||||
create,
|
||||
launch,
|
||||
list,
|
||||
close,
|
||||
update,
|
||||
create_skill,
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
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<OrchestratorService>, Arc<StructuredSessions>) {
|
||||
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<OrchestratorService>) -> 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<dyn Fn(DomainEvent) + Send + Sync>,
|
||||
Arc<Mutex<Vec<DomainEvent>>>,
|
||||
) {
|
||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = captured.clone();
|
||||
let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
@ -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 <tmp>/.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::<DomainEvent>::new()));
|
||||
let sink = captured.clone();
|
||||
let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
|
||||
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/<requester>/.
|
||||
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<DomainEvent> = 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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`). */
|
||||
|
||||
@ -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 (
|
||||
<li
|
||||
key={a.id}
|
||||
@ -347,6 +350,19 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
update available
|
||||
</span>
|
||||
)}
|
||||
{delegationSource && (
|
||||
<span
|
||||
aria-label={`delegation source ${delegationSource}`}
|
||||
title={
|
||||
delegationSource === "mcp"
|
||||
? "Last delegation arrived via the MCP server"
|
||||
: "Last delegation arrived via .ideai/requests"
|
||||
}
|
||||
className="rounded-full bg-raised px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted"
|
||||
>
|
||||
{delegationSource}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{profileName}</span>
|
||||
{live && (
|
||||
|
||||
@ -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(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AgentsPanel projectId={PROJECT_ID} projectRoot="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string, "mcp" | "file">;
|
||||
/** 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<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [runningAgentId, setRunningAgentId] = useState<string | null>(null);
|
||||
const [delegationSourceByRequester, setDelegationSourceByRequester] = useState<
|
||||
Record<string, "mcp" | "file">
|
||||
>({});
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user