diff --git a/.gitignore b/.gitignore index 4def58e..87e1b36 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ frontend/coverage/ # Derived vector store for semantic recall (LOT C / §14.5.3): embeddings of the # memory notes, rebuildable from the `.md` source of truth — not versioned. .ideai/memory/.index/ +# Runtime file-protocol orchestration requests/responses — transient I/O, not +# durable project state (curation .ideai §chantier secondaire). +.ideai/requests/ # ─── Editors / OS ─────────────────────────────────────────────────────────── .idea/ diff --git a/.ideai/agents.json b/.ideai/agents.json index 85ffe54..418a75a 100644 --- a/.ideai/agents.json +++ b/.ideai/agents.json @@ -14,6 +14,27 @@ "mdPath": "agents/architect.md", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "synchronized": false + }, + { + "agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "name": "DevBackend", + "mdPath": "agents/devbackend.md", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "synchronized": false + }, + { + "agentId": "af7f86da-76bc-48e1-9900-71f45a624800", + "name": "DevFrontend", + "mdPath": "agents/devfrontend.md", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "synchronized": false + }, + { + "agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "name": "QA", + "mdPath": "agents/qa.md", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "synchronized": false } ] } diff --git a/.ideai/briefs/conversation-pair-cadrage.md b/.ideai/briefs/conversation-pair-cadrage.md new file mode 100644 index 0000000..0967db3 --- /dev/null +++ b/.ideai/briefs/conversation-pair-cadrage.md @@ -0,0 +1,515 @@ +# Conversation par paire — cadrage d'architecture (multi-agent solide par construction) + +> **Agent Architecture.** Ce document tranche le modèle qui rend le multi-agent +> **solide par construction** : l'utilisateur n'a plus à « faire les choses dans le +> bon ordre ». Aucun code de production ici — décisions, contrats (ports/entités), +> découpage en lots testables, frontière backend/frontend. +> +> **Décisions produit arbitrées (NON négociables, rappel) :** (A) conversation par +> paire = un fil entre deux parties, session propre, matérialisation paresseuse ; +> (B) entrée médiée par IdeA (le terminal xterm reste la vue de sortie brute +> INCHANGÉE, seule l'entrée change de chemin), Envoyer=enqueue / Interrompre=préempte ; +> (C) FileGuard borné aux `.md` de contexte + la mémoire, via outils MCP, verrou +> lecteurs/écrivain ; (D) zéro git, hexagonal+SOLID stricts, corrélation par ticket, +> MCP Claude-only, fix `bind_endpoint`, abandon du band-aid `\n`→`\r`. +> +> **État du terrain (lu, pas présumé).** L'essentiel des briques existe déjà : +> `domain/src/mailbox.rs` (`AgentMailbox`, `Ticket`, `TicketId`, `PendingReply`, +> `MailboxError`) ; `infrastructure/src/mailbox/mod.rs` (`InMemoryMailbox`, FIFO par +> agent + `oneshot`) ; `application/src/orchestrator/service.rs` (`ask_agent`, +> `reply`, `ensure_live_pty`, verrou de tour `ask_locks`) ; surface MCP complète +> (`mcp/tools.rs`, `mcp/server.rs`) ; transport bindé (`app-tauri/src/mcp_endpoint.rs`, +> `state.rs::bind_endpoint`/`ensure_mcp_server`/`serve_peer`). **Ce cadrage +> formalise et complète ; il ne réécrit pas.** + +--- + +## 0. Synthèse exécutive (décisions tranchées) + +1. **La conversation devient une entité de premier plan** (`Conversation` + `ConversationId`), + absente aujourd'hui. Le couplage actuel « 1 session vivante / agent » + (`session-registry-agent-ambiguity`) est **remplacé** par « 1 session vivante / + **conversation** ». Un agent peut donc avoir **N sessions** simultanées (une par + fil), mais **une seule tâche traitée à la fois** (l'entrée reste sérialisée, §B). + C'est ce qui supprime la fuite de contexte : la délégation A→B n'emprunte plus la + conversation User↔B. + +2. **L'entrée passe par un `InputMediator`** (nouveau port application) : toutes les + entrées (humaine **et** inter-agents) convergent vers **une file FIFO unique par + agent**, `enqueue`/`preempt` distincts. Le terminal xterm n'écrit **plus jamais + en direct dans le PTY** ; il devient une **vue de sortie pure**. La file existante + (`AgentMailbox` + `ask_locks`) est **absorbée** par le `InputMediator` : la + messagerie inter-agents n'est qu'une **source d'entrée parmi deux**. + +3. **`FileGuard` (nouveau port domaine)** : un verrou lecteurs/écrivain **borné** aux + fichiers qu'IdeA possède (`.md` de contexte d'agent + mémoire). Les agents perdent + l'accès fs brut à ces chemins et passent par de **nouveaux outils MCP** + `idea_context_read/propose` et `idea_memory_read/write`. Le contexte **global + projet** est **mono-écrivain (l'orchestrateur)** ; les autres *proposent*. + +4. **Détection occupé/libre = double signal avec fallback sûr** : (a) **retour-de-prompt** + détecté par motif déclaré dans le profil CLI, (b) **signal explicite** de l'agent + (un `idea_reply`, ou fin de tour MCP). **En cas de doute → forwarder** (on + enqueue ; jamais piéger un message). L'occupé/libre remonte au front via un + `DomainEvent` (Channel Tauri), pas via parsing front. + +5. **Fixes durables embarqués** : `bind_endpoint` unlink déjà le socket cadavre + (`reclaim_name(true)`, état OK — on **verrouille ce comportement par un test de + non-régression**) ; le band-aid `\n`→`\r` et l'« injection PTV » de + `service.rs:459` **disparaissent** (l'entrée passe désormais par le `InputMediator`, + pas par une écriture PTY préfixée d'un orchestrateur). + +6. **Garde-fous d'orchestration** : timeout par tour (déjà), **plafond d'attente en + file** (déjà, `ASK_QUEUE_WAIT_CAP`), **détection de cycle** sur un graphe wait-for + (nouveau, dans le domaine — pur, testable) pour refuser une délégation + ré-entrante (A→B→A) avant deadlock. + +--- + +## 1. Modèle de domaine + +### 1.1 Nouvelles entités / VO + +#### `ConversationId` (VO) +- `newtype(uuid::Uuid)`, calqué sur `TicketId`/`AgentId`. Immuable, non vide. +- **Implémenté** : `crates/domain/src/conversation.rs` (nouveau module, à exporter + dans `lib.rs` à côté de `mailbox`). + +#### `ConversationParty` (VO, enum) +```text +ConversationParty = + | User // l'humain (une seule instance logique côté IdeA) + | Agent(AgentId) // un agent du projet +``` +- Invariant : une `Conversation` relie **deux parties distinctes** (jamais + `Agent(x)↔Agent(x)`, jamais `User↔User`). + +#### `Conversation` (entité) +```text +Conversation { + id: ConversationId, + left: ConversationParty, + right: ConversationParty, + session: ConversationSession, // état d'I/O (voir 1.2) + resumable_id: Option, // session-id reprenable de la CLI (suspend = stocke) +} +``` +- **Invariants** : `left != right` ; au plus **une** des deux parties est `User` ; + identité d'une conversation = la **paire non ordonnée** `{left, right}` pour un + agent donné (deux paires identiques ⇒ même conversation — clé de la matérialisation + paresseuse). Pur, I/O-free. +- **Matérialisation paresseuse** : une `Conversation` `Agent↔Agent` n'existe en + registre que s'il y a **au moins une tâche** ; suspendue, elle ne garde que + `resumable_id` (pas de session vivante). C'est une **règle du `ConversationRegistry`** + (application), pas un champ persistant lourd. + +#### `ConversationSession` (VO, enum — l'état d'I/O du fil) +```text +ConversationSession = + | Dormant // jamais lancée, ou suspendue (resumable_id seul) + | Live { handle_ref: SessionRef } // un flux d'I/O vivant (PTY ou structuré) +``` +- `SessionRef` = abstraction d'un handle de session (référence vers une `TerminalSession` + existante, cf. `domain/src/terminal.rs`). Le domaine ne tient **pas** le PTY (infra). + +#### `Task` / `Ticket` (extension de l'existant) +- `Ticket` (`domain/src/mailbox.rs`) est **étendu** pour porter **l'origine** et la + **conversation cible** : +```text +Ticket { + id: TicketId, // existant + source: InputSource, // NOUVEAU : Human | Agent(AgentId) + conversation: ConversationId, // NOUVEAU : le fil dans lequel la tâche entre + requester: String, // existant (label d'affichage du préfixe) + task: String, // existant +} +``` +- `InputSource` (VO, enum) : `Human | Agent(AgentId)`. Remplace l'actuel + `requester: String` libre comme **source de vérité** (le `String` reste un label + d'affichage dérivé). Permet de **propager l'identité du demandeur** (D) et + d'alimenter le graphe wait-for (détection de cycle). +- **Compat** : `Ticket::new` garde sa signature ; on ajoute `Ticket::from_human(...)` + et `Ticket::from_agent(source, conversation, ...)` (Open/Closed, pas de breaking). + +#### File FIFO + état occupé/libre (VO) +- `AgentInbox` (concept porté par le port `InputMediator`, pas une entité persistée) : + **une file FIFO par `AgentId`**, **une tâche en cours à la fois**. +- `AgentBusyState` (VO, enum) : `Idle | Busy { ticket: TicketId, since_ms: u64 }`. + Dérivé, publié au front. Invariant : un agent passe à `Busy` **à l'enqueue qui + démarre un tour** ; revient `Idle` sur **retour-de-prompt** OU **signal explicite** + (cf. §6) ; **en cas de doute, reste `Busy`** mais la file **continue d'accepter** + (forward, jamais bloquer l'émetteur). + +#### `WaitForGraph` (VO pur — détection de cycle) +- `domain/src/conversation.rs` : structure pure `wait_edges: Vec<(AgentId, AgentId)>` + (« A attend B »). Fonction pure `would_cycle(graph, from, to) -> bool`. +- Invariant : une `AskAgent` de `A` vers `B` est **refusée** (`MailboxError`/`AppError` + typé) si elle crée un cycle dans le graphe d'attente (A→B alors que B→…→A). + 100 % testable sans I/O. + +### 1.2 Invariants transverses + +- **1 session vivante / conversation** (remplace « 1 / agent »). `session_for(conversation)` + est déterministe ; `sessions_for_agent(agent)` peut renvoyer N (une par fil actif). +- **1 tâche traitée à la fois / agent** : l'`InputMediator` sérialise l'entrée. Deux + fils d'un même agent partagent **la même file d'entrée** (le process CLI sous-jacent + est unique — « 1 agent = 1 employé »). *Conséquence assumée : un agent occupé par + son fil User retarde une délégation entrante — c'est voulu (un employé, une tâche).* +- **Séparation stricte des contextes** : écrire dans la conversation `A↔B` ne touche + jamais `User↔B`. Garanti par le fait que la session reprise (`resumable_id`) est + **par conversation**, pas par agent. + +--- + +## 2. Ports (traits domaine) + +> Signatures **conceptuelles**. « Consommé par » = application ; « Implémenté par » = infra/app-tauri. + +### `ConversationRegistry` (NOUVEAU — domaine, `conversation.rs`) +- **Rôle** : résoudre/ouvrir paresseusement une conversation pour une paire, tenir son + `session`/`resumable_id`, suspendre/reprendre. +```rust +trait ConversationRegistry: Send + Sync { + /// Get-or-create paresseux : retourne le fil de la paire {a,b}, en l'ouvrant + /// (Dormant) s'il n'existait pas. Pur registre — n'ouvre AUCUNE session. + fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation; + /// Marque une conversation Live avec la session donnée. + fn bind_session(&self, id: ConversationId, session: SessionRef); + /// Suspend : passe Dormant, conserve le resumable_id rendu par la CLI. + fn suspend(&self, id: ConversationId, resumable_id: Option); + fn get(&self, id: ConversationId) -> Option; +} +``` +- **Consommé par** : `OrchestratorService` (au lieu de `session_for_agent` brut), + `LaunchAgent`, la reprise au redémarrage. +- **Implémenté par** : `InMemoryConversationRegistry` (infra) — `HashMap` + mutex sync, + jamais tenu en travers d'un `.await` (cf. `ask_locks` existant). + +### `InputMediator` (NOUVEAU — domaine ou application ; **décision : domaine**, `input.rs`) +- **Rôle** : le point de convergence de **toutes** les entrées d'un agent (FIFO unique), + avec `enqueue` (Envoyer) et `preempt` (Interrompre) **distincts**, plus l'état busy. +```rust +trait InputMediator: Send + Sync { + /// Envoyer = enqueue : ajoute la tâche en queue FIFO de l'agent, retourne le + /// PendingReply à attendre (réutilise le type mailbox existant). + fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply; + /// Interrompre = préempte : signale au tour en cours de s'arrêter (Échap/stop). + /// N'est PAS un enqueue ; ne corrèle aucun ticket. + fn preempt(&self, agent: AgentId); + /// Marque l'agent libre (retour-de-prompt ou signal explicite) ⇒ avance la file. + fn mark_idle(&self, agent: AgentId); + fn busy_state(&self, agent: AgentId) -> AgentBusyState; +} +``` +- **Décision frontière** : `InputMediator` **absorbe** `AgentMailbox`. Le mailbox + existant devient le **moteur de corrélation par ticket** *interne* à + l'implémentation du `InputMediator` (l'`InMemoryMailbox` est réutilisé tel quel, sa + FIFO + `oneshot` sont exactement ce qu'il faut). On **n'a donc pas** deux files + concurrentes : `ask_locks` (verrou de tour) + `InMemoryMailbox` (slots de réponse) + sont unifiés derrière ce port. *(Voir §5 pour le chemin de migration.)* +- **Consommé par** : `OrchestratorService::ask_agent` (source = `Agent`), et le + **nouveau** use case `SubmitHumanInput` (source = `Human`). +- **Implémenté par** : `MediatedInbox` (infra) composant `InMemoryMailbox` + le + registre de verrous de tour + l'état busy. + +### `FileGuard` (NOUVEAU — domaine, `fileguard.rs`) +- **Rôle** : verrou **lecteurs/écrivain par fichier** sur le périmètre **borné** + (contexte `.md` + mémoire). N lecteurs OU 1 écrivain ; mono-écrivain pour le + contexte global (l'orchestrateur). +```rust +enum GuardedResource { // VO — le périmètre borné, fermé + AgentContext(AgentId), + ProjectContext, // mono-écrivain : orchestrateur uniquement + Memory(MemorySlug), +} +trait FileGuard: Send + Sync { + async fn acquire_read(&self, who: ConversationParty, res: GuardedResource) + -> Result; + async fn acquire_write(&self, who: ConversationParty, res: GuardedResource) + -> Result; +} +``` +- `ReadLease`/`WriteLease` = gardes RAII (libèrent à la fin de portée). `GuardError` + typé : `Busy` (attendre), `Forbidden` (un agent ≠ orchestrateur veut écrire + `ProjectContext` ⇒ refus, doit *proposer*). +- **Invariant clé** : toute lecture/écriture des ressources gardées **transite par ce + port** ; l'accès fs brut à ces chemins est retiré aux agents (cf. §3 outils MCP). +- **Consommé par** : `UpdateAgentContext`, `MemoryStore`-consumers, les nouveaux + use cases `ReadContext`/`ProposeContext`/`ReadMemory`/`WriteMemory`. +- **Implémenté par** : `RwFileGuard` (infra) — `HashMap` + (tokio `RwLock` ou sémaphore), + la règle mono-écrivain pour `ProjectContext`. + +### `AgentMailbox` (existant — **conservé**, statut révisé) +- Reste le **contrat de rendez-vous par ticket** (corrélation **par `TicketId`**, voir + §3.3 — on **abandonne** la corrélation purement positionnelle « tête de file » dès + qu'un agent peut avoir plusieurs fils). Devient un **détail d'implémentation** du + `InputMediator` ; n'est plus injecté seul dans `OrchestratorService`. + +### Ports inchangés réutilisés +- `PtyPort` (écriture du tour dans le PTY = désormais le **seul** chemin d'écriture, + piloté par le `InputMediator`, plus par `ask_agent` directement). +- `ProfileStore` (porte le **motif de retour-de-prompt** par profil, §6). +- `EventBus` (publie `AgentBusyChanged`, `AgentReplied`). + +--- + +## 3. Adapters (infra) + outils MCP + +### 3.1 Adapters +| Port | Adapter | Notes | +|---|---|---| +| `ConversationRegistry` | `InMemoryConversationRegistry` | `HashMap` + index paire→id ; mutex sync. | +| `InputMediator` | `MediatedInbox` | compose `InMemoryMailbox` (existant) + verrous de tour + état busy ; publie `AgentBusyChanged`. | +| `FileGuard` | `RwFileGuard` | `RwLock` par `GuardedResource` ; règle mono-écrivain `ProjectContext`. | +| `AgentMailbox` | `InMemoryMailbox` | **inchangé** (réutilisé sous `MediatedInbox`). | + +### 3.2 Nouveaux outils MCP (`infrastructure/src/orchestrator/mcp/tools.rs`) +Ajouts **purement additifs** au `catalogue()` (Open/Closed — le dispatch reste intact) : + +- **`idea_context_read { target? }`** → action wire `context.read` → + `OrchestratorCommand::ReadContext { target }`. `target` absent = le contexte **global + projet** ; sinon le `.md` d'un agent. Passe par `FileGuard::acquire_read`. +- **`idea_context_propose { target?, content }`** → `context.propose` → + `OrchestratorCommand::ProposeContext`. Pour un agent : écriture directe sous verrou + écrivain. Pour le **global** : ce n'est **pas** une écriture, c'est une **proposition** + (déposée pour validation par l'orchestrateur/UI ; `FileGuard` refuse l'écriture + directe avec `Forbidden`). +- **`idea_memory_read { slug? }`** → `memory.read` → `ReadMemory` (sous `FileGuard`). +- **`idea_memory_write { slug, content }`** → `memory.write` → `WriteMemory` (verrou + écrivain ; mémoire = partagée projet, cf. `shared-project-memory`). + +Chaque outil suit le **patron existant** : `map_tool_call` construit un +`OrchestratorRequest`, `validate()` reste l'**unique autorité** de validation, le +`requester` du handshake porte l'identité (`ConversationParty::Agent`). + +### 3.3 Corrélation `idea_reply` **par ticket** (D) +- **Changement** : aujourd'hui `idea_reply` corrèle **positionnellement** (tête de la + file de l'émetteur — `mailbox.resolve(from, result)`). Dès qu'un agent peut avoir + **plusieurs fils**, la tête de « sa » file est ambiguë. +- **Décision** : le préfixe injecté dans le PTY (`[IdeA · tâche de A · ticket ]`) + porte **déjà** le `ticket_id`. On expose un champ **optionnel** `ticket` au schéma de + `idea_reply` (`{ result, ticket? }`) ; quand présent, `resolve` corrèle **par + `TicketId`** (déterministe, multi-fil) ; absent, on **retombe** sur la tête de file + (compat agents simples, mono-fil). Le préfixe doit donc **demander à l'agent de + renvoyer le `ticket`** (mise à jour de la description outil + protocole §B-5 + existant). `AgentMailbox::resolve` gagne une variante `resolve_ticket(agent, + ticket_id, result)`. + +--- + +## 4. Frontière front : vue de sortie (xterm inchangé) / entrée médiée + +### 4.1 État actuel à modifier +`frontend/src/features/terminals/TerminalView.tsx` câble aujourd'hui **directement** +les frappes au PTY : +```ts +const onKey = term.onData((data) => { + if (handle) void handle.write(encoder.encode(data)); // ← chemin à couper +}); +``` +C'est **exactement** le couplage que le Modèle B retire. + +### 4.2 Décision frontend +1. **xterm reste la vue de sortie brute, INCHANGÉE** : `onData (PTY) → term.write` + conservé tel quel. **Interdiction** de ressusciter `AgentChatView` (déjà supprimé + dans le diff courant — ne pas le réintroduire). +2. **`term.onData` (frappes) n'écrit plus dans le PTY** pour une cellule **agent**. + Deux modes : + - **Cellule terminal simple (non-agent)** : comportement actuel conservé (écriture + directe — pas de médiation, c'est un shell brut). + - **Cellule agent** : les frappes vont dans un **champ de saisie géré par IdeA** + (composant `MediatedInput`, rendu **sous** le terminal), pas dans le PTY. xterm + passe en lecture seule pour l'entrée (sortie toujours live). +3. **Nouveau port UI `InputGateway`** (`frontend/src/ports/index.ts`) : + ```ts + interface InputGateway { + submit(projectId: string, agentId: string, text: string): Promise; // Envoyer = enqueue + interrupt(projectId: string, agentId: string): Promise; // Interrompre = preempt + } + ``` + Adapter Tauri : `invoke("submit_agent_input", …)` / `invoke("interrupt_agent", …)` + (nouvelles commands app-tauri → `SubmitHumanInput` / `preempt`). Mock pour tests. +4. **Occupé/libre remonte par event** : un `DomainEvent::AgentBusyChanged { agent_id, + busy }` relayé en event Tauri (pas un Channel haute-fréquence — événement discret). + Le `MediatedInput` désactive « Envoyer » pendant `Busy` mais **autorise toujours + l'enqueue** (le bouton enfile derrière ; jamais bloqué — fallback « forward »), et + active « Interrompre ». Le front **ne parse jamais** la sortie pour deviner l'état. + +### 4.3 Composants/state touchés +- `features/terminals/TerminalView.tsx` : brancher le mode agent (entrée détournée). +- `features/terminals/MediatedInput.tsx` (**nouveau**) : champ + boutons Envoyer/Interrompre. +- `features/layout/LayoutGrid.tsx` : déjà route vers `TerminalView` ; ajoute le + `MediatedInput` sous le terminal quand `agent != null`. +- `ports/index.ts` + `adapters/agent.ts` (ou nouvel `adapters/input.ts`) + mock. +- state : un store léger `agentBusy: Record` alimenté par l'event. + +--- + +## 5. Impact sur le code existant + +### 5.1 Supprimé / retiré +- **L'écriture PTY préfixée par `ask_agent`** (`service.rs` ~459 : + `pty.write(&handle, "[IdeA · tâche …]\n")`) **n'est plus le chemin d'entrée**. La + tâche déléguée entre désormais par `InputMediator::enqueue` (qui, dans son impl, + écrira la ligne dans le PTY — mais **sérialisée derrière l'entrée humaine** du même + agent, ce qui n'était pas le cas avant). → la logique d'écriture **déménage** de + `ask_agent` vers l'impl `MediatedInbox`. +- **Band-aid `\n`→`\r`** : abandonné (le « mode injection PTV » disparaît). Plus de + réécriture de fin de ligne ad hoc. +- **`AgentChatView`** (front) : déjà supprimé dans le diff courant — **rester** supprimé. + +### 5.2 Modifié +- **`OrchestratorService`** : ne reçoit plus `with_mailbox(mailbox, pty)` séparément + mais `with_input_mediator(Arc)` + `with_conversations(Arc)`. `ask_agent` devient : résoudre la **conversation A↔B** + (paresseux), vérifier le **graphe wait-for** (refus si cycle), `enqueue` la tâche + (source = `Agent`), `await PendingReply` borné. `reply` corrèle **par ticket** (§3.3). + `ensure_live_pty` reste, mais branché sur `session_for(conversation)` au lieu de + `session_for_agent`. +- **`session_for_agent`** (registre `terminal/registry.rs`) : devient + `session_for(conversation_id)` ; `sessions_for_agent` (pluriel) ajouté. Lève + l'ambiguïté `session-registry-agent-ambiguity` **par construction** (la clé est la + conversation, pas l'agent). +- **`bind_endpoint`** (`state.rs`) : **déjà** `reclaim_name(true)` ⇒ unlink du cadavre. + **Action = verrouiller par un test** (ouvrir/fermer/SIGKILL simulé/rebind sans + `EADDRINUSE`). Pas de changement de code attendu, sauf si le test révèle un trou. +- **`idea_reply`** (tools.rs / orchestrator.rs / server.rs) : champ `ticket?` ajouté, + `Reply { from, ticket: Option, result }`, `map_tool_call` le propage. +- **`Ticket`** (`mailbox.rs`) : champs `source: InputSource`, `conversation: + ConversationId` ajoutés (constructeurs additifs). + +### 5.3 Ajouté +- Domaine : `conversation.rs` (`ConversationId`, `Conversation`, `ConversationParty`, + `ConversationSession`, `WaitForGraph`), `input.rs` (`InputMediator`, `InputSource`, + `AgentBusyState`), `fileguard.rs` (`FileGuard`, `GuardedResource`, leases). +- Application : use cases `SubmitHumanInput`, `ReadContext`/`ProposeContext`, + `ReadMemory`/`WriteMemory` ; détection de cycle câblée dans `ask_agent`. +- Infra : `InMemoryConversationRegistry`, `MediatedInbox`, `RwFileGuard` ; outils MCP + `idea_context_*` / `idea_memory_*`. +- app-tauri : commands `submit_agent_input`, `interrupt_agent` ; relais event + `AgentBusyChanged` ; câblage des nouveaux ports au composition root (`state.rs`). +- Front : `MediatedInput`, `InputGateway` + adapter + mock + store busy. + +--- + +## 6. Détection occupé/libre + +**Mécanisme retenu = double signal, OR, avec fallback sûr.** + +| Signal | Source | Fiabilité | +|---|---|---| +| **Retour-de-prompt** | motif (regex/literal) déclaré dans le **profil CLI** (`AgentProfile`, nouveau champ `prompt_ready_pattern: Option`), détecté sur le flux PTY par l'impl `MediatedInbox` | bon pour un shell/CLI au prompt stable ; faillible (motif dans la sortie) | +| **Signal explicite** | l'agent appelle `idea_reply` (fin d'une délégation) **ou** un signal de fin-de-tour MCP | déterministe quand l'agent coopère | + +- Transition `Busy → Idle` = **premier** des deux signaux qui arrive. +- **Fallback « en cas de doute → forwarder »** : si **aucun** signal n'est sûr (motif + absent du profil, agent muet), l'agent **reste marqué `Busy`** mais la file + **continue d'accepter** les `enqueue` ; un message entrant **n'est jamais rejeté**, + il patiente dans la FIFO. On ne « piège » donc jamais un message ; au pire il attend. +- **Garde-fou anti-blocage** : le timeout par tour (`ASK_AGENT_TIMEOUT`, existant) + retire le ticket de tête et **relâche** le tour même si aucun signal n'est venu ⇒ + la file avance. L'agent reste vivant. +- Le motif vit **dans le profil** (donnée, pas code) ⇒ ajouter une CLI = éditer un + profil (Open/Closed, cohérent §9 CLAUDE.md). + +--- + +## 7. Découpage en lots livrables (ordonnés par dépendance) + +> Chaque lot = binôme dev/test. **B = DevBackend (Rust)**, **F = DevFrontend (TS/React)**. +> Chemin critique : C1 → C2 → C3 → C4. FileGuard (C6) et front (F1/F2) parallélisables. + +### Bloc Conversation (cœur — backend) +| Lot | Côté | Périmètre | Tests | +|---|---|---|---| +| **C1** | B (domaine) | `conversation.rs` : `ConversationId`, `ConversationParty`, `Conversation`, `ConversationSession`, `WaitForGraph::would_cycle`. `input.rs` : `InputSource`, `AgentBusyState`. Extension `Ticket` (source+conversation, ctors additifs). | invariants paire (left≠right, ≤1 User) ; identité = paire non ordonnée ; `would_cycle` (A→B→A refusé, A→B→C ok) ; ticket porte source+conversation. Pur, sans I/O. | +| **C2** | B (domaine+infra) | Ports `ConversationRegistry` + `InputMediator` (domaine) ; adapters `InMemoryConversationRegistry` + `MediatedInbox` (compose `InMemoryMailbox` existant). | resolve paresseux (même paire ⇒ même id) ; enqueue→PendingReply ; preempt distinct d'enqueue ; busy_state transitions ; 2 enqueue même agent sérialisés ; agents ≠ parallèles. | +| **C3** | B (application) | `OrchestratorService` : `with_input_mediator`+`with_conversations` ; `ask_agent` réécrit (résout conversation A↔B, garde wait-for, enqueue source=Agent, await) ; `reply` par ticket. `session_for(conversation)`. Retrait écriture PTY directe + band-aid `\r`. | ask A→B route dans la bonne conversation (pas User↔B) ; cycle A→B→A ⇒ erreur typée avant deadlock ; reply corrèle par ticket (multi-fil) ; reply sans ticket = fallback tête ; timeout libère file, cible vivante. | +| **C4** | B (application+app-tauri) | Use case `SubmitHumanInput` (source=Human) + commands `submit_agent_input`/`interrupt_agent` ; event `AgentBusyChanged` relayé. Câblage composition root (`state.rs`). | submit humain enfile dans la **même** FIFO que les délégations ; interrupt = preempt (pas enqueue) ; busy event émis aux bons moments ; câblage : un ask et un submit concurrents sur A sérialisent. | + +### Bloc détection occupé/libre (backend) +| Lot | Côté | Périmètre | Tests | +|---|---|---|---| +| **C5** | B (domaine+infra) | Champ profil `prompt_ready_pattern` ; détection retour-de-prompt dans `MediatedInbox` ; OR avec signal explicite ; fallback « reste Busy mais accepte ». | motif détecté ⇒ Idle ; idea_reply ⇒ Idle ; ni l'un ni l'autre ⇒ Busy mais enqueue accepté ; timeout ⇒ file avance. | + +### Bloc FileGuard (backend — parallélisable après C1) +| Lot | Côté | Périmètre | Tests | +|---|---|---|---| +| **C6** | B (domaine+infra) | `fileguard.rs` (port + `GuardedResource` + leases) ; `RwFileGuard` ; règle mono-écrivain `ProjectContext`. | N lecteurs concurrents OK ; 1 écrivain exclusif ; agent≠orchestrateur écrit ProjectContext ⇒ `Forbidden` ; lease RAII libère. | +| **C7** | B (application+infra MCP) | Use cases `ReadContext`/`ProposeContext`/`ReadMemory`/`WriteMemory` sous FileGuard ; outils MCP `idea_context_*`/`idea_memory_*` ; retrait accès fs brut de ces chemins. | map_tool_call → command ; validate exige `content` ; propose global ≠ write direct ; lecture concurrente non bloquante ; écriture sérialisée. | + +### Bloc frontend +| Lot | Côté | Périmètre | Tests (Vitest/RTL, gateways mock) | +|---|---|---|---| +| **F1** | F | `InputGateway` (port+adapter+mock) ; `MediatedInput` (Envoyer=submit / Interrompre=interrupt) ; store busy alimenté par event. | submit appelle gateway.submit ; interrupt appelle interrupt ; busy event désactive Envoyer (mais enqueue possible), active Interrompre. | +| **F2** | F | `TerminalView` mode agent : frappes → `MediatedInput` (plus le PTY) ; xterm reste sortie live INCHANGÉE pour le non-agent. `LayoutGrid` monte `MediatedInput` sous le terminal si `agent != null`. | cellule agent ⇒ onData ne write pas le PTY ; cellule simple ⇒ comportement actuel ; sortie PTY toujours peinte ; jamais d'AgentChatView. | + +### Bloc durcissement +| Lot | Côté | Périmètre | Tests | +|---|---|---|---| +| **D1** | B (app-tauri) | Test de non-régression `bind_endpoint` : bind → drop (SIGKILL simulé : laisser le fichier socket) → rebind **sans** `EADDRINUSE`. Verrouille `reclaim_name(true)`. | rebind après cadavre OK ; idempotent ; pas de fuite de fichier après close. | + +**Ordre recommandé** : **C1 → C2 → C3 → C4** (cœur), **C5** après C2, **C6 → C7** +en parallèle (après C1), **F1 → F2** dès que les commands C4 existent (mock avant), +**D1** isolé n'importe quand. + +--- + +## 8. Stratégie de tests par couche + +| Couche | Type | Comment | +|---|---|---| +| **domaine** (`conversation`, `input`, `fileguard`, `mailbox` étendu) | unitaires **purs**, sans I/O ni async là où possible | invariants de paire, `would_cycle`, transitions `AgentBusyState`, ctors `Ticket`. Déterministe. C'est là que vit la garantie « solide par construction ». | +| **application** (`OrchestratorService`, `SubmitHumanInput`, use cases FileGuard) | unitaires avec **ports mockés** (fakes manuels, façon `service.rs` actuel) | ask route la bonne conversation ; cycle refusé ; reply par ticket ; submit+ask sérialisés ; FileGuard mono-écrivain. **Aucun vrai PTY/fs/MCP.** | +| **infra** (`MediatedInbox`, `RwFileGuard`, `InMemoryConversationRegistry`, outils MCP) | intégration **ciblée** | FIFO réelle + `oneshot` ; RwLock concurrence ; `map_tool_call` round-trip ; `bind_endpoint` (D1). Réutilise les tests `InMemoryMailbox` existants. | +| **app-tauri** | commands ↔ use cases | `submit_agent_input`/`interrupt_agent` mappent bien ; event `AgentBusyChanged` émis ; câblage composition root cohérent (endpoint partagé). | +| **frontend** (`MediatedInput`, `TerminalView`) | Vitest + RTL, **gateways mock** | entrée détournée hors PTY ; busy désactive Envoyer sans bloquer enqueue ; xterm sortie inchangée ; **sans backend**. | + +--- + +## 9. Risques / points ouverts + +1. **Fiabilité de la détection retour-de-prompt** (C5) — le plus dur. Un motif dans la + sortie d'un agent peut **faussement** signaler Idle (libère trop tôt) ou ne jamais + matcher (reste Busy). *Mitigation* : OR avec le signal explicite `idea_reply` + + fallback « reste Busy mais accepte » + timeout par tour. *Reste ouvert* : faut-il un + « heartbeat » MCP de fin-de-tour côté CLI ? (hors périmètre immédiat, Claude-only). + +2. **Suspension/reprise de session par conversation** (`resumable_id`) — un agent à N + fils doit reprendre **le bon** session-id par fil au redémarrage. Dépend du + `session{assignFlag,resumeFlag}` du profil (cf. `conversation-resume-architecture`). + *Ouvert* : capacité réelle des CLI à tenir N conversations resumables simultanées + pour un même process « 1 agent = 1 employé » — possible conflit entre « N fils » et + « 1 process ». **Décision de cadrage** : **1 process/agent**, les fils **partagent + la file d'entrée** (sérialisés) ; le `resumable_id` par conversation sert surtout à + la **reprise au redémarrage**, pas à du vrai parallélisme intra-process. + +3. **Deadlock & détection de cycle** (`WaitForGraph`) — couvre A→B→A directs et + transitifs, mais le graphe doit être **alimenté en temps réel** (arête posée à + l'enqueue, retirée au reply/timeout). *Risque* : arête fantôme si un reply se perd + ⇒ faux positif de cycle. *Mitigation* : retrait d'arête garanti par le RAII du tour + (comme `_turn` aujourd'hui) + timeout. + +4. **Corrélation par ticket vs agents « simples »** — un agent qui ne renvoie pas le + `ticket` dans `idea_reply` retombe sur la corrélation positionnelle (tête de file), + ambiguë en multi-fil. *Mitigation* : protocole §B-5 (description outil) **insiste** + sur le renvoi du ticket ; mono-fil reste correct sans. *Ouvert* : forcer le ticket + requis casserait des agents simples — on garde optionnel. + +5. **Périmètre FileGuard contournable** — tant que l'agent garde un shell brut (PTY), + il peut écrire les `.md`/mémoire **par le filesystem** malgré le verrou MCP. Le + verrou n'est étanche que si l'accès fs à ces chemins est **réellement** retiré + (sandbox, cf. `agent-permissions-architecture` / Landlock). *Ouvert* : sans sandbox + OS, le `FileGuard` est **coopératif** (protège des collisions IdeA↔IdeA, pas d'un + agent qui contourne). À acter : FileGuard = correction des collisions **dans le + chemin IdeA** d'abord ; étanchéité réelle = lot sandbox ultérieur. + +6. **Migration `AgentMailbox` → `InputMediator`** — risque de double-file transitoire. + *Mitigation* : `MediatedInbox` **enveloppe** `InMemoryMailbox` (pas de réécriture), + `OrchestratorService` bascule d'un `with_mailbox` vers `with_input_mediator` en un + lot (C2→C3), tests existants `InMemoryMailbox` conservés verts. + +--- + +*Document maintenu par l'Agent Architecture — cadrage « conversation par paire », +base des lots C1→C7 / F1→F2 / D1 avant tout code.* diff --git a/.ideai/briefs/option1-terminal-mcp-design.md b/.ideai/briefs/option1-terminal-mcp-design.md new file mode 100644 index 0000000..bf36e94 --- /dev/null +++ b/.ideai/briefs/option1-terminal-mcp-design.md @@ -0,0 +1,48 @@ +# Design — Option 1 « Terminal + MCP » (orchestration inter-agents) + +> Décision produit arbitrée (2026-06-11). Remplace la vue chat structurée par le +> terminal natif + délégation inter-agents par outils MCP. Source : agent Architecte. +> Statut : **design validé, dev NON commencé** (limite de session atteinte le 2026-06-11, +> reset 3:40am Europe/Paris). Reprendre par les lots backend B-0→B-5 et frontend F-1. + +## Objectif +- **Vue humaine = terminal brut natif** (PTY interactif). Réflexion live + Échap = natifs CLI, zéro parsing par modèle. On abandonne `AgentChatView`/stream-json comme vue. +- **Délégation cross-model via MCP** : `idea_ask_agent(target, task)` bloquant → la cible traite quand libre (FIFO) → rend son résultat via NOUVEL outil `idea_reply(result)` → IdeA débloque l'appelant. Fin-de-tour = signal MCP explicite. +- Principes : 1 agent = 1 employé (1 process/session, input FIFO) ; hexagonal + SOLID stricts ; plus aucun `parse_event` requis pour vue ni orchestration. + +## Découvertes clés de l'architecte (état réel du code) +1. La **file FIFO existe déjà** : `OrchestratorService` (`crates/application/src/orchestrator/service.rs`) a `ask_locks: Mutex>>>` + `ask_lock_for()` + `ASK_QUEUE_WAIT_CAP` (600s) + `ASK_AGENT_TIMEOUT` (300s). On la formalise en port `AgentMailbox` (pour porter un `oneshot` de réponse). +2. `idea_ask_agent` → `agent.message` → `OrchestratorCommand::AskAgent{target_agent, task}` **déjà câblé** (mcp/tools.rs, domain/orchestrator.rs, service.rs). On réimplémente le **corps** de `ask_agent()`. +3. Aujourd'hui `ask_agent` **exige une session structurée** et renvoie `AppError::Invalid` si la cible est en PTY brut (service.rs ~400-410). **Inverser cette branche** : PTY vivant = canal normal. +4. Routage structuré dans `crates/application/src/agent/lifecycle.rs` (`LaunchAgent` ~1100). Levier de bascule : **ne plus injecter la fabrique structurée au composition root** (`crates/app-tauri/src/state.rs`, `with_structured`). +5. `apply_mcp_config` (lifecycle.rs ~1391) écrit déjà `.mcp.json` + `--mcp-config` AVANT le spawn, **chemin PTY inclus** → la CLI PTY a déjà le serveur MCP IdeA (à vérifier par test B-0). Vigilance : `ensure_mcp_server` doit piloter `McpServer::serve` sur le loopback. +6. `idea_reply` n'existe nulle part : seul vrai ajout de surface. + +## Lots BACKEND (Rust — agent dev backend) ; NE PAS faire B-6 (nettoyage) avant coordination +- **B-0** Prérequis transport MCP : garantir CLI PTY reçoit `--mcp-config ` (endpoint/project/requester) + `serve` piloté loopback. Test : CLI factice PTY appelle `idea_list_agents`, reçoit réponse. +- **B-1** Port `AgentMailbox` + `InMemoryMailbox`. Domaine pur (`crates/domain/src/mailbox.rs` ou ports.rs) : trait + `Ticket{id,requester,task}`, `TicketId`, `MailboxError`. Infra (`crates/infrastructure/src/mailbox/`) : `HashMap)>>` + mutex ; `enqueue` rend `PendingReply` (sur `oneshot::Receiver`). Tests : FIFO ; `resolve` réveille le bon pending ; 2 ask même cible sérialisés ; cibles ≠ non bloquants ; timeout retire ticket de tête. +- **B-2** Bascule routage : tous en PTY. `state.rs` : retirer `with_structured` de `LaunchAgent`/`OrchestratorService`/`ChangeAgentProfile`. Tests : profil Claude → PTY ; DTO renvoie `CellKind::Pty`. Ne pas supprimer `launch_structured` (mort-code, nettoyage ultérieur). +- **B-3** Réimplémenter `ask_agent` : résoudre id → `mailbox.enqueue` → ticket en tête → garantir cible vivante PTY (sinon LaunchAgent PTY bg) → `PtyPort::write` préfixe `[IdeA · tâche de {A} · ticket {id}] {task}\n` → `await PendingReply` borné `ASK_AGENT_TIMEOUT`. PTY vivant = normal. Timeout : garder agent vivant, retirer ticket de tête. Publier `AgentReplied`. Injecter `Arc` + `Arc`. Tests : injection bon handle ; agent mort relancé ; timeout libère file ; AgentReplied. +- **B-4** Outil/action `idea_reply` : `ToolDef idea_reply` (schéma `{result:string}` seul, pas de ticket_id exposé), action wire `agent.reply`, `OrchestratorCommand::Reply{from:AgentId, result}`, `validate`, `map_tool_call` (passe `requester` du handshake comme `from`), bras dispatch → `mailbox.resolve(from, result)`. Corrélation implicite : `idea_reply` résout le ticket en tête de la file de l'émetteur (identité via handshake, pas via id géré par le modèle). `tool_returns_reply` : idea_reply = ACK sans inline. Tests : mapping ; validate exige result ; resolve corrèle tête ; reply sans ask = erreur typée (pas de panic). +- **B-5** Protocole délégation dans le contexte : injecter dans convention file (`apply_injection`) + description outil : « reçois `[IdeA · tâche …]` → traite → appelle IMPÉRATIVEMENT `idea_reply(result=…)` ; ne réponds jamais qu'en texte. » Test : convention file contient l'instruction. + +## Lots FRONTEND (TS/React — agent dev frontend) ; NE PAS faire F-2 (suppression) avant coordination +- **F-1** Router toute cellule agent vers `TerminalView` (jamais `AgentChatView`) ; ré-attache PTY + scrollback OK. Backend renverra `cellKind:"pty"`. Lire `frontend/src/features/layout/LayoutGrid.tsx`, `features/chat/AgentChatView.tsx`, `TerminalView`, `adapters/agent.ts`, `ports/index.ts`, `domain/index.ts`. Laisser `AgentChatView` inerte (non monté), pas supprimé. Tests Vitest : agent rend `TerminalView`, jamais `AgentChatView` ; re-mount repeint pty. + +## Ordre / dépendances +``` +B-0 ─┬─ B-2 ─┬─ B-3 ─ B-4 ─ B-5 +B-1 ─┘ └─ F-1 +Nettoyage (B-6, F-2) en dernier, coordonné. +``` +Chemin critique : B-0 → B-2 → B-3 → B-4 → B-5. B-1 ∥ B-0. F-1 dès B-2. + +## Cohérence +Domaine sans I/O (port + entités pures) ; oneshot/PTY/MCP = infra ; application via ports. Open/Closed (idea_reply = ajout, dispatch intact) ; Liskov (Claude/Codex identiques derrière PTY+MCP) ; 1 process/agent préservé. + +## Fichiers à toucher +- Domaine : `mailbox.rs` (nouveau) / `ports.rs` ; `orchestrator.rs` (variante `Reply` + action `agent.reply`). +- Application : `orchestrator/service.rs` (ask_agent + reply + injection ports) ; `agent/structured.rs` (supprimé au nettoyage) ; `agent/lifecycle.rs` (routage). +- Infra : `mailbox/` (nouveau) ; `orchestrator/mcp/tools.rs` (idea_reply) ; `orchestrator/mcp/server.rs` (passer requester). +- app-tauri : `state.rs` (retrait with_structured + injection mailbox + ensure_mcp_server) ; `commands.rs`/`dto.rs` (nettoyage ultérieur). +- Frontend : `features/layout/LayoutGrid.tsx` (routage TerminalView) ; `features/chat/*` (nettoyage ultérieur). diff --git a/.ideai/briefs/validation-reelle-inter-agents.md b/.ideai/briefs/validation-reelle-inter-agents.md new file mode 100644 index 0000000..c1ed417 --- /dev/null +++ b/.ideai/briefs/validation-reelle-inter-agents.md @@ -0,0 +1,35 @@ +# Protocole — Validation réelle de la conversation inter-agents via IdeA + +> À exécuter depuis la **nouvelle AppImage** (build 2026-06-10 18:23, contenant R0+A0+M5, +> commits `37e7274` / `6ca519b` / `cf89b3b`). L'ancienne image (testée avant) ne contenait +> pas ce code et a renvoyé l'erreur attendue « agent Ask pas pilotable en mode structuré ». + +## But +Prouver en conditions réelles qu'un agent (Main/Claude) peut **demander** une tâche à un autre +agent (Ask/Codex) **via IdeA** et **recevoir sa réponse inline** — pas seulement par tests à fakes. + +## Pré-requis pour que le `ask` aboutisse +- La cible (**Ask**) doit être pilotée en **mode structuré** (profil Codex avec adapter structuré). + Le menu de sélection d'agent ne propose normalement que des profils structurés (Claude/Codex). +- Ask **ne doit pas** déjà tourner comme **terminal brut** (PTY) dans une cellule : sinon + `ask_agent` refuse (invariant « 1 session/agent », cible PTY brut = pas de canal de réponse). +- Le plus simple : **laisser Ask éteint** et laisser `ask_agent` le **lancer lui-même** en + structuré (sémantique : cible morte ⇒ launch structuré background ⇒ send ⇒ Final). + +## Procédure (protocole fichier, identique au test précédent) +1. Déposer `.ideai/requests/main/.json` : + ```json + { "type": "agent.message", "requestedBy": "Main", "targetAgent": "Ask", + "task": "Petite recherche, pas de code : résume en 3 points ce que fait le module + crates/infrastructure/src/orchestrator/mcp/ et liste les outils idea_*." } + ``` +2. Attendre l'apparition de `.ideai/requests/main/.json.response.json`. + +## Succès attendu +`{ "ok": true, "action": "agent.message", "reply": "" }` — le champ **`reply`** +porte le contenu produit par Ask. (Échec précédent = `ok:false` + erreur PTY brut.) + +## Voie native MCP (bonus) +Le bind transport S-MCP (M5) est livré : un agent lancé avec un profil MCP voit les outils +`idea_*` (dont `idea_ask_agent`) et le résultat revient inline. À valider quand un profil MCP +est branché sur Claude/Codex. Voir `.ideai/briefs/orchestration-v5-transport-bind-cadrage.md`. diff --git a/.ideai/layouts.json b/.ideai/layouts.json index 3f373f6..549d22d 100644 --- a/.ideai/layouts.json +++ b/.ideai/layouts.json @@ -1,25 +1,26 @@ { "version": 1, - "activeId": "eed26045-b208-47ff-98ee-ca0d6e5933b3", + "activeId": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5", "layouts": [ { - "id": "eed26045-b208-47ff-98ee-ca0d6e5933b3", + "id": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5", "name": "Default", "kind": "terminal", "tree": { "root": { "type": "split", "node": { - "id": "8cbe4c7c-357f-49ad-ac20-c96802a84684", + "id": "1eb96c70-e954-42d0-907b-f8528d0442bf", "direction": "row", "children": [ { "node": { "type": "leaf", "node": { - "id": "e8933dbd-1c53-4342-96ca-020b9a9b7970", - "session": "51c96008-f875-4515-9b5b-50c4217f64d6", - "agent": "a6ced819-b893-4213-b003-9e9dc79b9641" + "id": "db40e3de-4980-4bed-b6fa-1c136f49d30e", + "session": "d7d4c099-eda3-4b33-82e6-e6f8d57b8b97", + "agent": "a6ced819-b893-4213-b003-9e9dc79b9641", + "agentWasRunning": true } }, "weight": 1.0 @@ -28,8 +29,10 @@ "node": { "type": "leaf", "node": { - "id": "50da405b-18f3-4273-9fe0-57bb242cb2f7", - "session": "5a796d0e-433e-4ded-9955-ed02b389d9c1" + "id": "92826533-1a7c-4d9e-b6b4-f1e904ddc81a", + "session": "5d20f53a-ac76-4677-9a6f-064d3319cc73", + "agent": "edce8090-4c57-47c5-a319-c08fd172438b", + "agentWasRunning": true } }, "weight": 1.0 diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md new file mode 100644 index 0000000..73331b8 --- /dev/null +++ b/.ideai/memory/MEMORY.md @@ -0,0 +1,5 @@ +# Memory Index + +- [agent-context-memory-and-profile-handoff](agent-context-memory-and-profile-handoff.md) — Decisions sur l'injection de contexte, la memoire durable, l'etat live et le handoff de profil entre agents IA. +- [idea-product-directives-main-handoff](idea-product-directives-main-handoff.md) — Directives produit consolidees pour guider Main sur la robustesse, la persistance, le handoff cross-profile et la sobriete UX. +- [remaining-work-idea-agent-control-ide](remaining-work-idea-agent-control-ide.md) — Etat des lieux des acquis et des chantiers restants pour aligner IdeA avec la cible d'IDE de controle d'agents IA. diff --git a/.ideai/memory/agent-context-memory-and-profile-handoff.md b/.ideai/memory/agent-context-memory-and-profile-handoff.md new file mode 100644 index 0000000..229e59c --- /dev/null +++ b/.ideai/memory/agent-context-memory-and-profile-handoff.md @@ -0,0 +1,106 @@ +--- +name: agent-context-memory-and-profile-handoff +description: Decisions sur l'injection de contexte, la memoire durable, l'etat live et le handoff de profil entre agents IA. +metadata: + type: project +--- +# Agent Context, Memory, and Profile Handoff + +## Resume + +This note captures the current product direction for IdeA around agent context injection, project memory, live state, and profile handoff between AI providers. + +See also: + +- `idea-product-directives-main-handoff` for product priorities and UX constraints. +- `remaining-work-idea-agent-control-ide` for the current implementation status and remaining work. + +## Context Injection + +- Agent context must be injected by IdeA at launch time; the model should not be expected to discover `AGENTS.md` or `CLAUDE.md` by itself. +- The existing `contextInjection` architecture is the right mechanism, especially `conventionFile` for providers that support a conventional file in the run directory. +- The current implementation is strongest for `conventionFile`; `flag`, `stdin`, and `env` do not yet receive the same fully-composed IdeA context. +- The `flag` strategy appears fragile with the isolated run directory model because the relative path passed to the CLI may not resolve from the run directory. + +## Shared Project Context + +- `.ideai/CONTEXT.md` is intended as shared project context. +- It is not created automatically; it only exists if something writes it. +- It should carry active project constraints and contribution rules. +- Example content for `CONTEXT.md`: architectural constraints, workflow rules, and operating conventions that every agent must apply immediately. + +## Durable Memory + +- `.ideai/memory/` is intended as durable, project-scoped memory shared by all agents of the same project. +- It is not created automatically; it only exists once at least one memory note is saved. +- The durable memory is a knowledge base, not a live activity log. +- It should contain stabilised knowledge such as: + - architecture decisions + - feature summaries to implement later + - user preferences that persist across sessions + - important project facts and references +- Durable memory should stay curated and low-noise. + +## Live State Versus Durable Memory + +- Durable memory should not be used as a shared real-time state feed for all agents. +- IdeA should distinguish between: + - global context (`.ideai/CONTEXT.md`) + - durable memory (`.ideai/memory/`) + - live operational state (separate store) + - handoff summaries between sessions or profiles +- Real-time work tracking, who-is-doing-what, and transient status should live in a dedicated state mechanism, not in durable memory. + +## Memory Consumption by Agents + +- The current launcher reads shared project context from `.ideai/CONTEXT.md` if present. +- It also recalls project memory via `MemoryRecall` and injects a `# Memoire projet` section into the convention file. +- This injection currently happens only for `conventionFile` profiles. +- The recalled memory is shared at the project level, but each agent may receive a different subset depending on its persona and recall query. +- Memory recall is computed at launch time and written into the generated context file. +- There is currently no automatic live refresh when `.ideai/memory/` changes during an active session. + +## Recommendation for Live Memory Refresh + +- Automatic memory refresh could be useful, but it should be explicit and controlled. +- If IdeA wants agents to benefit from memory changes while they are active, it should regenerate their effective context when needed instead of treating durable memory as a constantly streaming log. +- For PTY agents, no automatic reread exists today. +- For structured Claude/Codex sessions, each turn relaunches the CLI, but the generated convention file is not automatically rewritten when durable memory changes. + +## Profile Handoff and Session Continuity + +- A direct native session transfer from Claude to Codex is not the right mental model. +- The correct model is continuity of work state, not native provider-session continuity. +- IdeA should persist: + - a canonical conversation log + - a cumulative handoff summary + - the agent state + - the provider conversation id when useful +- On provider swap, IdeA should launch the new profile with: + - regenerated project context + - regenerated durable memory recall + - the current agent persona + - a handoff summary plus recent transcript +- The handoff summary should not be created only at the moment of swap. +- IdeA should maintain summaries incrementally or at checkpoints so that a swap is still possible when the current provider is near a token or session limit. + +## Agent Ability To Write Durable Memory + +- Agents should be allowed to promote important knowledge into durable memory. +- This should not rely on the agent guessing the capability. +- IdeA should expose the capability explicitly through tools or commands and should inject a clear rule explaining when an agent may save durable knowledge. +- This write ability should be constrained to stable, high-value information, not transient state. + +## Practical Classification Rule + +- Put immediate project rules and operating constraints in `.ideai/CONTEXT.md`. +- Put stable, reusable project knowledge in `.ideai/memory/`. +- Put current activity and coordination state in a separate live-state mechanism. +- Put cross-session or cross-profile recovery material in a handoff/session layer. + +## Current Product Direction + +- Keep `CONTEXT.md` for project rules. +- Keep `.ideai/memory/` for curated durable knowledge. +- Introduce a separate live-state mechanism if agents must stay aligned on in-progress work. +- Introduce persistent conversation logs and incremental handoff summaries to support profile swaps such as Claude to Codex. diff --git a/.ideai/memory/idea-product-directives-main-handoff.md b/.ideai/memory/idea-product-directives-main-handoff.md new file mode 100644 index 0000000..ce6a46b --- /dev/null +++ b/.ideai/memory/idea-product-directives-main-handoff.md @@ -0,0 +1,206 @@ +--- +name: idea-product-directives-main-handoff +description: Directives produit consolidees pour guider Main sur la robustesse, la persistance, le handoff cross-profile et la sobriete UX. +metadata: + type: project +--- +# IdeA Product Directives For Main + +## Resume + +Cette note consolide les arbitrages produit explicites donnes par l'utilisateur pour aider `Main` a poursuivre le projet sans ambiguite. + +See also: + +- `agent-context-memory-and-profile-handoff` for the structural model of context, durable memory, live state, and handoff. +- `remaining-work-idea-agent-control-ide` for the current implementation status and remaining work. + +Elle ne remplace pas les notes techniques existantes. Elle sert de reference prioritaire sur: + +- la robustesse attendue, +- la persistance et la reprise, +- le handoff entre profils IA, +- la memoire projet partagee, +- le live state projet, +- la sobriete UX. + +## Priorite Absolue + +La priorite produit numero un est la robustesse. + +Ordre de priorite impose: + +1. robustesse et solidite avant tout +2. persistance et reprise +3. handoff cross-profile +4. live state projet +5. reste des features et du polish + +Regle de pilotage: + +- un systeme incomplet mais solide vaut mieux qu'un systeme riche mais fragile +- `Main` doit privilegier les architectures et comportements qui reduisent les crashes, les incoherences d'etat et les flows difficiles a reprendre + +## Reprise Et Persistance + +Quand IdeA redemarre, l'objectif n'est pas seulement de rouvrir une UI ou de restaurer des handles techniques. + +La cible produit est: + +- qu'un agent sache compactement sur quoi il travaillait +- qu'IdeA fournisse ce materiel de reprise automatiquement +- que la reprise soit exploitable meme si la conversation visible precedente n'est pas restauree a l'identique + +Le bon modele est: + +- un log canonique IdeA comme source durable +- un resume/handoff genere par IdeA comme couche compacte de reprise + +Le resume/handoff n'est pas un confort secondaire. Il fait partie du comportement normal du produit. + +## Handoff Cross-Profile + +La cible ideale est double: + +- reprendre correctement le travail +- donner si possible une impression de continuite presque sans rupture + +Mais en cas de compromis, la priorite doit etre: + +- fidelite operationnelle du travail repris +- avant la parfaite illusion de continuite terminale ou conversationnelle + +Autrement dit: + +- si un agent passe de Claude a Codex, IdeA doit d'abord garantir que Codex puisse reprendre le plus fidelement possible le travail utile +- l'absence de restauration parfaite de l'ancien terminal est acceptable si le handoff reste bon + +## Perimetre Profils + +Le perimetre de reference immediat est: + +- Claude +- Codex + +Toute fonctionnalite importante doit etre faisable pour ces deux profils. + +Directive associée: + +- reduire au maximum les dependances a des commandes, flags ou comportements specifiques a un profil +- construire un noyau le plus generique possible tout en restant concretement compatible Claude/Codex +- les autres profils pourront etre ajoutes plus tard si possible, mais ne doivent pas detourner le coeur du chantier actuel + +## Memoire Projet Partagee + +La memoire projet partagee doit rester petite, stable et utile. + +Elle ne doit pas devenir un gros bloc qui siphonne les tokens de l'utilisateur a chaque requete. + +Ce qu'un agent peut ecrire automatiquement dans la memoire partagee si c'est stable et utile: + +- decisions durables d'architecture ou d'organisation +- preferences utilisateur durables +- regles de workflow durables +- references importantes a conserver +- resumes de handoff utiles a la reprise inter-session ou inter-profil + +Ce qu'un agent ne doit pas y ecrire automatiquement: + +- conversations brutes +- journaux detailles de travail +- etats temporaires +- files d'attente +- coordination temps reel +- essais/erreurs locaux +- hypotheses non stabilisees +- contenu redondant ou reconstructible ailleurs + +Principe de fond: + +- memoire durable = savoir stable +- log canonique = historique +- handoff = reprise compacte +- live state = coordination vivante + +Ces couches doivent rester separees. + +## Live State Projet + +Le live state projet partage doit exister comme mecanisme interne d'IdeA. + +Contraintes produit: + +- il doit rester invisible pour l'utilisateur +- il doit survivre au redemarrage d'IdeA + +Il ne doit pas se transformer en UI verbeuse ni en mecanisme demandant une intervention explicite de l'utilisateur. + +## UX Et Philosophie Produit + +IdeA doit etre tres facile d'utilisation. + +Objectif UX: + +- plug and play +- pas de sensation de parametrage impose +- pas de surcharge de tuto au premier lancement +- pas d'impression que le produit force des comportements internes a l'utilisateur + +Ligne directrice souhaitee: + +- esprit "maniere Linux" +- comportement simple et utile par defaut +- pas de contrainte tant qu'il n'y a pas un vrai besoin +- suggestion discrete seulement si IdeA detecte qu'une aide ou une optimisation devient utile + +Le precedent du compactage de contexte est considere comme la bonne direction: + +- pas de compactage impose d'emblee +- une popup proposee seulement si IdeA sent un besoin + +## Transparence Des Mecanismes Internes + +Les mecanismes suivants doivent rester quasi invisibles pour l'utilisateur: + +- delegations inter-agents +- FIFO +- handoffs + +Ils peuvent devenir visibles en debug ou quand le produit a une bonne raison UX de les exposer, mais ils ne doivent pas etre ressentis comme une charge cognitive normale d'utilisation. + +## Frontiere Avec Le Chantier Inter-Agents De Main + +Les choix fins touchant la communication entre agents ne doivent pas etre recadres ici si `Main` est deja en train de les traiter. + +Cette note ne doit donc pas etre lue comme une specification d'implementation inter-agents detaillee. + +Elle fixe seulement les invariants produit suivants: + +- robustesse avant richesse fonctionnelle +- reprise automatique par IdeA +- log canonique + handoff genere par IdeA +- support de reference pour Claude et Codex +- memoire durable compacte et curatee +- live state interne et persistant +- UX discrete, simple et peu intrusive + +## Directive Finale Pour Main + +Si un arbitrage technique oppose: + +- elegance theorique +- ou livraison rapide + +contre: + +- robustesse +- reprise fiable +- sobriete UX + +alors `Main` doit privilegier: + +- robustesse +- reprise fiable +- sobriete UX + +avant le reste. diff --git a/.ideai/memory/remaining-work-idea-agent-control-ide.md b/.ideai/memory/remaining-work-idea-agent-control-ide.md new file mode 100644 index 0000000..ebf3f46 --- /dev/null +++ b/.ideai/memory/remaining-work-idea-agent-control-ide.md @@ -0,0 +1,273 @@ +--- +name: remaining-work-idea-agent-control-ide +description: Etat des lieux des acquis et des chantiers restants pour aligner IdeA avec la cible d'IDE de controle d'agents IA. +metadata: + type: project +--- +# Remaining Work For IdeA Agent Control IDE + +## Resume + +Cette note sert de point de reprise pour l'agent `Main`. Elle distingue ce qui est deja implemente, ce qui reste a stabiliser, et ce qui reste a construire pour que IdeA corresponde pleinement a la vision "patron + employes IA" avec memoire projet partagee, contexte partage, messagerie inter-agents transparente, FIFO par agent, et persistance de reprise. + +See also: + +- `idea-product-directives-main-handoff` for product priorities and UX constraints. +- `agent-context-memory-and-profile-handoff` for the structural model separating context, durable memory, live state, and handoff. + +Etat observe le 2026-06-11 sur le depot local: + +- L'orchestration inter-agents synchrone est deja reelle cote application. +- La FIFO par agent, la conversation par paire et `idea_reply` sont deja couvertes par des tests verts. +- Le transport MCP natif par projet et le loopback sont testes verts localement. +- La reprise de conversation cote session/layout est largement presente dans le code. +- En revanche, plusieurs briques produit restent inachevees ou non consolidees de bout en bout. + +## Deja Livre Ou Tres Avance + +### 1. Artefacts projet dans `.ideai/` + +- Les agents projet sont persistes dans `.ideai/agents.json` et `.ideai/agents/*.md`. +- Le contexte projet partage est modelise via `.ideai/CONTEXT.md`. +- La memoire projet partagee est modelisee via `.ideai/memory/*.md` + `.ideai/memory/MEMORY.md`. +- Les requetes d'orchestration fichier vivent sous `.ideai/requests/`. + +Conclusion: la direction "tout ce qui releve d'IdeA pour un projet doit vivre dans `.ideai/`" est deja la bonne direction architecturale. Il reste surtout a eliminer les ecarts pratiques et a consolider l'usage reel. + +### 2. Memoire projet partagee + +- `FsMemoryStore` et `MemoryRecall` existent. +- Les agents peuvent recevoir un rappel de memoire projet a l'activation. +- Le frontend et les use cases CRUD memoire existent deja. + +Conclusion: la memoire partagee du projet n'est plus un concept a inventer. Le travail restant est plutot sur la qualite du rappel, la curation, et l'usage continu pendant la vie des sessions. + +### 3. Contexte partage et contexte agent + +- Le contexte partage projet est separe du contexte agent. +- Les contextes agent sont persistes sous `.ideai/agents/*.md`. +- Le launcher injecte deja le contexte compose au demarrage. + +Conclusion: la separation `contexte projet` / `contexte agent` est en place. + +### 4. Messagerie inter-agents transparente + +- `AgentMailbox` + `InMemoryMailbox` existent. +- `ConversationRegistry` + `InMemoryConversationRegistry` existent. +- `OrchestratorService::ask_agent` et `reply` existent. +- Les outils MCP `idea_ask_agent`, `idea_reply`, `idea_launch_agent`, `idea_list_agents`, `idea_stop_agent`, `idea_update_context`, `idea_create_skill` existent. +- Les tests applicatifs passent sur FIFO, reponse synchrone, prevention de cycle, timeout, parallélisme entre cibles differentes. + +Verification locale du 2026-06-11: + +- `cargo test -p application --test orchestrator_service` : OK +- `cargo test -p infrastructure --test mcp_server` : OK +- `cargo test -p app-tauri --test orchestrator_wiring` : OK + +Conclusion: le coeur de la communication inter-agents n'est plus un chantier de conception. Il est deja implementé et teste. + +### 5. FIFO transparente quand un agent est occupe + +- La file d'entree par agent existe deja. +- La serialisation des tours vers une meme cible existe. +- Les `ask` concurrents vers des agents differents peuvent tourner en parallele. + +Conclusion: l'exigence "si l'utilisateur ou un autre agent parle a un agent deja occupe, la requete part en file FIFO de maniere transparente" est deja largement satisfaite au niveau coeur applicatif. + +### 6. Reprise de conversation et persistance de session + +- Les cellules/layouts persistent `conversation_id` et `agent_was_running`. +- Les use cases de reprise (`ListResumableAgents`, popup de reprise, relance avec `conversation_id`) existent. +- Les sessions structurees Claude/Codex savent porter un `conversation_id`. + +Conclusion: la persistance de reprise a deja une base concrete et substantielle. + +## Reste A Faire En Priorite + +### 1. Consolider la persistance "conversation continue" au niveau produit, pas seulement "resume technique" + +Le code sait deja reprendre une conversation via `conversation_id`, mais la cible produit demande plus qu'une simple reprise technique: + +- conserver une vraie continuite de conversation lisible pour l'utilisateur au redemarrage, +- permettre au nouvel agent/profil de repartir avec l'etat utile, +- rendre la reprise completement transparente dans l'UX. + +Reste donc a verrouiller: + +- la persistance canonique des conversations exploitable au niveau produit, +- la strategie de resume/handoff quand on change de profil IA, +- la coherence UX entre reprise de cellule, reprise de conversation, et reprise de travail. + +### 2. Implementer une couche persistante de handoff / resume cross-profile + +La memoire partagee existante dit explicitement qu'il faut persister: + +- un canonical conversation log, +- un cumulative handoff summary, +- l'etat agent, +- les identifiants de conversation utiles par provider. + +Ce point n'apparait pas comme livre de bout en bout dans le depot actuel. + +Le besoin produit reste ouvert: + +- si un agent passe de Claude a Codex, IdeA doit reconstituer l'etat de travail sans dependre d'une session native transferable, +- le handoff doit etre incremental, pas fabrique seulement au moment de la panne ou du swap. + +### 3. Introduire un vrai live-state partage au niveau projet + +La memoire durable ne doit pas servir de journal temps reel. La note memoire existante le dit deja. + +Il manque encore une couche explicite de "live operational state" pour: + +- qui travaille sur quoi, +- tickets/intentions en cours, +- etat d'avancement d'un agent, +- derniere delegation utile, +- elements transitoires de coordination inter-agents. + +Sans cette couche, une partie de la coordination reste soit volatile, soit repoussee dans des endroits qui ne sont pas faits pour ca. + +### 4. Verifier et finir l'integration MCP natif "IdeA-only" de bout en bout dans le flux reel de l'application + +Les tests locaux du transport MCP passent, ce qui place cette zone en fin de chantier plutot qu'au debut. + +Mais il reste a confirmer en situation reelle utilisateur: + +- qu'un agent lance par IdeA voit effectivement ses outils MCP sans action manuelle, +- que les profils supportes utilisent bien cette voie par defaut, +- que le fallback fichier+prose reste coherent quand MCP n'est pas disponible, +- que l'observabilite UI des delegations et des replies est suffisamment claire. + +Point important: l'architecture historique qui mentionne encore un verrou M5 ouvert est probablement en retard par rapport au worktree local. Avant de planifier le prochain lot, `Main` doit revalider la documentation d'architecture a la lumiere du code/tests actuels. + +### 5. Stabiliser le registre de sessions et clarifier le modele singleton d'agent + +Le produit veut "1 agent = 1 employe". Cela impose une verite unique sur: + +- la session vivante de l'agent, +- sa conversation courante, +- sa cellule visible ou son execution en arriere-plan, +- son etat occupé/libre/interrompu. + +Le code a deja beaucoup avance sur ce point, mais le worktree local montre encore un chantier actif autour de: + +- `application/src/terminal/registry.rs` +- `application/src/orchestrator/service.rs` +- `application/src/agent/lifecycle.rs` +- `app-tauri/src/state.rs` + +Conclusion: ne pas considerer le sujet comme totalement clos tant que le worktree n'est pas nettoye et que la suite de tests ciblee n'est pas executee sur l'ensemble du flux concerne. + +### 6. Rendre la mise a jour de memoire/contexte vraiment automatique pendant la vie d'un agent + +La cible utilisateur dit qu'il ne doit jamais demander: + +- de charger une memoire, +- de charger un contexte, +- de mettre a jour la memoire, +- de mettre a jour le contexte. + +Le lancement injecte deja beaucoup de choses automatiquement, mais il reste a verrouiller le comportement "pendant la vie" d'un agent: + +- quand regenerer le contexte effectif, +- quand promouvoir une information stable vers la memoire durable, +- comment distinguer signal utile et bruit, +- comment eviter de compter sur des consignes manuelles a l'utilisateur. + +### 7. Unifier la conversation utilisateur <-> agent et agent <-> agent dans l'UX + +Le backend sait deja distinguer `User<->Agent` et `Agent<->Agent`. + +Le travail restant est surtout produit/frontend: + +- affichage clair des delegations et des retours, +- visualisation non confuse des conversations par paire, +- reprise lisible des threads, +- transparence totale pour l'utilisateur final. + +Le diff local frontend suggere justement un remaniement en cours de la surface terminal/chat. + +### 8. Consolider la restriction et l'affordance des profils supportes + +Le modele actuel oriente fortement vers Claude/Codex structures, ce qui est coherent avec la fiabilite attendue. + +Reste a clarifier produit: + +- quels profils sont officiellement "employes IdeA" de premiere classe, +- quel fallback proposer pour les profils non structures, +- quelle UI montrer quand un profil ne supporte pas la delegation native fiable. + +### 9. Persistance conversationnelle globale de l'application + +La demande utilisateur mentionne explicitement qu'en relancant IdeA il faut retrouver la conversation. + +La reprise par `conversation_id` et layouts existe, mais il reste a confirmer ou completer: + +- la persistance lisible de l'historique conversationnel pour l'utilisateur, +- la restauration des vues au redemarrage, +- la coherence entre session technique, resume visuel et histoire de travail. + +Autrement dit: "reprendre une session moteur" n'est pas encore automatiquement equivalent a "retrouver sa conversation produit" dans tous les cas. + +## Chantiers Secondaires Mais Importants + +### 1. Mettre la documentation d'architecture a jour + +Le code local et les tests verts semblent avoir depasse certains passages de `ARCHITECTURE.md` et de briefs anciens. + +Il faut une passe de synchronisation documentaire pour eviter que `Main` suive un etat obsolete, en particulier sur: + +- statut reel du transport MCP, +- statut reel de la FIFO inter-agents, +- statut reel des conversations par paire, +- ce qui reste vraiment ouvert entre handoff, live-state et UX. + +### 2. Curater le dossier `.ideai/` + +Le principe "tout ce qui est IdeA-projet va dans `.ideai/`" est bon, mais il faudra surveiller: + +- la proliferation de fichiers run/request/debug, +- ce qui est durable vs derivable, +- ce qui doit etre committe vs ignore. + +### 3. Formaliser les regles de promotion memoire + +Le systeme doit savoir quand enregistrer une connaissance stable sans polluer la memoire partagee. + +Il manque probablement encore: + +- une politique claire de promotion, +- des heuristiques/outils explicites pour les agents, +- des garde-fous contre la memoire bruit. + +## Worktree Local A Prendre En Compte + +Le depot local est actuellement dirty avec un chantier large non committe autour de: + +- orchestration MCP / loopback / serveur Tauri, +- mailbox / conversations / session registry, +- adaptation frontend terminal/chat/layout, +- fichiers `.ideai/` du projet lui-meme. + +Consequence pour `Main`: + +- ne pas planifier a partir de `ARCHITECTURE.md` seulement, +- d'abord relire le diff local, +- ensuite reexecuter la suite de tests ciblee des zones touchees, +- puis seulement decider si le prochain lot est "finition", "integration UI", ou "harden/persistence". + +## Ordre Recommande Pour La Suite + +1. Revalider et documenter l'etat reel du chantier MCP/orchestration a partir du code courant, puis remettre `ARCHITECTURE.md` a jour. +2. Fermer proprement le sujet "1 agent = 1 session vivante coherente" en nettoyant le registre/session lifecycle encore en mouvement. +3. Concevoir puis implementer une vraie couche de live-state partage projet. +4. Concevoir puis implementer la couche persistante de handoff/canonical conversation log cross-session et cross-profile. +5. Finir l'integration UX/frontend pour que toute cette orchestration reste invisible et naturelle pour l'utilisateur final. + +## Synthese Courte + +Le plus gros changement de perception pour `Main` est le suivant: + +- IdeA n'est plus au stade "il faut inventer la delegation inter-agents". +- IdeA est plutot au stade "le coeur de delegation existe deja; il faut maintenant le consolider, le documenter, le rendre pleinement persistant, et le rendre transparent dans l'UX". diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 5c16879..d3c9fcf 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -40,7 +40,8 @@ use crate::dto::{ EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, - LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, + InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, + LiveAgentListDto, SubmitAgentInputRequestDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, @@ -1037,8 +1038,10 @@ pub async fn launch_agent( // M5c handshake guard (`serve_peer`) compares against; the `--requester` is the // launching agent's id. A missing executable path (should not happen) degrades // to no runtime ⇒ apply_mcp_config writes the minimal declaration. - let mcp_runtime = std::env::current_exe().ok().map(|exe| McpRuntime { - exe: exe.to_string_lossy().into_owned(), + // L'exe vient de `idea_exe_path()` (privilégie `$APPIMAGE`, sinon `current_exe`) + // pour que chemin GUI et chemin `ask` écrivent un `command` identique et stable. + let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { + exe, endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) .as_cli_arg() .to_owned(), @@ -1203,6 +1206,55 @@ pub async fn agent_send( Ok(()) } +/// `submit_agent_input` — the human **Envoyer** path (cadrage C4 §4.2). +/// +/// Routes the operator's text to [`OrchestratorService::submit_human_input`], which +/// enqueues a `from_human` ticket in the **same FIFO** the inter-agent delegations +/// use (serialised per agent). Fire-and-forget: the human watches the terminal for +/// the answer. The mediator emits `AgentBusyChanged` at the source. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator, +/// `NOT_FOUND` if the project or agent is unknown, `PROCESS` on a launch/PTY failure). +#[tauri::command] +pub async fn submit_agent_input( + request: SubmitAgentInputRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + state + .orchestrator_service + .submit_human_input(&project, agent_id, request.text) + .await + .map(|_| ()) + .map_err(ErrorDto::from) +} + +/// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2). +/// +/// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's +/// running turn (best-effort interrupt to its PTY). Not an enqueue; resolves no +/// ticket. Idempotent on an idle agent. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator, +/// `NOT_FOUND` if the project or agent is unknown). +#[tauri::command] +pub async fn interrupt_agent( + request: InterruptAgentRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + state + .orchestrator_service + .interrupt_agent(&project, agent_id) + .await + .map(|_| ()) + .map_err(ErrorDto::from) +} + /// `reattach_agent_chat` — re-bind a view to a **still-living** structured session /// without re-sending or re-spawning it (ARCHITECTURE §17.6/§17.7). /// diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 8a30daa..6734dda 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -1186,6 +1186,31 @@ impl From for TerminalSessionDto { } } +/// Request DTO for `submit_agent_input` (cadrage C4 §4.2): the human Envoyer path. +/// The frontend's [`InputGateway`](../../../frontend/src/ports) sends +/// `{ request: { projectId, agentId, text } }`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmitAgentInputRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent to send the human input to. + pub agent_id: String, + /// The text the operator typed (enqueued as a `from_human` ticket). + pub text: String, +} + +/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The +/// frontend sends `{ request: { projectId, agentId } }`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InterruptAgentRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose running turn to preempt. + pub agent_id: String, +} + /// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime /// profile, optionally relaunching its live session in place. #[derive(Debug, Clone, Deserialize)] diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 7910306..c8d2c28 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -47,6 +47,15 @@ pub enum DomainEventDto { /// Exit code. code: i32, }, + /// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims + /// "Envoyer" while `busy` is `true`. + #[serde(rename_all = "camelCase")] + AgentBusyChanged { + /// Agent id. + agent_id: String, + /// `true` when a turn is in flight, `false` when idle. + busy: bool, + }, /// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4). #[serde(rename_all = "camelCase")] AgentReplied { @@ -212,6 +221,10 @@ impl From<&DomainEvent> for DomainEventDto { agent_id: agent_id.to_string(), code: *code, }, + DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged { + agent_id: agent_id.to_string(), + busy: *busy, + }, DomainEvent::AgentReplied { agent_id, reply_len, diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 9b1eede..43c35cb 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -166,6 +166,8 @@ pub fn run() { commands::launch_agent, commands::change_agent_profile, commands::agent_send, + commands::submit_agent_input, + commands::interrupt_agent, commands::reattach_agent_chat, commands::close_agent_session, commands::list_resumable_agents, diff --git a/crates/app-tauri/src/mcp_endpoint.rs b/crates/app-tauri/src/mcp_endpoint.rs index 645406b..25c9ab8 100644 --- a/crates/app-tauri/src/mcp_endpoint.rs +++ b/crates/app-tauri/src/mcp_endpoint.rs @@ -41,7 +41,8 @@ use std::path::PathBuf; -use domain::ProjectId; +use application::{McpRuntime, McpRuntimeProvider}; +use domain::{AgentId, Project, ProjectId}; /// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`] /// returns. Deterministic per [`ProjectId`]; the single source of truth shared by @@ -127,6 +128,48 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint { McpEndpoint { addr } } +/// Chemin du binaire IdeA à inscrire comme `command` dans la déclaration MCP. +/// +/// Privilégie `$APPIMAGE` (chemin **stable** du `.AppImage`, qui survit aux +/// redémarrages) car sous AppImage `current_exe()` pointe sur un montage éphémère +/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP. +/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si +/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale. +pub(crate) fn idea_exe_path() -> Option { + std::env::var("APPIMAGE") + .ok() + .or_else(|| std::env::current_exe().ok().map(|p| p.to_string_lossy().into_owned())) +} + +/// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à +/// l'orchestrateur (couche `application`) les **faits OS/runtime** nécessaires pour +/// écrire la déclaration MCP réelle quand il (re)lance une cible sur le chemin +/// `ask` (`ensure_live_pty`). +/// +/// Sans état : tout est dérivé du `Project` et de l'`AgentId` reçus + de +/// l'environnement (`$APPIMAGE`/`current_exe`). C'est le **seul** détenteur légitime +/// de ces faits (la couche `application` ne doit pas dépendre de `current_exe` / +/// `$APPIMAGE` / `mcp_endpoint`, cadrage v5 §0.3 / §7) ; seules des **chaînes** +/// traversent la frontière via [`McpRuntime`]. +pub struct AppMcpRuntimeProvider; + +impl McpRuntimeProvider for AppMcpRuntimeProvider { + /// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera + /// `idea_reply`). Réutilise la **même** logique que le chemin GUI + /// (`commands.rs::launch_agent`) : même endpoint (source de vérité unique), + /// même forme `simple` 32-hex du `project_id`. `None` (exe introuvable) ⇒ + /// l'orchestrateur dégrade vers la déclaration minimale, jamais d'échec de + /// lancement. + fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option { + Some(McpRuntime { + exe: idea_exe_path()?, + endpoint: mcp_endpoint(&project.id).as_cli_arg().to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: agent_id.to_string(), + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -170,4 +213,77 @@ mod tests { assert!(path.extension().is_some_and(|e| e == "sock")); assert_eq!(path.parent().unwrap(), unix_runtime_dir()); } + + /// `idea_exe_path()` : `$APPIMAGE` posé ⇒ on renvoie sa valeur (chemin stable du + /// `.AppImage`, qui survit aux redémarrages) ; absent ⇒ on retombe sur + /// `current_exe()`. Les deux assertions sont regroupées dans **un seul** test pour + /// éviter une course inter-tests sur la variable d'env globale du process ; la var + /// est sauvegardée/restaurée pour ne pas polluer les autres tests. + #[test] + fn idea_exe_path_prefers_appimage_then_falls_back_to_current_exe() { + let saved = std::env::var_os("APPIMAGE"); + + // APPIMAGE posé ⇒ valeur exacte renvoyée. + std::env::set_var("APPIMAGE", "/opt/idea/IdeA.AppImage"); + assert_eq!( + idea_exe_path().as_deref(), + Some("/opt/idea/IdeA.AppImage"), + "$APPIMAGE doit primer" + ); + + // APPIMAGE absent ⇒ repli sur current_exe() (présent dans un binaire de test). + std::env::remove_var("APPIMAGE"); + let fallback = idea_exe_path(); + let expected = std::env::current_exe() + .ok() + .map(|p| p.to_string_lossy().into_owned()); + assert_eq!(fallback, expected, "repli attendu sur current_exe()"); + assert!(fallback.is_some(), "current_exe() résolvable dans le test"); + + // Restauration de l'état initial de la variable. + match saved { + Some(v) => std::env::set_var("APPIMAGE", v), + None => std::env::remove_var("APPIMAGE"), + } + } + + /// `AppMcpRuntimeProvider::runtime_for` : cohérence des champs avec les sources de + /// vérité — endpoint identique à `mcp_endpoint(project.id).as_cli_arg()`, + /// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`. + #[test] + fn app_provider_runtime_for_matches_sources_of_truth() { + use domain::{AgentId, ProjectId, Project}; + use domain::project::ProjectPath; + use domain::remote::RemoteRef; + + let project = Project::new( + ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()), + "demo", + ProjectPath::new("/home/me/proj").unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap(); + let agent_id = AgentId::from_uuid(Uuid::from_u128(42)); + + // On s'assure que current_exe/$APPIMAGE est résolvable (sinon runtime_for None). + let rt = AppMcpRuntimeProvider + .runtime_for(&project, agent_id) + .expect("runtime_for doit produire un McpRuntime (exe résolvable)"); + + // Endpoint = même source de vérité que le listener. + assert_eq!( + rt.endpoint, + mcp_endpoint(&project.id).as_cli_arg(), + "endpoint cohérent avec mcp_endpoint()" + ); + // project_id en forme simple 32-hex (hyphen-free), consommée par la garde M5c. + assert_eq!(rt.project_id, project.id.as_uuid().simple().to_string()); + assert_eq!(rt.project_id.len(), 32, "forme simple 32-hex"); + assert!(!rt.project_id.contains('-'), "pas de tirets"); + // requester = l'id de la cible relancée. + assert_eq!(rt.requester, agent_id.to_string()); + // exe non vide. + assert!(!rt.exe.is_empty(), "exe renseigné"); + } } diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 483ef0d..e8dcd8d 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -42,7 +42,8 @@ use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, - Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, McpServer, + Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, + LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, @@ -50,7 +51,7 @@ use infrastructure::{ }; use crate::chat::ChatBridge; -use crate::mcp_endpoint::{mcp_endpoint, McpEndpoint}; +use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint}; use crate::pty::PtyBridge; use infrastructure::StdioTransport; @@ -558,27 +559,29 @@ impl AppState { )); // LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal // use cases — indispensable for the PtyBridge to work correctly. - let launch_agent = Arc::new( - LaunchAgent::new( - Arc::clone(&contexts_port), - Arc::clone(&profile_store_port), - Arc::clone(&runtime_port), - Arc::clone(&fs_port), - Arc::clone(&pty_port), - Arc::clone(&skill_store_port), - Arc::clone(&terminal_sessions), - Arc::clone(&events_port), - Arc::clone(&ids) as Arc, - Arc::clone(&memory_recall_port), - Some(Arc::clone(&check_embedder_suggestion)), - ) - // Routage structuré §17.4 : un profil avec `structured_adapter` lance une - // AgentSession (cellule chat) au lieu d'un PTY ; sinon chemin PTY inchangé. - .with_structured( - Arc::clone(&session_factory), - Arc::clone(&structured_sessions), - ), - ); + // + // Option 1 « Terminal + MCP » (lot B-2) : on **ne câble plus** la fabrique + // structurée. La vue humaine d'un agent est désormais le **terminal brut + // natif** (PTY interactif) — réflexion live + Échap natifs CLI, zéro parsing — + // et la délégation inter-agents passe par les outils MCP (`idea_ask_agent` / + // `idea_reply`), pas par une `AgentChatView`. Sans `with_structured`, le point + // de routage §17.4 de `LaunchAgent::execute` retombe **toujours** sur le chemin + // PTH : tout profil (Claude/Codex inclus) ouvre une cellule terminal. Le code + // `launch_structured` reste en place (mort-code retiré au lot de nettoyage B-6). + let _session_factory = session_factory; // décâblé en B-2 (nettoyage B-6) + let launch_agent = Arc::new(LaunchAgent::new( + Arc::clone(&contexts_port), + Arc::clone(&profile_store_port), + Arc::clone(&runtime_port), + Arc::clone(&fs_port), + Arc::clone(&pty_port), + Arc::clone(&skill_store_port), + Arc::clone(&terminal_sessions), + Arc::clone(&events_port), + Arc::clone(&ids) as Arc, + Arc::clone(&memory_recall_port), + Some(Arc::clone(&check_embedder_suggestion)), + )); // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ // profile/project/fs stores, the live-session registry and PTY port, and @@ -737,6 +740,30 @@ impl AppState { // the UI drives (IdeA stays the single source of truth for the agent/skill // lifecycle). The per-project watcher that feeds it is started lazily when // a project is opened (see `ensure_orchestrator_watch`). + // File inter-agents (Option 1 « Terminal + MCP », B-3) : un ticket par tâche + // déléguée, résolu par `idea_reply`. Une instance par AppState ⇒ partagée par + // tous les projets (clé interne = AgentId, jamais de collision cross-projet). + // Médiateur d'entrée (cadrage C3) : enveloppe l'`InMemoryMailbox` (moteur de + // corrélation par ticket) + porte la **livraison** du tour dans le PTY de la + // cible (écriture sérialisée, une seule voie d'entrée). Le mailbox concret est + // partagé pour `resolve`/`resolve_ticket`/`cancel_head` côté orchestrateur. + let inmemory_mailbox = Arc::new(InMemoryMailbox::new()); + let mailbox = + Arc::clone(&inmemory_mailbox) as Arc; + let input_mediator = Arc::new( + MediatedInbox::with_pty( + Arc::clone(&inmemory_mailbox), + Arc::new(SystemMillisClock), + Arc::clone(&pty_port), + ) + // Émet `AgentBusyChanged` à la source (Busy à l'enqueue qui démarre un + // tour, Idle au mark_idle) ⇒ relayé au front en event Tauri (cadrage C4). + .with_events(Arc::clone(&events_port)), + ) as Arc; + // Registre des conversations par paire (cadrage C3) : un fil par paire, session + // vivante keyée par conversation (lève l'ambiguïté session/agent). + let conversation_registry = + Arc::new(InMemoryConversationRegistry::new()) as Arc; let orchestrator_service = Arc::new( OrchestratorService::new( Arc::clone(&create_agent), @@ -748,10 +775,17 @@ impl AppState { Arc::clone(&profile_store_port), Arc::clone(&terminal_sessions), ) - // Messagerie inter-agents §17.4 : registre structuré + bus pour - // `agent.message` (AskAgent) — rendez-vous synchrone via send_blocking. - .with_structured(Arc::clone(&structured_sessions)) - .with_events(Arc::clone(&events_port)), + // Messagerie inter-agents (cadrage C3) : médiateur d'entrée (file + livraison + // PTV sérialisée) + registre de conversations + bus pour AgentReplied. + .with_input_mediator(Arc::clone(&input_mediator), Arc::clone(&mailbox)) + .with_conversations(Arc::clone(&conversation_registry)) + .with_events(Arc::clone(&events_port)) + // Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour + // que les (re)lancements issus du chemin `ask` écrivent la déclaration MCP + // réelle ⇒ le pont MCP de la cible se spawne et `idea_reply` débloque le round-trip. + .with_mcp_runtime_provider( + Arc::new(AppMcpRuntimeProvider) as Arc + ), ); // --- Windows (L10) --- @@ -982,6 +1016,21 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } + // D1 — reclaim the **corpse** socket left by a SIGKILL'd run BEFORE binding. + // `reclaim_name(true)` (below) only unlinks the socket on *drop*; in + // `interprocess` 2.4 it does **not** clear a pre-existing inode at bind time, + // so a stale socket file makes the bind fail with `EADDRINUSE` (the D1 test + // proves this). We therefore unlink it ourselves first — but only when the + // path is actually a **socket** (never clobber a real file we don't own). + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + if let Ok(meta) = std::fs::symlink_metadata(&path) { + if meta.file_type().is_socket() { + let _ = std::fs::remove_file(&path); + } + } + } } let name = endpoint .as_cli_arg() @@ -989,7 +1038,7 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option { .ok()?; ListenerOptions::new() .name(name) - // Replace a corpse socket left by a previous run; reclaim (unlink) on drop. + // Reclaim (unlink) on drop so a clean close leaves no socket behind. .reclaim_name(true) .create_tokio() .ok() @@ -1234,7 +1283,10 @@ mod mcp_serve_peer_tests { PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; - use domain::profile::{AgentProfile, ContextInjection}; + use domain::profile::{ + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + StructuredAdapter, + }; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; @@ -1532,6 +1584,9 @@ mod mcp_serve_peer_tests { /// Builds an `OrchestratorService` over the in-memory fakes (no structured /// registry), mirroring `infrastructure/tests/mcp_server.rs::build_service`. fn build_service(contexts: FakeContexts) -> Arc { + // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) : + // seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour + // `idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`. let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( ProfileId::from_uuid(Uuid::from_u128(9)), "Claude Code", @@ -1542,7 +1597,12 @@ mod mcp_serve_peer_tests { "{agentRunDir}", None, ) - .unwrap()]))); + .unwrap() + .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").unwrap(), + McpTransport::Stdio, + ))]))); let sessions = Arc::new(TerminalSessions::new()); let bus = Arc::new(NoopBus); let create = Arc::new(CreateAgentFromScratch::new( @@ -1681,14 +1741,24 @@ mod mcp_serve_peer_tests { for expected in [ "idea_list_agents", "idea_ask_agent", + "idea_reply", "idea_launch_agent", "idea_stop_agent", "idea_update_context", "idea_create_skill", + // FileGuard-mediated context/memory tools (cadrage C7). + "idea_context_read", + "idea_context_propose", + "idea_memory_read", + "idea_memory_write", ] { assert!(names.contains(&expected), "missing tool {expected}; got {names:?}"); } - assert_eq!(tools.len(), 6, "exactly the six idea_* tools; got {names:?}"); + assert_eq!( + tools.len(), + 11, + "exactly the eleven idea_* tools (7 base + 4 FileGuard C7); got {names:?}" + ); drop(client); // EOF ⇒ serve loop ends tokio::time::timeout(TIMEOUT, peer) @@ -2158,19 +2228,22 @@ mod mcp_e2e_loopback_tests { use application::{ CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, - OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext, + OrchestratorService, TerminalSessions, UpdateAgentContext, }; use domain::agent::{AgentManifest, ManifestEntry}; use domain::events::{DomainEvent, OrchestrationSource}; use domain::ids::{AgentId, NodeId, ProfileId, ProjectId, SkillId}; 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, + AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, + ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, + PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, + StoreError, + }; + use domain::profile::{ + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + StructuredAdapter, }; - use domain::profile::{AgentProfile, ContextInjection}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; @@ -2180,7 +2253,9 @@ mod mcp_e2e_loopback_tests { use super::{bind_endpoint, mcp_endpoint, McpServerHandle}; use crate::mcp_endpoint::McpEndpoint; - use infrastructure::McpServer; + use infrastructure::{ + InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, SystemMillisClock, + }; /// Test timeout for any single loopback interaction. Generous but finite: a /// correct round-trip is sub-millisecond, so this only ever fires on a real hang. @@ -2417,36 +2492,6 @@ mod mcp_e2e_loopback_tests { } } - /// A structured session whose turn deterministically ends on a `Final` carrying a - /// fixed reply — lets us exercise `idea_ask_agent` without a real CLI. - struct FakeSession { - id: SessionId, - reply: String, - } - #[async_trait] - impl AgentSession for FakeSession { - fn id(&self) -> SessionId { - self.id - } - fn conversation_id(&self) -> Option { - None - } - async fn send(&self, _prompt: &str) -> Result { - let events = vec![ - ReplyEvent::TextDelta { - text: "thinking…".to_owned(), - }, - ReplyEvent::Final { - content: self.reply.clone(), - }, - ]; - Ok(Box::new(events.into_iter())) - } - async fn shutdown(&self) -> Result<(), AgentSessionError> { - Ok(()) - } - } - #[derive(Default, Clone)] struct NoopBus; impl EventBus for NoopBus { @@ -2495,17 +2540,19 @@ mod mcp_e2e_loopback_tests { } /// Builds an `OrchestratorService` over the in-memory fakes, returning the - /// shared `TerminalSessions` (so a test can pre-bind a live **PTY** session for a - /// raw-PTY target) and `StructuredSessions` (so a test can pre-insert a replying - /// **structured** session for `idea_ask_agent`). Mirrors the established harness; - /// no use case is re-invented. + /// shared `TerminalSessions` (so a test can pre-bind a live PTY target for an + /// `idea_ask_agent`) and the `InMemoryMailbox` (so a test can observe pending + /// tickets). Mirrors the established harness; no use case is re-invented. fn build_service( contexts: FakeContexts, ) -> ( Arc, Arc, - Arc, + Arc, ) { + // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) : + // seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour + // `idea_ask_agent` — le round-trip e2e testé ici suppose une cible éligible. let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( ProfileId::from_uuid(Uuid::from_u128(9)), "Claude Code", @@ -2516,9 +2563,14 @@ mod mcp_e2e_loopback_tests { "{agentRunDir}", None, ) - .unwrap()]))); + .unwrap() + .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").unwrap(), + McpTransport::Stdio, + ))]))); let sessions = Arc::new(TerminalSessions::new()); - let structured = Arc::new(StructuredSessions::new()); + let mailbox = Arc::new(InMemoryMailbox::new()); let bus = Arc::new(NoopBus); let create = Arc::new(CreateAgentFromScratch::new( Arc::new(contexts.clone()), @@ -2545,6 +2597,13 @@ mod mcp_e2e_loopback_tests { Arc::new(FakeSkills) as Arc, Arc::new(SeqIds(Mutex::new(1))), )); + let input = Arc::new(MediatedInbox::with_pty( + Arc::clone(&mailbox), + Arc::new(SystemMillisClock), + Arc::new(FakePty) as Arc, + )) as Arc; + let conversations = + Arc::new(InMemoryConversationRegistry::new()) as Arc; let service = OrchestratorService::new( create, launch, @@ -2555,8 +2614,26 @@ mod mcp_e2e_loopback_tests { Arc::clone(&profiles) as Arc, Arc::clone(&sessions), ) - .with_structured(Arc::clone(&structured)); - (Arc::new(service), sessions, structured) + .with_input_mediator( + input, + Arc::clone(&mailbox) as Arc, + ) + .with_conversations(conversations); + (Arc::new(service), sessions, mailbox) + } + + /// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it. + fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) { + sessions.insert( + PtyHandle { session_id }, + TerminalSession::starting( + session_id, + NodeId::from_uuid(Uuid::from_u128(7)), + ProjectPath::new("/home/me/proj").unwrap(), + SessionKind::Agent { agent_id }, + PtySize { rows: 24, cols: 80 }, + ), + ); } // --- real loopback harness --------------------------------------------- @@ -2648,7 +2725,7 @@ mod mcp_e2e_loopback_tests { let contexts = FakeContexts::new(); contexts.seed_agent("architect"); contexts.seed_agent("dev-backend"); - let (service, _pty, _structured) = build_service(contexts); + let (service, _sessions, _mailbox) = build_service(contexts); let proj = project(); let (handle, endpoint) = start_real_server(service, &proj, None); @@ -2689,34 +2766,31 @@ mod mcp_e2e_loopback_tests { } // ----------------------------------------------------------------------- - // 2. idea_ask_agent e2e: structured target ⇒ reply returned INLINE. + // 2. idea_ask_agent → idea_reply e2e over the real loopback (Option 1, B-3/B-4): + // the asker's `idea_ask_agent` blocks on the mailbox; the target's `idea_reply` + // (on a second connection, with its agent id as the handshake requester) + // resolves it and the asker gets the result INLINE. // ----------------------------------------------------------------------- #[tokio::test] - async fn ask_structured_agent_returns_reply_inline_over_real_loopback() { + async fn ask_then_reply_round_trips_inline_over_real_loopback() { let contexts = FakeContexts::new(); let agent_id = contexts.seed_agent("architect"); - let (service, _pty, structured) = build_service(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 (service, sessions, mailbox) = build_service(contexts); + // Target already live in the PTY registry, so the ask reuses its terminal. + seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242))); let proj = project(); let (handle, endpoint) = start_real_server(service, &proj, None); - let conn = connect_client(&endpoint).await; - let (read_half, mut write_half) = tokio::io::split(conn); - let mut reader = BufReader::new(read_half); - - write_half + // Connection A: the asker. + let conn_a = connect_client(&endpoint).await; + let (read_a, mut write_a) = tokio::io::split(conn_a); + let mut reader_a = BufReader::new(read_a); + write_a .write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes()) .await .unwrap(); - write_half + write_a .write_all( tools_call_line( 7, @@ -2727,9 +2801,41 @@ mod mcp_e2e_loopback_tests { ) .await .unwrap(); - write_half.flush().await.unwrap(); + write_a.flush().await.unwrap(); - let resp = read_one_response(&mut reader) + // The ask is now blocked awaiting the reply — observe the pending ticket. + tokio::time::timeout(TIMEOUT, async { + while mailbox.pending(&agent_id) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("ask must enqueue a ticket"); + + // Connection B: the target rendering its result. Its handshake requester is + // the target agent's id, which the server injects as the Reply `from`. + let conn_b = connect_client(&endpoint).await; + let (read_b, mut write_b) = tokio::io::split(conn_b); + let mut reader_b = BufReader::new(read_b); + write_b + .write_all(handshake_line(&project_id_arg(&proj), &agent_id.to_string()).as_bytes()) + .await + .unwrap(); + write_b + .write_all( + tools_call_line(8, "idea_reply", json!({ "result": "the answer is 42" })) + .as_bytes(), + ) + .await + .unwrap(); + write_b.flush().await.unwrap(); + let reply_resp = read_one_response(&mut reader_b) + .await + .expect("a reply ack line"); + assert_eq!(reply_resp["result"]["isError"], json!(false), "got {reply_resp}"); + + // The asker now receives its inline result. + let resp = read_one_response(&mut reader_a) .await .expect("an ask response line"); assert_eq!(resp["id"], json!(7)); @@ -2742,33 +2848,21 @@ mod mcp_e2e_loopback_tests { "ask reply must be returned inline over the real loopback; got {result}" ); - drop(write_half); + drop(write_a); + drop(write_b); handle.stop(); } // ----------------------------------------------------------------------- - // 3. idea_ask_agent against a raw-PTY target ⇒ typed tool error (isError:true), - // no panic, connection stays healthy for a follow-up call. + // 3. idea_reply with no in-flight ask ⇒ typed tool error (isError:true), no + // panic, connection stays healthy for a follow-up call. // ----------------------------------------------------------------------- #[tokio::test] - async fn ask_raw_pty_target_is_typed_error_over_real_loopback() { + async fn orphan_reply_is_typed_error_over_real_loopback() { let contexts = FakeContexts::new(); let agent_id = contexts.seed_agent("dev"); - let (service, pty, _structured) = build_service(contexts); - // Pre-bind a live **PTY** session for the agent (no structured session): the - // ask path must reject it with AppError::Invalid ⇒ tool result isError:true. - let session_id = SessionId::from_uuid(Uuid::from_u128(555)); - pty.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 }, - ), - ); + let (service, _sessions, _mailbox) = build_service(contexts); let proj = project(); let (handle, endpoint) = start_real_server(service, &proj, None); @@ -2776,14 +2870,15 @@ mod mcp_e2e_loopback_tests { let (read_half, mut write_half) = tokio::io::split(conn); let mut reader = BufReader::new(read_half); + // The peer identifies as `agent_id`; an idea_reply with no matching ask in + // flight ⇒ the IdeA command fails ⇒ tool result isError:true (not transport). write_half - .write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes()) + .write_all(handshake_line(&project_id_arg(&proj), &agent_id.to_string()).as_bytes()) .await .unwrap(); write_half .write_all( - tools_call_line(3, "idea_ask_agent", json!({ "target": "dev", "task": "hi" })) - .as_bytes(), + tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes(), ) .await .unwrap(); @@ -2791,14 +2886,12 @@ mod mcp_e2e_loopback_tests { let resp = read_one_response(&mut reader) .await - .expect("an ask response line"); + .expect("a reply response line"); assert_eq!(resp["id"], json!(3)); - // Healthy connection: NO JSON-RPC transport error... assert!( resp["error"].is_null(), - "a raw-PTY target must be a tool error, not a transport error: {resp}" + "an orphan reply must be a tool error, not a transport error: {resp}" ); - // ...but the tool result is flagged as an error. let result = &resp["result"]; assert_eq!(result["isError"], json!(true), "got {result}"); assert!( @@ -2844,7 +2937,7 @@ mod mcp_e2e_loopback_tests { async fn malformed_jsonrpc_after_handshake_errors_and_server_survives() { let contexts = FakeContexts::new(); contexts.seed_agent("architect"); - let (service, _pty, _structured) = build_service(contexts); + let (service, _sessions, _mailbox) = build_service(contexts); let proj = project(); let (handle, endpoint) = start_real_server(service, &proj, None); @@ -2906,7 +2999,7 @@ mod mcp_e2e_loopback_tests { #[tokio::test] async fn handshake_requester_propagates_over_real_loopback() { let contexts = FakeContexts::new(); - let (service, _pty, _structured) = build_service(contexts); + let (service, _sessions, _mailbox) = build_service(contexts); let (publish, captured) = capturing_events(); let proj = project(); let (handle, endpoint) = start_real_server(service, &proj, Some(publish)); @@ -2991,3 +3084,78 @@ mod mcp_e2e_loopback_tests { handle.stop(); } } + +#[cfg(test)] +mod bind_endpoint_d1_tests { + //! D1 — non-regression lock on [`bind_endpoint`]'s corpse-socket reclaim + //! (cadrage §7 row D1, §5.2 "verrouille `reclaim_name(true)`"). + //! + //! A crashed run (SIGKILL) leaves the Unix socket **file** behind: a plain + //! `bind` would then fail with `EADDRINUSE`. The production path passes + //! `reclaim_name(true)`, which **unlinks the corpse** before binding. These tests + //! pin that behaviour so a future refactor cannot silently drop the flag and + //! resurrect the "address already in use" failure on restart. + //! + //! Unix-only: on Windows the endpoint is a named pipe with no filesystem corpse to + //! reclaim, so there is nothing to assert. + + #![cfg(unix)] + + use super::{bind_endpoint, mcp_endpoint}; + use domain::ProjectId; + use uuid::Uuid; + + /// Rebinding after a **corpse** socket (file left in place, as after a SIGKILL) + /// succeeds — no `EADDRINUSE` — because `reclaim_name(true)` unlinks it first. + /// + /// The corpse is reproduced **faithfully**: a `std::os::unix::net::UnixListener` + /// binds the path then is dropped — std does **not** unlink on drop, so the socket + /// inode is left on the filesystem with **no live listener**, exactly the state a + /// SIGKILL'd run leaves behind (the fd is gone, the inode lingers). A plain `bind` + /// over that inode would return `EADDRINUSE`; `bind_endpoint`'s `reclaim_name(true)` + /// must unlink it first. + #[tokio::test] + async fn rebind_after_corpse_socket_succeeds() { + use std::os::unix::net::UnixListener; + + let ep = mcp_endpoint(&ProjectId::from_uuid(Uuid::from_u128(0xD1_0001))); + let path = ep.socket_path().expect("unix endpoint exposes a socket path"); + + // Clean any leftover from a previous run of this very test. + let _ = std::fs::remove_file(&path); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + // 1) Lay down a CORPSE: bind with std (which leaves the inode on drop) and + // drop it ⇒ socket file remains with no live listener (SIGKILL aftermath). + { + let corpse = UnixListener::bind(&path).expect("lay corpse socket"); + drop(corpse); + } + assert!(path.exists(), "corpse socket inode remains (no live listener)"); + + // 2) Rebind over the corpse: must succeed (reclaim unlinks then binds), NOT + // fail with EADDRINUSE. + let first = bind_endpoint(&ep); + assert!( + first.is_some(), + "rebind over the corpse socket must succeed (reclaim_name unlinks it)" + ); + assert!(path.exists(), "a fresh live socket now sits at the path"); + + // 3) Idempotent over a live socket: a second bind also reclaims + succeeds. + let second = bind_endpoint(&ep); + assert!(second.is_some(), "bind is idempotent across a live socket"); + + // 4) No file leak after a clean close: dropping the live listener(s) unlinks + // the socket (interprocess reclaim guard on drop). + drop(first); + drop(second); + assert!( + !path.exists(), + "socket file unlinked on clean close (no leak)" + ); + } +} + diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 521df47..fec5d08 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -1750,6 +1750,17 @@ pub(crate) fn compose_convention_file( (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", ); + // Protocole de délégation (Option 1, B-5) : côté agent SOLLICITÉ. Une tâche + // déléguée arrive dans ton terminal préfixée `[IdeA · tâche de … · ticket …]`. + // Tu DOIS y répondre via l'outil `idea_reply`, jamais en texte libre — sinon + // l'agent qui t'a sollicité reste bloqué (sa réponse ne lui parviendra pas). + out.push_str( + "Quand tu reçois une tâche déléguée par IdeA (un message préfixé \ + `[IdeA · tâche de … · ticket …]`), traite-la puis appelle \ + **impérativement** l'outil `idea_reply(result=…)` pour rendre ton \ + résultat. Ne réponds **jamais** uniquement en texte : seul `idea_reply` \ + débloque l'agent qui t'a sollicité.\n\n", + ); } else { out.push_str( "Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \ @@ -1785,6 +1796,7 @@ pub(crate) fn compose_convention_file( out.push_str("- ["); out.push_str(&entry.title); out.push_str("]("); + out.push_str(".ideai/memory/"); out.push_str(entry.slug.as_str()); out.push_str(".md) — "); out.push_str(&entry.hook); @@ -1998,9 +2010,13 @@ mod tests { let section_at = doc.find("# Mémoire projet").unwrap(); assert!(persona_at < section_at, "memory comes after the persona"); - // Exact line format: `- [Title](slug.md) — hook (type)`. - assert!(doc.contains("- [Alpha](alpha-note.md) — the first hook (user)")); - assert!(doc.contains("- [Beta](beta-note.md) — the second hook (reference)")); + // Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`. + assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)")); + assert!( + doc.contains( + "- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)" + ) + ); // Deterministic order: first entry precedes the second. let alpha_at = doc.find("[Alpha]").unwrap(); @@ -2033,7 +2049,7 @@ mod tests { assert!(doc.contains("# Skills")); assert!(doc.contains("REFAC_BODY")); assert!(doc.contains("# Mémoire projet")); - assert!(doc.contains("- [Note](note.md) — a hook (project)")); + assert!(doc.contains("- [Note](.ideai/memory/note.md) — a hook (project)")); // Skills section precedes the memory section (persona → skills → memory). let skills_at = doc.find("# Skills").unwrap(); @@ -2066,6 +2082,25 @@ mod tests { ); } + #[test] + fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() { + // B-5 — the solicited-agent side of the protocol: a delegated task arrives as + // `[IdeA · tâche …]` and MUST be answered via `idea_reply`, never plain text. + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], true); + assert!( + doc.contains("idea_reply"), + "MCP prose must instruct answering via idea_reply" + ); + assert!( + doc.contains("[IdeA · tâche"), + "MCP prose must describe the delegated-task prefix it answers to" + ); + // Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply + // instruction (zero regression on the file path). + let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], false); + assert!(!file_doc.contains("idea_reply")); + } + #[test] fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() { // mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged, @@ -2087,6 +2122,60 @@ mod tests { ); } + #[test] + fn mcp_declaration_with_runtime_points_the_bridge_at_the_real_endpoint() { + // B-0 — a PTY-launched MCP-capable CLI (Claude/Codex REPL) auto-discovers the + // `.mcp.json` in its cwd (the run dir). When the composition root injects the + // real `McpRuntime`, the declaration written there must spawn *this* IdeA exe + // in `mcp-server` mode and dial the project's exact loopback endpoint — the + // same source of truth `ensure_mcp_server` binds — so the CLI can call + // `idea_list_agents` and get a reply end-to-end. + let rt = McpRuntime { + exe: "/opt/idea/idea".to_owned(), + endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(), + project_id: "0123456789abcdef0123456789abcdef".to_owned(), + requester: "11112222-3333-4444-5555-666677778888".to_owned(), + }; + let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, Some(&rt)); + + // It is valid JSON with the IdeA server under `mcpServers/idea`. + let parsed: serde_json::Value = + serde_json::from_str(&decl).expect("declaration is valid JSON"); + let idea = &parsed["mcpServers"]["idea"]; + assert_eq!(idea["command"], "/opt/idea/idea"); + let args = idea["args"].as_array().expect("args is an array"); + let args: Vec<&str> = args.iter().filter_map(serde_json::Value::as_str).collect(); + assert_eq!(args[0], "mcp-server"); + // The real endpoint / project / requester are all threaded through. + assert!(args.contains(&"--endpoint")); + assert!(args.contains(&"/run/user/1000/idea-mcp/proj.sock")); + assert!(args.contains(&"--project")); + assert!(args.contains(&"0123456789abcdef0123456789abcdef")); + assert!(args.contains(&"--requester")); + assert_eq!(idea["transport"], "stdio"); + } + + #[test] + fn mcp_declaration_without_runtime_is_a_coherent_minimal_fallback() { + // Launches issued from inside `application` (orchestrator/hot-swap/tests) have + // no OS/runtime facts: the declaration falls back to the bare `idea mcp-server` + // command — still a valid, self-consistent `mcpServers/idea` entry. + let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, None); + let parsed: serde_json::Value = + serde_json::from_str(&decl).expect("minimal declaration is valid JSON"); + let idea = &parsed["mcpServers"]["idea"]; + assert_eq!(idea["command"], "idea"); + let args: Vec<&str> = idea["args"] + .as_array() + .expect("args array") + .iter() + .filter_map(serde_json::Value::as_str) + .collect(); + assert_eq!(args, vec!["mcp-server"]); + // No endpoint/project/requester when no runtime was injected. + assert!(!args.contains(&"--endpoint")); + } + #[test] fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() { let json = claude_settings_seed("/home/me/proj"); diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index a1783c7..f6cd7bf 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -71,7 +71,7 @@ pub use memory::{ RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, }; -pub use orchestrator::{OrchestratorOutcome, OrchestratorService}; +pub use orchestrator::{McpRuntimeProvider, OrchestratorOutcome, OrchestratorService}; pub use project::{ CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject, diff --git a/crates/application/src/orchestrator/context_guard.rs b/crates/application/src/orchestrator/context_guard.rs new file mode 100644 index 0000000..577f3d1 --- /dev/null +++ b/crates/application/src/orchestrator/context_guard.rs @@ -0,0 +1,819 @@ +//! FileGuard-mediated context & memory use cases (cadrage C7). +//! +//! Four use cases — [`ReadContext`], [`ProposeContext`], [`ReadMemory`], +//! [`WriteMemory`] — route every read/write of IdeA-owned `.md` context and memory +//! through the domain [`FileGuard`] port **before** touching a store. Each acquires +//! the right lease (shared read / exclusive write) for the requesting +//! [`ConversationParty`], then delegates to the existing store ports. +//! +//! ## Single-writer global context +//! +//! The global project context is single-writer: only the orchestrator +//! ([`ConversationParty::User`]) may write it directly. A project agent that +//! *proposes* a change to the global context receives [`GuardError::Forbidden`] from +//! the guard; [`ProposeContext`] catches that and **materialises a proposal** under +//! `.ideai/proposals/-.md` for later validation by the orchestrator/UI — +//! never overwriting the live context. An agent's *own* `.md` (and memory) is written +//! directly under a write-lease. +//! +//! ## Cooperative scope (cadrage §9.5) +//! +//! The guard is **cooperative**: it serialises access inside the IdeA path (these use +//! cases + the MCP tools). It does **not** sandbox an agent that keeps a raw shell — +//! airtight revocation of raw fs access is an OS-sandbox (Landlock) concern, out of +//! scope here. + +use std::sync::Arc; + +use domain::conversation::ConversationParty; +use domain::fileguard::{FileGuard, GuardError, GuardedResource}; +use domain::markdown::MarkdownDoc; +use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType}; +use domain::ports::{ + AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath, +}; +use domain::{AgentId, Project}; + +use crate::error::AppError; + +/// Convention filename of the project's global context at the project root. +const PROJECT_CONTEXT_FILE: &str = "CLAUDE.md"; +/// `.ideai/` subdirectory where a rejected global-context change is materialised. +const PROPOSALS_DIR: &str = ".ideai/proposals"; + +/// Joins a project root with a POSIX-relative segment (valid on every target). +fn join_root(project: &Project, rel: &str) -> RemotePath { + let base = project.root.as_str().trim_end_matches(['/', '\\']); + RemotePath::new(format!("{base}/{rel}")) +} + +/// Resolves an agent display name to its [`AgentId`] via the project manifest +/// (case-insensitive), or [`AppError::NotFound`]. +async fn resolve_agent( + contexts: &Arc, + project: &Project, + name: &str, +) -> Result { + let manifest = contexts.load_manifest(project).await?; + manifest + .entries + .into_iter() + .find(|e| e.name.eq_ignore_ascii_case(name)) + .map(|e| e.agent_id) + .ok_or_else(|| AppError::NotFound(format!("agent `{name}`"))) +} + +/// Reads an IdeA-owned context under a **shared read-lease** ([`GuardedResource`]). +/// +/// `target` absent ⇒ the global project context; otherwise the named agent's `.md`. +pub struct ReadContext { + guard: Arc, + contexts: Arc, + fs: Arc, +} + +/// Input for [`ReadContext`]. +pub struct ReadContextInput { + /// The project to read within. + pub project: Project, + /// Target agent display name; `None` ⇒ the global project context. + pub target: Option, + /// The reading party (drives the read-lease holder identity). + pub requester: ConversationParty, +} + +impl ReadContext { + /// Builds the use case from its ports. + #[must_use] + pub fn new( + guard: Arc, + contexts: Arc, + fs: Arc, + ) -> Self { + Self { guard, contexts, fs } + } + + /// Reads the requested context, returning its Markdown body. + /// + /// # Errors + /// [`AppError`] when the agent/context does not exist or the store/fs fails. + pub async fn execute(&self, input: ReadContextInput) -> Result { + let ReadContextInput { project, target, requester } = input; + match target { + None => { + // Global project context: shared read-lease, then read the root file. + let _lease = self + .guard + .acquire_read(requester, GuardedResource::ProjectContext) + .await + .map_err(map_guard_err)?; + let path = join_root(&project, PROJECT_CONTEXT_FILE); + let bytes = self.fs.read(&path).await?; + let text = String::from_utf8(bytes) + .map_err(|e| AppError::Invalid(e.to_string()))?; + Ok(MarkdownDoc::new(text)) + } + Some(name) => { + let agent = resolve_agent(&self.contexts, &project, &name).await?; + let _lease = self + .guard + .acquire_read(requester, GuardedResource::AgentContext(agent)) + .await + .map_err(map_guard_err)?; + Ok(self.contexts.read_context(&project, &agent).await?) + } + } + } +} + +/// Proposes new content for an IdeA-owned context under the [`FileGuard`]. +/// +/// For an **agent** context: a direct write under an exclusive write-lease. For the +/// **global** project context by a non-orchestrator: the guard returns +/// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal* +/// (a file under `.ideai/proposals/`) — never an overwrite of the live context. +pub struct ProposeContext { + guard: Arc, + contexts: Arc, + fs: Arc, + clock: Arc, +} + +/// Outcome of a [`ProposeContext`] call: whether it wrote directly or filed a proposal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProposeOutcome { + /// The content was written directly (agent context, or global by the orchestrator). + Written, + /// A proposal was materialised at the given `.ideai/proposals/…` path (a + /// non-orchestrator targeting the single-writer global context). + Proposed { + /// The proposal file's path. + path: String, + }, +} + +/// Input for [`ProposeContext`]. +pub struct ProposeContextInput { + /// The project to write within. + pub project: Project, + /// Target agent display name; `None` ⇒ the global project context. + pub target: Option, + /// The proposed Markdown body. + pub content: String, + /// The proposing party. + pub requester: ConversationParty, +} + +impl ProposeContext { + /// Builds the use case from its ports. + #[must_use] + pub fn new( + guard: Arc, + contexts: Arc, + fs: Arc, + clock: Arc, + ) -> Self { + Self { guard, contexts, fs, clock } + } + + /// Applies the proposal: direct write under a write-lease, or a materialised + /// proposal when the guard forbids a direct global-context write. + /// + /// # Errors + /// [`AppError`] when the agent does not exist or the store/fs fails. + pub async fn execute(&self, input: ProposeContextInput) -> Result { + let ProposeContextInput { project, target, content, requester } = input; + match target { + Some(name) => { + // Per-agent context: direct write under an exclusive write-lease. + let agent = resolve_agent(&self.contexts, &project, &name).await?; + let _lease = self + .guard + .acquire_write(requester, GuardedResource::AgentContext(agent)) + .await + .map_err(map_guard_err)?; + self.contexts + .write_context(&project, &agent, &MarkdownDoc::new(content)) + .await?; + Ok(ProposeOutcome::Written) + } + None => { + // Global project context: single-writer. Try to acquire the write + // lease; Forbidden ⇒ materialise a proposal instead of overwriting. + match self + .guard + .acquire_write(requester, GuardedResource::ProjectContext) + .await + { + Ok(_lease) => { + let path = join_root(&project, PROJECT_CONTEXT_FILE); + self.fs.write(&path, content.as_bytes()).await?; + Ok(ProposeOutcome::Written) + } + Err(GuardError::Forbidden) => { + let path = self.file_proposal(&project, requester, &content).await?; + Ok(ProposeOutcome::Proposed { path }) + } + Err(other) => Err(map_guard_err(other)), + } + } + } + } + + /// Materialises a rejected global-context change as a proposal file under + /// `.ideai/proposals/-.md`, returning its path. + async fn file_proposal( + &self, + project: &Project, + who: ConversationParty, + content: &str, + ) -> Result { + let who_label = match who { + ConversationParty::User => "orchestrator".to_owned(), + ConversationParty::Agent { agent_id } => agent_id.to_string(), + }; + let ts = self.clock.now_millis(); + let rel = format!("{PROPOSALS_DIR}/{who_label}-{ts}.md"); + let dir = join_root(project, PROPOSALS_DIR); + self.fs.create_dir_all(&dir).await?; + let path = join_root(project, &rel); + self.fs.write(&path, content.as_bytes()).await?; + Ok(path.as_str().to_owned()) + } +} + +/// Reads project memory under a shared read-lease. +/// +/// `slug` absent ⇒ the aggregated index (as Markdown lines); otherwise one note's body. +pub struct ReadMemory { + guard: Arc, + memory: Arc, +} + +/// Input for [`ReadMemory`]. +pub struct ReadMemoryInput { + /// The project to read within. + pub project: Project, + /// Target note slug; `None` ⇒ the aggregated index. + pub slug: Option, + /// The reading party. + pub requester: ConversationParty, +} + +impl ReadMemory { + /// Builds the use case from its ports. + #[must_use] + pub fn new(guard: Arc, memory: Arc) -> Self { + Self { guard, memory } + } + + /// Reads the requested memory, returning its Markdown content. + /// + /// # Errors + /// [`AppError`] when the note does not exist or the store fails. An invalid slug + /// is [`AppError::Invalid`]. + pub async fn execute(&self, input: ReadMemoryInput) -> Result { + let ReadMemoryInput { project, slug, requester } = input; + match slug { + Some(raw) => { + let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?; + let _lease = self + .guard + .acquire_read(requester, GuardedResource::Memory(slug.clone())) + .await + .map_err(map_guard_err)?; + let note = self.memory.get(&project.root, &slug).await?; + Ok(note.body.into_string()) + } + None => { + // The aggregated index is project-shared; read it as a rendered list. + let entries = self.memory.read_index(&project.root).await?; + let lines: Vec = entries + .into_iter() + .map(|e| format!("- [{}]({}.md) — {}", e.title, e.slug, e.hook)) + .collect(); + Ok(lines.join("\n")) + } + } + } +} + +/// Writes (creates or replaces) a project memory note under an exclusive write-lease. +pub struct WriteMemory { + guard: Arc, + memory: Arc, +} + +/// Input for [`WriteMemory`]. +pub struct WriteMemoryInput { + /// The project to write within. + pub project: Project, + /// Target note slug. + pub slug: String, + /// The Markdown body to store. + pub content: String, + /// The writing party. + pub requester: ConversationParty, +} + +impl WriteMemory { + /// Builds the use case from its ports. + #[must_use] + pub fn new(guard: Arc, memory: Arc) -> Self { + Self { guard, memory } + } + + /// Writes the note under a write-lease. + /// + /// # Errors + /// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store + /// failure. + pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> { + let WriteMemoryInput { project, slug, content, requester } = input; + let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?; + let _lease = self + .guard + .acquire_write(requester, GuardedResource::Memory(slug.clone())) + .await + .map_err(map_guard_err)?; + let frontmatter = MemoryFrontmatter { + name: slug.clone(), + description: format!("memory note {slug}"), + r#type: MemoryType::Project, + }; + let note = Memory::new(frontmatter, MarkdownDoc::new(content)) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.memory.save(&project.root, ¬e).await?; + Ok(()) + } +} + +/// Maps a [`GuardError`] onto the application error shape. `Forbidden` is an invariant +/// violation ([`AppError::Invalid`]); `Busy` is a transient contention the cooperative +/// blocking adapter never returns, but is mapped for completeness. +fn map_guard_err(e: GuardError) -> AppError { + match e { + GuardError::Forbidden => AppError::Invalid( + "writing the global project context is reserved to the orchestrator; propose instead" + .to_owned(), + ), + GuardError::Busy => AppError::Invalid("guarded resource is busy".to_owned()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use domain::agent::{AgentManifest, ManifestEntry}; + use domain::conversation::ConversationParty; + use domain::fileguard::{may_write_directly, ReadLease, WriteLease}; + use domain::ports::{FsError, MemoryError, StoreError}; + use domain::project::ProjectPath; + use domain::{ProfileId, ProjectId, RemoteRef}; + use std::collections::HashMap; + use std::sync::{Arc as StdArc, Mutex}; + use std::time::Duration; + use tokio::sync::RwLock; + + fn project() -> Project { + Project::new( + ProjectId::from_uuid(uuid::Uuid::from_u128(1)), + "demo", + ProjectPath::new("/tmp/demo").unwrap(), + RemoteRef::local(), + 0, + ) + .unwrap() + } + + /// In-test [`FileGuard`] mirroring `infrastructure::RwFileGuard` (one tokio + /// `RwLock` per resource + the single-writer rule), so the application crate + /// stays free of any dependency on infrastructure (no dependency cycle). + #[derive(Default)] + struct TestGuard { + locks: Mutex>>>, + } + + impl TestGuard { + fn lock_for(&self, res: &GuardedResource) -> StdArc> { + self.locks + .lock() + .unwrap() + .entry(res.clone()) + .or_insert_with(|| StdArc::new(RwLock::new(()))) + .clone() + } + } + + #[async_trait] + impl FileGuard for TestGuard { + async fn acquire_read( + &self, + _who: ConversationParty, + res: GuardedResource, + ) -> Result { + let lock = self.lock_for(&res); + Ok(ReadLease::new(Box::new(lock.read_owned().await))) + } + async fn acquire_write( + &self, + who: ConversationParty, + res: GuardedResource, + ) -> Result { + if !may_write_directly(who, &res) { + return Err(GuardError::Forbidden); + } + let lock = self.lock_for(&res); + Ok(WriteLease::new(Box::new(lock.write_owned().await))) + } + } + + fn agent_party(n: u128) -> ConversationParty { + ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n))) + } + + // ---- Fakes ----------------------------------------------------------- + + #[derive(Default)] + struct FakeFs { + files: Mutex>>, + dirs: Mutex>, + } + + #[async_trait] + impl FileSystem for FakeFs { + async fn read(&self, path: &RemotePath) -> Result, FsError> { + self.files + .lock() + .unwrap() + .get(path.as_str()) + .cloned() + .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())) + } + async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { + self.files + .lock() + .unwrap() + .insert(path.as_str().to_owned(), data.to_vec()); + Ok(()) + } + async fn exists(&self, path: &RemotePath) -> Result { + Ok(self.files.lock().unwrap().contains_key(path.as_str())) + } + async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { + self.dirs.lock().unwrap().push(path.as_str().to_owned()); + Ok(()) + } + async fn list(&self, _path: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + } + + struct FakeContexts { + manifest: AgentManifest, + contexts: Mutex>, + } + + #[async_trait] + impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _project: &Project, + agent: &AgentId, + ) -> Result { + self.contexts + .lock() + .unwrap() + .get(agent) + .cloned() + .map(MarkdownDoc::new) + .ok_or(StoreError::NotFound) + } + async fn write_context( + &self, + _project: &Project, + agent: &AgentId, + md: &MarkdownDoc, + ) -> Result<(), StoreError> { + self.contexts + .lock() + .unwrap() + .insert(*agent, md.as_str().to_owned()); + Ok(()) + } + async fn load_manifest(&self, _project: &Project) -> Result { + Ok(self.manifest.clone()) + } + async fn save_manifest( + &self, + _project: &Project, + _manifest: &AgentManifest, + ) -> Result<(), StoreError> { + Ok(()) + } + } + + #[derive(Default)] + struct FakeMemory { + notes: Mutex>, + } + + #[async_trait] + impl MemoryStore for FakeMemory { + async fn list(&self, _root: &ProjectPath) -> Result, MemoryError> { + Ok(Vec::new()) + } + async fn get( + &self, + _root: &ProjectPath, + slug: &MemorySlug, + ) -> Result { + let body = self + .notes + .lock() + .unwrap() + .get(slug.as_str()) + .cloned() + .ok_or(MemoryError::NotFound)?; + Memory::new( + MemoryFrontmatter { + name: slug.clone(), + description: "d".to_owned(), + r#type: MemoryType::Project, + }, + MarkdownDoc::new(body), + ) + .map_err(|e| MemoryError::Frontmatter(e.to_string())) + } + async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> { + self.notes + .lock() + .unwrap() + .insert(memory.slug().to_string(), memory.body.as_str().to_owned()); + Ok(()) + } + async fn delete( + &self, + _root: &ProjectPath, + _slug: &MemorySlug, + ) -> Result<(), MemoryError> { + Ok(()) + } + async fn read_index( + &self, + _root: &ProjectPath, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } + async fn resolve_links( + &self, + _root: &ProjectPath, + _slug: &MemorySlug, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } + } + + struct FixedClock; + impl Clock for FixedClock { + fn now_millis(&self) -> i64 { + 42 + } + } + + fn guard() -> Arc { + Arc::new(TestGuard::default()) + } + + fn contexts_with(name: &str, agent: AgentId, body: &str) -> Arc { + let mut contexts = HashMap::new(); + contexts.insert(agent, body.to_owned()); + Arc::new(FakeContexts { + manifest: AgentManifest { + version: 1, + entries: vec![ManifestEntry { + agent_id: agent, + name: name.to_owned(), + md_path: "agents/x.md".to_owned(), + profile_id: ProfileId::from_uuid(uuid::Uuid::from_u128(99)), + template_id: None, + synchronized: false, + synced_template_version: None, + skills: Vec::new(), + }], + }, + contexts: Mutex::new(contexts), + }) + } + + // ---- Tests ----------------------------------------------------------- + + #[tokio::test] + async fn read_agent_context_returns_body() { + let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7)); + let uc = ReadContext::new( + guard(), + contexts_with("Dev", agent, "# hello"), + Arc::new(FakeFs::default()), + ); + let md = uc + .execute(ReadContextInput { + project: project(), + target: Some("dev".to_owned()), // case-insensitive + requester: agent_party(1), + }) + .await + .unwrap(); + assert_eq!(md.as_str(), "# hello"); + } + + #[tokio::test] + async fn read_global_context_reads_root_file() { + let fs = Arc::new(FakeFs::default()); + fs.files + .lock() + .unwrap() + .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# project".to_vec()); + let uc = ReadContext::new( + guard(), + contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"), + fs, + ); + let md = uc + .execute(ReadContextInput { + project: project(), + target: None, + requester: ConversationParty::User, + }) + .await + .unwrap(); + assert_eq!(md.as_str(), "# project"); + } + + #[tokio::test] + async fn concurrent_reads_do_not_block_each_other() { + // Two readers on the same global context, held at once. If the read-lease + // were exclusive this would deadlock; a bounded timeout proves it does not. + let guard = guard(); + let r1 = guard + .acquire_read(ConversationParty::User, GuardedResource::ProjectContext) + .await + .unwrap(); + let r2 = tokio::time::timeout( + Duration::from_millis(200), + guard.acquire_read(agent_party(1), GuardedResource::ProjectContext), + ) + .await + .expect("a second reader must not block") + .unwrap(); + drop((r1, r2)); + } + + #[tokio::test] + async fn agent_proposing_global_context_files_a_proposal_not_a_write() { + let fs = Arc::new(FakeFs::default()); + fs.files + .lock() + .unwrap() + .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# original".to_vec()); + let uc = ProposeContext::new( + guard(), + contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"), + Arc::clone(&fs) as Arc, + Arc::new(FixedClock), + ); + let outcome = uc + .execute(ProposeContextInput { + project: project(), + target: None, + content: "# hijack".to_owned(), + requester: agent_party(3), + }) + .await + .unwrap(); + // It is a *proposal*, not a write: the live context is untouched. + assert!(matches!(outcome, ProposeOutcome::Proposed { .. })); + assert_eq!( + fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), + b"# original", + "the live global context must NOT be overwritten by a proposal" + ); + // The proposal landed under .ideai/proposals/. + let files = fs.files.lock().unwrap(); + assert!(files + .keys() + .any(|k| k.contains("/.ideai/proposals/") && k.ends_with("-42.md"))); + } + + #[tokio::test] + async fn orchestrator_writes_global_context_directly() { + let fs = Arc::new(FakeFs::default()); + let uc = ProposeContext::new( + guard(), + contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"), + Arc::clone(&fs) as Arc, + Arc::new(FixedClock), + ); + let outcome = uc + .execute(ProposeContextInput { + project: project(), + target: None, + content: "# new".to_owned(), + requester: ConversationParty::User, + }) + .await + .unwrap(); + assert_eq!(outcome, ProposeOutcome::Written); + assert_eq!( + fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), + b"# new" + ); + } + + #[tokio::test] + async fn propose_agent_context_writes_directly() { + let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7)); + let contexts = contexts_with("Dev", agent, "# old"); + let uc = ProposeContext::new( + guard(), + Arc::clone(&contexts), + Arc::new(FakeFs::default()), + Arc::new(FixedClock), + ); + let outcome = uc + .execute(ProposeContextInput { + project: project(), + target: Some("Dev".to_owned()), + content: "# new body".to_owned(), + requester: agent_party(3), + }) + .await + .unwrap(); + assert_eq!(outcome, ProposeOutcome::Written); + assert_eq!( + contexts + .read_context(&project(), &agent) + .await + .unwrap() + .as_str(), + "# new body" + ); + } + + #[tokio::test] + async fn write_then_read_memory_round_trips_under_guard() { + let memory = Arc::new(FakeMemory::default()); + let writer = WriteMemory::new(guard(), Arc::clone(&memory) as Arc); + writer + .execute(WriteMemoryInput { + project: project(), + slug: "note-a".to_owned(), + content: "body".to_owned(), + requester: agent_party(2), + }) + .await + .unwrap(); + + let reader = ReadMemory::new(guard(), Arc::clone(&memory) as Arc); + let body = reader + .execute(ReadMemoryInput { + project: project(), + slug: Some("note-a".to_owned()), + requester: agent_party(2), + }) + .await + .unwrap(); + assert_eq!(body, "body"); + } + + #[tokio::test] + async fn writes_to_same_memory_note_serialise() { + // Two write leases on the same note must not overlap (exclusive writer). + let guard = guard(); + let slug = GuardedResource::Memory(MemorySlug::new("n").unwrap()); + let w1 = guard + .acquire_write(agent_party(1), slug.clone()) + .await + .unwrap(); + // While w1 is held, a second writer blocks; it only succeeds after release. + let blocked = tokio::time::timeout( + Duration::from_millis(100), + guard.acquire_write(agent_party(2), slug.clone()), + ) + .await; + assert!(blocked.is_err(), "a second writer must block while w1 holds"); + drop(w1); + let w2 = tokio::time::timeout( + Duration::from_millis(200), + guard.acquire_write(agent_party(2), slug), + ) + .await + .expect("w2 acquires after w1 releases") + .unwrap(); + drop(w2); + } +} diff --git a/crates/application/src/orchestrator/mod.rs b/crates/application/src/orchestrator/mod.rs index cfe1fb2..bf77476 100644 --- a/crates/application/src/orchestrator/mod.rs +++ b/crates/application/src/orchestrator/mod.rs @@ -4,6 +4,11 @@ //! use-case calls the UI makes, so an orchestrator agent can drive IdeA without //! ever spawning a process itself. See [`service::OrchestratorService`]. +mod context_guard; mod service; -pub use service::{OrchestratorOutcome, OrchestratorService}; +pub use context_guard::{ + ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory, + ReadMemoryInput, WriteMemory, WriteMemoryInput, +}; +pub use service::{McpRuntimeProvider, OrchestratorOutcome, OrchestratorService}; diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index d902a63..0ecefff 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -19,16 +19,25 @@ use std::time::Duration; use tokio::sync::Mutex as AsyncMutex; -use domain::ports::{EventBus, ProfileStore}; +use domain::conversation::{ + ConversationParty, ConversationRegistry, SessionRef, WaitForGraph, +}; +use domain::input::InputMediator; +use domain::mailbox::{Ticket, TicketId}; +use domain::ports::{EventBus, ProfileStore, PtyHandle}; use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project}; use crate::agent::{ - send_blocking, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, - ListAgents, ListAgentsInput, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput, + CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, + ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput, }; use crate::error::AppError; +use crate::orchestrator::{ + ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory, + ReadMemoryInput, WriteMemory, WriteMemoryInput, +}; use crate::skill::{CreateSkill, CreateSkillInput}; -use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions}; +use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions}; /// Default terminal geometry for an orchestrator-launched agent cell. The UI /// resizes the PTY to the real cell size on attach; these are sane starting rows @@ -61,6 +70,19 @@ const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300); /// pleins d'attente. const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600); +/// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP +/// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans +/// app-tauri (seul détenteur de current_exe/$APPIMAGE/mcp_endpoint). +/// +/// La couche `application` ne connaît que ce **port** : elle ne calcule jamais le chemin +/// de l'exécutable ni l'endpoint loopback (ces faits vivent dans `app-tauri`, cadrage v5 +/// §0.3 / §7). Seules les **chaînes** d'un [`McpRuntime`] traversent la frontière. +pub trait McpRuntimeProvider: Send + Sync { + /// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera idea_reply). + /// `None` ⇒ dégrade vers la déclaration minimale (jamais d'échec de lancement). + fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option; +} + /// Dispatches validated orchestrator commands to the agent/terminal use cases. pub struct OrchestratorService { create_agent: Arc, @@ -71,11 +93,27 @@ pub struct OrchestratorService { create_skill: Arc, profiles: Arc, sessions: Arc, - /// Registre des sessions **structurées** (§17.5) — la cible d'un `agent.message` - /// y est cherchée pour le rendez-vous synchrone. Injecté au câblage via - /// [`Self::with_structured`] ; `None` ⇒ `AskAgent` ne peut pas être servi (les - /// call sites/tests legacy qui n'utilisent pas la messagerie restent verts). - structured: Option>, + /// Médiateur d'entrée (cadrage C3 §5.2) — point de convergence unique de l'entrée + /// d'un agent. La cible d'un `agent.message`/`idea_ask_agent` y reçoit un ticket + /// (`enqueue`) dont on **attend** la résolution (`idea_reply` ⇒ + /// [`OrchestratorCommand::Reply`]) ; son impl écrit aussi le tour dans le PTY de la + /// cible (livraison sérialisée, plus d'écriture ad hoc ici). Injecté via + /// [`Self::with_input_mediator`] ; `None` ⇒ `AskAgent`/`Reply` non servis (call + /// sites/tests legacy restent verts). + input: Option>, + /// Mailbox sous-jacent du médiateur, pour `resolve`/`resolve_ticket`/`cancel_head` + /// (corrélation par ticket). C'est le **même** moteur de corrélation que celui que + /// `input` enveloppe ; injecté ensemble via [`Self::with_input_mediator`]. + mailbox: Option>, + /// Registre des conversations par paire (cadrage C3 §5.2) — résout paresseusement + /// le fil `A↔B` (ou `User↔B`) d'un `ask`, sépare strictement les contextes. + /// Injecté via [`Self::with_conversations`] ; `None` ⇒ on retombe sur un routage + /// par agent sans matérialisation de fil (legacy). + conversations: Option>, + /// Graphe d'attente inter-agents (cadrage C3 §6) — arête posée à l'`enqueue` d'un + /// `ask` A→B, retirée au reply/timeout (RAII via le garde de tour). Sert à + /// **refuser** une délégation ré-entrante (A→B→…→A) avant deadlock. + wait_for: StdMutex, /// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un /// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de /// publication (l'`ask` fonctionne quand même). @@ -99,6 +137,30 @@ pub struct OrchestratorService { /// Croissance bornée en pratique au nombre d'agents du projet ; une entrée /// morte ne coûte qu'un `Arc>` vide (pas de session, pas de process). ask_locks: StdMutex>>>, + /// Fournisseur des faits OS/runtime (exe + endpoint) pour écrire la déclaration + /// MCP **réelle** quand `ensure_live_pty` (re)lance une cible sur le chemin `ask` + /// (B-3). Injecté au câblage via [`Self::with_mcp_runtime_provider`] depuis + /// app-tauri ; `None` ⇒ on conserve la déclaration minimale (`mcp_runtime: None`), + /// donc zéro régression pour les call sites/tests qui ne le branchent pas. + mcp_runtime_provider: Option>, + /// FileGuard-mediated context/memory use cases (cadrage C7). Injected via + /// [`Self::with_context_guard`] ; `None` ⇒ les commandes `context.*`/`memory.*` + /// renvoient une erreur typée (call sites/tests legacy restent verts). + context_guard: Option>, +} + +/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés +/// ensemble dans l'[`OrchestratorService`] (cadrage C7). Regroupés pour garder la +/// signature de [`OrchestratorService::with_context_guard`] simple (un seul `Arc`). +pub struct ContextGuardUseCases { + /// Lecture d'un contexte `.md` IdeA sous read-lease. + pub read_context: Arc, + /// Proposition/écriture d'un contexte `.md` IdeA sous le garde. + pub propose_context: Arc, + /// Lecture mémoire sous read-lease. + pub read_memory: Arc, + /// Écriture mémoire sous write-lease. + pub write_memory: Arc, } /// Outcome of dispatching a command — a short, human-readable success summary the @@ -137,12 +199,26 @@ impl OrchestratorService { create_skill, profiles, sessions, - structured: None, + input: None, + mailbox: None, + conversations: None, + wait_for: StdMutex::new(WaitForGraph::new()), events: None, ask_locks: StdMutex::new(HashMap::new()), + mcp_runtime_provider: None, + context_guard: None, } } + /// Branche les use cases C7 (`context.*`/`memory.*`) sous + /// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`] + /// inchangée). + #[must_use] + pub fn with_context_guard(mut self, guard: Arc) -> Self { + self.context_guard = Some(guard); + self + } + /// Returns the per-agent **turn lock**, creating it on first use. /// /// Get-or-create under the synchronous map mutex (held only for this lookup, @@ -157,13 +233,30 @@ impl OrchestratorService { Arc::clone(locks.entry(*agent_id).or_default()) } - /// Branche le registre des sessions **structurées** (§17.5) pour servir - /// `agent.message`/[`OrchestratorCommand::AskAgent`]. Builder additif façon D3 : - /// signature de [`Self::new`] **inchangée** (les tests/call sites legacy restent - /// verts), le câblage fait `OrchestratorService::new(...).with_structured(reg)`. + /// Branche le **médiateur d'entrée** (cadrage C3 §5.2) pour servir + /// `agent.message`/[`OrchestratorCommand::AskAgent`] et + /// `agent.reply`/[`OrchestratorCommand::Reply`]. Le `mailbox` est le moteur de + /// corrélation **sous-jacent** au médiateur (le même `InMemoryMailbox` que + /// `MediatedInbox` enveloppe) : on l'injecte ensemble pour pouvoir `resolve`/ + /// `resolve_ticket`/`cancel_head` un ticket. Builder additif : signature de + /// [`Self::new`] **inchangée** (les tests/call sites legacy restent verts). #[must_use] - pub fn with_structured(mut self, structured: Arc) -> Self { - self.structured = Some(structured); + pub fn with_input_mediator( + mut self, + input: Arc, + mailbox: Arc, + ) -> Self { + self.input = Some(input); + self.mailbox = Some(mailbox); + self + } + + /// Branche le [`ConversationRegistry`] (cadrage C3 §5.2) pour résoudre + /// paresseusement le fil `A↔B` (ou `User↔B`) d'un `ask` et séparer les contextes. + /// Builder additif (signature de [`Self::new`] inchangée). + #[must_use] + pub fn with_conversations(mut self, conversations: Arc) -> Self { + self.conversations = Some(conversations); self } @@ -175,6 +268,17 @@ impl OrchestratorService { self } + /// Branche le [`McpRuntimeProvider`] (app-tauri) pour que les (re)lancements + /// issus du chemin `ask` (`ensure_live_pty`) écrivent la déclaration MCP **réelle** + /// (endpoint + exe + requester) au lieu de la minimale — sans quoi le pont MCP + /// n'est jamais spawné et la cible ne peut pas appeler `idea_reply` (timeout). + /// Builder additif : signature de [`Self::new`] **inchangée**. + #[must_use] + pub fn with_mcp_runtime_provider(mut self, provider: Arc) -> Self { + self.mcp_runtime_provider = Some(provider); + self + } + /// Dispatches a validated command against `project`. /// /// # Errors @@ -196,9 +300,16 @@ impl OrchestratorService { self.spawn_agent(project, name, profile, context, visibility) .await } - OrchestratorCommand::AskAgent { target, task } => { - self.ask_agent(project, target, task).await - } + OrchestratorCommand::AskAgent { + target, + task, + requester, + } => self.ask_agent(project, target, task, requester).await, + OrchestratorCommand::Reply { + from, + ticket, + result, + } => self.reply(from, ticket, result), OrchestratorCommand::ListAgents => self.list_agents(project).await, OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await, OrchestratorCommand::UpdateAgentContext { name, context } => { @@ -209,9 +320,135 @@ impl OrchestratorService { content, scope, } => self.create_skill(project, name, content, scope).await, + OrchestratorCommand::ReadContext { target, requester } => { + self.read_context(project, target, requester).await + } + OrchestratorCommand::ProposeContext { + target, + content, + requester, + } => self.propose_context(project, target, content, requester).await, + OrchestratorCommand::ReadMemory { slug, requester } => { + self.read_memory(project, slug, requester).await + } + OrchestratorCommand::WriteMemory { + slug, + content, + requester, + } => self.write_memory(project, slug, content, requester).await, } } + /// Returns the injected C7 use cases, or a typed error when unwired. + fn require_context_guard(&self) -> Result<&ContextGuardUseCases, AppError> { + self.context_guard.as_deref().ok_or_else(|| { + AppError::Invalid("FileGuard context/memory tools are not configured".to_owned()) + }) + } + + /// `context.read` → reads an IdeA-owned context under a shared read-lease; the + /// body is returned inline in the outcome's `reply`. + async fn read_context( + &self, + project: &Project, + target: Option, + requester: ConversationParty, + ) -> Result { + let md = self + .require_context_guard()? + .read_context + .execute(ReadContextInput { + project: project.clone(), + target: target.clone(), + requester, + }) + .await?; + Ok(OrchestratorOutcome { + detail: format!( + "read {} context", + target.as_deref().unwrap_or("project") + ), + reply: Some(md.into_string()), + }) + } + + /// `context.propose` → direct write (agent ctx / orchestrator on global) or a + /// materialised proposal (non-orchestrator on global). + async fn propose_context( + &self, + project: &Project, + target: Option, + content: String, + requester: ConversationParty, + ) -> Result { + let outcome = self + .require_context_guard()? + .propose_context + .execute(ProposeContextInput { + project: project.clone(), + target: target.clone(), + content, + requester, + }) + .await?; + let detail = match outcome { + ProposeOutcome::Written => format!( + "wrote {} context", + target.as_deref().unwrap_or("project") + ), + ProposeOutcome::Proposed { path } => { + format!("filed proposal for project context at {path}") + } + }; + Ok(OrchestratorOutcome { detail, reply: None }) + } + + /// `memory.read` → reads a note (or the index) under a shared read-lease; the + /// content is returned inline in the outcome's `reply`. + async fn read_memory( + &self, + project: &Project, + slug: Option, + requester: ConversationParty, + ) -> Result { + let content = self + .require_context_guard()? + .read_memory + .execute(ReadMemoryInput { + project: project.clone(), + slug: slug.clone(), + requester, + }) + .await?; + Ok(OrchestratorOutcome { + detail: format!("read memory {}", slug.as_deref().unwrap_or("index")), + reply: Some(content), + }) + } + + /// `memory.write` → writes a note under an exclusive write-lease. + async fn write_memory( + &self, + project: &Project, + slug: String, + content: String, + requester: ConversationParty, + ) -> Result { + self.require_context_guard()? + .write_memory + .execute(WriteMemoryInput { + project: project.clone(), + slug: slug.clone(), + content, + requester, + }) + .await?; + Ok(OrchestratorOutcome { + detail: format!("wrote memory {slug}"), + reply: None, + }) + } + /// `spawn_agent`: create the agent if the manifest doesn't already hold one by /// that name, then launch it (which publishes `AgentLaunched` → the UI opens a /// cell + the Agents tab). @@ -328,88 +565,346 @@ impl OrchestratorService { }) } - /// `agent.message`: the **synchronous inter-agent rendezvous** (§17.4). + /// `agent.message` / `idea_ask_agent`: the **inter-agent delegation rendezvous** + /// (Option 1 « Terminal + MCP », lot B-3). /// - /// Resolves the target by name, ensures it has a **live structured session** - /// (launching it in structured mode if needed), sends `task` and **waits for the - /// turn's `Final`** via [`send_blocking`], then returns its content as - /// [`OrchestratorOutcome::reply`] and publishes [`DomainEvent::AgentReplied`]. + /// The target's human-facing view is now a **raw native terminal** (PTY REPL), and + /// delegation flows through the terminal's single FIFO input plus the MCP mailbox: /// - /// Invariants: - /// - **1 session vivante/agent** : on réutilise la session structurée vivante si - /// elle existe ; sinon `LaunchAgent` (gardé sur les deux registres) la crée. - /// - **Timeout ne tue pas la session** : [`send_blocking`] remonte - /// [`AppError::Process`] (via [`domain::ports::AgentSessionError::Timeout`]) en - /// laissant la session vivante dans le registre (retry possible). - /// - **Cible PTY-only** (profil sans `structured_adapter`, ou agent déjà vivant en - /// PTY) ⇒ [`AppError::Invalid`] explicite, **jamais** un ACK trompeur. + /// 1. Resolve the target by name and acquire its **per-agent turn lock** so two + /// `ask`s for the same target serialise FIFO (1 agent = 1 employee). + /// 2. Ensure the target is **live in the PTY registry** — reusing its terminal if + /// it is already running, otherwise launching it in the background (a normal + /// PTH launch: a live PTY *is* the channel now, not an error as before). + /// 3. **Enqueue a ticket** in the [`AgentMailbox`] (registering the reply slot) + /// **then write** the task into the target's terminal, prefixed with the asking + /// agent + ticket id so the target knows to answer via `idea_reply`. + /// 4. **Await** the [`domain::mailbox::PendingReply`] bounded by [`ASK_AGENT_TIMEOUT`]: + /// the target's later `idea_reply(result)` lands in [`Self::reply`] → + /// `mailbox.resolve`, waking this await. On timeout the ticket is retired from + /// the head ([`AgentMailbox::cancel_head`]) — **the target stays alive** — and a + /// typed timeout is returned (retry possible). + /// 5. Return the reply as [`OrchestratorOutcome::reply`] and publish + /// [`DomainEvent::AgentReplied`]. /// /// # Errors - /// - [`AppError::NotFound`] si l'agent cible est inconnu ; - /// - [`AppError::Invalid`] si la messagerie structurée n'est pas câblée, ou si la - /// cible n'est pas pilotable en mode structuré ; - /// - [`AppError::Process`] sur échec/timeout du tour structuré. + /// - [`AppError::NotFound`] if the target agent is unknown; + /// - [`AppError::Invalid`] if the mailbox/PTY channel is not wired; + /// - [`AppError::Process`] on a launch/PTY-write failure, or on the await timeout + /// (turn timeout *or* queue-wait timeout — same typed error). async fn ask_agent( &self, project: &Project, target: String, task: String, + requester: Option, ) -> Result { - let structured = self.structured.as_ref().ok_or_else(|| { - AppError::Invalid( - "la messagerie inter-agents (agent.message) n'est pas disponible : \ - registre des sessions structurées non câblé" - .to_owned(), - ) - })?; + let (input, mailbox) = match (&self.input, &self.mailbox) { + (Some(i), Some(m)) => (i, m), + _ => { + return Err(AppError::Invalid( + "la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \ + médiateur d'entrée non câblé" + .to_owned(), + )) + } + }; - let agent_id = self - .find_agent_id_by_name(project, &target) + let agent = self + .find_agent_by_name(project, &target) .await? .ok_or_else(|| AppError::NotFound(format!("agent {target}")))?; + let agent_id = agent.id; - // Sérialisation FIFO **par agent** (A0, cadrage v5 §4) : on acquiert le verrou - // de tour de la **cible** avant tout `send_blocking`, et on le tient pour TOUT - // le tour réel (rendez-vous direct *ou* lancement-puis-envoi sur cible morte). - // Le garde `_turn` est RAII : il tombe en fin de portée, y compris sur chaque - // early-return d'erreur ci-dessous ⇒ le tour suivant en file démarre alors. - // - // Verrou par `agent_id` ⇒ un `ask` vers A et un vers B ne se bloquent jamais ; - // un seul verrou tenu (celui de la cible) ⇒ pas d'inter-verrouillage/deadlock. - // Le timeout de TOUR ([`ASK_AGENT_TIMEOUT`]) borne `send_blocking`, **pas** - // l'attente du verrou ; cette attente a son **propre** plafond - // ([`ASK_QUEUE_WAIT_CAP`]) pour éviter l'inanition. + // F2 — garde profil : refuser **immédiatement** une cible dont le profil ne + // sait pas consommer le pont `idea_*` matérialisé via `.mcp.json`, plutôt que + // de laisser le round-trip échouer en timeout muet (300s). + self.guard_mcp_bridge_supported(&agent.profile_id, &target) + .await?; + + // Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la + // cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle + // d'attente (B attend déjà …→A). Pur, sans I/O ⇒ jamais de deadlock. + if let Some(from) = requester { + let cycles = { + let g = self + .wait_for + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + g.would_cycle(from, agent_id) + }; + if cycles { + return Err(AppError::Invalid(format!( + "délégation ré-entrante refusée : demander à l'agent '{target}' créerait \ + un cycle d'attente inter-agents (deadlock évité)" + ))); + } + } + + // Résoudre paresseusement le **fil** de l'ask : A↔B si un agent demande, sinon + // User↔B. La session vivante est désormais keyée par conversation (lève + // `session-registry-agent-ambiguity`). + let conversation_id = self.resolve_conversation(requester, agent_id); + + // Sérialisation FIFO **par agent** (A0) : verrou de tour de la **cible**, tenu + // pour TOUT le tour (enqueue → réponse). RAII : tombe sur chaque early-return. let lock = self.ask_lock_for(&agent_id); let _turn = match tokio::time::timeout(ASK_QUEUE_WAIT_CAP, lock.lock_owned()).await { Ok(guard) => guard, Err(_elapsed) => { - // Réutilise le **même** type de timeout que le tour (cadrage v5 §4) : - // `AgentSessionError::Timeout` ⇒ `AppError::Process`. - return Err(AppError::from( - domain::ports::AgentSessionError::Timeout, - )); + return Err(AppError::from(domain::ports::AgentSessionError::Timeout)); } }; - // 1. Session structurée déjà vivante ? ⇒ rendez-vous direct. - if let Some(session) = structured.session_for_agent(&agent_id) { - let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?; - return Ok(self.reply_outcome(agent_id, &target, content)); + // Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`). + let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id)); + + // 1. Garantir la cible vivante en PTY pour CE fil ; lier sa session à la + // conversation, et brancher son handle d'entrée sur le médiateur (livraison). + let handle = self + .ensure_live_pty(project, agent_id, conversation_id, &target) + .await?; + // Arm prompt-ready detection (C5) with the target profile's literal marker, so a + // return-to-prompt frees the turn (the other OR signal being `idea_reply`). + let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await; + input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern); + + // 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur + // (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket + // porte la source (Human/Agent) et la conversation cible. + let requester_label = self.requester_label(project, requester).await; + let ticket_id = TicketId::new_random(); + let ticket = match requester { + Some(from) => { + Ticket::from_agent(ticket_id, from, conversation_id, requester_label, task) + } + None => Ticket::from_human(ticket_id, conversation_id, requester_label, task), + }; + let pending = input.enqueue(agent_id, ticket); + // Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the + // turn into the bound handle). The service no longer writes the PTY directly — + // no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1). + + // 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ retirer le ticket + // (cible laissée vivante) et renvoyer une erreur typée. + match tokio::time::timeout(ASK_AGENT_TIMEOUT, pending).await { + Ok(Ok(result)) => Ok(self.reply_outcome(agent_id, &target, result)), + Ok(Err(_cancelled)) => { + mailbox.cancel_head(agent_id, ticket_id); + Err(AppError::Process(format!( + "agent {target} : canal de réponse fermé avant un résultat" + ))) + } + Err(_elapsed) => { + mailbox.cancel_head(agent_id, ticket_id); + Err(AppError::from(domain::ports::AgentSessionError::Timeout)) + } + } + } + + /// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path. + /// + /// The operator types into IdeA's mediated input; this resolves the `User↔Agent` + /// thread, ensures the target is live in the PTY registry, binds its handle on the + /// mediator, and **enqueues** a `Ticket::from_human` into the **same FIFO** the + /// inter-agent delegations use (`InputMediator::enqueue`) — so a human submit and a + /// delegation serialise on the same agent («1 agent = 1 employee»). + /// + /// Unlike [`Self::ask_agent`], it is **fire-and-forget**: the human watches the + /// terminal for the answer, so we do **not** await the [`PendingReply`] (the reply + /// slot is registered and simply left to resolve/expire on its own). The busy + /// event is emitted at the mediator's source (Idle→Busy on the starting enqueue). + /// + /// # Errors + /// - [`AppError::Invalid`] if the input mediator is not wired; + /// - [`AppError::NotFound`] if the target agent is unknown; + /// - [`AppError::Process`] on a launch/PTY failure while ensuring the live session. + pub async fn submit_human_input( + &self, + project: &Project, + agent_id: AgentId, + text: String, + ) -> Result { + let input = self.input.as_ref().ok_or_else(|| { + AppError::Invalid( + "l'entrée médiée (submit_agent_input) n'est pas disponible : \ + médiateur d'entrée non câblé" + .to_owned(), + ) + })?; + + // Display label for error/launch messages; the agent must exist in the + // manifest. A human submit to an unknown id is a NotFound, never a panic. + let target = self + .find_name_by_agent_id(project, agent_id) + .await + .ok_or_else(|| AppError::NotFound(format!("agent {agent_id}")))?; + let target = target.as_str(); + + // User↔Agent thread (no requester ⇒ left = User). Same lazy resolution as ask. + let conversation_id = self.resolve_conversation(None, agent_id); + + // Ensure the target is live for this thread and bind its input handle on the + // mediator (delivery path). Same call the ask path uses. + let handle = self + .ensure_live_pty(project, agent_id, conversation_id, target) + .await?; + let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await; + input.bind_handle_with_prompt(agent_id, handle, prompt_pattern); + + // Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and- + // forget: we drop the PendingReply (the human reads the terminal). The + // mediator emits AgentBusyChanged at the source on a starting turn. + let ticket = Ticket::from_human(TicketId::new_random(), conversation_id, "vous", text); + let _pending = input.enqueue(agent_id, ticket); + + Ok(OrchestratorOutcome { + detail: format!("submitted human input to agent {target}"), + reply: None, + }) + } + + /// Interrupt (cadrage C4 §5.3) — the **Interrompre** path (Échap/stop). + /// + /// Resolves the target by name and calls [`InputMediator::preempt`], which signals + /// the running turn to stop (best-effort interrupt byte to the agent's bound PTY + /// handle). It is **not** an enqueue and resolves **no** ticket — a pending caller + /// is never silently answered. Idempotent: interrupting an idle agent is a no-op. + /// + /// # Errors + /// - [`AppError::Invalid`] if the input mediator is not wired; + /// - [`AppError::NotFound`] if the target agent is unknown. + pub async fn interrupt_agent( + &self, + project: &Project, + agent_id: AgentId, + ) -> Result { + let input = self.input.as_ref().ok_or_else(|| { + AppError::Invalid( + "l'interruption (interrupt_agent) n'est pas disponible : \ + médiateur d'entrée non câblé" + .to_owned(), + ) + })?; + + // Confirm the agent exists (typed NotFound rather than a silent no-op on a + // bogus id). The manifest lookup also keeps the contract symmetric with submit. + if self + .find_name_by_agent_id(project, agent_id) + .await + .is_none() + { + return Err(AppError::NotFound(format!("agent {agent_id}"))); } - // 2. Pas de session structurée. Si l'agent est vivant en **PTY** (terminal - // brut), il n'est pas adressable en `ask` ⇒ erreur typée explicite (pas - // d'ACK « launched »). - if self.sessions.session_for_agent(&agent_id).is_some() { - return Err(AppError::Invalid(format!( - "agent {target} n'est pas pilotable en mode structuré \ - (session terminal brut, pas de canal de réponse)" - ))); + input.preempt(agent_id); + + Ok(OrchestratorOutcome { + detail: format!("interrupted agent {agent_id}"), + reply: None, + }) + } + + /// Resolves the conversation thread id for an ask: `A↔B` when an agent requests, + /// else `User↔B` (cadrage C3 §5.2). Without a wired registry, falls back to a + /// stable per-agent id derived from the target (legacy routing — never panics). + fn resolve_conversation( + &self, + requester: Option, + target: AgentId, + ) -> domain::conversation::ConversationId { + let left = match requester { + Some(from) => ConversationParty::agent(from), + None => ConversationParty::User, + }; + let right = ConversationParty::agent(target); + match &self.conversations { + Some(reg) => reg.resolve(left, right).id, + None => domain::conversation::ConversationId::from_uuid(target.as_uuid()), + } + } + + + /// `agent.reply` / `idea_reply`: the target agent renders the result of the task + /// it is currently processing (Option 1, lot B-4). + /// + /// Positional correlation: `from` is the **emitting** agent (its identity comes + /// from the MCP handshake, not from a model-managed id), so the result resolves + /// the ticket at the **head** of *that agent's* mailbox queue — the task it is + /// working on. ACK only: no inline payload, no `AgentReplied` here (that belongs + /// to the asking side's `ask_agent`). + /// + /// # Errors + /// - [`AppError::Invalid`] if the mailbox is not wired; + /// - [`AppError::Invalid`] if `from` has no in-flight request (a reply with no + /// matching ask) — typed, never a panic. + fn reply( + &self, + from: AgentId, + ticket: Option, + result: String, + ) -> Result { + let mailbox = self.mailbox.as_ref().ok_or_else(|| { + AppError::Invalid( + "idea_reply n'est pas disponible : file inter-agents non câblée".to_owned(), + ) + })?; + // Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ; + // sinon repli sur la tête de file de l'émetteur (compat agents mono-fil). + match ticket { + Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result), + None => mailbox.resolve(from, result), + } + .map_err(|e| AppError::Invalid(e.to_string()))?; + // Explicit «end-of-turn» signal (cadrage §6, lot C5): an `idea_reply` means the + // emitting agent `from` finished its delegated task ⇒ mark it Idle so its FIFO + // advances to the next queued ticket. This is the deterministic OR signal that + // pairs with prompt-ready detection; whichever fires first frees the turn. No-op + // (and no spurious event) when the mediator is absent or `from` was already idle. + if let Some(input) = self.input.as_ref() { + input.mark_idle(from); + } + Ok(OrchestratorOutcome { + detail: format!("reply from agent {from} delivered"), + reply: None, + }) + } + + /// Ensures the target agent has a **live PTY session**, returning its handle. + /// + /// Reuses the running terminal when present; otherwise launches the agent in the + /// background (a normal PTH launch — a live PTY is the delegation channel). After + /// a launch the handle is resolved from the registry; a missing handle is a + /// [`AppError::Process`] (the launch did not register a PTY session, e.g. a profile + /// IdeA cannot drive as a terminal). + async fn ensure_live_pty( + &self, + project: &Project, + agent_id: AgentId, + conversation_id: domain::conversation::ConversationId, + target: &str, + ) -> Result { + // «1 session vivante / conversation» (cadrage C3 §5.2) : on cherche d'abord la + // session du **fil**, puis on retombe sur la session de l'agent (compat : un + // agent mono-fil dont la session n'a pas encore été liée à sa conversation). + let existing = self + .sessions + .session_for(conversation_id) + .or_else(|| self.sessions.session_for_agent(&agent_id)); + if let Some(session_id) = existing { + if let Some(handle) = self.sessions.handle(&session_id) { + // (Re)lier le fil à cette session vivante (idempotent). + self.bind_conversation_session(conversation_id, session_id); + return Ok(handle); + } } - // 3. Cible morte : on la lance en mode structuré (background) puis on envoie. - let launched = self - .launch_agent + // Dead target: launch it in the background (PTY). On injecte ici la déclaration + // MCP **réelle** via le [`McpRuntimeProvider`] câblé (app-tauri détient l'exe et + // l'endpoint) — c'est ce qui permet au pont MCP de la cible de se spawner et donc + // à la cible d'appeler `idea_reply`. Provider absent (ou `runtime_for` → `None`) + // ⇒ déclaration minimale comme avant (dégradation gracieuse). + self.launch_agent .execute(LaunchAgentInput { project: project.clone(), agent_id, @@ -417,30 +912,71 @@ impl OrchestratorService { cols: DEFAULT_COLS, node_id: None, conversation_id: None, - // ask_agent revival launch (inside `application`): MCP runtime not - // injected here (see the `spawn_agent` note above). - mcp_runtime: None, + mcp_runtime: self + .mcp_runtime_provider + .as_ref() + .and_then(|p| p.runtime_for(project, agent_id)), }) .await?; - // Si le lancement n'a pas produit de session structurée, le profil de la cible - // est PTY-only (pas de `structured_adapter`) ⇒ non adressable en `ask`. - if launched.structured.is_none() { - return Err(AppError::Invalid(format!( - "agent {target} n'est pas pilotable en mode structuré \ - (profil sans adaptateur structuré)" - ))); - } + let session_id = self + .sessions + .session_for_agent(&agent_id) + .ok_or_else(|| { + AppError::Process(format!( + "agent {target} n'a pas de session terminal vivante après lancement" + )) + })?; + // Lier la session fraîchement lancée à CE fil (registre terminal + registre de + // conversations) ⇒ un prochain ask sur le même fil la réutilise. + self.bind_conversation_session(conversation_id, session_id); + self.sessions.handle(&session_id).ok_or_else(|| { + AppError::Process(format!("handle PTY de l'agent {target} introuvable après lancement")) + }) + } - // La session vient d'être enregistrée par LaunchAgent : on la récupère par - // agent (invariant « 1 session/agent » ⇒ non ambigu). - let session = structured.session_for_agent(&agent_id).ok_or_else(|| { - AppError::Process(format!( - "session structurée de l'agent {target} introuvable après lancement" - )) - })?; - let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?; - Ok(self.reply_outcome(agent_id, &target, content)) + /// Binds `conversation` to `session` in both the terminal registry (fast + /// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the + /// latter is wired. Idempotent. + fn bind_conversation_session( + &self, + conversation: domain::conversation::ConversationId, + session: domain::SessionId, + ) { + self.sessions.bind_conversation(conversation, session); + if let Some(reg) = &self.conversations { + reg.bind_session(conversation, SessionRef::new(session)); + } + } + + /// Resolves a human-friendly label for the **requesting** agent to prefix the + /// delegated task with. Best-effort: there is no requester id threaded into + /// [`OrchestratorCommand::AskAgent`] today, so this falls back to a stable + /// `"un autre agent"` label. Kept as a seam so a future requester-aware ask can + /// surface the real name without touching the call sites. + async fn requester_label(&self, project: &Project, requester: Option) -> String { + match requester { + None => "un autre agent".to_owned(), + Some(from) => self + .find_name_by_agent_id(project, from) + .await + .unwrap_or_else(|| "un autre agent".to_owned()), + } + } + + /// Best-effort display name for an agent id (manifest lookup). `None` when the id + /// is unknown — the caller falls back to a generic label. + async fn find_name_by_agent_id(&self, project: &Project, id: AgentId) -> Option { + self.list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await + .ok()? + .agents + .into_iter() + .find(|a| a.id == id) + .map(|a| a.name) } /// Builds the success outcome of an `ask` and publishes [`DomainEvent::AgentReplied`] @@ -590,6 +1126,104 @@ impl OrchestratorService { .map(|a| a.id)) } + /// Finds the full [`domain::Agent`] by display name (case-insensitive) in the + /// project manifest. Variante de [`Self::find_agent_id_by_name`] qui conserve + /// l'agent entier (notamment son `profile_id`), nécessaire à la garde F2. + async fn find_agent_by_name( + &self, + project: &Project, + name: &str, + ) -> Result, AppError> { + let listed = self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await?; + Ok(listed + .agents + .into_iter() + .find(|a| a.name.eq_ignore_ascii_case(name))) + } + + /// Garde F2 : vérifie que le profil de la cible **sait consommer** le pont + /// `idea_*` matérialisé par IdeA, et renvoie sinon une [`AppError::Invalid`] + /// **immédiate** (au lieu d'un timeout 300s muet sur le round-trip). + /// + /// **Critère retenu** (le plus robuste aujourd'hui) : le pont est honoré ssi le + /// profil porte une capacité MCP en stratégie `ConfigFile` ciblant `.mcp.json` + /// **ET** que son adaptateur structuré est `Claude`. En effet IdeA matérialise le + /// serveur MCP sous forme d'un fichier `.mcp.json` dans le run dir, ce que **seul** + /// Claude Code lit réellement ; Codex déclare pourtant la même stratégie + /// `ConfigFile(.mcp.json)` mais lit en pratique `~/.codex/config.toml` ⇒ le pont + /// n'est jamais branché et la cible ne peut pas appeler `idea_reply`. On exige donc + /// l'adaptateur `Claude` plutôt qu'une simple présence de capacité MCP, ce qui + /// exclut Codex de fait et reste valable pour tout futur profil non-Claude. + /// + /// Profil introuvable ⇒ on **n'interdit pas** (laisse le flux suivre son cours + /// comme avant) : la garde ne fait que transformer un échec connu en erreur typée. + async fn guard_mcp_bridge_supported( + &self, + profile_id: &ProfileId, + target: &str, + ) -> Result<(), AppError> { + use domain::profile::{McpConfigStrategy, StructuredAdapter}; + + let Some(profile) = self + .profiles + .list() + .await? + .into_iter() + .find(|p| &p.id == profile_id) + else { + return Ok(()); + }; + + let honours_mcp_json = matches!( + profile.mcp.as_ref().map(|c| &c.config), + Some(McpConfigStrategy::ConfigFile { target }) if target == ".mcp.json" + ); + let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude); + + if honours_mcp_json && is_claude { + return Ok(()); + } + + Err(AppError::Invalid(format!( + "la cible '{target}' (profil '{}', adaptateur {:?}) ne supporte pas encore le \ + pont idea_* : la délégation inter-agents passe par un serveur MCP déclaré en \ + .mcp.json, que seul un profil Claude consomme aujourd'hui. Cible un agent au \ + profil Claude.", + profile.name, profile.structured_adapter + ))) + } + + /// Resolves the **prompt-ready pattern** (cadrage §6, lot C5) of the agent's + /// profile, used to arm the [`InputMediator`]'s prompt detection at `bind_handle` + /// time. Returns `None` when the agent, its profile, or the pattern is absent — + /// the safe fallback: no pattern ⇒ Idle only via explicit signal/timeout. + async fn prompt_pattern_for_agent( + &self, + project: &Project, + agent_id: AgentId, + ) -> Option { + let agent = self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await + .ok()? + .agents + .into_iter() + .find(|a| a.id == agent_id)?; + let profiles = self.profiles.list().await.ok()?; + profiles + .into_iter() + .find(|p| p.id == agent.profile_id) + .and_then(|p| p.prompt_ready_pattern) + } + /// Resolves a human-friendly profile reference (slug like `claude-code`, /// command like `claude`, or display name like `Claude Code`) to a configured /// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by @@ -612,6 +1246,44 @@ impl OrchestratorService { } } +/// RAII guard that posts a wait-for edge `from → to` for the duration of an ask and +/// removes it on drop (reply, timeout, or any early return) — the same discipline as +/// the per-agent turn lock. Holds a raw pointer-free borrow via the shared mutex on +/// the service's [`WaitForGraph`]; constructed only inside `ask_agent` where the +/// service outlives the guard. +struct WaitEdgeGuard<'a> { + graph: &'a StdMutex, + from: AgentId, + to: AgentId, +} + +impl<'a> WaitEdgeGuard<'a> { + fn new(service: &'a OrchestratorService, from: AgentId, to: AgentId) -> Self { + { + let mut g = service + .wait_for + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + g.add_edge(from, to); + } + Self { + graph: &service.wait_for, + from, + to, + } + } +} + +impl Drop for WaitEdgeGuard<'_> { + fn drop(&mut self) { + let mut g = self + .graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + g.remove_edge(self.from, self.to); + } +} + /// Normalises a profile reference for tolerant matching: lowercased, with spaces, /// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"` /// → comparable forms; `claude` ⊂ ... handled by the command match above). diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 79c03c9..7df2ae0 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; +use domain::conversation::ConversationId; use domain::ports::{AgentSession, PtyHandle}; use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession}; @@ -44,6 +45,11 @@ pub trait LiveAgentRegistry: Send + Sync { #[derive(Default)] pub struct TerminalSessions { entries: Mutex>, + /// Conversation → live session binding (cadrage C3 §5.2): «1 session vivante / + /// **conversation**» (remplace «1 / agent»). Populated by the orchestrator when an + /// ask resolves/launches the session for a given thread. Separate from `entries` + /// (the domain [`TerminalSession`] does not carry a conversation id). + conversations: Mutex>, } impl LiveAgentRegistry for TerminalSessions { @@ -65,9 +71,50 @@ impl TerminalSessions { pub fn new() -> Self { Self { entries: Mutex::new(HashMap::new()), + conversations: Mutex::new(HashMap::new()), } } + /// Binds `conversation` to the live `session` (cadrage C3 §5.2). Idempotent: + /// re-binding the same conversation overwrites the target session. + pub fn bind_conversation(&self, conversation: ConversationId, session: SessionId) { + if let Ok(mut m) = self.conversations.lock() { + m.insert(conversation, session); + } + } + + /// Returns the live [`SessionId`] bound to `conversation`, if any **and** still + /// registered (a stale binding to a closed session resolves to `None`). + /// + /// «1 session vivante / conversation» — deterministic, replacing the ambiguous + /// per-agent lookup for the orchestrator's ask path. + #[must_use] + pub fn session_for(&self, conversation: ConversationId) -> Option { + let sid = self.conversations.lock().ok()?.get(&conversation).copied()?; + // Only return it if the session is still live in `entries`. + self.entries + .lock() + .ok() + .filter(|m| m.contains_key(&sid)) + .map(|_| sid) + } + + /// Lists every live [`SessionId`] hosting `agent_id` (cadrage C3 §1.2 — an agent + /// may take part in several threads, so this is the **plural** of + /// [`Self::session_for_agent`]). + #[must_use] + pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec { + self.entries + .lock() + .map(|m| { + m.values() + .filter(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id)) + .map(|e| e.session.id) + .collect() + }) + .unwrap_or_default() + } + /// Inserts a freshly-opened session. pub fn insert(&self, handle: PtyHandle, session: TerminalSession) { if let Ok(mut map) = self.entries.lock() { @@ -184,8 +231,12 @@ impl TerminalSessions { .unwrap_or_default() } - /// Removes a session from the registry, returning its handle if present. + /// Removes a session from the registry, returning its handle if present. Also + /// drops any conversation binding pointing at it (no stale `session_for`). pub fn remove(&self, id: &SessionId) -> Option { + if let Ok(mut c) = self.conversations.lock() { + c.retain(|_, sid| sid != id); + } self.entries .lock() .ok() diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index fcf37bc..cdf2514 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -1312,11 +1312,11 @@ async fn launch_conventionfile_injects_project_memory_in_order() { "memory section present: {doc}" ); assert!( - doc.contains("- [Git optionnel](git-optional.md) — git reste un simple tool (project)"), + doc.contains("- [Git optionnel](.ideai/memory/git-optional.md) — git reste un simple tool (project)"), "first memory line exact format: {doc}" ); assert!( - doc.contains("- [Permissions](perm-archi.md) — sandbox OS + résumé injecté (reference)"), + doc.contains("- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"), "second memory line exact format: {doc}" ); // Recalled order preserved; section after the persona. diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 02ff4ac..60c5a2c 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -21,13 +21,15 @@ use domain::ids::SkillId; use domain::ids::{AgentId, NodeId, ProfileId, ProjectId}; use domain::markdown::MarkdownDoc; use domain::ports::{ - AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory, - ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, - IdGenerator, OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, - RemotePath, ReplyEvent, ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, + AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, + ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, + PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; -use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; +use domain::profile::{ + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + StructuredAdapter, +}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; @@ -37,7 +39,7 @@ use uuid::Uuid; use application::{ CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, - OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext, + OrchestratorService, TerminalSessions, UpdateAgentContext, }; // --------------------------------------------------------------------------- @@ -301,6 +303,9 @@ struct FakePty { next_id: SessionId, kills: Arc>>, spawns: Arc>>, + /// Records `(session_id, text)` of every `write` so the inter-agent tests can + /// assert the delegated task was written into the target's terminal. + writes: Arc>>, } impl FakePty { fn new(next_id: SessionId) -> Self { @@ -308,14 +313,26 @@ impl FakePty { next_id, kills: Arc::new(Mutex::new(Vec::new())), spawns: Arc::new(Mutex::new(Vec::new())), + writes: Arc::new(Mutex::new(Vec::new())), } } fn spawns(&self) -> Vec { self.spawns.lock().unwrap().clone() } + #[allow(dead_code)] fn kills(&self) -> Vec { self.kills.lock().unwrap().clone() } + /// The texts written to a given session (lossy UTF-8), in order. + fn writes_for(&self, session_id: SessionId) -> Vec { + self.writes + .lock() + .unwrap() + .iter() + .filter(|(s, _)| *s == session_id) + .map(|(_, t)| t.clone()) + .collect() + } } #[async_trait] impl PtyPort for FakePty { @@ -325,7 +342,11 @@ impl PtyPort for FakePty { session_id: self.next_id, }) } - fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> { + fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> { + self.writes + .lock() + .unwrap() + .push((handle.session_id, String::from_utf8_lossy(data).into_owned())); Ok(()) } fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> { @@ -359,108 +380,6 @@ impl EventBus for SpyBus { } } -// --------------------------------------------------------------------------- -// D6 fakes: structured session + factory (inter-agent messaging) -// --------------------------------------------------------------------------- - -/// What the scripted session does on its next `send`. -enum SendScript { - /// Emit this event stream verbatim (drained to its `Final`). - Stream(Vec), - /// Sleep `delay` *before* emitting the stream — forces a `send_blocking` timeout. - Delayed { - delay: std::time::Duration, - events: Vec, - }, -} - -/// A fake [`AgentSession`] that records every `send` prompt and every `shutdown`, -/// so a test can assert the rendezvous happened (send) and the session was *not* -/// killed (zero shutdowns) on timeout. -struct FakeSession { - id: SessionId, - script: Mutex>, - sends: Arc>>, - shutdowns: Arc>, -} -impl FakeSession { - fn new(id: SessionId, script: SendScript) -> Arc { - Arc::new(Self { - id, - script: Mutex::new(Some(script)), - sends: Arc::new(Mutex::new(Vec::new())), - shutdowns: Arc::new(Mutex::new(0)), - }) - } -} -#[async_trait] -impl AgentSession for FakeSession { - fn id(&self) -> SessionId { - self.id - } - fn conversation_id(&self) -> Option { - None - } - async fn send(&self, prompt: &str) -> Result { - self.sends.lock().unwrap().push(prompt.to_owned()); - let script = self - .script - .lock() - .unwrap() - .take() - .expect("send scripted exactly once"); - match script { - SendScript::Stream(events) => Ok(Box::new(events.into_iter())), - SendScript::Delayed { delay, events } => { - tokio::time::sleep(delay).await; - Ok(Box::new(events.into_iter())) - } - } - } - async fn shutdown(&self) -> Result<(), AgentSessionError> { - *self.shutdowns.lock().unwrap() += 1; - Ok(()) - } -} - -/// A fake [`AgentSessionFactory`] that hands out a pre-built [`FakeSession`] on -/// `start`, so launching a *dead* target in structured mode registers a session -/// the orchestrator can then drive. `supports` is true for any profile bearing a -/// `structured_adapter` (mirrors the real factory's gate). -struct FakeFactory { - session: Arc, - starts: Arc>, -} -impl FakeFactory { - fn new(session: Arc) -> Self { - Self { - session, - starts: Arc::new(Mutex::new(0)), - } - } -} -#[async_trait] -impl AgentSessionFactory for FakeFactory { - fn supports(&self, profile: &AgentProfile) -> bool { - profile.structured_adapter.is_some() - } - async fn start( - &self, - _profile: &AgentProfile, - _ctx: &PreparedContext, - _cwd: &ProjectPath, - _session: &SessionPlan, - ) -> Result, AgentSessionError> { - *self.starts.lock().unwrap() += 1; - Ok(Arc::clone(&self.session) as Arc) - } -} - -fn final_(c: &str) -> ReplyEvent { - ReplyEvent::Final { - content: c.to_owned(), - } -} struct SeqIds(Mutex); impl SeqIds { @@ -506,27 +425,9 @@ fn project() -> Project { } fn claude_profile() -> AgentProfile { - AgentProfile::new( - pid(9), - "Claude Code", - "claude", - Vec::new(), - ContextInjection::stdin(), - Some("claude --version".to_owned()), - "{agentRunDir}", - None, - ) - .unwrap() -} - -fn scratch_agent(id: AgentId, name: &str, md: &str) -> Agent { - Agent::new(id, name, md, pid(9), AgentOrigin::Scratch, false).unwrap() -} - -/// Same id (`pid(9)`) as [`claude_profile`] so agents created with it resolve to a -/// structured-capable profile, but bearing a `structured_adapter` so `LaunchAgent` -/// routes through the (fake) [`AgentSessionFactory`] instead of the PTY. -fn structured_profile() -> AgentProfile { + // Profil Claude **complet** : adaptateur structuré + capacité MCP `.mcp.json`. + // C'est la condition que la garde F2 (`guard_mcp_bridge_supported`) exige pour + // accepter une cible d'`idea_ask_agent` (seul Claude consomme le pont `.mcp.json`). AgentProfile::new( pid(9), "Claude Code", @@ -539,6 +440,14 @@ fn structured_profile() -> AgentProfile { ) .unwrap() .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").unwrap(), + McpTransport::Stdio, + )) +} + +fn scratch_agent(id: AgentId, name: &str, md: &str) -> Agent { + Agent::new(id, name, md, pid(9), AgentOrigin::Scratch, false).unwrap() } /// Everything wired for a dispatch test. @@ -616,106 +525,7 @@ fn cmd(json: &str) -> OrchestratorCommand { } // --------------------------------------------------------------------------- -// D6 fixture: orchestrator wired for `agent.message` (inter-agent rendezvous) -// --------------------------------------------------------------------------- - -/// Everything wired for an `agent.message` dispatch test (§17.4). -struct AskFixture { - service: OrchestratorService, - structured: Arc, - /// PTY (terminal) registry the service holds — lets a test register a raw - /// terminal session for an agent to exercise the PTY-only-live branch. - sessions: Arc, - bus: SpyBus, - /// PTY spawn/kill spy of the underlying `LaunchAgent` — used to prove a - /// dead-target launch did *not* fall back to a raw PTY (zero spawns). - pty: FakePty, - /// `start` counter of the fake factory (dead-target launch path). - factory_starts: Arc>, -} - -/// Builds an orchestrator with the structured registry + event bus wired, and a -/// `LaunchAgent` whose structured routing uses `factory` over `profiles`. -/// -/// `pty_only` controls the launched profile: `false` ⇒ structured-capable profile -/// (factory hands out a session); `true` ⇒ plain PTY profile (no -/// `structured_adapter`) so `launched.structured` stays `None`. -fn ask_fixture( - contexts: FakeContexts, - factory_session: Arc, - pty_only: bool, -) -> AskFixture { - let profile = if pty_only { - claude_profile() - } else { - structured_profile() - }; - let profiles = Arc::new(FakeProfiles::new(vec![profile])); - let sessions = Arc::new(TerminalSessions::new()); - let structured = Arc::new(StructuredSessions::new()); - let pty = FakePty::new(sid(777)); - let bus = SpyBus::default(); - let factory = Arc::new(FakeFactory::new(factory_session)); - let factory_starts = Arc::clone(&factory.starts); - - let create = Arc::new(CreateAgentFromScratch::new( - Arc::new(contexts.clone()), - Arc::new(SeqIds::new()), - Arc::new(bus.clone()), - )); - let launch = Arc::new( - LaunchAgent::new( - Arc::new(contexts.clone()), - Arc::clone(&profiles) as Arc, - Arc::new(FakeRuntime), - Arc::new(FakeFs), - Arc::new(pty.clone()), - Arc::new(FakeSkills), - Arc::clone(&sessions), - Arc::new(bus.clone()), - Arc::new(SeqIds::new()), - Arc::new(FakeRecall), - None, - ) - .with_structured(factory as Arc, Arc::clone(&structured)), - ); - let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); - let close = Arc::new(CloseTerminal::new( - Arc::new(pty.clone()), - Arc::clone(&sessions), - )); - let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone()))); - let skills = RecordingSkills::default(); - let create_skill = Arc::new(CreateSkill::new( - Arc::new(skills) as Arc, - Arc::new(SeqIds::new()), - )); - - let service = OrchestratorService::new( - create, - launch, - list, - close, - update, - create_skill, - Arc::clone(&profiles) as Arc, - Arc::clone(&sessions), - ) - .with_structured(Arc::clone(&structured)) - .with_events(Arc::new(bus.clone())); - - AskFixture { - service, - structured, - sessions, - bus, - pty, - factory_starts, - } -} - -// --------------------------------------------------------------------------- -// Tests +// Basic dispatch coverage (spawn / stop / update / skill) — non-ask commands. // --------------------------------------------------------------------------- #[tokio::test] @@ -732,12 +542,9 @@ async fn spawn_unknown_agent_creates_then_launches() { .expect("dispatch ok"); assert!(out.detail.contains("dev-backend")); - // The agent was created (manifest grew to one entry) and launched (session - // registered as an agent, AgentLaunched published). let manifest = fx.contexts.manifest(); assert_eq!(manifest.entries.len(), 1); assert_eq!(manifest.entries[0].name, "dev-backend"); - assert!(fx.sessions.session(&sid(777)).is_some()); let launched = fx.bus.events().into_iter().any( |e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777)), @@ -758,7 +565,6 @@ async fn spawn_known_agent_launches_without_recreating() { .await .expect("dispatch ok"); - // No second manifest entry — the existing agent was reused, just launched. assert_eq!(fx.contexts.manifest().entries.len(), 1); assert_eq!(fx.contexts.manifest().entries[0].agent_id, agent.id); assert!(fx.sessions.session(&sid(777)).is_some()); @@ -781,9 +587,8 @@ async fn agent_run_existing_agent_does_not_require_profile() { assert!(fx.sessions.session(&sid(777)).is_some()); } -/// **Réattache même cellule (R0a, orchestrateur)** : un `spawn`/`agent.run` -/// `Visible` qui re-cible la **même** cellule hôte d'un agent déjà vivant est un -/// rebind de vue ⇒ pas d'erreur, pas de respawn. +/// R0a (orchestrator): a `Visible` spawn re-targeting the same host cell of a live +/// agent is a view rebind ⇒ no error, no respawn. #[tokio::test] async fn agent_run_visible_same_cell_rebinds_existing_session() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); @@ -817,10 +622,8 @@ async fn agent_run_visible_same_cell_rebinds_existing_session() { assert_eq!(fx.pty.spawns().len(), 1, "rebind must not respawn"); } -/// **Second lancement ailleurs refusé (R0a, orchestrateur)** : un `spawn`/`agent.run` -/// `Visible` qui vise une **autre** cellule d'un agent singleton déjà vivant est un -/// second lancement ⇒ refus `AgentAlreadyRunning` (node hôte rapporté), pas de -/// respawn, session non déplacée. +/// R0a (orchestrator): a `Visible` spawn targeting a *different* cell of a live +/// singleton ⇒ refused `AgentAlreadyRunning` (host cell reported), no respawn. #[tokio::test] async fn agent_run_visible_other_cell_refuses_when_live_elsewhere() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); @@ -857,7 +660,6 @@ async fn agent_run_visible_other_cell_refuses_when_live_elsewhere() { other => panic!("expected AgentAlreadyRunning, got {other:?}"), } - // Session not moved, no respawn. let session = fx.sessions.session(&sid(777)).expect("live session"); assert_eq!(session.node_id, first_cell, "session stays on its host cell"); assert_eq!(fx.pty.spawns().len(), 1, "refused launch must not respawn"); @@ -868,7 +670,6 @@ async fn stop_agent_kills_the_right_session() { let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md"); let fx = fixture(FakeContexts::with_agent(&agent, "# persona")); - // Launch it first so a session is registered for the agent. fx.service .dispatch( &project(), @@ -878,7 +679,6 @@ async fn stop_agent_kills_the_right_session() { .unwrap(); assert!(fx.sessions.session(&sid(777)).is_some()); - // Now stop it. fx.service .dispatch( &project(), @@ -887,7 +687,6 @@ async fn stop_agent_kills_the_right_session() { .await .expect("stop ok"); - // The PTY for that session was killed and the session de-registered. assert_eq!(fx.pty.kills(), vec![sid(777)]); assert!(fx.sessions.session(&sid(777)).is_none()); } @@ -942,8 +741,6 @@ async fn spawn_with_unknown_profile_is_not_found_and_does_not_create() { .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); - - // No agent was created when the profile could not be resolved. assert!(fx.contexts.manifest().entries.is_empty()); assert!(fx.sessions.session(&sid(777)).is_none()); } @@ -960,7 +757,6 @@ async fn create_skill_persists_a_project_scoped_skill_by_default() { ) .await .expect("create_skill ok"); - assert!(out.detail.contains("deploy")); let saved = fx.skills.0.lock().unwrap(); @@ -988,102 +784,512 @@ async fn create_skill_honours_global_scope() { assert_eq!(saved[0].scope, SkillScope::Global); } + // --------------------------------------------------------------------------- -// D6 — agent.message (synchronous inter-agent rendezvous, §17.4) +// Option 1 « Terminal + MCP » — idea_ask_agent / idea_reply (B-3 / B-4) +// +// The target's view is a raw native terminal (PTY). Delegation flows through the +// mailbox: `ask_agent` ensures the target is live in the PTY registry, writes the +// prefixed task into its terminal, and AWAITS a `PendingReply`; the target's later +// `idea_reply` resolves the head ticket of its queue, waking the awaiting ask. +// +// To exercise the blocking rendezvous deterministically, a test spawns the ask as a +// task, waits until the mailbox holds a pending ticket, then dispatches the matching +// `Reply` (or cancels via timeout) to unblock it. A real `InMemoryMailbox` is used +// (the production adapter), wired through `with_mailbox`. // --------------------------------------------------------------------------- +use std::collections::VecDeque; + +use domain::conversation::{ + Conversation, ConversationId, ConversationParty, ConversationRegistry, ConversationSession, + SessionRef, +}; +use domain::input::{AgentBusyState, InputMediator}; +use domain::mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId}; +use tokio::time::{timeout, Duration}; + +/// Safety guard: no awaited rendezvous in these tests should ever exceed this. +const TEST_GUARD: Duration = Duration::from_secs(10); + +/// Minimal in-test [`AgentMailbox`] mirroring the production `InMemoryMailbox` +/// (which lives in `infrastructure` and cannot be a dev-dep of `application` — that +/// would be a dependency cycle). Per-agent FIFO of `(Ticket, oneshot::Sender)`. +#[derive(Default)] +struct TestMailbox { + queues: Mutex)>>>, +} +impl TestMailbox { + fn new() -> Self { + Self { + queues: Mutex::new(HashMap::new()), + } + } + fn pending(&self, agent: &AgentId) -> usize { + self.queues.lock().unwrap().get(agent).map_or(0, VecDeque::len) + } + /// Ids of the tickets queued for `agent`, in FIFO order (test inspection). + fn ticket_ids(&self, agent: &AgentId) -> Vec { + self.queues + .lock() + .unwrap() + .get(agent) + .map(|q| q.iter().map(|(t, _)| t.id).collect()) + .unwrap_or_default() + } +} +impl AgentMailbox for TestMailbox { + fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + let (tx, rx) = tokio::sync::oneshot::channel::(); + self.queues + .lock() + .unwrap() + .entry(agent) + .or_default() + .push_back((ticket, tx)); + PendingReply::new(Box::pin(async move { + rx.await.map_err(|_| MailboxError::Cancelled) + })) + } + fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError> { + let slot = { + let mut q = self.queues.lock().unwrap(); + let queue = q + .get_mut(&agent) + .filter(|q| !q.is_empty()) + .ok_or(MailboxError::NoPendingRequest(agent))?; + queue.pop_front().expect("non-empty") + }; + let _ = slot.1.send(result); + Ok(()) + } + fn resolve_ticket( + &self, + agent: AgentId, + ticket_id: TicketId, + result: String, + ) -> Result<(), MailboxError> { + let slot = { + let mut q = self.queues.lock().unwrap(); + let queue = q + .get_mut(&agent) + .filter(|q| !q.is_empty()) + .ok_or(MailboxError::NoPendingRequest(agent))?; + let pos = queue + .iter() + .position(|(t, _)| t.id == ticket_id) + .ok_or(MailboxError::NoPendingRequest(agent))?; + queue.remove(pos).expect("found position") + }; + let _ = slot.1.send(result); + Ok(()) + } + fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) { + let mut q = self.queues.lock().unwrap(); + if let Some(queue) = q.get_mut(&agent) { + if queue.front().map(|(t, _)| t.id) == Some(ticket_id) { + queue.pop_front(); + } + } + } +} + +/// A delivering [`InputMediator`] for the application tests: it composes the +/// [`TestMailbox`] (correlation engine) and writes the prefixed turn into the agent's +/// bound PTY handle — the test mirror of `infrastructure::MediatedInbox::with_pty` +/// (which `application` cannot depend on without a dependency cycle). +struct TestMediator { + mailbox: Arc, + pty: Arc, + handles: Mutex>, + busy: Mutex>, + /// Records every agent passed to `preempt`, so the interrupt test can assert the + /// Interrompre path calls preempt (and only preempt — no enqueue, no resolve). + preempts: Arc>>, +} +impl TestMediator { + fn new(mailbox: Arc, pty: Arc) -> Self { + Self { + mailbox, + pty, + handles: Mutex::new(HashMap::new()), + busy: Mutex::new(HashMap::new()), + preempts: Arc::new(Mutex::new(Vec::new())), + } + } + fn preempts(&self) -> Vec { + self.preempts.lock().unwrap().clone() + } +} +impl InputMediator for TestMediator { + fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + let ticket_id = ticket.id; + { + let mut b = self.busy.lock().unwrap(); + let st = b.entry(agent).or_insert(AgentBusyState::Idle); + if !st.is_busy() { + *st = AgentBusyState::Busy { + ticket: ticket_id, + since_ms: 0, + }; + } + } + if let Some(handle) = self.handles.lock().unwrap().get(&agent).cloned() { + let line = format!( + "[IdeA · tâche de {} · ticket {}] {}\n", + ticket.requester, ticket_id, ticket.task + ); + let _ = self.pty.write(&handle, line.as_bytes()); + } + self.mailbox.enqueue(agent, ticket) + } + fn bind_handle(&self, agent: AgentId, handle: PtyHandle) { + self.handles.lock().unwrap().insert(agent, handle); + } + fn delivers_turn(&self, agent: AgentId) -> bool { + self.handles.lock().unwrap().contains_key(&agent) + } + fn preempt(&self, agent: AgentId) { + self.preempts.lock().unwrap().push(agent); + } + fn mark_idle(&self, agent: AgentId) { + self.busy.lock().unwrap().insert(agent, AgentBusyState::Idle); + } + fn busy_state(&self, agent: AgentId) -> AgentBusyState { + self.busy + .lock() + .unwrap() + .get(&agent) + .copied() + .unwrap_or(AgentBusyState::Idle) + } +} + +/// Minimal in-test [`ConversationRegistry`] (pair-keyed lazy get-or-create), mirroring +/// `infrastructure::InMemoryConversationRegistry`. +#[derive(Default)] +struct TestConversations { + by_pair: Mutex>, + by_id: Mutex>, +} +impl TestConversations { + fn new() -> Self { + Self::default() + } + fn key(a: ConversationParty, b: ConversationParty) -> (String, String) { + let s = |p: ConversationParty| match p { + ConversationParty::User => "user".to_owned(), + ConversationParty::Agent { agent_id } => agent_id.to_string(), + }; + let (ka, kb) = (s(a), s(b)); + if ka <= kb { + (ka, kb) + } else { + (kb, ka) + } + } +} +impl ConversationRegistry for TestConversations { + fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation { + let key = Self::key(a, b); + let mut pairs = self.by_pair.lock().unwrap(); + if let Some(id) = pairs.get(&key).copied() { + return self.by_id.lock().unwrap().get(&id).cloned().unwrap(); + } + let id = ConversationId::new_random(); + let conv = Conversation::try_new(id, a, b).expect("valid pair"); + pairs.insert(key, id); + self.by_id.lock().unwrap().insert(id, conv.clone()); + conv + } + fn bind_session(&self, id: ConversationId, session: SessionRef) { + if let Some(c) = self.by_id.lock().unwrap().get_mut(&id) { + c.session = ConversationSession::Live { handle_ref: session }; + } + } + fn suspend(&self, id: ConversationId, resumable_id: Option) { + if let Some(c) = self.by_id.lock().unwrap().get_mut(&id) { + c.session = ConversationSession::Dormant; + c.resumable_id = resumable_id; + } + } + fn get(&self, id: ConversationId) -> Option { + self.by_id.lock().unwrap().get(&id).cloned() + } +} + +/// Everything wired for an `idea_ask_agent` / `idea_reply` dispatch test. +struct AskFixture { + service: Arc, + /// The real in-memory mailbox the service holds — lets a test observe pending + /// tickets and (in production it is the orchestrator that resolves them). + mailbox: Arc, + /// PTY (terminal) registry — the channel the target's task is written into. + sessions: Arc, + bus: SpyBus, + /// PTY spy: records spawns (a dead target is launched as a PTY) and writes. + pty: FakePty, + /// The mediated inbox the service holds — lets a test observe `busy_state` + /// transitions (e.g. an `idea_reply` flips the emitter back to Idle — lot C5). + mediator: Arc, +} + +/// Builds an orchestrator wired with a real [`InMemoryMailbox`] + PTY (B-3) and a +/// plain (non-structured) [`LaunchAgent`] — so a dead target is launched as a PTY, +/// exactly like production after B-2. +fn ask_fixture(contexts: FakeContexts) -> AskFixture { + let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()])); + let sessions = Arc::new(TerminalSessions::new()); + let pty = FakePty::new(sid(777)); + let bus = SpyBus::default(); + let mailbox = Arc::new(TestMailbox::new()); + + let create = Arc::new(CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds::new()), + Arc::new(bus.clone()), + )); + let launch = Arc::new(LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(FakeFs), + Arc::new(pty.clone()), + Arc::new(FakeSkills), + Arc::clone(&sessions), + Arc::new(bus.clone()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall), + None, + )); + let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); + let close = Arc::new(CloseTerminal::new( + Arc::new(pty.clone()), + Arc::clone(&sessions), + )); + let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone()))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(RecordingSkills::default()) as Arc, + Arc::new(SeqIds::new()), + )); + + let mediator = Arc::new(TestMediator::new( + Arc::clone(&mailbox), + Arc::new(pty.clone()) as Arc, + )); + let conversations = Arc::new(TestConversations::new()) as Arc; + let service = Arc::new( + OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + ) + .with_input_mediator( + Arc::clone(&mediator) as Arc, + Arc::clone(&mailbox) as Arc, + ) + .with_conversations(conversations) + .with_events(Arc::new(bus.clone())), + ); + + AskFixture { + service, + mailbox, + sessions, + bus, + pty, + mediator, + } +} + +/// Seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it instead +/// of launching one. +fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) { + let session = TerminalSession::starting( + session_id, + nid(1), + ProjectPath::new("/home/me/proj").unwrap(), + SessionKind::Agent { agent_id }, + PtySize::new(24, 80).unwrap(), + ); + sessions.insert(PtyHandle { session_id }, session); +} + +/// Waits (bounded) for a condition to hold, yielding between polls. +async fn await_until bool>(cond: F) { + timeout(TEST_GUARD, async { + while !cond() { + tokio::task::yield_now().await; + } + }) + .await + .expect("condition never reached within guard (possible hang/deadlock)"); +} + const ASK_JSON: &str = r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#; -/// Zone 1 — live structured target ⇒ `send_blocking` is driven and the outcome's -/// `reply` carries the turn's `Final` content. Anti-always-green: the assertion is -/// on the *exact* content, so it fails for `None` or any other string. +/// Builds an `agent.reply` command for `from` with `result` (the handshake-injected +/// path, exactly what the MCP server produces from a peer's `idea_reply`). +fn reply_cmd(from: AgentId, result: &str) -> OrchestratorCommand { + OrchestratorCommand::Reply { + from, + ticket: None, + result: result.to_owned(), + } +} + +/// A reply correlated **by ticket** (multi-thread correlation). +fn reply_cmd_ticket(from: AgentId, ticket: TicketId, result: &str) -> OrchestratorCommand { + OrchestratorCommand::Reply { + from, + ticket: Some(ticket), + result: result.to_owned(), + } +} + +// --- B-3: ask blocks on the mailbox, reply unblocks it --------------------- + +/// A live-PTY target: `ask` writes the prefixed task into its terminal and AWAITS; +/// a matching `idea_reply` resolves the head ticket and the ask returns its content. #[tokio::test] -async fn ask_live_structured_target_returns_final_content() { +async fn ask_live_pty_target_writes_task_and_returns_reply() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); - // A live session already registered for the agent in the *structured* registry. - let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_("the §17 answer")])); - let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); - let fx = ask_fixture( - FakeContexts::with_agent(&agent, "# persona"), - throwaway, - false, - ); - fx.structured - .insert(Arc::clone(&session) as Arc, aid(1), nid(1)); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + // Target already live in the PTY registry (session 800). + seed_live_pty(&fx.sessions, aid(1), sid(800)); - let out = fx - .service - .dispatch(&project(), cmd(ASK_JSON)) + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + + // The ask enqueues a ticket and blocks awaiting the reply. + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + // The task was written into the target's terminal, prefixed for idea_reply. + let writes = fx.pty.writes_for(sid(800)); + assert_eq!(writes.len(), 1, "exactly one task write to the live terminal"); + assert!(writes[0].contains("Analyse §17"), "task body written"); + assert!(writes[0].contains("[IdeA · tâche"), "delegated-task prefix present"); + assert!(writes[0].contains("idea_reply") || writes[0].contains("ticket"), "carries ticket/idea_reply cue"); + // No PTY spawned: the live terminal was reused. + assert!(fx.pty.spawns().is_empty(), "live target reused, not respawned"); + + // The target renders its result via idea_reply ⇒ resolves the head ticket. + fx.service + .dispatch(&project(), reply_cmd(aid(1), "the §17 answer")) .await - .expect("ask ok"); + .expect("reply ok"); - // The exact Final content is returned (would fail on None or any other text). + let out = timeout(TEST_GUARD, ask) + .await + .expect("ask completes once replied") + .expect("join ok") + .expect("ask ok"); assert_eq!(out.reply.as_deref(), Some("the §17 answer")); - // The prompt actually reached the live session. - assert_eq!(session.sends.lock().unwrap().as_slice(), &["Analyse §17"]); - // Live session reused: factory never started a new one, no PTY spawned. - assert_eq!(*fx.factory_starts.lock().unwrap(), 0); - assert!(fx.pty.spawns().is_empty(), "must not spawn a PTY"); - // Session not killed. - assert_eq!(*session.shutdowns.lock().unwrap(), 0); + assert_eq!(fx.mailbox.pending(&aid(1)), 0, "ticket drained on reply"); } -/// Anti-always-green guard, explicit negative: the same fixture but the session -/// returns a *different* Final ⇒ the strict equality from the test above must NOT -/// hold. Proves the assertion has teeth. +/// Lot C5 — explicit OR signal: an `idea_reply` from the emitting agent flips it back +/// to `Idle` (its FIFO can advance), independently of any prompt-ready pattern. Before +/// the reply the agent is `Busy` on the delegated turn; after, it is `Idle`. #[tokio::test] -async fn ask_reply_assertion_is_content_sensitive() { +async fn idea_reply_marks_emitter_idle() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); - let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_("SOMETHING ELSE")])); - let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); - let fx = ask_fixture( - FakeContexts::with_agent(&agent, "# persona"), - throwaway, - false, - ); - fx.structured - .insert(session as Arc, aid(1), nid(1)); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); - let out = fx - .service - .dispatch(&project(), cmd(ASK_JSON)) + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + // The delegated turn started ⇒ the target is Busy. + assert!( + fx.mediator.busy_state(aid(1)).is_busy(), + "target Busy while processing the delegated turn" + ); + + // The target renders its result via idea_reply ⇒ explicit end-of-turn signal. + fx.service + .dispatch(&project(), reply_cmd(aid(1), "the §17 answer")) .await + .expect("reply ok"); + timeout(TEST_GUARD, ask) + .await + .expect("ask completes") + .expect("join ok") .expect("ask ok"); - assert_ne!(out.reply.as_deref(), Some("the §17 answer")); - assert_eq!(out.reply.as_deref(), Some("SOMETHING ELSE")); + + // C5: the reply marked the emitter Idle (FIFO can advance) — no prompt pattern needed. + assert_eq!( + fx.mediator.busy_state(aid(1)), + AgentBusyState::Idle, + "idea_reply is the explicit signal that frees the turn" + ); } -/// Zone 4 — on success, `DomainEvent::AgentReplied` is published with the right -/// agent id and `reply_len` (byte length of the content). +/// A dead target is launched in the background (PTY) before the task is written. +#[tokio::test] +async fn ask_dead_target_launches_pty_then_writes_and_replies() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + assert!(fx.sessions.session_for_agent(&aid(1)).is_none()); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + // The dead target was launched as a PTY (spawn recorded, session registered). + assert_eq!(fx.pty.spawns(), vec![sid(777)], "dead target launched as PTY"); + assert_eq!(fx.sessions.session_for_agent(&aid(1)), Some(sid(777))); + // The delegated task was written into the freshly-launched terminal (the Stdin + // context injection may also write the persona, so we look for the task prefix). + assert!( + fx.pty + .writes_for(sid(777)) + .iter() + .any(|w| w.contains("[IdeA · tâche") && w.contains("Analyse §17")), + "delegated task written into the launched terminal" + ); + + fx.service + .dispatch(&project(), reply_cmd(aid(1), "launched reply")) + .await + .expect("reply ok"); + let out = timeout(TEST_GUARD, ask) + .await + .expect("ask completes") + .expect("join ok") + .expect("ask ok"); + assert_eq!(out.reply.as_deref(), Some("launched reply")); +} + +/// On a successful reply, `DomainEvent::AgentReplied` is published with the right +/// agent id and byte length. #[tokio::test] async fn ask_success_publishes_agent_replied_event() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); - let content = "réponse"; // multibyte: len() in bytes, not chars - let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_(content)])); - let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); - let fx = ask_fixture( - FakeContexts::with_agent(&agent, "# persona"), - throwaway, - false, - ); - fx.structured - .insert(session as Arc, aid(1), nid(1)); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + let content = "réponse"; // multibyte: len() in bytes + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; fx.service - .dispatch(&project(), cmd(ASK_JSON)) + .dispatch(&project(), reply_cmd(aid(1), content)) .await - .expect("ask ok"); + .expect("reply ok"); + timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); let replied = fx .bus .events() .into_iter() .find_map(|e| match e { - DomainEvent::AgentReplied { - agent_id, - reply_len, - } => Some((agent_id, reply_len)), + DomainEvent::AgentReplied { agent_id, reply_len } => Some((agent_id, reply_len)), _ => None, }) .expect("AgentReplied must be published"); @@ -1091,205 +1297,12 @@ async fn ask_success_publishes_agent_replied_event() { assert_eq!(replied.1, content.len()); } -/// Zone 2 (orchestrator seam) — a failing turn (stream ends without a `Final`) -/// surfaces a typed `PROCESS` error WITHOUT the orchestrator killing the session, -/// and the session stays registered (retry possible). The *genuine* timeout -/// variant of this invariant is asserted in -/// [`timeout_invariant_send_blocking_does_not_kill_session`] (the 300s prod bound -/// cannot be awaited here, so we drive the same "never shutdown on ask" branch via -/// the no-`Final` error and the bounded `send_blocking` directly). -#[tokio::test] -async fn ask_failed_turn_does_not_kill_session_and_errors() { - let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); - let session = FakeSession::new(sid(500), SendScript::Stream(vec![/* no Final */])); - let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); - let fx = ask_fixture( - FakeContexts::with_agent(&agent, "# persona"), - throwaway, - false, - ); - fx.structured - .insert(Arc::clone(&session) as Arc, aid(1), nid(1)); +// --- B-3: errors / not-found / not-wired ----------------------------------- - let err = fx - .service - .dispatch(&project(), cmd(ASK_JSON)) - .await - .unwrap_err(); - - // Typed PROCESS error (send_blocking's AgentSessionError → AppError::Process). - assert_eq!(err.code(), "PROCESS", "got {err:?}"); - // Session NOT killed and STILL registered (retry possible). - assert_eq!( - *session.shutdowns.lock().unwrap(), - 0, - "ask must never shutdown the target session" - ); - assert!( - fx.structured.session_for_agent(&aid(1)).is_some(), - "session must remain in the registry after a failed turn" - ); -} - -/// Zone 2 (real timeout) — uses a *short-bounded* `send_blocking` directly against -/// the orchestrator's session is impossible (bound is internal/300s); instead we -/// prove the genuine timeout-does-not-kill invariant at the `send_blocking` seam -/// with a real delayed session, mirroring how the orchestrator awaits it. -#[tokio::test] -async fn timeout_invariant_send_blocking_does_not_kill_session() { - use application::send_blocking; - let session = FakeSession::new( - sid(500), - SendScript::Delayed { - delay: std::time::Duration::from_secs(1), - events: vec![final_("too late")], - }, - ); - let out = send_blocking( - session.as_ref(), - "Analyse §17", - Some(std::time::Duration::from_millis(20)), - ) - .await; - assert_eq!(out, Err(AgentSessionError::Timeout)); - assert_eq!( - *session.shutdowns.lock().unwrap(), - 0, - "timeout must not kill the session" - ); - // Negative control: had we waited (no bound) the call would succeed — proves the - // timeout above is the cause of the error, not a broken session. - let session2 = FakeSession::new( - sid(501), - SendScript::Delayed { - delay: std::time::Duration::from_millis(5), - events: vec![final_("eventually")], - }, - ); - let ok = send_blocking(session2.as_ref(), "x", None).await; - assert_eq!(ok, Ok("eventually".to_owned())); -} - -/// Zone 3 — dead target ⇒ `LaunchAgent` is invoked in structured mode (factory -/// `start` called, structured session registered) *then* `send_blocking`; the -/// reply is returned and no raw PTY is spawned. -#[tokio::test] -async fn ask_dead_target_launches_structured_then_sends() { - let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); - // The session the fake factory will hand out on launch. - let session = FakeSession::new(sid(700), SendScript::Stream(vec![final_("launched reply")])); - let fx = ask_fixture( - FakeContexts::with_agent(&agent, "# persona"), - Arc::clone(&session), - false, - ); - // No live session up front. - assert!(fx.structured.session_for_agent(&aid(1)).is_none()); - - let out = fx - .service - .dispatch(&project(), cmd(ASK_JSON)) - .await - .expect("ask ok"); - - assert_eq!(out.reply.as_deref(), Some("launched reply")); - // Factory was started exactly once (structured launch), the prompt was sent. - assert_eq!(*fx.factory_starts.lock().unwrap(), 1); - assert_eq!(session.sends.lock().unwrap().as_slice(), &["Analyse §17"]); - // Structured-mode launch ⇒ NO raw PTY spawn. - assert!(fx.pty.spawns().is_empty(), "structured launch must not spawn a PTY"); - // The session is now registered (1 session/agent). - assert!(fx.structured.session_for_agent(&aid(1)).is_some()); -} - -/// Zone 5a — target already live in the **PTY** registry (raw terminal, no -/// structured channel) ⇒ typed `INVALID`, never an ACK, never a launch. -#[tokio::test] -async fn ask_pty_live_target_is_invalid_no_ack() { - let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); - let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); - let fx = ask_fixture( - FakeContexts::with_agent(&agent, "# persona"), - throwaway, - false, - ); - // Register the agent as live in the **PTY** (terminal) registry only — a raw - // terminal with no structured reply channel. - let pty_session = TerminalSession::starting( - sid(800), - nid(2), - project().root.clone(), - SessionKind::Agent { agent_id: aid(1) }, - PtySize::new(24, 80).unwrap(), - ); - fx.sessions - .insert(PtyHandle { session_id: sid(800) }, pty_session); - assert!(fx.structured.session_for_agent(&aid(1)).is_none()); - - let err = fx - .service - .dispatch(&project(), cmd(ASK_JSON)) - .await - .unwrap_err(); - - assert_eq!( - err.code(), - "INVALID", - "PTY-live target is not addressable by ask: {err:?}" - ); - // Never launched, never sent: factory untouched, no PTY spawned. - assert_eq!(*fx.factory_starts.lock().unwrap(), 0); - assert!(fx.pty.spawns().is_empty()); - // No structured session conjured. - assert!(fx.structured.session_for_agent(&aid(1)).is_none()); -} - -/// Zone 5b — after launching, the target turns out PTY-only (profile without a -/// `structured_adapter` ⇒ `launched.structured` is `None`) ⇒ typed `INVALID`, no -/// ACK, and the orchestrator does not pretend a reply. -#[tokio::test] -async fn ask_pty_only_profile_after_launch_is_invalid() { - let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); - let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); - // pty_only = true ⇒ launched profile has no structured_adapter ⇒ structured None. - let fx = ask_fixture( - FakeContexts::with_agent(&agent, "# persona"), - throwaway, - true, - ); - assert!(fx.structured.session_for_agent(&aid(1)).is_none()); - - let err = fx - .service - .dispatch(&project(), cmd(ASK_JSON)) - .await - .unwrap_err(); - - assert_eq!(err.code(), "INVALID", "PTY-only target must error typed: {err:?}"); - // No structured session was registered, no reply pretended. - assert!(fx.structured.session_for_agent(&aid(1)).is_none()); -} - -/// Zone 6/7 — structured registry not wired ⇒ `ask` is INVALID (legacy service). -#[tokio::test] -async fn ask_without_structured_wired_is_invalid() { - // The default `fixture` builds the service via `OrchestratorService::new` - // WITHOUT `.with_structured(...)`, so AskAgent cannot be served. - let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); - let fx = fixture(FakeContexts::with_agent(&agent, "# persona")); - let err = fx - .service - .dispatch(&project(), cmd(ASK_JSON)) - .await - .unwrap_err(); - assert_eq!(err.code(), "INVALID", "got {err:?}"); -} - -/// Zone 6/7 — unknown target agent ⇒ typed `NOT_FOUND`, no launch, no reply. +/// Unknown target ⇒ typed `NOT_FOUND`, no launch, no ticket. #[tokio::test] async fn ask_unknown_target_is_not_found() { - let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); - let fx = ask_fixture(FakeContexts::new(), throwaway, false); + let fx = ask_fixture(FakeContexts::new()); let err = fx .service .dispatch( @@ -1299,21 +1312,30 @@ async fn ask_unknown_target_is_not_found() { .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); - assert_eq!(*fx.factory_starts.lock().unwrap(), 0); assert!(fx.pty.spawns().is_empty()); } -/// Zone 7 — non-regression: `agent.run` (fire-and-forget) still yields `reply: -/// None` even through a structured-wired orchestrator. +/// Mailbox/PTY not wired (legacy `OrchestratorService::new` without `with_mailbox`) +/// ⇒ `ask` is INVALID. +#[tokio::test] +async fn ask_without_mailbox_wired_is_invalid() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + // The plain `fixture` builds the service WITHOUT `.with_mailbox(...)`. + let fx = fixture(FakeContexts::with_agent(&agent, "# persona")); + let err = fx + .service + .dispatch(&project(), cmd(ASK_JSON)) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID", "got {err:?}"); +} + +/// `agent.run` (fire-and-forget) still yields `reply: None` through a mailbox-wired +/// orchestrator (non-regression). #[tokio::test] async fn non_regression_agent_run_reply_is_none() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); - let session = FakeSession::new(sid(700), SendScript::Stream(vec![final_("x")])); - let fx = ask_fixture( - FakeContexts::with_agent(&agent, "# persona"), - session, - false, - ); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); let out = fx .service .dispatch( @@ -1325,378 +1347,962 @@ async fn non_regression_agent_run_reply_is_none() { assert_eq!(out.reply, None, "agent.run must not carry a reply"); } +// --- B-4: idea_reply (Reply command) --------------------------------------- + +/// A `Reply` with no in-flight ask for that agent ⇒ typed `INVALID` (no panic). +#[tokio::test] +async fn reply_without_pending_ask_is_invalid() { + let fx = ask_fixture(FakeContexts::new()); + let err = fx + .service + .dispatch(&project(), reply_cmd(aid(7), "orphan result")) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID", "orphan reply is typed INVALID: {err:?}"); +} + +/// `Reply` resolves the HEAD ticket of the emitting agent's queue (positional +/// correlation): two asks to the same target, two replies, FIFO order. +#[tokio::test] +async fn reply_resolves_head_of_queue_fifo() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + // Two concurrent asks to the SAME target. The per-agent turn lock serialises + // them: only the first holds the lock and enqueues; the second waits on the lock. + let svc1 = Arc::clone(&fx.service); + let ask1 = tokio::spawn(async move { + svc1.dispatch( + &project(), + cmd(r#"{ "type":"agent.message", "targetAgent":"architect", "task":"T1" }"#), + ) + .await + }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + + // First reply resolves T1; ask1 returns it. Then ask2 may proceed. + fx.service + .dispatch(&project(), reply_cmd(aid(1), "R1")) + .await + .expect("reply 1 ok"); + let out1 = timeout(TEST_GUARD, ask1).await.unwrap().unwrap().unwrap(); + assert_eq!(out1.reply.as_deref(), Some("R1")); + + let svc2 = Arc::clone(&fx.service); + let ask2 = tokio::spawn(async move { + svc2.dispatch( + &project(), + cmd(r#"{ "type":"agent.message", "targetAgent":"architect", "task":"T2" }"#), + ) + .await + }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + fx.service + .dispatch(&project(), reply_cmd(aid(1), "R2")) + .await + .expect("reply 2 ok"); + let out2 = timeout(TEST_GUARD, ask2).await.unwrap().unwrap().unwrap(); + assert_eq!(out2.reply.as_deref(), Some("R2")); +} + +/// Two asks to DIFFERENT targets do not block each other (per-agent lock + per-agent +/// queue): A is held (never replied) while B completes. +#[tokio::test] +async fn ask_different_targets_run_in_parallel() { + let contexts = FakeContexts::new(); + { + let a = scratch_agent(aid(1), "alpha", "agents/alpha.md"); + let b = scratch_agent(aid(2), "beta", "agents/beta.md"); + let mut inner = contexts.0.lock().unwrap(); + inner.manifest.entries.push(ManifestEntry::from_agent(&a)); + inner.manifest.entries.push(ManifestEntry::from_agent(&b)); + inner.contents.insert(a.context_path.clone(), "# a".to_owned()); + inner.contents.insert(b.context_path.clone(), "# b".to_owned()); + } + let fx = ask_fixture(contexts); + seed_live_pty(&fx.sessions, aid(1), sid(801)); + seed_live_pty(&fx.sessions, aid(2), sid(802)); + + // A: launched and held (never replied during the test). + let svc_a = Arc::clone(&fx.service); + let _ask_a = tokio::spawn(async move { + svc_a + .dispatch( + &project(), + cmd(r#"{ "type":"agent.message", "targetAgent":"alpha", "task":"A1" }"#), + ) + .await + }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + + // B completes while A is still in flight (no cross-agent contention). + let svc_b = Arc::clone(&fx.service); + let ask_b = tokio::spawn(async move { + svc_b + .dispatch( + &project(), + cmd(r#"{ "type":"agent.message", "targetAgent":"beta", "task":"B1" }"#), + ) + .await + }); + await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; + fx.service + .dispatch(&project(), reply_cmd(aid(2), "B-done")) + .await + .expect("reply B ok"); + let out_b = timeout(TEST_GUARD, ask_b) + .await + .expect("B completes while A is held") + .unwrap() + .unwrap(); + assert_eq!(out_b.reply.as_deref(), Some("B-done")); + // A is still pending: it was not unblocked by B. + assert_eq!(fx.mailbox.pending(&aid(1)), 1, "A still in flight"); +} + // --------------------------------------------------------------------------- -// A0 — sérialisation FIFO des tours par agent (cadrage v5 §4) +// F1 — McpRuntimeProvider câblé (preuve que le runtime atteint LaunchAgentInput) +// + F2 — garde guard_mcp_bridge_supported // -// Le verrou `ask_locks` de l'orchestrateur doit garantir : (1) deux `ask` -// concurrents vers la **même** cible sont sérialisés FIFO (le 2ᵉ `send` ne -// démarre PAS tant que le 1ᵉ tour n'a pas rendu son `Final`) ; (2) deux `ask` -// vers des agents **différents** s'exécutent en parallèle ; (3) le verrou est -// relâché même si le tour se solde par une erreur. -// -// Pour observer le *timing*, on remplace le `FakeSession` scripté one-shot par -// un `GatedSession` **pilotable** : chaque `send` enregistre son démarrage -// (compteur + ordre) puis **bloque** sur un permis de `Semaphore` que le test -// délivre à la demande, avant de retourner son `Final`. Cela matérialise un tour -// dont on contrôle la fin (« retient le Final jusqu'à ce que le test le libère »). +// F1 était le bug : sur le chemin `ask`, `ensure_live_pty` passait +// `mcp_runtime: None` ⇒ `.mcp.json` minimal ⇒ pont MCP jamais spawné ⇒ timeout. +// Le fix injecte un `McpRuntimeProvider`. On le prouve **de bout en bout** : la +// déclaration `.mcp.json` réellement écrite par `LaunchAgent::apply_mcp_config` +// (via le FileSystem) doit porter l'endpoint/exe/requester du runtime fourni — +// ce qui n'est possible QUE si le runtime a traversé `LaunchAgentInput.mcp_runtime`. // --------------------------------------------------------------------------- -use std::sync::atomic::{AtomicUsize, Ordering}; -use tokio::sync::Semaphore; -use tokio::time::{timeout, Duration}; - -/// Borne de sûreté : aucun `await` de test ne doit jamais dépasser ça (garde-fou -/// anti-hang du lot de concurrence). Largement au-dessus du temps réel attendu. -const TEST_GUARD: Duration = Duration::from_secs(10); - -/// Une session **pilotable** : chaque `send` signale son démarrage (incrémente -/// `started`, pousse le prompt dans `order`) puis **attend un permis** du -/// sémaphore `gate` avant de retourner son `Final` (contenu = prompt reçu, pour -/// vérifier l'ordre de complétion). Le test délivre les permis un par un -/// (`release_one`) ⇒ il contrôle exactement quand chaque tour se termine. -/// -/// `mode` choisit l'issue du tour : `Final` (succès) ou `NoFinal` (flux vide ⇒ -/// `send_blocking` renvoie une erreur, pour tester la libération du verrou sur -/// erreur). -struct GatedSession { - id: SessionId, - /// Nombre de `send` **démarrés** (avant déblocage). Observé par le test pour - /// prouver qu'un 2ᵉ tour n'a PAS démarré pendant que le 1ᵉ est retenu. - started: AtomicUsize, - /// Nombre de `send` **terminés** (Final rendu / flux retourné). - finished: AtomicUsize, - /// Ordre des prompts reçus (FIFO attendu). - order: Arc>>, - /// Permis débloquant un tour à la fois. - gate: Semaphore, - /// Nombre de `shutdown` (doit rester 0 : l'orchestrateur ne tue jamais la - /// session de la cible). - shutdowns: AtomicUsize, - mode: GateMode, -} - -#[derive(Clone, Copy)] -enum GateMode { - /// Le tour rend un `Final` dont le contenu reprend le prompt. - FinalEchoesPrompt, - /// Le flux est vide (pas de `Final`) ⇒ tour interrompu, erreur typée. - NoFinal, -} - -impl GatedSession { - fn new(id: SessionId, mode: GateMode) -> Arc { - Arc::new(Self { - id, - started: AtomicUsize::new(0), - finished: AtomicUsize::new(0), - order: Arc::new(Mutex::new(Vec::new())), - gate: Semaphore::new(0), - shutdowns: AtomicUsize::new(0), - mode, - }) +/// FileSystem enregistrant chaque `write(path, content)` pour pouvoir inspecter la +/// déclaration `.mcp.json` matérialisée par le lancement. `exists → false` pour que +/// `apply_mcp_config` écrive bien le fichier (chemin non-clobbering). +#[derive(Clone, Default)] +struct CapturingFs(Arc>>); +impl CapturingFs { + fn writes(&self) -> Vec<(String, String)> { + self.0.lock().unwrap().clone() } - fn started(&self) -> usize { - self.started.load(Ordering::SeqCst) - } - fn finished(&self) -> usize { - self.finished.load(Ordering::SeqCst) - } - fn order(&self) -> Vec { - self.order.lock().unwrap().clone() - } - /// Délivre un permis ⇒ débloque exactement UN tour en attente. - fn release_one(&self) { - self.gate.add_permits(1); + /// Contenu écrit dont le chemin se termine par `suffix` (ex. `.mcp.json`). + fn content_ending_with(&self, suffix: &str) -> Option { + self.0 + .lock() + .unwrap() + .iter() + .find(|(p, _)| p.ends_with(suffix)) + .map(|(_, c)| c.clone()) } } - #[async_trait] -impl AgentSession for GatedSession { - fn id(&self) -> SessionId { - self.id +impl FileSystem for CapturingFs { + async fn read(&self, path: &RemotePath) -> Result, FsError> { + Err(FsError::NotFound(path.as_str().to_owned())) } - fn conversation_id(&self) -> Option { - None + async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { + self.0 + .lock() + .unwrap() + .push((path.as_str().to_owned(), String::from_utf8_lossy(data).into_owned())); + Ok(()) } - async fn send(&self, prompt: &str) -> Result { - self.started.fetch_add(1, Ordering::SeqCst); - self.order.lock().unwrap().push(prompt.to_owned()); - // Bloque jusqu'à ce que le test délivre un permis (retient le Final). - let permit = self.gate.acquire().await.expect("gate not closed"); - permit.forget(); - self.finished.fetch_add(1, Ordering::SeqCst); - match self.mode { - GateMode::FinalEchoesPrompt => Ok(Box::new(std::iter::once(final_(prompt)))), - GateMode::NoFinal => Ok(Box::new(std::iter::empty())), - } + async fn exists(&self, _path: &RemotePath) -> Result { + Ok(false) } - async fn shutdown(&self) -> Result<(), AgentSessionError> { - self.shutdowns.fetch_add(1, Ordering::SeqCst); + async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + async fn list(&self, _path: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { Ok(()) } } -/// Attend (borné) qu'une condition observée sur le fake devienne vraie, en cédant -/// la main au runtime entre deux lectures. Évite tout `sleep` fixe fragile. -async fn await_until bool>(cond: F) { - timeout(TEST_GUARD, async { - while !cond() { - tokio::task::yield_now().await; +/// Provider de runtime MCP de test : renvoie un [`McpRuntime`] connu et **enregistre** +/// l'`(project_id, agent_id)` reçus, prouvant que l'orchestrateur l'interroge avec la +/// cible relancée. +#[derive(Clone, Default)] +struct FakeMcpRuntimeProvider { + calls: Arc>>, +} +impl FakeMcpRuntimeProvider { + fn calls(&self) -> Vec<(ProjectId, AgentId)> { + self.calls.lock().unwrap().clone() + } + /// Le runtime connu que ce provider injecte (valeurs sentinelles repérables dans + /// le `.mcp.json` écrit). + fn known_runtime(agent_id: AgentId) -> application::McpRuntime { + application::McpRuntime { + exe: "/opt/idea/idea.AppImage".to_owned(), + endpoint: "/run/user/1000/idea-mcp/SENTINEL-endpoint.sock".to_owned(), + project_id: "deadbeefdeadbeefdeadbeefdeadbeef".to_owned(), + requester: agent_id.to_string(), } - }) - .await - .expect("condition never reached within guard (possible hang/deadlock)"); + } +} +impl application::McpRuntimeProvider for FakeMcpRuntimeProvider { + fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option { + self.calls.lock().unwrap().push((project.id, agent_id)); + Some(Self::known_runtime(agent_id)) + } } -const ASK_ARCHITECT: &str = - r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"T1" }"#; -const ASK_ARCHITECT_2: &str = - r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"T2" }"#; - -/// **A0 #1 — FIFO même cible (cœur du lot).** Deux `ask` concurrents vers le -/// **même** agent, avec une session pilotable qui retient son `Final`. On prouve -/// que le 2ᵉ tour (`send`) ne **démarre pas** tant que le 1ᵉ n'a pas rendu son -/// `Final`, puis qu'en libérant, les deux complètent **dans l'ordre FIFO** (T1 -/// avant T2), sans entrelacement. -#[tokio::test] -async fn ask_same_target_serialises_turns_fifo() { - let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); - let session = GatedSession::new(sid(500), GateMode::FinalEchoesPrompt); - let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); - let fx = ask_fixture( - FakeContexts::with_agent(&agent, "# persona"), - throwaway, - false, - ); - fx.structured - .insert(Arc::clone(&session) as Arc, aid(1), nid(1)); - - let proj = project(); - let svc = &fx.service; - - timeout(TEST_GUARD, async { - // Tour 1 : on le pilote jusqu'à ce que son `send` soit en vol (verrou - // acquis + bloqué sur le gate) AVANT de lancer le tour 2 ⇒ ordre d'entrée - // en file déterministe (T1 devant T2). `biased` : on tente d'abord r1. - let r1 = svc.dispatch(&proj, cmd(ASK_ARCHITECT)); - tokio::pin!(r1); - loop { - tokio::select! { - biased; - _ = &mut r1 => panic!("turn 1 should stay blocked on its Final"), - () = await_until(|| session.started() >= 1) => break, - } - } - - // À ce point : tour 1 démarré et BLOQUÉ sur le Final. - assert_eq!(session.started(), 1, "exactly the first turn started"); - assert_eq!(session.finished(), 0, "first turn is still blocked on Final"); - - // Lance le tour 2 concurremment. Il doit rester EN FILE (verrou de la même - // cible tenu par le tour 1) : son `send` ne doit PAS démarrer. On poll les - // DEUX futures pendant qu'on laisse au runtime des occasions de mal faire. - let r2 = svc.dispatch(&proj, cmd(ASK_ARCHITECT_2)); - tokio::pin!(r2); - for _ in 0..50 { - tokio::select! { - biased; - _ = &mut r1 => panic!("turn 1 still blocked, must not complete yet"), - _ = &mut r2 => panic!("turn 2 must not run before turn 1 releases the lock"), - () = tokio::task::yield_now() => {} - } - } - assert_eq!( - session.started(), - 1, - "second turn MUST NOT start while the first holds the per-agent lock (no interleaving)" - ); - - // Libère le tour 1 ⇒ il rend son Final, relâche le verrou, le tour 2 démarre. - session.release_one(); - let out1 = (&mut r1).await.expect("turn 1 ok"); - assert_eq!(out1.reply.as_deref(), Some("T1")); - - // Le tour 2 peut maintenant démarrer (verrou libre) ; il bloque sur SON - // gate. On le pilote jusqu'à son démarrage, puis on le libère. - loop { - tokio::select! { - biased; - res = &mut r2 => { - // Démarré ET libéré dans la même boucle : accepte la complétion. - let out2 = res.expect("turn 2 ok"); - assert_eq!(out2.reply.as_deref(), Some("T2")); - break; - } - () = await_until(|| session.started() >= 2) => { - session.release_one(); - } - } - } - }) - .await - .expect("FIFO scenario hung (deadlock?)"); - - // Complétion FIFO stricte : T1 puis T2, sans entrelacement. - assert_eq!(session.order(), vec!["T1".to_owned(), "T2".to_owned()]); - assert_eq!(session.finished(), 2); - assert_eq!( - session.shutdowns.load(Ordering::SeqCst), - 0, - "ask must never shutdown the target session" - ); +/// Codex : profil structuré NON supporté par le pont `.mcp.json` (la garde F2 doit +/// le refuser, même s'il déclare une capacité MCP `ConfigFile(.mcp.json)`). +fn codex_profile() -> AgentProfile { + AgentProfile::new( + pid(11), + "Codex CLI", + "codex", + Vec::new(), + ContextInjection::stdin(), + Some("codex --version".to_owned()), + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::Codex) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").unwrap(), + McpTransport::Stdio, + )) } -/// **A0 #2 — agents différents en parallèle.** Un `ask` vers A (retenu sur son -/// `Final`) ne doit PAS bloquer un `ask` vers B : le tour de B démarre et -/// **complète** pendant que A est encore en cours. Verrou **par agent_id** ⇒ pas -/// de contention croisée. Si A bloquait B, l'attente de complétion de B -/// dépasserait le garde-fou et le test échouerait. -#[tokio::test] -async fn ask_different_targets_run_in_parallel() { - // Deux agents distincts dans le manifeste. - let contexts = FakeContexts::new(); - { - let agent_a = scratch_agent(aid(1), "alpha", "agents/alpha.md"); - let agent_b = scratch_agent(aid(2), "beta", "agents/beta.md"); - let mut inner = contexts.0.lock().unwrap(); - inner - .manifest - .entries - .push(ManifestEntry::from_agent(&agent_a)); - inner - .manifest - .entries - .push(ManifestEntry::from_agent(&agent_b)); - inner - .contents - .insert(agent_a.context_path.clone(), "# a".to_owned()); - inner - .contents - .insert(agent_b.context_path.clone(), "# b".to_owned()); +/// Fixture `ask` paramétrable : choix du FileSystem (capturant), des profils, et d'un +/// éventuel [`McpRuntimeProvider`]. Reproduit `ask_fixture` mais expose les leviers +/// nécessaires aux preuves F1/F2. +struct AskFixtureEx { + service: Arc, + mailbox: Arc, + sessions: Arc, + pty: FakePty, + fs: CapturingFs, +} + +fn ask_fixture_ex( + contexts: FakeContexts, + profiles: Vec, + provider: Option>, +) -> AskFixtureEx { + let profiles = Arc::new(FakeProfiles::new(profiles)); + let sessions = Arc::new(TerminalSessions::new()); + let pty = FakePty::new(sid(777)); + let bus = SpyBus::default(); + let mailbox = Arc::new(TestMailbox::new()); + let fs = CapturingFs::default(); + + let create = Arc::new(CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds::new()), + Arc::new(bus.clone()), + )); + let launch = Arc::new(LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(fs.clone()), + Arc::new(pty.clone()), + Arc::new(FakeSkills), + Arc::clone(&sessions), + Arc::new(bus.clone()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall), + None, + )); + let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); + let close = Arc::new(CloseTerminal::new( + Arc::new(pty.clone()), + Arc::clone(&sessions), + )); + let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone()))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(RecordingSkills::default()) as Arc, + Arc::new(SeqIds::new()), + )); + + let mediator = Arc::new(TestMediator::new( + Arc::clone(&mailbox), + Arc::new(pty.clone()) as Arc, + )) as Arc; + let conversations = Arc::new(TestConversations::new()) as Arc; + let mut service = OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + ) + .with_input_mediator(mediator, Arc::clone(&mailbox) as Arc) + .with_conversations(conversations) + .with_events(Arc::new(bus.clone())); + + if let Some(p) = provider { + service = + service.with_mcp_runtime_provider(p as Arc); } - let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); - let fx = ask_fixture(contexts, throwaway, false); - - // A : retenu indéfiniment (jamais libéré pendant le test). B : libérable. - let sess_a = GatedSession::new(sid(500), GateMode::FinalEchoesPrompt); - let sess_b = GatedSession::new(sid(600), GateMode::FinalEchoesPrompt); - fx.structured - .insert(Arc::clone(&sess_a) as Arc, aid(1), nid(1)); - fx.structured - .insert(Arc::clone(&sess_b) as Arc, aid(2), nid(2)); - - let proj = project(); - let svc = &fx.service; - - let ask_a = r#"{ "type":"agent.message", "targetAgent":"alpha", "task":"A1" }"#; - let ask_b = r#"{ "type":"agent.message", "targetAgent":"beta", "task":"B1" }"#; - - timeout(TEST_GUARD, async { - // Lance A et attends qu'il soit en vol (bloqué sur son Final), sans le libérer. - let a_fut = svc.dispatch(&proj, cmd(ask_a)); - tokio::pin!(a_fut); - // Pilote a_fut jusqu'à ce que A soit en vol. - let drive_a_until_inflight = async { - // a_fut bloque dans send → on le poll une fois via select avec la condition. - loop { - tokio::select! { - biased; - _ = &mut a_fut => panic!("A should stay blocked on its Final"), - () = await_until(|| sess_a.started() >= 1) => break, - } - } - }; - drive_a_until_inflight.await; - assert_eq!(sess_a.started(), 1); - assert_eq!(sess_a.finished(), 0, "A is held on its Final"); - - // Pendant que A est retenu, B doit pouvoir démarrer ET compléter. - let b_out = async { - // B bloque aussi sur son gate : on attend qu'il démarre, on le libère. - let b_fut = svc.dispatch(&proj, cmd(ask_b)); - tokio::pin!(b_fut); - loop { - tokio::select! { - biased; - res = &mut b_fut => break res, - () = await_until(|| sess_b.started() >= 1) => { - sess_b.release_one(); - } - } - } - } - .await - .expect("B completes while A is still in flight"); - - assert_eq!(b_out.reply.as_deref(), Some("B1")); - // A est toujours en vol, non terminé : la parallélisme est prouvée. - assert_eq!(sess_a.finished(), 0, "A must still be in flight (not blocked by B, nor B by A)"); - assert_eq!(sess_b.finished(), 1); - }) - .await - .expect("parallel scenario hung — A likely blocked B (cross-agent contention)"); + AskFixtureEx { + service: Arc::new(service), + mailbox, + sessions, + pty, + fs, + } } -/// **A0 #3 — libération du verrou sur erreur.** Un 1ᵉ tour qui se solde par une -/// **erreur** (flux sans `Final` ⇒ `PROCESS`) doit néanmoins **relâcher** le -/// verrou de la cible : un `ask` suivant vers le **même** agent peut alors -/// acquérir le verrou et démarrer (pas de verrou fuité / pas de deadlock). +/// F1 — PREUVE DIRECTE. Un `McpRuntimeProvider` câblé renvoie un runtime connu ; un +/// `ask` vers une cible **morte** passe par `ensure_live_pty` → `launch_agent`, et la +/// déclaration `.mcp.json` réellement matérialisée porte l'endpoint/exe/requester du +/// runtime fourni. C'est la preuve que `mcp_runtime: Some(runtime)` a traversé +/// `LaunchAgentInput` (avant le fix il valait `None` ⇒ déclaration minimale). #[tokio::test] -async fn ask_releases_lock_after_errored_turn() { +async fn f1_ask_dead_target_injects_provider_runtime_into_mcp_json() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); - // 1ᵉ tour : flux vide ⇒ send_blocking renvoie une erreur (PROCESS). - let session = GatedSession::new(sid(500), GateMode::NoFinal); - let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); - let fx = ask_fixture( + let provider = Arc::new(FakeMcpRuntimeProvider::default()); + let fx = ask_fixture_ex( FakeContexts::with_agent(&agent, "# persona"), - throwaway, - false, + vec![claude_profile()], + Some(Arc::clone(&provider)), ); - fx.structured - .insert(Arc::clone(&session) as Arc, aid(1), nid(1)); + assert!(fx.sessions.session_for_agent(&aid(1)).is_none()); - let proj = project(); - let svc = &fx.service; + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; - timeout(TEST_GUARD, async { - // 1ᵉ tour : on le débloque immédiatement, il échoue (pas de Final). - session.release_one(); - let err = svc - .dispatch(&proj, cmd(ASK_ARCHITECT)) - .await - .expect_err("first turn errors (no Final)"); - assert_eq!(err.code(), "PROCESS", "errored turn surfaces typed PROCESS: {err:?}"); - assert_eq!(session.started(), 1); + // Le provider a été interrogé avec le projet et la cible relancée. + let calls = provider.calls(); + assert_eq!(calls.len(), 1, "provider interrogé exactement une fois"); + assert_eq!(calls[0].0, project().id, "interrogé pour le bon projet"); + assert_eq!(calls[0].1, aid(1), "interrogé pour la cible relancée (= --requester)"); - // 2ᵉ tour vers la MÊME cible : si le verrou avait fuité sur l'erreur, ce - // `dispatch` resterait bloqué sur `lock_owned()` et le garde-fou sauterait. - // On débloque le 2ᵉ tour et on attend qu'il RENDE LA MAIN — preuve que le - // verrou était libre. (Cette session est `NoFinal` ⇒ le 2ᵉ tour échoue lui - // aussi ; ce qui compte ici est qu'il a pu *acquérir le verrou et démarrer*, - // pas l'issue du tour — l'issue est couverte par le test FIFO.) - session.release_one(); - let err2 = svc - .dispatch(&proj, cmd(ASK_ARCHITECT_2)) - .await - .expect_err("NoFinal session ⇒ second turn also errors typed"); - assert_eq!(err2.code(), "PROCESS", "got {err2:?}"); - assert_eq!( - session.started(), - 2, - "second turn actually started — the lock was freed after the errored first turn" - ); - }) - .await - .expect("lock appears leaked after an errored turn (second ask never acquired it)"); + // La déclaration `.mcp.json` écrite porte les valeurs sentinelles du runtime. + let decl = fx + .fs + .content_ending_with(".mcp.json") + .expect("un .mcp.json doit être écrit au lancement de la cible morte"); + assert!( + decl.contains("/opt/idea/idea.AppImage"), + "exe du runtime injecté présent dans le .mcp.json: {decl}" + ); + assert!( + decl.contains("SENTINEL-endpoint.sock"), + "endpoint du runtime injecté présent: {decl}" + ); + assert!( + decl.contains("deadbeefdeadbeefdeadbeefdeadbeef"), + "project_id du runtime injecté présent: {decl}" + ); + assert!( + decl.contains(&aid(1).to_string()), + "requester (= cible) présent: {decl}" + ); + // Preuve de discrimination avec le mode dégradé : le `command` est l'exe injecté, + // PAS la valeur minimale `"command": "idea"` qu'on aurait sans runtime. + assert!( + !decl.contains("\"command\": \"idea\""), + "command NE doit PAS être la valeur minimale (runtime injecté): {decl}" + ); + + // Débloque l'ask pour ne pas laisser la tâche pendante. + fx.service + .dispatch(&project(), reply_cmd(aid(1), "done")) + .await + .expect("reply ok"); + timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); } -// A0 #4 — Plafond d'attente en file (`ASK_QUEUE_WAIT_CAP = 600 s`). NON testé en -// unitaire : il n'est ni injectable ni réductible depuis l'extérieur (constante -// privée), et un vrai test exigerait d'attendre 600 s — exclu (durée). La -// sémantique nominale (attente puis acquisition du verrou) est couverte par -// `ask_same_target_serialises_turns_fifo` (le 2ᵉ tour attend en file PUIS -// acquiert). Le chemin d'expiration (timeout d'attente ⇒ `AppError::Process`, -// même type que le timeout de tour) reste non couvert ici — signalé au Dev : -// rendre le cap injectable (ex. champ optionnel surchargeable en test) permettrait -// un test déterministe court. +/// F1 — NON-RÉGRESSION. Sans provider câblé (`OrchestratorService::new` legacy), le +/// lancement issu d'`ask` passe `mcp_runtime: None` ⇒ déclaration **minimale** +/// (`command = "idea"`, pas d'endpoint). Comportement legacy intact. +#[tokio::test] +async fn f1_ask_without_provider_writes_minimal_mcp_json() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture_ex( + FakeContexts::with_agent(&agent, "# persona"), + vec![claude_profile()], + None, // pas de provider câblé + ); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + + let decl = fx + .fs + .content_ending_with(".mcp.json") + .expect("un .mcp.json minimal doit être écrit"); + assert!( + decl.contains("\"command\": \"idea\""), + "command minimal attendu sans provider: {decl}" + ); + assert!( + !decl.contains("--endpoint"), + "aucune déclaration d'endpoint sans runtime injecté: {decl}" + ); + + fx.service + .dispatch(&project(), reply_cmd(aid(1), "done")) + .await + .expect("reply ok"); + timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); +} + +/// F2 — GARDE (rejet). Une cible au profil Codex (structuré mais non consommateur du +/// pont `.mcp.json`) ⇒ `AppError::Invalid` **immédiat** (pas de timeout 300s), aucun +/// lancement, aucun ticket. +#[tokio::test] +async fn f2_ask_codex_target_is_invalid_no_launch() { + let agent = Agent::new( + aid(1), + "coder", + "agents/coder.md", + pid(11), // profil Codex + AgentOrigin::Scratch, + false, + ) + .unwrap(); + let fx = ask_fixture_ex( + FakeContexts::with_agent(&agent, "# persona"), + vec![codex_profile()], + Some(Arc::new(FakeMcpRuntimeProvider::default())), + ); + + let err = fx + .service + .dispatch( + &project(), + cmd(r#"{ "type":"agent.message", "targetAgent":"coder", "task":"refactor" }"#), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID", "garde F2 = erreur typée Invalid: {err:?}"); + let msg = err.to_string(); + assert!( + msg.contains("idea_*") || msg.contains("pont") || msg.contains(".mcp.json"), + "message explicite sur le pont non supporté: {msg}" + ); + assert!(fx.pty.spawns().is_empty(), "aucun lancement sur cible refusée"); + assert_eq!(fx.mailbox.pending(&aid(1)), 0, "aucun ticket enfilé"); +} + +/// F2 — GARDE (passant). Une cible au profil Claude conforme passe la garde et +/// l'`ask` se déroule normalement jusqu'à la réponse (preuve que la garde ne bloque +/// pas les cibles légitimes). +#[tokio::test] +async fn f2_ask_claude_target_passes_guard() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture_ex( + FakeContexts::with_agent(&agent, "# persona"), + vec![claude_profile()], + Some(Arc::new(FakeMcpRuntimeProvider::default())), + ); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + fx.service + .dispatch(&project(), reply_cmd(aid(1), "ok claude")) + .await + .expect("reply ok"); + let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); + assert_eq!(out.reply.as_deref(), Some("ok claude")); +} + +// --------------------------------------------------------------------------- +// C3 — conversation par paire : routage de fil, détection de cycle, corrélation +// par ticket, fallback tête, timeout libère la file. +// --------------------------------------------------------------------------- + +/// Builds an ask-wired service exposing the **shared** [`TestConversations`] registry +/// (so a test can assert the resolved thread is A↔B, never User↔B). Mirrors +/// `ask_fixture` but returns the registry handle. +struct AskFixtureC3 { + service: Arc, + mailbox: Arc, + sessions: Arc, + conversations: Arc, +} + +fn ask_fixture_c3(contexts: FakeContexts) -> AskFixtureC3 { + let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()])); + let sessions = Arc::new(TerminalSessions::new()); + let pty = FakePty::new(sid(777)); + let bus = SpyBus::default(); + let mailbox = Arc::new(TestMailbox::new()); + let conversations = Arc::new(TestConversations::new()); + + let create = Arc::new(CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds::new()), + Arc::new(bus.clone()), + )); + let launch = Arc::new(LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(FakeFs), + Arc::new(pty.clone()), + Arc::new(FakeSkills), + Arc::clone(&sessions), + Arc::new(bus.clone()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall), + None, + )); + let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); + let close = Arc::new(CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions))); + let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone()))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(RecordingSkills::default()) as Arc, + Arc::new(SeqIds::new()), + )); + let mediator = Arc::new(TestMediator::new( + Arc::clone(&mailbox), + Arc::new(pty.clone()) as Arc, + )) as Arc; + + let service = Arc::new( + OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + ) + .with_input_mediator(mediator, Arc::clone(&mailbox) as Arc) + .with_conversations(Arc::clone(&conversations) as Arc) + .with_events(Arc::new(bus.clone())), + ); + + AskFixtureC3 { + service, + mailbox, + sessions, + conversations, + } +} + +/// Seeds two agents A (id 1) and B (id 2) in the manifest. +fn seed_two_agents(contexts: &FakeContexts) { + let a = scratch_agent(aid(1), "alpha", "agents/alpha.md"); + let b = scratch_agent(aid(2), "beta", "agents/beta.md"); + let mut inner = contexts.0.lock().unwrap(); + inner.manifest.entries.push(ManifestEntry::from_agent(&a)); + inner.manifest.entries.push(ManifestEntry::from_agent(&b)); + inner.contents.insert(a.context_path.clone(), "# a".to_owned()); + inner.contents.insert(b.context_path.clone(), "# b".to_owned()); +} + +/// An `agent.message` from agent A (uuid requester) to a named target. +fn ask_from_agent_json(requester: AgentId, target: &str, task: &str) -> String { + format!( + r#"{{ "type":"agent.message", "requestedBy":"{requester}", "targetAgent":"{target}", "task":"{task}" }}"# + ) +} + +/// C3 — an ask A→B routes into the **A↔B** thread, never User↔B. The resolved +/// conversation relates the two agents, and is distinct from the User↔B thread. +#[tokio::test] +async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() { + let contexts = FakeContexts::new(); + seed_two_agents(&contexts); + let fx = ask_fixture_c3(contexts); + seed_live_pty(&fx.sessions, aid(2), sid(802)); // B live + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { + svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "do B"))) + .await + }); + await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; + + // The registry now holds the A↔B thread (agent 1 ↔ agent 2), not User↔B. + let a_to_b = fx + .conversations + .resolve(ConversationParty::agent(aid(1)), ConversationParty::agent(aid(2))); + let user_b = fx + .conversations + .resolve(ConversationParty::User, ConversationParty::agent(aid(2))); + assert_ne!(a_to_b.id, user_b.id, "A↔B is a distinct thread from User↔B"); + assert!( + a_to_b.same_pair( + ConversationParty::agent(aid(1)), + ConversationParty::agent(aid(2)) + ), + "ask A→B resolved the A↔B pair" + ); + // The live B session is bound to the A↔B thread (session keyed by conversation). + assert!(a_to_b.session.is_live(), "A↔B thread bound to a live session"); + + fx.service + .dispatch(&project(), reply_cmd(aid(2), "B done")) + .await + .expect("reply ok"); + let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); + assert_eq!(out.reply.as_deref(), Some("B done")); +} + +/// C3 — a re-entrant delegation A→B→A is refused with a typed error **before** any +/// deadlock: while A→B is in flight (edge A→B posted), B→A must be rejected. +#[tokio::test] +async fn cycle_a_to_b_to_a_is_refused_before_deadlock() { + let contexts = FakeContexts::new(); + seed_two_agents(&contexts); + let fx = ask_fixture_c3(contexts); + seed_live_pty(&fx.sessions, aid(1), sid(801)); // A live + seed_live_pty(&fx.sessions, aid(2), sid(802)); // B live + + // A→B in flight (held — B never replies during the test): edge A→B is posted. + let svc = Arc::clone(&fx.service); + let _ask_ab = tokio::spawn(async move { + svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B"))) + .await + }); + await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; + + // Now B→A would close the cycle ⇒ typed INVALID, no enqueue on A, returns fast. + let err = timeout( + TEST_GUARD, + fx.service + .dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "back to A"))), + ) + .await + .expect("cycle ask must return fast, never deadlock") + .unwrap_err(); + assert_eq!(err.code(), "INVALID", "re-entrant cycle is typed INVALID: {err:?}"); + assert!( + err.to_string().contains("cycle") || err.to_string().contains("ré-entrante"), + "explicit cycle message: {err}" + ); + assert_eq!(fx.mailbox.pending(&aid(1)), 0, "no ticket enqueued on A"); +} + +/// C3 — once an A→B turn completes, the edge is cleared, so a later B→A is allowed +/// (no phantom edge): proves the wait-for edge is RAII-scoped to the turn. +#[tokio::test] +async fn edge_cleared_after_turn_allows_later_reverse_delegation() { + let contexts = FakeContexts::new(); + seed_two_agents(&contexts); + let fx = ask_fixture_c3(contexts); + seed_live_pty(&fx.sessions, aid(1), sid(801)); + seed_live_pty(&fx.sessions, aid(2), sid(802)); + + // A→B completes. + let svc = Arc::clone(&fx.service); + let ask_ab = tokio::spawn(async move { + svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B"))) + .await + }); + await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; + fx.service + .dispatch(&project(), reply_cmd(aid(2), "ok")) + .await + .expect("reply ok"); + timeout(TEST_GUARD, ask_ab).await.unwrap().unwrap().unwrap(); + + // Edge A→B is now gone ⇒ B→A is allowed (would only cycle if A→B still pending). + let svc2 = Arc::clone(&fx.service); + let ask_ba = tokio::spawn(async move { + svc2.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "now to A"))) + .await + }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + fx.service + .dispatch(&project(), reply_cmd(aid(1), "A ok")) + .await + .expect("reply ok"); + let out = timeout(TEST_GUARD, ask_ba).await.unwrap().unwrap().unwrap(); + assert_eq!(out.reply.as_deref(), Some("A ok"), "reverse delegation allowed"); +} + +/// C3 — reply correlates **by ticket** (deterministic id-keyed resolution). The agent +/// echoes the ticket id from the `[IdeA · … · ticket ]` prefix; replying by that +/// id resolves exactly that request. (Per «1 agent = 1 employee» the turn lock keeps +/// at most one turn live per agent, so resolution is sequential here — by-ticket is +/// what makes it deterministic regardless of which requester it serves.) +#[tokio::test] +async fn reply_correlates_by_ticket() { + let agent = scratch_agent(aid(2), "beta", "agents/beta.md"); + let fx = ask_fixture_c3(FakeContexts::with_agent(&agent, "# b")); + seed_live_pty(&fx.sessions, aid(2), sid(802)); + + // Requester A1 asks B; capture B's queued ticket id and reply it by id. + let svc1 = Arc::clone(&fx.service); + let ask1 = tokio::spawn(async move { + svc1.dispatch(&project(), cmd(&ask_from_agent_json(aid(10), "beta", "T1"))) + .await + }); + await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; + let t1 = fx.mailbox.ticket_ids(&aid(2))[0]; + fx.service + .dispatch(&project(), reply_cmd_ticket(aid(2), t1, "R1")) + .await + .expect("reply T1 by ticket ok"); + let out1 = timeout(TEST_GUARD, ask1).await.unwrap().unwrap().unwrap(); + assert_eq!(out1.reply.as_deref(), Some("R1")); + + // A different requester A2 asks B; replying a STALE/unknown ticket id is a typed + // error (no panic), while the correct id resolves it. + let svc2 = Arc::clone(&fx.service); + let ask2 = tokio::spawn(async move { + svc2.dispatch(&project(), cmd(&ask_from_agent_json(aid(11), "beta", "T2"))) + .await + }); + await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; + let bogus = TicketId::new_random(); + let err = fx + .service + .dispatch(&project(), reply_cmd_ticket(aid(2), bogus, "wrong")) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID", "unknown ticket id ⇒ typed INVALID: {err:?}"); + let t2 = fx.mailbox.ticket_ids(&aid(2))[0]; + fx.service + .dispatch(&project(), reply_cmd_ticket(aid(2), t2, "R2")) + .await + .expect("reply T2 by ticket ok"); + let out2 = timeout(TEST_GUARD, ask2).await.unwrap().unwrap().unwrap(); + assert_eq!(out2.reply.as_deref(), Some("R2")); +} + +/// C3 — a reply **without** a ticket falls back to the head of the queue (compat +/// mono-thread agents). +#[tokio::test] +async fn reply_without_ticket_falls_back_to_head() { + let agent = scratch_agent(aid(2), "beta", "agents/beta.md"); + let fx = ask_fixture_c3(FakeContexts::with_agent(&agent, "# b")); + seed_live_pty(&fx.sessions, aid(2), sid(802)); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { + svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(10), "beta", "T"))) + .await + }); + await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; + // No ticket id ⇒ head-of-queue fallback. + fx.service + .dispatch(&project(), reply_cmd(aid(2), "head reply")) + .await + .expect("reply ok"); + let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); + assert_eq!(out.reply.as_deref(), Some("head reply")); +} + +/// C3 — when the rendezvous resolves to a closed reply channel (the same retirement +/// path the bounded turn timeout takes), the ticket is **retired** from the queue and +/// the target session stays **alive** (ready for the next turn). We trigger it by +/// cancelling the head ticket (drops the sender) while the ask awaits — `cancel_head` +/// + the `Cancelled` arm mirror the timeout's `cancel_head` exactly. +#[tokio::test] +async fn timeout_path_frees_queue_and_keeps_target_alive() { + let agent = scratch_agent(aid(2), "beta", "agents/beta.md"); + let fx = ask_fixture_c3(FakeContexts::with_agent(&agent, "# b")); + seed_live_pty(&fx.sessions, aid(2), sid(802)); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { + svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(10), "beta", "T"))) + .await + }); + await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; + let t = fx.mailbox.ticket_ids(&aid(2))[0]; + + // Retire the head ticket (drops its sender) ⇒ the awaiting ask sees a closed + // channel and returns a typed error, the SAME cleanup the turn timeout performs. + fx.mailbox.cancel_head(aid(2), t); + let err = timeout(TEST_GUARD, ask) + .await + .expect("ask returns promptly once the channel closes") + .unwrap() + .unwrap_err(); + assert!( + matches!(err.code(), "PROCESS" | "INVALID"), + "closed-channel ask is a typed error: {err:?}" + ); + // Queue freed, and the target B is still live (never killed by the failed ask). + assert_eq!(fx.mailbox.pending(&aid(2)), 0, "queue freed after retirement"); + assert_eq!( + fx.sessions.session_for_agent(&aid(2)), + Some(sid(802)), + "target stays alive for the next turn" + ); +} + +// --------------------------------------------------------------------------- +// C4 — SubmitHumanInput (Envoyer) + interrupt (Interrompre) +// --------------------------------------------------------------------------- + +/// Fixture for the C4 human-input / interrupt tests. Identical wiring to +/// [`ask_fixture`] but keeps the **concrete** [`TestMediator`] so a test can assert +/// the preempt path (no enqueue, no resolved ticket) and the shared FIFO. +struct C4Fixture { + service: Arc, + mailbox: Arc, + sessions: Arc, + mediator: Arc, + pty: FakePty, +} + +fn c4_fixture(contexts: FakeContexts) -> C4Fixture { + let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()])); + let sessions = Arc::new(TerminalSessions::new()); + let pty = FakePty::new(sid(777)); + let bus = SpyBus::default(); + let mailbox = Arc::new(TestMailbox::new()); + + let create = Arc::new(CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds::new()), + Arc::new(bus.clone()), + )); + let launch = Arc::new(LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(FakeFs), + Arc::new(pty.clone()), + Arc::new(FakeSkills), + Arc::clone(&sessions), + Arc::new(bus.clone()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall), + None, + )); + let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); + let close = Arc::new(CloseTerminal::new( + Arc::new(pty.clone()), + Arc::clone(&sessions), + )); + let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone()))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(RecordingSkills::default()) as Arc, + Arc::new(SeqIds::new()), + )); + + let mediator = Arc::new(TestMediator::new( + Arc::clone(&mailbox), + Arc::new(pty.clone()) as Arc, + )); + let conversations = Arc::new(TestConversations::new()) as Arc; + let service = Arc::new( + OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + ) + .with_input_mediator( + Arc::clone(&mediator) as Arc, + Arc::clone(&mailbox) as Arc, + ) + .with_conversations(conversations) + .with_events(Arc::new(bus.clone())), + ); + + C4Fixture { + service, + mailbox, + sessions, + mediator, + pty, + } +} + +/// A human `submit` enqueues a `from_human` ticket in the SAME FIFO the delegations +/// use: an `ask` A→B and a `submit` Human→B serialise on B's single queue. +#[tokio::test] +async fn submit_and_ask_share_one_fifo_per_agent() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = c4_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + // A delegation A→B blocks in B's FIFO (it awaits a reply). + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { + svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(10), "architect", "deleg"))) + .await + }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + + // A human submit to the SAME agent lands behind it in the SAME FIFO. + fx.service + .submit_human_input(&project(), aid(1), "human task".to_owned()) + .await + .expect("submit succeeds"); + + assert_eq!( + fx.mailbox.pending(&aid(1)), + 2, + "human submit serialises behind the delegation in one FIFO" + ); + // The human ticket's task text was written into the target's live terminal. + let writes = fx.pty.writes_for(sid(800)); + assert!( + writes.iter().any(|w| w.contains("human task")), + "human turn delivered to the PTY: {writes:?}" + ); + + // Unblock the delegation so the spawned task ends cleanly. + fx.mailbox.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]); + let _ = timeout(TEST_GUARD, ask).await; +} + +/// `interrupt` = `preempt`: it calls the mediator's preempt for the agent and does +/// NOT enqueue anything nor resolve any ticket. +#[tokio::test] +async fn interrupt_preempts_without_enqueue_or_resolve() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = c4_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + assert_eq!(fx.mailbox.pending(&aid(1)), 0); + fx.service + .interrupt_agent(&project(), aid(1)) + .await + .expect("interrupt succeeds"); + + assert_eq!(fx.mediator.preempts(), vec![aid(1)], "preempt called once"); + assert_eq!( + fx.mailbox.pending(&aid(1)), + 0, + "interrupt enqueues nothing" + ); +} + +/// A human submit to an unknown agent id is a typed NotFound (never a panic). +#[tokio::test] +async fn submit_unknown_agent_is_not_found() { + let fx = c4_fixture(FakeContexts::new()); + let err = fx + .service + .submit_human_input(&project(), aid(999), "x".to_owned()) + .await + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND", "unknown agent submit: {err:?}"); +} + +/// An interrupt to an unknown agent id is a typed NotFound and never calls preempt. +#[tokio::test] +async fn interrupt_unknown_agent_is_not_found() { + let fx = c4_fixture(FakeContexts::new()); + let err = fx + .service + .interrupt_agent(&project(), aid(999)) + .await + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND", "unknown agent interrupt: {err:?}"); + assert!(fx.mediator.preempts().is_empty(), "no preempt on unknown agent"); +} diff --git a/crates/domain/src/conversation.rs b/crates/domain/src/conversation.rs new file mode 100644 index 0000000..8966cb1 --- /dev/null +++ b/crates/domain/src/conversation.rs @@ -0,0 +1,431 @@ +//! Conversation-by-pair domain model (cadrage « conversation par paire », lot C1/C2). +//! +//! A [`Conversation`] is a **thread between two distinct parties** with its own +//! I/O session. Its identity is the **unordered pair** `{left, right}`: the same +//! two parties always denote the same conversation (this is the key to the +//! *lazy materialisation* the [`ConversationRegistry`] performs — `resolve(a, b)` +//! and `resolve(b, a)` yield the same [`ConversationId`]). +//! +//! This module is **pure** (ARCHITECTURE dependency rule): no `tokio`, no +//! `std::fs`, no `std::process`. It owns the value objects and entities plus the +//! [`ConversationRegistry`] port (a trait the application depends on; the infra +//! provides `InMemoryConversationRegistry`). The session handle is held only as a +//! [`SessionRef`] (a reference to a [`crate::terminal::TerminalSession`]); the +//! domain never holds the PTY itself. +//! +//! ## Why this exists +//! +//! The previous coupling «1 live session / agent» was ambiguous once an agent can +//! take part in several threads (`session-registry-agent-ambiguity`). Here the live +//! session is keyed **by conversation**, so a delegation `A↔B` never borrows the +//! `User↔B` thread — the context leak is closed by construction. + +use serde::{Deserialize, Serialize}; + +use crate::ids::{AgentId, SessionId}; + +/// Identifies one [`Conversation`]. +/// +/// Newtype around [`uuid::Uuid`], calqué sur [`crate::ids::AgentId`] / +/// [`crate::mailbox::TicketId`]. Minted by the [`ConversationRegistry`] when a pair +/// is first resolved; stable for the lifetime of that pair's thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ConversationId(pub uuid::Uuid); + +impl ConversationId { + /// Wraps an existing [`uuid::Uuid`]. + #[must_use] + pub const fn from_uuid(id: uuid::Uuid) -> Self { + Self(id) + } + + /// Mints a fresh random conversation id. + #[must_use] + pub fn new_random() -> Self { + Self(uuid::Uuid::new_v4()) + } + + /// Returns the inner [`uuid::Uuid`]. + #[must_use] + pub const fn as_uuid(&self) -> uuid::Uuid { + self.0 + } +} + +impl std::fmt::Display for ConversationId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// One end of a [`Conversation`]. +/// +/// Invariant (enforced by [`Conversation::try_new`]): a conversation relates **two +/// distinct** parties and carries **at most one** [`ConversationParty::User`] +/// (never `User↔User`, never `Agent(x)↔Agent(x)`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum ConversationParty { + /// The human operator — a single logical instance on IdeA's side. + User, + /// A project agent. + #[serde(rename_all = "camelCase")] + Agent { + /// The agent on this end. + agent_id: AgentId, + }, +} + +impl ConversationParty { + /// Convenience constructor for an agent party. + #[must_use] + pub const fn agent(agent_id: AgentId) -> Self { + Self::Agent { agent_id } + } + + /// Returns the [`AgentId`] when this party is an agent. + #[must_use] + pub const fn as_agent(&self) -> Option { + match self { + Self::Agent { agent_id } => Some(*agent_id), + Self::User => None, + } + } + + /// Whether this party is the human user. + #[must_use] + pub const fn is_user(&self) -> bool { + matches!(self, Self::User) + } +} + +/// Errors raised when constructing a [`Conversation`]. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ConversationError { + /// The two ends are the **same** party (`left == right`). + #[error("a conversation must relate two distinct parties")] + SameParty, + /// **Both** ends are [`ConversationParty::User`] — at most one is allowed. + #[error("a conversation may carry at most one User party")] + TwoUsers, +} + +/// The I/O state of a conversation thread. +/// +/// The domain holds only a [`SessionRef`]; the live PTY/structured stream is an +/// infrastructure concern (`session-registry`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum ConversationSession { + /// Never launched, or suspended (only `resumable_id` is retained on the + /// [`Conversation`]). + Dormant, + /// A live I/O stream backs this thread. + #[serde(rename_all = "camelCase")] + Live { + /// Reference to the backing [`crate::terminal::TerminalSession`]. + handle_ref: SessionRef, + }, +} + +impl ConversationSession { + /// Whether the thread currently has a live session. + #[must_use] + pub const fn is_live(&self) -> bool { + matches!(self, Self::Live { .. }) + } +} + +/// An opaque reference to a live session handle (a [`crate::terminal::TerminalSession`]). +/// +/// The domain never owns the PTY; it only names the session by its [`SessionId`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SessionRef(pub SessionId); + +impl SessionRef { + /// Wraps a [`SessionId`]. + #[must_use] + pub const fn new(id: SessionId) -> Self { + Self(id) + } + + /// Returns the referenced [`SessionId`]. + #[must_use] + pub const fn session_id(&self) -> SessionId { + self.0 + } +} + +/// A thread between two distinct parties, with its own session and resumable id. +/// +/// Identity is the **unordered pair** `{left, right}`: see [`Conversation::same_pair`]. +/// Pure value/entity — no I/O. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Conversation { + /// Stable identifier (minted by the registry on first resolve). + pub id: ConversationId, + /// One end of the thread. + pub left: ConversationParty, + /// The other end of the thread. + pub right: ConversationParty, + /// Current I/O state. + pub session: ConversationSession, + /// CLI session-id to resume on restart (`suspend` stores it; `Dormant` keeps it). + pub resumable_id: Option, +} + +impl Conversation { + /// Builds a `Dormant` conversation for the pair `{left, right}`. + /// + /// # Errors + /// - [`ConversationError::SameParty`] if `left == right`. + /// - [`ConversationError::TwoUsers`] if both ends are [`ConversationParty::User`]. + pub fn try_new( + id: ConversationId, + left: ConversationParty, + right: ConversationParty, + ) -> Result { + if left.is_user() && right.is_user() { + // More specific than SameParty: "at most one User" invariant. + return Err(ConversationError::TwoUsers); + } + if left == right { + return Err(ConversationError::SameParty); + } + Ok(Self { + id, + left, + right, + session: ConversationSession::Dormant, + resumable_id: None, + }) + } + + /// Whether this conversation relates the **same unordered pair** as `{a, b}`. + /// + /// `{left, right}` is order-insensitive: `same_pair(a, b) == same_pair(b, a)`. + #[must_use] + pub fn same_pair(&self, a: ConversationParty, b: ConversationParty) -> bool { + (self.left == a && self.right == b) || (self.left == b && self.right == a) + } +} + +/// A pure wait-for graph for deadlock (cycle) detection on inter-agent delegation. +/// +/// Each edge `(A, B)` reads «A is waiting for B». A delegation `from → to` would +/// deadlock iff `to` already (transitively) waits on `from`; [`WaitForGraph::would_cycle`] +/// answers that **without** mutating the graph, so the caller can refuse the ask +/// **before** posting the edge. 100 % pure and testable. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WaitForGraph { + edges: Vec<(AgentId, AgentId)>, +} + +impl WaitForGraph { + /// An empty graph. + #[must_use] + pub fn new() -> Self { + Self { edges: Vec::new() } + } + + /// Records that `from` waits for `to` (no-op if the edge already exists). + pub fn add_edge(&mut self, from: AgentId, to: AgentId) { + if !self.edges.contains(&(from, to)) { + self.edges.push((from, to)); + } + } + + /// Removes the edge `from → to` (the wait resolved or timed out). + pub fn remove_edge(&mut self, from: AgentId, to: AgentId) { + self.edges.retain(|e| *e != (from, to)); + } + + /// Number of recorded edges (test/inspection helper). + #[must_use] + pub fn len(&self) -> usize { + self.edges.len() + } + + /// Whether the graph holds no edges. + #[must_use] + pub fn is_empty(&self) -> bool { + self.edges.is_empty() + } + + /// Whether adding `from → to` would close a cycle in the wait-for graph. + /// + /// `true` when `to` already reaches `from` through existing edges (so `from` + /// waiting on `to` would deadlock), or when `from == to` (self-wait). The graph + /// is **not** modified. + #[must_use] + pub fn would_cycle(&self, from: AgentId, to: AgentId) -> bool { + if from == to { + return true; + } + // Does `to` already reach `from`? DFS over existing edges from `to`. + let mut stack = vec![to]; + let mut seen = vec![to]; + while let Some(node) = stack.pop() { + if node == from { + return true; + } + for (a, b) in &self.edges { + if *a == node && !seen.contains(b) { + seen.push(*b); + stack.push(*b); + } + } + } + false + } +} + +/// Lazy, get-or-create registry of conversations keyed by **unordered pair**. +/// +/// Pure port (no I/O); the infra adapter `InMemoryConversationRegistry` owns the +/// interior mutability (`HashMap` + sync mutex, never held across an `.await`). +/// Kept object-safe so the application holds it as `Arc`. +pub trait ConversationRegistry: Send + Sync { + /// Get-or-create: returns the thread for the pair `{a, b}`, opening it + /// (`Dormant`) if it did not exist. Pure registry — opens **no** session. + /// The same unordered pair always yields the same [`ConversationId`]. + fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation; + + /// Marks the conversation `id` `Live` with the given session reference. + fn bind_session(&self, id: ConversationId, session: SessionRef); + + /// Suspends `id` (back to `Dormant`), retaining `resumable_id` for restart. + fn suspend(&self, id: ConversationId, resumable_id: Option); + + /// Returns the current snapshot of `id`, if it exists. + fn get(&self, id: ConversationId) -> Option; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + fn conv_id(n: u128) -> ConversationId { + ConversationId::from_uuid(uuid::Uuid::from_u128(n)) + } + + #[test] + fn conversation_id_roundtrips_through_uuid() { + let u = uuid::Uuid::from_u128(42); + let id = ConversationId::from_uuid(u); + assert_eq!(id.as_uuid(), u); + assert_eq!(id.to_string(), u.to_string()); + } + + #[test] + fn rejects_same_party_pair() { + let a = ConversationParty::agent(agent(1)); + assert_eq!( + Conversation::try_new(conv_id(1), a, a), + Err(ConversationError::SameParty) + ); + } + + #[test] + fn rejects_two_users() { + assert_eq!( + Conversation::try_new( + conv_id(1), + ConversationParty::User, + ConversationParty::User + ), + Err(ConversationError::TwoUsers) + ); + } + + #[test] + fn accepts_user_agent_and_agent_agent() { + assert!(Conversation::try_new( + conv_id(1), + ConversationParty::User, + ConversationParty::agent(agent(1)) + ) + .is_ok()); + assert!(Conversation::try_new( + conv_id(2), + ConversationParty::agent(agent(1)), + ConversationParty::agent(agent(2)) + ) + .is_ok()); + } + + #[test] + fn fresh_conversation_is_dormant_without_resumable() { + let c = Conversation::try_new( + conv_id(1), + ConversationParty::User, + ConversationParty::agent(agent(1)), + ) + .unwrap(); + assert_eq!(c.session, ConversationSession::Dormant); + assert!(!c.session.is_live()); + assert_eq!(c.resumable_id, None); + } + + #[test] + fn identity_is_the_unordered_pair() { + let a = ConversationParty::agent(agent(1)); + let b = ConversationParty::agent(agent(2)); + let c = Conversation::try_new(conv_id(1), a, b).unwrap(); + assert!(c.same_pair(a, b)); + assert!(c.same_pair(b, a), "pair identity is order-insensitive"); + let other = ConversationParty::agent(agent(3)); + assert!(!c.same_pair(a, other)); + } + + #[test] + fn would_cycle_refuses_direct_back_edge() { + // A→B exists; B→A would close the cycle. + let mut g = WaitForGraph::new(); + g.add_edge(agent(1), agent(2)); // A waits B + assert!(g.would_cycle(agent(2), agent(1)), "B→A closes A→B→A"); + } + + #[test] + fn would_cycle_allows_linear_chain() { + // A→B exists; B→C is fine (no path C→…→B). + let mut g = WaitForGraph::new(); + g.add_edge(agent(1), agent(2)); // A waits B + assert!( + !g.would_cycle(agent(2), agent(3)), + "A→B→C is acyclic" + ); + } + + #[test] + fn would_cycle_detects_transitive_cycle() { + // A→B, B→C; C→A would close A→B→C→A. + let mut g = WaitForGraph::new(); + g.add_edge(agent(1), agent(2)); + g.add_edge(agent(2), agent(3)); + assert!(g.would_cycle(agent(3), agent(1)), "transitive cycle"); + } + + #[test] + fn would_cycle_refuses_self_wait() { + let g = WaitForGraph::new(); + assert!(g.would_cycle(agent(1), agent(1))); + } + + #[test] + fn add_edge_is_idempotent_and_removable() { + let mut g = WaitForGraph::new(); + g.add_edge(agent(1), agent(2)); + g.add_edge(agent(1), agent(2)); + assert_eq!(g.len(), 1); + g.remove_edge(agent(1), agent(2)); + assert!(g.is_empty()); + } +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index f34d453..4c97b93 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -59,6 +59,17 @@ pub enum DomainEvent { /// Exit code. code: i32, }, + /// An agent's **busy/idle** state changed (cadrage C4 §4.2): it went `Busy` on + /// the enqueue that started a turn, or `Idle` on `mark_idle` (prompt-ready or an + /// explicit signal). Discrete, low-frequency beacon relayed to the front so the + /// mediated-input view can dim "Envoyer" while a turn is in flight (it never + /// blocks the enqueue — the FIFO keeps accepting; the flag is purely advisory). + AgentBusyChanged { + /// The agent whose state changed. + agent_id: AgentId, + /// `true` when a turn is in flight, `false` when the agent is idle. + busy: bool, + }, /// An agent's runtime profile was changed (hot-swap of the AI engine). AgentProfileChanged { /// The agent. diff --git a/crates/domain/src/fileguard.rs b/crates/domain/src/fileguard.rs new file mode 100644 index 0000000..1fa7c59 --- /dev/null +++ b/crates/domain/src/fileguard.rs @@ -0,0 +1,236 @@ +//! File-access guard (cadrage « FileGuard », lot C6). +//! +//! A **reader/writer lock per guarded resource**, bounded to the files IdeA owns: +//! an agent's `.md` context, the project's global context, and a memory note. +//! N concurrent readers **or** a single exclusive writer per resource; the global +//! [`GuardedResource::ProjectContext`] is additionally **single-writer**: only the +//! orchestrator may `acquire_write` it — any other party must *propose* instead and +//! receives [`GuardError::Forbidden`]. +//! +//! ## Cooperative scope (cadrage §9.5) +//! +//! This guard corrects collisions **inside the IdeA path** (access through the MCP +//! tools and the UI). It is **cooperative**: an agent that keeps a raw shell can +//! still write these `.md`/memory files directly through the filesystem, bypassing +//! the lock. Real airtightness (revoking raw fs access to these paths) is an **OS +//! sandbox** concern (Landlock — `agent-permissions-architecture`) and is **out of +//! scope** here. The FileGuard fixes IdeA↔IdeA races; it does not sandbox an agent. +//! +//! This module is **pure** (no `tokio`, no `std::fs`): it owns the value objects +//! ([`GuardedResource`], [`GuardError`]), the RAII leases ([`ReadLease`], +//! [`WriteLease`]) and the [`FileGuard`] port. The infra adapter (`RwFileGuard`) +//! provides the actual reader/writer locks. + +use async_trait::async_trait; + +use crate::conversation::ConversationParty; +use crate::ids::AgentId; +use crate::memory::MemorySlug; + +/// The **bounded, closed** set of resources the [`FileGuard`] arbitrates. +/// +/// Closed by construction: only an agent's context, the global project context, and +/// a memory note are guardable. Anything outside this enum is **not** guarded (and +/// must not be routed through the guard). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum GuardedResource { + /// One agent's `.md` context file. + AgentContext(AgentId), + /// The project's global context (`CLAUDE.md`/root context). + /// + /// **Single-writer**: only the orchestrator may write it; every other party is + /// limited to *proposing* a change (cf. [`GuardError::Forbidden`]). + ProjectContext, + /// A single memory note, keyed by its slug. + Memory(MemorySlug), +} + +impl GuardedResource { + /// Whether this resource is the single-writer global project context. + #[must_use] + pub const fn is_project_context(&self) -> bool { + matches!(self, Self::ProjectContext) + } +} + +/// Errors raised when acquiring a guard lease. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum GuardError { + /// The lease could not be taken because the resource is held incompatibly + /// (a writer wanted while readers/a writer hold it, or vice-versa) and the + /// caller asked for a **non-blocking** acquire. Blocking acquires serialize + /// instead of returning this. + #[error("guarded resource is busy")] + Busy, + /// A party that is **not** the orchestrator attempted to `acquire_write` the + /// single-writer [`GuardedResource::ProjectContext`]. It must *propose* the + /// change for validation rather than writing directly. + #[error("writing the global project context is reserved to the orchestrator; propose instead")] + Forbidden, +} + +/// A RAII **read** lease: holding it guarantees shared read access to the resource; +/// dropping it releases the reader slot. N read leases may coexist on one resource. +/// +/// The lease owns an opaque release guard (a boxed handle the adapter fills with its +/// lock guard). The domain only knows the lease releases on drop. +#[must_use = "a read lease releases the guard as soon as it is dropped"] +pub struct ReadLease { + _guard: Box, +} + +impl ReadLease { + /// Wraps an adapter-provided release guard into a domain [`ReadLease`]. + /// + /// The infra adapter passes its own lock guard (e.g. a tokio `OwnedRwLockReadGuard`); + /// the domain treats it opaquely and only relies on its `Drop` to release. + #[must_use] + pub fn new(guard: Box) -> Self { + Self { _guard: guard } + } +} + +impl std::fmt::Debug for ReadLease { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ReadLease") + } +} + +/// A RAII **write** lease: holding it guarantees exclusive write access to the +/// resource; dropping it releases the writer slot. At most one write lease exists +/// for a resource at a time, and it excludes all readers. +#[must_use = "a write lease releases the guard as soon as it is dropped"] +pub struct WriteLease { + _guard: Box, +} + +impl WriteLease { + /// Wraps an adapter-provided release guard into a domain [`WriteLease`]. + #[must_use] + pub fn new(guard: Box) -> Self { + Self { _guard: guard } + } +} + +impl std::fmt::Debug for WriteLease { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("WriteLease") + } +} + +/// A reader/writer guard over the bounded set of IdeA-owned files +/// ([`GuardedResource`]). +/// +/// Contract: +/// - `acquire_read` grants a shared [`ReadLease`]; **N readers** may hold one +/// resource concurrently. +/// - `acquire_write` grants an exclusive [`WriteLease`]; it excludes all other +/// readers and writers of the **same** resource (different resources are +/// independent). +/// - [`GuardedResource::ProjectContext`] is **single-writer**: `acquire_write` by a +/// `who` that is not the orchestrator returns [`GuardError::Forbidden`]. +/// +/// Object-safe (held as `Arc`); blocking acquires serialize, so the +/// only error is `Forbidden` (the cooperative path never spuriously fails a wait). +#[async_trait] +pub trait FileGuard: Send + Sync { + /// Acquires a shared **read** lease on `res` for `who`. Serializes behind any + /// in-flight writer of the same resource, then returns a [`ReadLease`]. + /// + /// # Errors + /// [`GuardError`] — reading is never `Forbidden`, so a blocking adapter returns + /// `Ok` once the writer (if any) releases. + async fn acquire_read( + &self, + who: ConversationParty, + res: GuardedResource, + ) -> Result; + + /// Acquires an exclusive **write** lease on `res` for `who`. Serializes behind + /// any in-flight readers/writer of the same resource. + /// + /// # Errors + /// [`GuardError::Forbidden`] when `who` is not the orchestrator and `res` is the + /// single-writer [`GuardedResource::ProjectContext`]. + async fn acquire_write( + &self, + who: ConversationParty, + res: GuardedResource, + ) -> Result; +} + +/// Whether `who` is allowed to **write** `res` directly (vs. having to propose). +/// +/// Pure policy, shared by the port's documented contract and the adapter: only the +/// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone +/// may write the per-agent context and memory. The orchestrator is modelled as +/// [`ConversationParty::User`] — IdeA's single logical operator identity (the +/// human-driven orchestrator), never a project [`AgentId`]. +#[must_use] +pub fn may_write_directly(who: ConversationParty, res: &GuardedResource) -> bool { + if res.is_project_context() { + is_orchestrator(who) + } else { + true + } +} + +/// Whether `who` is the orchestrator identity for the single-writer rule. +/// +/// The orchestrator is the human-driven operator ([`ConversationParty::User`]); a +/// project agent ([`ConversationParty::Agent`]) is never the orchestrator and must +/// propose changes to the global context. +#[must_use] +pub const fn is_orchestrator(who: ConversationParty) -> bool { + matches!(who, ConversationParty::User) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn agent_party(n: u128) -> ConversationParty { + ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n))) + } + + #[test] + fn project_context_is_single_writer_to_orchestrator_only() { + assert!(may_write_directly( + ConversationParty::User, + &GuardedResource::ProjectContext + )); + assert!(!may_write_directly( + agent_party(1), + &GuardedResource::ProjectContext + )); + } + + #[test] + fn agent_context_and_memory_are_writable_by_anyone() { + let mem = GuardedResource::Memory(MemorySlug::new("note").unwrap()); + let ctx = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(9))); + for who in [ConversationParty::User, agent_party(1)] { + assert!(may_write_directly(who, &mem)); + assert!(may_write_directly(who, &ctx)); + } + } + + #[test] + fn is_orchestrator_only_for_user() { + assert!(is_orchestrator(ConversationParty::User)); + assert!(!is_orchestrator(agent_party(1))); + } + + #[test] + fn guarded_resource_equality_keys_on_identity() { + let a = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1))); + let b = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1))); + let c = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(2))); + assert_eq!(a, b); + assert_ne!(a, c); + assert_ne!( + GuardedResource::ProjectContext, + GuardedResource::Memory(MemorySlug::new("x").unwrap()) + ); + } +} diff --git a/crates/domain/src/input.rs b/crates/domain/src/input.rs new file mode 100644 index 0000000..856f32d --- /dev/null +++ b/crates/domain/src/input.rs @@ -0,0 +1,203 @@ +//! Mediated agent input (cadrage « entrée médiée par IdeA », lot C1/C2). +//! +//! Every input to an agent — **human** keystrokes *and* **inter-agent** delegations +//! — converges on **one FIFO per agent**, with `enqueue` (Envoyer) and `preempt` +//! (Interrompre) kept distinct. This module owns the pure value objects +//! ([`InputSource`], [`AgentBusyState`]) and the [`InputMediator`] port. +//! +//! The mediator **absorbs** [`crate::mailbox::AgentMailbox`]: the existing mailbox +//! (FIFO + one-shot reply) is reused as the *correlation engine* inside the infra +//! adapter (`MediatedInbox`), rather than spawning a second concurrent queue. The +//! delegation mailbox is thus just **one input source among two**. +//! +//! Pure (no I/O): the FIFO, locks and busy bookkeeping live in the infra adapter. + +use serde::{Deserialize, Serialize}; + +use crate::ids::AgentId; +use crate::mailbox::{PendingReply, Ticket, TicketId}; +use crate::ports::PtyHandle; + +/// Where a queued input came from. +/// +/// Replaces the free-form `requester: String` as the **source of truth** (the +/// string remains a derived display label). Lets IdeA propagate the requester's +/// identity and feed the wait-for graph (cycle detection). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum InputSource { + /// The human operator (a `SubmitHumanInput` use case). + Human, + /// Another agent delegating via `idea_ask_agent`. + #[serde(rename_all = "camelCase")] + Agent { + /// The delegating agent. + agent_id: AgentId, + }, +} + +impl InputSource { + /// Convenience constructor for an agent source. + #[must_use] + pub const fn agent(agent_id: AgentId) -> Self { + Self::Agent { agent_id } + } + + /// Returns the [`AgentId`] when this source is an agent. + #[must_use] + pub const fn as_agent(&self) -> Option { + match self { + Self::Agent { agent_id } => Some(*agent_id), + Self::Human => None, + } + } + + /// Whether the source is the human operator. + #[must_use] + pub const fn is_human(&self) -> bool { + matches!(self, Self::Human) + } +} + +/// Whether an agent is currently processing a task. +/// +/// Derived state, published to the front (`AgentBusyChanged`). An agent goes `Busy` +/// on the enqueue that **starts a turn**, and returns `Idle` on prompt-ready OR an +/// explicit signal (cf. cadrage §6). In doubt it stays `Busy`, but the FIFO keeps +/// accepting enqueues (forward, never reject the sender). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum AgentBusyState { + /// No turn in flight; the next enqueued ticket can start. + Idle, + /// A turn is in flight. + #[serde(rename_all = "camelCase")] + Busy { + /// The ticket whose turn is running. + ticket: TicketId, + /// When the turn started (epoch millis), for UI age display. + since_ms: u64, + }, +} + +impl AgentBusyState { + /// Whether a turn is currently in flight. + #[must_use] + pub const fn is_busy(&self) -> bool { + matches!(self, Self::Busy { .. }) + } + + /// The in-flight ticket, if any. + #[must_use] + pub const fn ticket(&self) -> Option { + match self { + Self::Busy { ticket, .. } => Some(*ticket), + Self::Idle => None, + } + } +} + +/// The single convergence point of **all** of an agent's input (one FIFO/agent), +/// with `enqueue` (Envoyer) and `preempt` (Interrompre) kept **distinct**, plus the +/// busy state. +/// +/// Object-safe (held as `Arc`). The infra adapter `MediatedInbox` +/// composes the existing [`crate::mailbox::AgentMailbox`] (correlation by ticket) + +/// turn locks + busy bookkeeping — it does **not** create a second queue. +pub trait InputMediator: Send + Sync { + /// Envoyer = enqueue: appends `ticket` to `agent`'s FIFO and returns the + /// [`PendingReply`] to await (the mailbox reply type, reused). + /// + /// **Delivery** (cadrage C3 §5.2): the implementation also **writes** the turn + /// into the agent's input stream — *this* is the single, serialized write path + /// that replaces the orchestrator's former ad-hoc PTY write. The write target is + /// the [`PtyHandle`] previously registered with [`InputMediator::bind_handle`]; + /// without one, the enqueue still queues the ticket (forward, never reject) and + /// the orchestrator falls back to its own delivery. + fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply; + + /// Registers (or refreshes) the live input [`PtyHandle`] of `agent` so a later + /// [`InputMediator::enqueue`] delivers the turn through it (cadrage C3 §5.2). + /// + /// Keyed by **agent** (one live input stream per agent — «1 agent = 1 employee»); + /// the orchestrator calls this once it has resolved/launched the agent's live + /// session for the target conversation. Default: no-op (a mediator that does not + /// own the delivery write). + fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {} + + /// Like [`InputMediator::bind_handle`], but also arms **prompt-ready detection** + /// (cadrage §6, lot C5): `prompt_ready_pattern` is the agent profile's optional + /// literal marker (`AgentProfile::prompt_ready_pattern`). When `Some`, the mediator + /// watches the bound handle's output stream and calls [`InputMediator::mark_idle`] + /// the first time the marker appears (one of the two OR signals; the other is an + /// explicit `idea_reply`). When `None`, no pattern detection is armed — the agent + /// only returns `Idle` on the explicit signal or the per-turn timeout (fallback + /// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`). + /// + /// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern (a + /// mediator that does not observe the output stream). The infra adapter overrides + /// it to arm the watcher. + fn bind_handle_with_prompt( + &self, + agent: AgentId, + handle: PtyHandle, + _prompt_ready_pattern: Option, + ) { + self.bind_handle(agent, handle); + } + + /// Whether this mediator owns the turn-delivery write for `agent` (i.e. it has a + /// bound handle **and** a PTY port). When `false`, the orchestrator must deliver + /// the turn itself. Default: `false`. + #[must_use] + fn delivers_turn(&self, _agent: AgentId) -> bool { + false + } + + /// Interrompre = preempt: signals the running turn to stop (Échap/stop). This + /// is **not** an enqueue and correlates **no** ticket. + fn preempt(&self, agent: AgentId); + + /// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances. + fn mark_idle(&self, agent: AgentId); + + /// The current [`AgentBusyState`] of `agent`. + fn busy_state(&self, agent: AgentId) -> AgentBusyState; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + fn ticket(n: u128) -> TicketId { + TicketId::from_uuid(uuid::Uuid::from_u128(n)) + } + + #[test] + fn input_source_human_vs_agent() { + assert!(InputSource::Human.is_human()); + assert_eq!(InputSource::Human.as_agent(), None); + let s = InputSource::agent(agent(1)); + assert!(!s.is_human()); + assert_eq!(s.as_agent(), Some(agent(1))); + } + + #[test] + fn busy_state_transitions_and_accessors() { + let idle = AgentBusyState::Idle; + assert!(!idle.is_busy()); + assert_eq!(idle.ticket(), None); + + let busy = AgentBusyState::Busy { + ticket: ticket(7), + since_ms: 1000, + }; + assert!(busy.is_busy()); + assert_eq!(busy.ticket(), Some(ticket(7))); + assert_ne!(idle, busy); + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index bbd6744..f0d46eb 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -31,11 +31,15 @@ #![warn(missing_docs)] pub mod agent; +pub mod conversation; pub mod error; pub mod events; +pub mod fileguard; pub mod git; pub mod ids; +pub mod input; pub mod layout; +pub mod mailbox; pub mod markdown; pub mod memory; pub mod orchestrator; @@ -72,6 +76,20 @@ pub use profile::{ AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy, }; +pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId}; + +pub use conversation::{ + Conversation, ConversationError, ConversationId, ConversationParty, ConversationRegistry, + ConversationSession, SessionRef, WaitForGraph, +}; + +pub use input::{AgentBusyState, InputMediator, InputSource}; + +pub use fileguard::{ + is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, + WriteLease, +}; + pub use markdown::MarkdownDoc; pub use memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType}; diff --git a/crates/domain/src/mailbox.rs b/crates/domain/src/mailbox.rs new file mode 100644 index 0000000..5e924b3 --- /dev/null +++ b/crates/domain/src/mailbox.rs @@ -0,0 +1,330 @@ +//! Inter-agent **mailbox** port (Option 1 « Terminal + MCP », lot B-1). +//! +//! A delegating agent calls `idea_ask_agent(target, task)` and **blocks** until the +//! target renders a result via `idea_reply(result)`. Between those two MCP calls, +//! IdeA must (1) hand the task to the target's single FIFO input and (2) hold the +//! caller's await on a reply slot that the target's later `idea_reply` resolves. +//! +//! This module owns the **pure** contract of that rendezvous: the [`AgentMailbox`] +//! port plus its value objects ([`Ticket`], [`TicketId`], [`MailboxError`]) and the +//! [`PendingReply`] handle the caller awaits. It is **I/O-free**: the actual queue, +//! its mutex and the one-shot reply channel are an infrastructure concern (the +//! `InMemoryMailbox` adapter). Keeping the contract here means the FIFO/resolution +//! invariants are expressed against a port the application layer depends on, never +//! against a concrete channel type (hexagonal + DIP). +//! +//! ## Model +//! +//! - **One queue per target agent** (`AgentId`). `enqueue` appends a [`Ticket`] and +//! returns a [`PendingReply`] the caller awaits. +//! - **`resolve(agent, result)` corrèle implicitement** the result with the ticket +//! at the **head** of that agent's queue — the agent is processing one task at a +//! time (1 agent = 1 employee, FIFO input), so its current `idea_reply` answers +//! the task it is currently working on. No ticket id is exposed to the model. +//! - **Timeout** is the caller's concern: it drops its [`PendingReply`] and calls +//! [`AgentMailbox::cancel_head`] to retire the stuck head ticket so the queue +//! advances. + +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use crate::conversation::ConversationId; +use crate::ids::AgentId; +use crate::input::InputSource; + +/// Identifies one queued [`Ticket`] within a target agent's mailbox. +/// +/// Newtype around [`uuid::Uuid`]; minted by the adapter on `enqueue`. It is **never +/// exposed to the model** (the MCP `idea_reply` schema carries only `result`): +/// correlation is positional (head of the FIFO), the id only lets the caller name +/// the exact ticket it wants retired on timeout ([`AgentMailbox::cancel_head`]). +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, +)] +#[serde(transparent)] +pub struct TicketId(pub uuid::Uuid); + +impl TicketId { + /// Wraps an existing [`uuid::Uuid`]. + #[must_use] + pub const fn from_uuid(id: uuid::Uuid) -> Self { + Self(id) + } + + /// Mints a fresh random ticket id. + #[must_use] + pub fn new_random() -> Self { + Self(uuid::Uuid::new_v4()) + } + + /// Returns the inner [`uuid::Uuid`]. + #[must_use] + pub const fn as_uuid(&self) -> uuid::Uuid { + self.0 + } +} + +impl std::fmt::Display for TicketId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A delegation request queued for a target agent: who asked, and what for. +/// +/// The `id` lets the caller retire **this** ticket on timeout; `requester` and +/// `task` are carried so the application layer can prefix the task with the asking +/// agent's identity when it writes to the target's input (`[IdeA · tâche de A · +/// ticket …]`). Pure value object (no behaviour, no I/O). +/// +/// **Origin & target** (cadrage C1): a ticket also carries its [`InputSource`] +/// (Human or Agent — the *source of truth* for the requester, the `requester` +/// string being a derived display label) and the [`ConversationId`] of the thread +/// it enters. These are added through **additive** constructors ([`Ticket::from_human`], +/// [`Ticket::from_agent`]); [`Ticket::new`] keeps its signature (Open/Closed) and +/// defaults to a `Human` source with a nil conversation for back-compat. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ticket { + /// Stable id of this queued request (minted by the adapter on `enqueue`). + pub id: TicketId, + /// The origin of this input (Human or a delegating Agent). + pub source: InputSource, + /// The conversation thread this task enters. + pub conversation: ConversationId, + /// Display name (or id) of the agent that issued the `idea_ask_agent`. + pub requester: String, + /// The task/message to deliver to the target agent. + pub task: String, +} + +impl Ticket { + /// Builds a ticket from its parts (back-compat: `Human` source, nil conversation). + /// + /// Preserved for existing call sites. Prefer [`Ticket::from_human`] / + /// [`Ticket::from_agent`] when the source and conversation are known. + #[must_use] + pub fn new(id: TicketId, requester: impl Into, task: impl Into) -> Self { + Self { + id, + source: InputSource::Human, + conversation: ConversationId::from_uuid(uuid::Uuid::nil()), + requester: requester.into(), + task: task.into(), + } + } + + /// Builds a ticket originating from the **human** operator, for a given thread. + #[must_use] + pub fn from_human( + id: TicketId, + conversation: ConversationId, + requester: impl Into, + task: impl Into, + ) -> Self { + Self { + id, + source: InputSource::Human, + conversation, + requester: requester.into(), + task: task.into(), + } + } + + /// Builds a ticket originating from a delegating **agent**, for a given thread. + #[must_use] + pub fn from_agent( + id: TicketId, + source: AgentId, + conversation: ConversationId, + requester: impl Into, + task: impl Into, + ) -> Self { + Self { + id, + source: InputSource::agent(source), + conversation, + requester: requester.into(), + task: task.into(), + } + } +} + +/// Errors raised by the [`AgentMailbox`] port. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum MailboxError { + /// `resolve` was called for an agent whose queue is **empty** — an + /// `idea_reply` with no matching `idea_ask_agent` in flight. Surfaced as a typed + /// error (never a panic) so the offending agent can read it and adjust. + #[error("no pending request to reply to for agent {0}")] + NoPendingRequest(AgentId), + /// The awaited reply channel closed before a result arrived — the target's + /// session ended (or was killed) without rendering an `idea_reply`. The caller's + /// await resolves to this instead of hanging forever. + #[error("reply channel closed before a result was rendered")] + Cancelled, +} + +/// A handle the caller awaits to receive a target agent's reply. +/// +/// Returned by [`AgentMailbox::enqueue`]. Awaiting it yields the `String` result a +/// later [`AgentMailbox::resolve`] feeds into this ticket's slot, or +/// [`MailboxError::Cancelled`] if the reply channel closes first. The future is +/// **opaque** (the adapter builds it from its one-shot receiver): the domain stays +/// free of any concrete channel type, and the await point lives in the application +/// layer's `ask_agent`. +pub struct PendingReply { + inner: Pin> + Send>>, +} + +impl PendingReply { + /// Wraps a reply future built by the adapter (e.g. over a one-shot receiver). + #[must_use] + pub fn new( + inner: Pin> + Send>>, + ) -> Self { + Self { inner } + } +} + +impl Future for PendingReply { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.inner.as_mut().poll(cx) + } +} + +impl std::fmt::Debug for PendingReply { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PendingReply").finish_non_exhaustive() + } +} + +/// The inter-agent rendezvous port: a per-agent FIFO of delegation tickets, each +/// awaiting a one-shot reply (Option 1, lot B-1). +/// +/// **Per-agent FIFO** (1 agent = 1 employee): tickets for the **same** target are +/// served head-first; tickets for **different** targets never block one another. +/// `enqueue` returns the [`PendingReply`] the caller awaits; the target's later +/// `idea_reply` lands in [`AgentMailbox::resolve`], which corrèle it with the head +/// of that agent's queue. +/// +/// Kept object-safe (`&self`, owned/`Copy` args) so the application layer holds it +/// as `Arc`; the implementation owns all interior mutability. +pub trait AgentMailbox: Send + Sync { + /// Appends `ticket` to `agent`'s FIFO and returns the handle to await its reply. + /// + /// The returned [`PendingReply`] resolves when a later [`AgentMailbox::resolve`] + /// feeds a result to this ticket (once it reaches the head and is answered), or + /// to [`MailboxError::Cancelled`] if the reply channel closes first. + fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply; + + /// Resolves the request at the **head** of `agent`'s FIFO with `result`, + /// waking its awaiting [`PendingReply`] and removing it from the queue. + /// + /// Positional correlation: the agent processes one task at a time, so its + /// current `idea_reply` answers the head ticket. + /// + /// # Errors + /// [`MailboxError::NoPendingRequest`] when `agent` has no queued ticket (an + /// `idea_reply` with no matching ask in flight). + fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError>; + + /// Resolves the request identified by `ticket_id` **anywhere** in `agent`'s FIFO + /// with `result`, waking its awaiting [`PendingReply`] and removing it from the + /// queue (cadrage C3 §3.3 — deterministic, multi-thread correlation). + /// + /// This is the **by-ticket** counterpart of [`AgentMailbox::resolve`]: when an + /// agent answers a delegation it echoes the ticket id from the `[IdeA · … · + /// ticket ]` prefix, so IdeA correlates exactly that request even when the + /// agent has several threads in flight. + /// + /// The default implementation falls back to [`AgentMailbox::resolve`] (head of + /// queue), so a mailbox that does not track ids stays correct for mono-thread + /// agents. The in-memory adapter overrides it with true id-keyed resolution. + /// + /// # Errors + /// [`MailboxError::NoPendingRequest`] when no queued ticket matches `ticket_id` + /// (and, for the default, when `agent`'s queue is empty). + fn resolve_ticket( + &self, + agent: AgentId, + _ticket_id: TicketId, + result: String, + ) -> Result<(), MailboxError> { + self.resolve(agent, result) + } + + /// Retires the ticket `ticket_id` **iff** it is currently at the head of + /// `agent`'s FIFO (the caller timed out waiting for it), so the queue advances. + /// + /// A no-op when the head is a different ticket (the timed-out one was already + /// resolved, or another caller's ticket is now in front) — idempotent and safe. + fn cancel_head(&self, agent: AgentId, ticket_id: TicketId); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ticket_carries_its_parts() { + let id = TicketId::new_random(); + let t = Ticket::new(id, "Main", "do X"); + assert_eq!(t.id, id); + assert_eq!(t.requester, "Main"); + assert_eq!(t.task, "do X"); + // Back-compat default: human source, nil conversation. + assert_eq!(t.source, InputSource::Human); + assert_eq!( + t.conversation, + ConversationId::from_uuid(uuid::Uuid::nil()) + ); + } + + #[test] + fn from_human_carries_source_and_conversation() { + let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(5)); + let t = Ticket::from_human(TicketId::new_random(), conv, "User", "do X"); + assert_eq!(t.source, InputSource::Human); + assert_eq!(t.conversation, conv); + assert_eq!(t.task, "do X"); + } + + #[test] + fn from_agent_carries_agent_source_and_conversation() { + let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(6)); + let from = AgentId::from_uuid(uuid::Uuid::from_u128(2)); + let t = Ticket::from_agent(TicketId::new_random(), from, conv, "A", "delegate"); + assert_eq!(t.source, InputSource::agent(from)); + assert_eq!(t.source.as_agent(), Some(from)); + assert_eq!(t.conversation, conv); + } + + #[test] + fn ticket_id_roundtrips_through_uuid() { + let u = uuid::Uuid::from_u128(7); + let id = TicketId::from_uuid(u); + assert_eq!(id.as_uuid(), u); + assert_eq!(id.to_string(), u.to_string()); + } + + #[test] + fn mailbox_errors_are_distinct_and_typed() { + let a = AgentId::from_uuid(uuid::Uuid::from_u128(1)); + assert_ne!( + MailboxError::NoPendingRequest(a), + MailboxError::Cancelled + ); + } +} diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index 4b474ea..19ff4de 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -13,7 +13,9 @@ use serde::{Deserialize, Serialize}; -use crate::ids::NodeId; +use crate::conversation::ConversationParty; +use crate::ids::{AgentId, NodeId}; +use crate::mailbox::TicketId; use crate::skill::SkillScope; /// Errors raised while validating a raw [`OrchestratorRequest`]. @@ -46,6 +48,14 @@ pub enum OrchestratorError { /// The offending visibility value. visibility: String, }, + /// The emitting-agent id (`requestedBy`) for `agent.reply` is not a valid id. + #[error("invalid emitting-agent id `{value}` for action `{action}`")] + InvalidAgentId { + /// The action being validated. + action: String, + /// The offending id value. + value: String, + }, } /// The raw, wire-level orchestrator request as deserialised from a request file. @@ -103,6 +113,25 @@ pub struct OrchestratorRequest { /// other actions. #[serde(default, skip_serializing_if = "Option::is_none")] pub scope: Option, + /// Result body for `agent.reply` (`idea_reply`): the content the emitting agent + /// renders for the requester. Required by `agent.reply`, ignored otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + /// Optional ticket id echoed by `agent.reply` (`idea_reply`): when present, the + /// reply correlates **by ticket** (deterministic, multi-thread); absent, it falls + /// back to the head of the emitting agent's queue (cadrage C3 §3.3). Ignored by + /// the other actions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ticket: Option, + /// Markdown body for the FileGuard-mediated context/memory tools (`context.propose`, + /// `memory.write`, cadrage C7). Required by those actions, ignored otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + /// Target memory note slug for the memory tools (`memory.read`/`memory.write`, + /// cadrage C7). Required by `memory.write`; optional for `memory.read` (absent ⇒ + /// the aggregated index). Ignored by the other actions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option, } /// A validated orchestrator command — the only thing the application layer acts on. @@ -149,6 +178,31 @@ pub enum OrchestratorCommand { target: String, /// The task/message to send and await a reply for. task: String, + /// The **requesting** agent (handshake identity), when the ask originates + /// from another agent's `idea_ask_agent`. `None` for an ask with no carried + /// requester (e.g. a file-protocol request without `requestedBy`): the + /// orchestrator then routes it on the `User↔target` thread. This is what lets + /// `ask_agent` resolve the **A↔B** conversation and feed the wait-for graph + /// (cadrage C3 §5.2). + requester: Option, + }, + /// The emitting agent renders the **result** of the task it is currently + /// processing (Option 1 « Terminal + MCP », `idea_reply`). + /// + /// Correlation is **implicit and positional**: `from` is the emitting agent's id + /// (carried by the MCP handshake, never a model-managed value), so the result + /// resolves the ticket at the head of *that agent's* mailbox queue — the + /// delegation it is working on. No ticket id is exposed to the model. + Reply { + /// The emitting agent (handshake identity), whose request the result resolves. + from: AgentId, + /// The ticket the reply correlates to (echoed by the agent from the + /// `[IdeA · … · ticket ]` prefix). `Some` ⇒ correlate **by ticket** + /// (deterministic, multi-thread); `None` ⇒ fall back to the head of `from`'s + /// queue (compat mono-thread agents) (cadrage C3 §3.3). + ticket: Option, + /// The result content the requester awaits. + result: 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 @@ -163,6 +217,47 @@ pub enum OrchestratorCommand { /// Scope the skill is created in (defaults to [`SkillScope::Project`]). scope: SkillScope, }, + /// Read an IdeA-owned context `.md` under the [`crate::fileguard::FileGuard`] + /// (cadrage C7). `target` absent = the **global project context**; otherwise the + /// named agent's context. Reading is shared (N readers). + ReadContext { + /// Target agent display name; `None` ⇒ the global project context. + target: Option, + /// The party that issued the read (handshake identity), so the guard knows + /// who acquires the read lease. + requester: ConversationParty, + }, + /// Propose new content for a context `.md` under the + /// [`crate::fileguard::FileGuard`] (cadrage C7). For an **agent** context this is + /// a direct write under a write-lease; for the **global** project context it is a + /// *proposal* (the guard forbids a non-orchestrator direct write — the change is + /// materialised as a proposal for validation). + ProposeContext { + /// Target agent display name; `None` ⇒ the global project context. + target: Option, + /// The proposed Markdown body. + content: String, + /// The proposing party (handshake identity). + requester: ConversationParty, + }, + /// Read a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7). + /// `slug` absent = the aggregated `MEMORY.md` index; otherwise one note. + ReadMemory { + /// Target note slug; `None` ⇒ the aggregated index. + slug: Option, + /// The reading party (handshake identity). + requester: ConversationParty, + }, + /// Write a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7). + /// Memory is project-shared; written directly under a write-lease. + WriteMemory { + /// Target note slug (required). + slug: String, + /// The Markdown body to store. + content: String, + /// The writing party (handshake identity). + requester: ConversationParty, + }, } /// Where IdeA should place a launched agent session. @@ -230,6 +325,30 @@ impl OrchestratorRequest { "agent.message" => Ok(OrchestratorCommand::AskAgent { target: self.require_target_agent(action)?, task: self.require("task", action, self.task.as_deref())?, + requester: self.optional_requester(action)?, + }), + "agent.reply" => Ok(OrchestratorCommand::Reply { + from: self.require_from(action)?, + ticket: self.optional_ticket(action)?, + result: self.require("result", action, self.result.as_deref())?, + }), + "context.read" => Ok(OrchestratorCommand::ReadContext { + target: self.optional_target_agent(), + requester: self.requester_party(), + }), + "context.propose" => Ok(OrchestratorCommand::ProposeContext { + target: self.optional_target_agent(), + content: self.require("content", action, self.content.as_deref())?, + requester: self.requester_party(), + }), + "memory.read" => Ok(OrchestratorCommand::ReadMemory { + slug: self.optional_slug(), + requester: self.requester_party(), + }), + "memory.write" => Ok(OrchestratorCommand::WriteMemory { + slug: self.require("slug", action, self.slug.as_deref())?, + content: self.require("content", action, self.content.as_deref())?, + requester: self.requester_party(), }), "list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents), "create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill { @@ -284,6 +403,90 @@ impl OrchestratorRequest { self.require("name", action, self.name.as_deref()) } + /// Parses the emitting agent id (`requestedBy`) for `agent.reply` into an + /// [`AgentId`]. The MCP server injects this from the loopback **handshake** + /// `requester` (never a model-managed value), so a well-formed reply always + /// carries it; a missing/blank one is a `MissingField`, a non-uuid one an + /// `InvalidAgentId`. + fn require_from(&self, action: &str) -> Result { + let raw = self.require("requestedBy", action, self.requested_by.as_deref())?; + uuid::Uuid::parse_str(&raw) + .map(AgentId::from_uuid) + .map_err(|_| OrchestratorError::InvalidAgentId { + action: action.to_owned(), + value: raw, + }) + } + + /// Parses the **optional** requester id (`requestedBy`) for `agent.message` into + /// an [`AgentId`]. The MCP handshake injects a real agent uuid here; the legacy + /// file protocol may carry a free-form **display name** instead. So this is + /// **lenient**: absent/blank/non-uuid ⇒ `None` (the ask carries no machine + /// requester and routes on the `User↔target` thread); a well-formed uuid ⇒ + /// `Some(agent)`, enabling A↔B conversation routing + the wait-for guard. + #[allow(clippy::unnecessary_wraps, clippy::unused_self)] + fn optional_requester(&self, _action: &str) -> Result, OrchestratorError> { + Ok(self + .requested_by + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) + .map(AgentId::from_uuid)) + } + + /// Parses the **optional** echoed ticket id for `agent.reply` into a [`TicketId`]. + /// Absent/blank ⇒ `None` (head-of-queue fallback); present-but-non-uuid ⇒ + /// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error). + fn optional_ticket(&self, action: &str) -> Result, OrchestratorError> { + match self.ticket.as_deref().map(str::trim) { + None | Some("") => Ok(None), + Some(raw) => uuid::Uuid::parse_str(raw) + .map(|u| Some(TicketId::from_uuid(u))) + .map_err(|_| OrchestratorError::InvalidAgentId { + action: action.to_owned(), + value: raw.to_owned(), + }), + } + } + + /// The optional target agent name for the FileGuard context tools: `targetAgent` + /// (or legacy `name`), trimmed; absent/blank ⇒ `None` (the global project context). + fn optional_target_agent(&self) -> Option { + self.target_agent + .as_deref() + .or(self.name.as_deref()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + } + + /// The optional memory slug for `memory.read`: trimmed; absent/blank ⇒ `None` + /// (the aggregated index). + fn optional_slug(&self) -> Option { + self.slug + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + } + + /// The party that issued a FileGuard-mediated request, derived from `requestedBy`. + /// A well-formed agent uuid (the MCP handshake path) ⇒ [`ConversationParty::Agent`]; + /// anything else (absent/blank/non-uuid, e.g. the orchestrator's own file-protocol + /// path) ⇒ [`ConversationParty::User`] — IdeA's single orchestrator identity, the + /// only party allowed to write the global project context directly. + fn requester_party(&self) -> ConversationParty { + self.requested_by + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) + .map_or(ConversationParty::User, |u| { + ConversationParty::agent(AgentId::from_uuid(u)) + }) + } + fn require_target_agent(&self, action: &str) -> Result { self.require( "targetAgent", @@ -447,6 +650,56 @@ mod tests { OrchestratorCommand::AskAgent { target: "Architect".to_owned(), task: "Analyse §17".to_owned(), + // `requestedBy: "Main"` is a display name, not a uuid ⇒ lenient `None`. + requester: None, + } + ); + } + + /// A well-formed uuid `requestedBy` is parsed into `Some(agent)` (the MCP + /// handshake path), enabling A↔B conversation routing. + #[test] + fn agent_message_carries_uuid_requester() { + let uid = uuid::Uuid::from_u128(7); + let r = req(&format!( + r#"{{ "type":"agent.message", "requestedBy":"{uid}", "targetAgent":"B", "task":"go" }}"# + )); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::AskAgent { + target: "B".to_owned(), + task: "go".to_owned(), + requester: Some(AgentId::from_uuid(uid)), + } + ); + } + + /// `agent.reply` correlates by ticket when the agent echoes it. + #[test] + fn agent_reply_carries_optional_ticket() { + let from = uuid::Uuid::from_u128(3); + let tkt = uuid::Uuid::from_u128(99); + let r = req(&format!( + r#"{{ "type":"agent.reply", "requestedBy":"{from}", "ticket":"{tkt}", "result":"done" }}"# + )); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::Reply { + from: AgentId::from_uuid(from), + ticket: Some(TicketId::from_uuid(tkt)), + result: "done".to_owned(), + } + ); + // Without a ticket ⇒ None (head-of-queue fallback). + let r2 = req(&format!( + r#"{{ "type":"agent.reply", "requestedBy":"{from}", "result":"done" }}"# + )); + assert_eq!( + r2.validate().unwrap(), + OrchestratorCommand::Reply { + from: AgentId::from_uuid(from), + ticket: None, + result: "done".to_owned(), } ); } @@ -575,6 +828,102 @@ mod tests { ); } + #[test] + fn context_read_global_without_target_is_user_party() { + let r = req(r#"{ "type": "context.read" }"#); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::ReadContext { + target: None, + requester: ConversationParty::User, + } + ); + } + + #[test] + fn context_read_with_uuid_requester_is_agent_party() { + let uid = uuid::Uuid::from_u128(5); + let r = req(&format!( + r#"{{ "type":"context.read", "requestedBy":"{uid}", "targetAgent":"Dev" }}"# + )); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::ReadContext { + target: Some("Dev".to_owned()), + requester: ConversationParty::agent(AgentId::from_uuid(uid)), + } + ); + } + + #[test] + fn context_propose_requires_content() { + let ok = req(r##"{ "type":"context.propose", "targetAgent":"Dev", "content":"# body" }"##); + assert_eq!( + ok.validate().unwrap(), + OrchestratorCommand::ProposeContext { + target: Some("Dev".to_owned()), + content: "# body".to_owned(), + requester: ConversationParty::User, + } + ); + let missing = req(r#"{ "type":"context.propose", "targetAgent":"Dev" }"#); + assert_eq!( + missing.validate(), + Err(OrchestratorError::MissingField { + action: "context.propose".to_owned(), + field: "content".to_owned(), + }) + ); + } + + #[test] + fn memory_read_optional_slug() { + assert_eq!( + req(r#"{ "type":"memory.read" }"#).validate().unwrap(), + OrchestratorCommand::ReadMemory { + slug: None, + requester: ConversationParty::User, + } + ); + assert_eq!( + req(r#"{ "type":"memory.read", "slug":"note-a" }"#) + .validate() + .unwrap(), + OrchestratorCommand::ReadMemory { + slug: Some("note-a".to_owned()), + requester: ConversationParty::User, + } + ); + } + + #[test] + fn memory_write_requires_slug_and_content() { + assert_eq!( + req(r#"{ "type":"memory.write", "slug":"note-a", "content":"hi" }"#) + .validate() + .unwrap(), + OrchestratorCommand::WriteMemory { + slug: "note-a".to_owned(), + content: "hi".to_owned(), + requester: ConversationParty::User, + } + ); + assert_eq!( + req(r#"{ "type":"memory.write", "content":"hi" }"#).validate(), + Err(OrchestratorError::MissingField { + action: "memory.write".to_owned(), + field: "slug".to_owned(), + }) + ); + assert_eq!( + req(r#"{ "type":"memory.write", "slug":"note-a" }"#).validate(), + Err(OrchestratorError::MissingField { + action: "memory.write".to_owned(), + field: "content".to_owned(), + }) + ); + } + #[test] fn unknown_action_is_rejected() { let r = req(r#"{ "action": "delete_everything", "name": "a" }"#); diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index a024e09..dbd8e7e 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -283,6 +283,30 @@ pub struct AgentProfile { /// sérialisation : un profil sans MCP sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp: Option, + /// Motif de **retour-de-prompt** (cadrage « conversation par paire » §6, lot C5). + /// + /// Donnée **déclarative** (pas de code par CLI — Open/Closed) : un motif **littéral** + /// recherché par sous-chaîne dans le flux de sortie PTY du tour courant. Quand il + /// apparaît, IdeA considère l'agent **revenu au prompt** et le marque `Idle` + /// (`InputMediator::mark_idle`), ce qui fait avancer sa file. C'est l'un des deux + /// signaux du « double signal OR » (l'autre étant un `idea_reply` explicite) ; le + /// **premier** des deux qui arrive libère le tour. + /// + /// **Choix tranché : littéral, pas regex.** Une sous-chaîne littérale est + /// déterministe, sans dépendance (`regex`), suffisante pour un sigil de prompt + /// stable (ex. `"\n> "`), et ne risque pas le faux-positif d'un motif regex mal + /// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard + /// comme variante déclarative (Open/Closed) si le besoin se confirme. + /// + /// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par + /// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via + /// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste + /// `Busy` mais la file continue d'accepter » (jamais de faux `Idle`). + /// + /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation : + /// un profil sans motif sérialise exactement comme avant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_ready_pattern: Option, } /// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel). @@ -418,6 +442,7 @@ impl AgentProfile { session, structured_adapter: None, mcp: None, + prompt_ready_pattern: None, }) } @@ -439,6 +464,15 @@ impl AgentProfile { self } + /// Builder : fixe le motif de **retour-de-prompt** (§6, lot C5) et renvoie le + /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les + /// profils sans détection de prompt ne l'appellent simplement pas. + #[must_use] + pub fn with_prompt_ready_pattern(mut self, pattern: impl Into) -> Self { + self.prompt_ready_pattern = Some(pattern.into()); + 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 @@ -625,4 +659,48 @@ mod mcp_tests { let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid"); assert_eq!(s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() }); } + + // -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) -------------- + + #[test] + fn profile_default_has_no_prompt_ready_pattern() { + // Existing profiles (built via `new`) carry no pattern ⇒ no false Idle. + assert!(profile_without_mcp().prompt_ready_pattern.is_none()); + } + + #[test] + fn profile_without_prompt_pattern_omits_key_in_json() { + let json = serde_json::to_string(&profile_without_mcp()).expect("serialise"); + assert!( + !json.contains("promptReadyPattern"), + "a profile without a prompt pattern must NOT serialise the key (zero regression); got: {json}" + ); + } + + #[test] + fn legacy_json_without_prompt_pattern_deserialises_to_none() { + 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.prompt_ready_pattern.is_none()); + } + + #[test] + fn with_prompt_ready_pattern_sets_and_round_trips() { + let profile = profile_without_mcp().with_prompt_ready_pattern("\n> "); + assert_eq!(profile.prompt_ready_pattern.as_deref(), Some("\n> ")); + + let json = serde_json::to_string(&profile).expect("serialise"); + assert!(json.contains("promptReadyPattern"), "key present: {json}"); + let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(profile, back); + assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> ")); + } } diff --git a/crates/infrastructure/src/conversation/mod.rs b/crates/infrastructure/src/conversation/mod.rs new file mode 100644 index 0000000..74f4e3f --- /dev/null +++ b/crates/infrastructure/src/conversation/mod.rs @@ -0,0 +1,194 @@ +//! [`InMemoryConversationRegistry`] — the [`ConversationRegistry`] adapter (lot C2). +//! +//! The driven side of conversation-by-pair: a `HashMap` +//! plus a pair→id index, so `resolve` is **lazy get-or-create** and the same +//! unordered pair `{a, b}` always maps to the same [`ConversationId`]. +//! +//! ## Concurrency +//! +//! A single **synchronous** [`Mutex`] guards both maps; it is held only for the O(1) +//! lookups/mutations and **never across an `.await`** (this registry is fully sync, +//! cf. the `ask_locks` discipline of `service.rs`). + +use std::collections::HashMap; +use std::sync::Mutex; + +use domain::conversation::{ + Conversation, ConversationId, ConversationParty, ConversationRegistry, ConversationSession, + SessionRef, +}; + +/// Order-insensitive key for a conversation pair `{a, b}`. +/// +/// Normalised so that `{a, b}` and `{b, a}` hash/compare equal — this is what makes +/// `resolve(a, b)` and `resolve(b, a)` resolve to the same conversation. +fn pair_key(a: ConversationParty, b: ConversationParty) -> (ConversationParty, ConversationParty) { + // Stable, total ordering over parties so the smaller end is always `left` in the + // key. `User` sorts before any agent; agents order by their UUID. + fn rank(p: ConversationParty) -> (u8, u128) { + match p { + ConversationParty::User => (0, 0), + ConversationParty::Agent { agent_id } => (1, agent_id.as_uuid().as_u128()), + } + } + if rank(a) <= rank(b) { + (a, b) + } else { + (b, a) + } +} + +/// In-memory, pair-keyed conversation registry (the production [`ConversationRegistry`]). +#[derive(Default)] +pub struct InMemoryConversationRegistry { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + by_id: HashMap, + by_pair: HashMap<(ConversationParty, ConversationParty), ConversationId>, +} + +impl InMemoryConversationRegistry { + /// Creates an empty registry. + #[must_use] + pub fn new() -> Self { + Self { + inner: Mutex::new(Inner::default()), + } + } + + /// Number of conversations currently held (test/inspection helper). + #[must_use] + pub fn len(&self) -> usize { + self.lock().by_id.len() + } + + /// Whether the registry is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.lock().by_id.is_empty() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { + self.inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl ConversationRegistry for InMemoryConversationRegistry { + fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation { + let key = pair_key(a, b); + let mut inner = self.lock(); + if let Some(id) = inner.by_pair.get(&key).copied() { + // Existing thread for this pair — return its current snapshot. + return inner + .by_id + .get(&id) + .cloned() + .expect("by_pair id always present in by_id"); + } + // Lazy create: mint a fresh Dormant conversation for the pair. The pair is + // valid by construction at call sites (resolve is only asked for real pairs); + // `try_new` still guards the invariants, and on the (impossible) error we fall + // back to a normalised pair to never panic in production. + let id = ConversationId::new_random(); + let conv = Conversation::try_new(id, key.0, key.1) + .expect("pair_key yields a valid distinct/≤1-user pair"); + inner.by_pair.insert(key, id); + inner.by_id.insert(id, conv.clone()); + conv + } + + fn bind_session(&self, id: ConversationId, session: SessionRef) { + let mut inner = self.lock(); + if let Some(conv) = inner.by_id.get_mut(&id) { + conv.session = ConversationSession::Live { + handle_ref: session, + }; + } + } + + fn suspend(&self, id: ConversationId, resumable_id: Option) { + let mut inner = self.lock(); + if let Some(conv) = inner.by_id.get_mut(&id) { + conv.session = ConversationSession::Dormant; + conv.resumable_id = resumable_id; + } + } + + fn get(&self, id: ConversationId) -> Option { + self.lock().by_id.get(&id).cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::ids::{AgentId, SessionId}; + + fn agent(n: u128) -> ConversationParty { + ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n))) + } + + #[test] + fn resolve_is_lazy_get_or_create() { + let reg = InMemoryConversationRegistry::new(); + assert!(reg.is_empty()); + let c = reg.resolve(ConversationParty::User, agent(1)); + assert_eq!(reg.len(), 1); + // Same pair ⇒ same id, no new conversation created. + let c2 = reg.resolve(ConversationParty::User, agent(1)); + assert_eq!(c.id, c2.id); + assert_eq!(reg.len(), 1); + } + + #[test] + fn same_pair_unordered_yields_same_id() { + let reg = InMemoryConversationRegistry::new(); + let c1 = reg.resolve(agent(1), agent(2)); + let c2 = reg.resolve(agent(2), agent(1)); // swapped order + assert_eq!(c1.id, c2.id, "unordered pair identity"); + assert_eq!(reg.len(), 1); + } + + #[test] + fn distinct_pairs_get_distinct_ids() { + let reg = InMemoryConversationRegistry::new(); + let user_b = reg.resolve(ConversationParty::User, agent(2)); + let a_b = reg.resolve(agent(1), agent(2)); + assert_ne!(user_b.id, a_b.id, "User↔B and A↔B are different threads"); + assert_eq!(reg.len(), 2); + } + + #[test] + fn fresh_resolve_is_dormant() { + let reg = InMemoryConversationRegistry::new(); + let c = reg.resolve(ConversationParty::User, agent(1)); + assert_eq!(c.session, ConversationSession::Dormant); + } + + #[test] + fn bind_session_makes_it_live_then_suspend_restores_dormant() { + let reg = InMemoryConversationRegistry::new(); + let c = reg.resolve(ConversationParty::User, agent(1)); + let sref = SessionRef::new(SessionId::from_uuid(uuid::Uuid::from_u128(99))); + reg.bind_session(c.id, sref); + let live = reg.get(c.id).unwrap(); + assert!(live.session.is_live()); + assert_eq!(live.session, ConversationSession::Live { handle_ref: sref }); + + reg.suspend(c.id, Some("sess-abc".to_owned())); + let dormant = reg.get(c.id).unwrap(); + assert_eq!(dormant.session, ConversationSession::Dormant); + assert_eq!(dormant.resumable_id.as_deref(), Some("sess-abc")); + } + + #[test] + fn get_unknown_is_none() { + let reg = InMemoryConversationRegistry::new(); + assert!(reg.get(ConversationId::new_random()).is_none()); + } +} diff --git a/crates/infrastructure/src/fileguard/mod.rs b/crates/infrastructure/src/fileguard/mod.rs new file mode 100644 index 0000000..a5ac76b --- /dev/null +++ b/crates/infrastructure/src/fileguard/mod.rs @@ -0,0 +1,230 @@ +//! [`RwFileGuard`] — the [`FileGuard`] adapter (lot C6). +//! +//! A reader/writer lock **per [`GuardedResource`]**, backed by one +//! [`tokio::sync::RwLock`] per resource (created lazily and shared via `Arc`). N +//! concurrent readers **or** one exclusive writer per resource; different resources +//! are independent (their locks are distinct). +//! +//! The single-writer rule for [`GuardedResource::ProjectContext`] is enforced +//! **before** taking any lock: a `who` that is not the orchestrator +//! ([`domain::may_write_directly`] returning `false`) is rejected with +//! [`GuardError::Forbidden`] — it must *propose* instead. +//! +//! ## Cooperative scope (cadrage §9.5) +//! +//! This guard serialises access **inside the IdeA path** (the MCP tools / use cases). +//! It is **cooperative**: it does not sandbox an agent that keeps a raw shell — real +//! airtightness (revoking raw fs access to these paths) is an OS-sandbox concern +//! (Landlock) and is **out of scope** here. +//! +//! ## Concurrency +//! +//! The resource → lock registry lives behind a **synchronous** [`Mutex`], held only +//! for the O(1) get-or-insert of an `Arc>` and **never across an +//! `.await`**. The actual wait happens on the tokio `RwLock`'s `owned` guard, whose +//! lifetime is `'static` (it owns its `Arc`), so it can be boxed into the domain's +//! RAII [`ReadLease`]/[`WriteLease`]. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use tokio::sync::RwLock; + +use domain::conversation::ConversationParty; +use domain::fileguard::{ + may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, WriteLease, +}; + +/// The [`FileGuard`] adapter: one reader/writer lock per guarded resource. +/// +/// Held at the composition root as `Arc`; cloneable bookkeeping is +/// interior (the registry is a shared `Mutex>`). +#[derive(Debug, Default)] +pub struct RwFileGuard { + /// Lazily-created per-resource locks. The unit `()` payload is irrelevant — the + /// lock's *mode* (read vs write) is the whole point; the lease only proves the + /// slot is held until drop. + locks: Mutex>>>, +} + +impl RwFileGuard { + /// Builds an empty guard (no resource is contended until first acquired). + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Returns the shared lock for `res`, creating it on first use. The registry + /// mutex is held only for this O(1) get-or-insert, never across an `.await`. + fn lock_for(&self, res: &GuardedResource) -> Arc> { + let mut registry = self + .locks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + registry + .entry(res.clone()) + .or_insert_with(|| Arc::new(RwLock::new(()))) + .clone() + } +} + +#[async_trait] +impl FileGuard for RwFileGuard { + async fn acquire_read( + &self, + _who: ConversationParty, + res: GuardedResource, + ) -> Result { + // Reading is never forbidden (cooperative contract): serialise behind any + // in-flight writer of the same resource, then hand back the RAII lease. + let lock = self.lock_for(&res); + let guard = lock.read_owned().await; + Ok(ReadLease::new(Box::new(guard))) + } + + async fn acquire_write( + &self, + who: ConversationParty, + res: GuardedResource, + ) -> Result { + // Single-writer rule for the global project context: refuse *before* taking + // any lock so a forbidden writer never blocks readers. + if !may_write_directly(who, &res) { + return Err(GuardError::Forbidden); + } + let lock = self.lock_for(&res); + let guard = lock.write_owned().await; + Ok(WriteLease::new(Box::new(guard))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::ids::AgentId; + use domain::memory::MemorySlug; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + fn agent_party(n: u128) -> ConversationParty { + ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n))) + } + + fn mem(slug: &str) -> GuardedResource { + GuardedResource::Memory(MemorySlug::new(slug).unwrap()) + } + + #[tokio::test] + async fn many_readers_share_one_resource_concurrently() { + let guard = RwFileGuard::new(); + // Three read leases held *at once* on the same resource must all be granted. + let a = guard + .acquire_read(ConversationParty::User, mem("note")) + .await + .unwrap(); + let b = guard + .acquire_read(agent_party(1), mem("note")) + .await + .unwrap(); + let c = guard + .acquire_read(agent_party(2), mem("note")) + .await + .unwrap(); + // If reads were exclusive this would have deadlocked above; reaching here is + // the proof. Keep the leases alive until now. + drop((a, b, c)); + } + + #[tokio::test] + async fn writer_excludes_other_writers_serialising_them() { + let guard = Arc::new(RwFileGuard::new()); + let counter = Arc::new(AtomicUsize::new(0)); + let observed_max = Arc::new(AtomicUsize::new(0)); + + let mut handles = Vec::new(); + for _ in 0..8 { + let guard = Arc::clone(&guard); + let counter = Arc::clone(&counter); + let observed_max = Arc::clone(&observed_max); + handles.push(tokio::spawn(async move { + let _lease = guard + .acquire_write(ConversationParty::User, mem("shared")) + .await + .unwrap(); + // While holding the write lease, at most one task may be inside. + let inside = counter.fetch_add(1, Ordering::SeqCst) + 1; + observed_max.fetch_max(inside, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(5)).await; + counter.fetch_sub(1, Ordering::SeqCst); + })); + } + for h in handles { + h.await.unwrap(); + } + assert_eq!( + observed_max.load(Ordering::SeqCst), + 1, + "a write lease must be exclusive — never two writers inside at once" + ); + } + + #[tokio::test] + async fn agent_writing_project_context_is_forbidden() { + let guard = RwFileGuard::new(); + let err = guard + .acquire_write(agent_party(1), GuardedResource::ProjectContext) + .await + .unwrap_err(); + assert_eq!(err, GuardError::Forbidden); + } + + #[tokio::test] + async fn orchestrator_may_write_project_context() { + let guard = RwFileGuard::new(); + let lease = guard + .acquire_write(ConversationParty::User, GuardedResource::ProjectContext) + .await; + assert!(lease.is_ok()); + } + + #[tokio::test] + async fn write_lease_releases_on_drop_letting_the_next_writer_in() { + let guard = Arc::new(RwFileGuard::new()); + { + let _lease = guard + .acquire_write(ConversationParty::User, mem("r")) + .await + .unwrap(); + // Held here; a concurrent writer would block. + } // <- RAII release at end of scope. + + // After the scope, a fresh writer acquires immediately (bounded wait). + let again = tokio::time::timeout( + Duration::from_millis(200), + guard.acquire_write(ConversationParty::User, mem("r")), + ) + .await + .expect("lease must have released on drop") + .unwrap(); + drop(again); + } + + #[tokio::test] + async fn distinct_resources_do_not_block_each_other() { + let guard = RwFileGuard::new(); + let _w1 = guard + .acquire_write(ConversationParty::User, mem("alpha")) + .await + .unwrap(); + // A writer on a *different* resource is independent — must not block. + let w2 = tokio::time::timeout( + Duration::from_millis(200), + guard.acquire_write(ConversationParty::User, mem("beta")), + ) + .await + .expect("independent resources must not contend") + .unwrap(); + drop(w2); + } +} diff --git a/crates/infrastructure/src/input/mod.rs b/crates/infrastructure/src/input/mod.rs new file mode 100644 index 0000000..7e26fdf --- /dev/null +++ b/crates/infrastructure/src/input/mod.rs @@ -0,0 +1,789 @@ +//! [`MediatedInbox`] — the [`InputMediator`] adapter (lot C2). +//! +//! The single convergence point of an agent's input. It **composes** the existing +//! [`InMemoryMailbox`] (FIFO + one-shot reply — the correlation engine) and adds the +//! two things the mailbox alone does not express: +//! +//! - a **busy/turn** bookkeeping per agent ([`AgentBusyState`]), so the front can be +//! told when an agent is processing; +//! - a **preempt** signal distinct from `enqueue` (Interrompre ≠ Envoyer): it does +//! **not** queue anything and correlates no ticket. +//! +//! It does **not** spawn a second queue: the FIFO is the mailbox's. The first +//! enqueue while `Idle` starts a turn (agent → `Busy`); `mark_idle` ends it and lets +//! the next ticket start. In doubt we stay `Busy` but **keep accepting** enqueues +//! (forward, never reject — cf. cadrage §6 fallback). +//! +//! ## Concurrency +//! +//! Busy state lives behind a **synchronous** [`Mutex`], held only for O(1) reads and +//! mutations and **never across an `.await`** (the await is the caller's, on the +//! returned [`PendingReply`]). The mailbox owns its own locking. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use domain::events::DomainEvent; +use domain::ids::AgentId; +use domain::input::{AgentBusyState, InputMediator}; +use domain::mailbox::{AgentMailbox, PendingReply, Ticket}; +use domain::ports::{EventBus, PtyHandle, PtyPort}; + +use crate::mailbox::InMemoryMailbox; + +/// Shared busy/idle bookkeeping for one set of agents. +/// +/// Extracted so the **prompt-ready watcher** (a detached thread observing an agent's +/// PTY output, lot C5) can flip an agent back to `Idle` without holding the whole +/// [`MediatedInbox`]: it only needs the busy map + the event bus. This is the single +/// authority for the `Busy→Idle` transition and its `AgentBusyChanged` event, so +/// every path (explicit `mark_idle`, prompt-ready match) stays consistent. +struct BusyTracker { + busy: Mutex>, + events: Option>, +} + +impl BusyTracker { + fn new(events: Option>) -> Self { + Self { + busy: Mutex::new(HashMap::new()), + events, + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.busy + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn busy_state(&self, agent: AgentId) -> AgentBusyState { + self.lock() + .get(&agent) + .copied() + .unwrap_or(AgentBusyState::Idle) + } + + /// Marks `agent` `Busy` if it was `Idle`, returning whether a turn actually + /// started (so the caller publishes `AgentBusyChanged{busy:true}` only once). + fn start_turn(&self, agent: AgentId, state: AgentBusyState) -> bool { + let mut busy = self.lock(); + let entry = busy.entry(agent).or_insert(AgentBusyState::Idle); + if entry.is_busy() { + false + } else { + *entry = state; + true + } + } + + /// Marks `agent` `Idle`, publishing `AgentBusyChanged{busy:false}` only on a real + /// `Busy→Idle` transition. Idempotent: a `mark_idle` on an already-idle agent is a + /// no-op and emits nothing. + fn mark_idle(&self, agent: AgentId) { + let was_busy = { + let mut busy = self.lock(); + busy.insert(agent, AgentBusyState::Idle) + .is_some_and(|s| s.is_busy()) + }; + if was_busy { + if let Some(events) = &self.events { + events.publish(DomainEvent::AgentBusyChanged { + agent_id: agent, + busy: false, + }); + } + } + } +} + +/// Supplies the epoch-millis stamp used for `AgentBusyState::Busy { since_ms }`. +/// +/// Injectable so tests are deterministic and the adapter stays decoupled from the +/// wall clock (the composition root wires the real clock). +pub trait MillisClock: Send + Sync { + /// Current time as milliseconds since the Unix epoch. + fn now_ms(&self) -> u64; +} + +/// Wall-clock implementation of [`MillisClock`] (composition-root default). +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemMillisClock; + +impl MillisClock for SystemMillisClock { + fn now_ms(&self) -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) + .unwrap_or(0) + } +} + +/// In-memory mediated inbox: one FIFO per agent (the mailbox) plus busy state. +/// +/// When a [`PtyPort`] is wired (cadrage C3 §5.2), `enqueue` **delivers** the turn — +/// it writes the prefixed task line into the agent's bound [`PtyHandle`]. This is the +/// single, serialized write path that replaces the orchestrator's former ad-hoc PTY +/// write (no more `[IdeA · tâche …]` line emitted from `ask_agent`, no `\r` band-aid). +pub struct MediatedInbox { + mailbox: Arc, + /// Shared busy/idle authority (also handed to prompt-ready watcher threads, C5). + tracker: Arc, + clock: Arc, + /// Optional PTY port: present ⇒ the inbox owns the turn-delivery write **and** can + /// observe an agent's output stream for prompt-ready detection (lot C5). + pty: Option>, + /// Per-agent live input handle (one stream per agent), fed by `bind_handle`. + handles: Mutex>, + /// Agents whose prompt-ready watcher thread is already armed, so re-binding the same + /// handle does not spawn a duplicate watcher (lot C5). Shared (`Arc`) because each + /// watcher thread un-arms its own entry on exit. + watched: Arc>>, +} + +impl MediatedInbox { + /// Builds an inbox over a shared [`InMemoryMailbox`] with the given clock, without + /// turn delivery (the orchestrator writes the turn itself). + #[must_use] + pub fn new(mailbox: Arc, clock: Arc) -> Self { + Self { + mailbox, + tracker: Arc::new(BusyTracker::new(None)), + clock, + pty: None, + handles: Mutex::new(HashMap::new()), + watched: Arc::new(Mutex::new(HashSet::new())), + } + } + + /// Wires an [`EventBus`] so busy/idle transitions publish + /// [`DomainEvent::AgentBusyChanged`] at their source (cadrage C4 §4.2). Builder + /// additive: callers that do not wire a bus stay silent. + #[must_use] + pub fn with_events(mut self, events: Arc) -> Self { + self.tracker = Arc::new(BusyTracker::new(Some(events))); + self + } + + /// Builds an inbox that **delivers** the turn through `pty` to each agent's bound + /// handle (cadrage C3 §5.2). Use [`MediatedInbox::bind_handle`] to register the + /// agent's live handle before/at enqueue time. + #[must_use] + pub fn with_pty( + mailbox: Arc, + clock: Arc, + pty: Arc, + ) -> Self { + Self { + mailbox, + tracker: Arc::new(BusyTracker::new(None)), + clock, + pty: Some(pty), + handles: Mutex::new(HashMap::new()), + watched: Arc::new(Mutex::new(HashSet::new())), + } + } + + /// Convenience constructor over a fresh mailbox and the wall clock. + #[must_use] + pub fn in_memory() -> Self { + Self::new(Arc::new(InMemoryMailbox::new()), Arc::new(SystemMillisClock)) + } + + fn handles(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.handles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// The underlying mailbox (e.g. for `cancel_head` / `resolve` from the orchestrator). + #[must_use] + pub fn mailbox(&self) -> Arc { + Arc::clone(&self.mailbox) + } + + fn watched(&self) -> std::sync::MutexGuard<'_, HashSet> { + self.watched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Arms the **prompt-ready watcher** for `agent` (lot C5). + /// + /// Requires a wired [`PtyPort`] **and** a non-empty literal `pattern`. Spawns a + /// detached thread that consumes the handle's output stream and calls + /// [`BusyTracker::mark_idle`] the **first** time `pattern` appears as a substring of + /// the cumulative output, then exits (one-shot per arming). A sliding buffer keeps + /// the last `pattern.len()-1` bytes so a marker split across two chunks still + /// matches. No watcher is armed when the port is absent or the pattern is empty + /// (no detection ⇒ Idle only via explicit signal/timeout — the safe fallback). + /// + /// Re-arming the same agent is a no-op while a watcher is already live (tracked in + /// `watched`), so re-binding a handle never spawns duplicate watchers. + fn arm_prompt_watcher(&self, agent: AgentId, handle: &PtyHandle, pattern: String) { + if pattern.is_empty() { + return; + } + let Some(pty) = self.pty.clone() else { + return; + }; + // Already watching this agent ⇒ keep the live watcher (avoid duplicates). + if !self.watched().insert(agent) { + return; + } + let stream = match pty.subscribe_output(handle) { + Ok(s) => s, + Err(_) => { + // Could not subscribe (unknown handle): un-arm so a later bind retries. + self.watched().remove(&agent); + return; + } + }; + let tracker = Arc::clone(&self.tracker); + let watched = Arc::clone(&self.watched); + let needle = pattern.into_bytes(); + std::thread::spawn(move || { + // Cumulative tail kept small: just enough to catch a marker split across two + // chunks (keep the last needle.len()-1 bytes between reads). + let keep = needle.len().saturating_sub(1); + let mut window: Vec = Vec::with_capacity(keep + 1); + for chunk in stream { + window.extend_from_slice(&chunk); + if window + .windows(needle.len()) + .any(|w| w == needle.as_slice()) + { + // Prompt-ready: first OR signal wins ⇒ Idle (advances the FIFO). + tracker.mark_idle(agent); + break; + } + if window.len() > keep { + let drop_to = window.len() - keep; + window.drain(..drop_to); + } + } + // Watcher done (matched or stream closed at EOF): un-arm so a future bind + // (e.g. after a relaunch) can re-arm a fresh watcher. + watched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&agent); + }); + } +} + +impl InputMediator for MediatedInbox { + fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + let ticket_id = ticket.id; + // If the agent is Idle, this enqueue starts its turn ⇒ go Busy. If already + // Busy, we still accept (queue grows; the turn advances on mark_idle) — never + // reject the sender (forward fallback). + let started_turn = self.tracker.start_turn( + agent, + AgentBusyState::Busy { + ticket: ticket_id, + since_ms: self.clock.now_ms(), + }, + ); + // Publish Busy only on the enqueue that **starts** a turn (Idle→Busy); a + // second enqueue while Busy queues behind without re-announcing (cadrage + // C4 §4.2). Published outside the busy mutex (the tracker released it above). + if started_turn { + if let Some(events) = &self.tracker.events { + events.publish(DomainEvent::AgentBusyChanged { + agent_id: agent, + busy: true, + }); + } + } + // Delivery: write the prefixed task line into the agent's bound handle. The + // prefix carries the requester + ticket id so the target replies via + // `idea_reply(result, ticket)`. A best-effort write: a missing handle/port or + // a write error never drops the ticket (the orchestrator's await + timeout + // remain the safety net) — the reply slot is registered regardless. + if let Some(pty) = &self.pty { + if let Some(handle) = self.handles().get(&agent).cloned() { + let line = format!( + "[IdeA · tâche de {} · ticket {}] {}\n", + ticket.requester, ticket_id, ticket.task + ); + let _ = pty.write(&handle, line.as_bytes()); + } + } + self.mailbox.enqueue(agent, ticket) + } + + fn bind_handle(&self, agent: AgentId, handle: PtyHandle) { + self.handles().insert(agent, handle); + } + + fn bind_handle_with_prompt( + &self, + agent: AgentId, + handle: PtyHandle, + prompt_ready_pattern: Option, + ) { + // Register the input handle (delivery path) exactly like `bind_handle`, then arm + // the prompt-ready watcher when the profile declares a literal marker (C5). A + // `None`/empty pattern arms nothing: Idle then comes only from the explicit + // signal or the per-turn timeout (safe fallback, never a false Idle). + self.handles().insert(agent, handle.clone()); + if let Some(pattern) = prompt_ready_pattern { + self.arm_prompt_watcher(agent, &handle, pattern); + } + } + + fn delivers_turn(&self, agent: AgentId) -> bool { + self.pty.is_some() && self.handles().get(&agent).is_some() + } + + fn preempt(&self, agent: AgentId) { + // Interrompre: signals the running turn to stop. It is NOT an enqueue and + // correlates **no** ticket (we never pop/resolve a pending caller — preempt + // must never silently answer one). The only effect is a best-effort interrupt + // byte written into the agent's bound PTY handle: ESC (`\x1b`), the stop key + // CLI agents honour. A missing handle/port is a no-op (the agent simply has no + // live stream to interrupt). The busy state is left untouched: it returns to + // Idle through `mark_idle` (prompt-ready / explicit signal), not here. + if let Some(pty) = &self.pty { + if let Some(handle) = self.handles().get(&agent).cloned() { + let _ = pty.write(&handle, b"\x1b"); + } + } + } + + fn mark_idle(&self, agent: AgentId) { + // Single authority (also used by the prompt-ready watcher): real Busy→Idle only, + // publishing AgentBusyChanged{busy:false} once. + self.tracker.mark_idle(agent); + } + + fn busy_state(&self, agent: AgentId) -> AgentBusyState { + self.tracker.busy_state(agent) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::conversation::ConversationId; + use domain::mailbox::TicketId; + + /// Deterministic clock for assertions on `since_ms`. + struct FixedClock(u64); + impl MillisClock for FixedClock { + fn now_ms(&self) -> u64 { + self.0 + } + } + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + fn ticket(n: u128, task: &str) -> Ticket { + Ticket::from_human( + TicketId::from_uuid(uuid::Uuid::from_u128(n)), + ConversationId::from_uuid(uuid::Uuid::from_u128(1000 + n)), + "User", + task, + ) + } + + fn inbox_at(now_ms: u64) -> MediatedInbox { + MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(now_ms))) + } + + /// Records every [`DomainEvent`] published, for busy/idle assertions. + #[derive(Default)] + struct RecordingBus(Mutex>); + impl EventBus for RecordingBus { + fn publish(&self, event: DomainEvent) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(event); + } + fn subscribe(&self) -> domain::ports::EventStream { + unreachable!("RecordingBus is publish-only for these tests") + } + } + impl RecordingBus { + fn busy_events(&self) -> Vec<(AgentId, bool)> { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter_map(|e| match e { + DomainEvent::AgentBusyChanged { agent_id, busy } => Some((*agent_id, *busy)), + _ => None, + }) + .collect() + } + } + + #[test] + fn busy_event_fires_on_turn_start_and_idle_on_mark_idle() { + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + + // First enqueue starts a turn ⇒ exactly one Busy(true) event. + inbox.enqueue(a, ticket(10, "first")); + assert_eq!(bus.busy_events(), vec![(a, true)]); + + // Second enqueue while Busy queues behind ⇒ NO new busy event. + inbox.enqueue(a, ticket(11, "second")); + assert_eq!(bus.busy_events(), vec![(a, true)], "no re-announce while busy"); + + // mark_idle on a busy agent ⇒ exactly one Idle(false) event. + inbox.mark_idle(a); + assert_eq!(bus.busy_events(), vec![(a, true), (a, false)]); + + // mark_idle on an already-idle agent ⇒ no spurious event. + inbox.mark_idle(a); + assert_eq!(bus.busy_events(), vec![(a, true), (a, false)]); + } + + #[test] + fn preempt_emits_no_busy_event() { + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + inbox.enqueue(a, ticket(10, "t")); + inbox.preempt(a); + // Only the enqueue's Busy(true); preempt does not toggle busy state. + assert_eq!(bus.busy_events(), vec![(a, true)]); + } + + #[tokio::test] + async fn enqueue_returns_pending_reply_resolved_via_mailbox() { + let inbox = inbox_at(5); + let a = agent(1); + let pending = inbox.enqueue(a, ticket(10, "do X")); + // Resolve through the shared mailbox (the orchestrator's path). + inbox.mailbox().resolve(a, "done".to_owned()).unwrap(); + assert_eq!(pending.await.unwrap(), "done"); + } + + #[test] + fn first_enqueue_marks_busy_with_ticket_and_stamp() { + let inbox = inbox_at(1234); + let a = agent(1); + assert_eq!(inbox.busy_state(a), AgentBusyState::Idle); + inbox.enqueue(a, ticket(10, "t")); + assert_eq!( + inbox.busy_state(a), + AgentBusyState::Busy { + ticket: TicketId::from_uuid(uuid::Uuid::from_u128(10)), + since_ms: 1234, + } + ); + } + + #[test] + fn second_enqueue_while_busy_keeps_first_ticket_and_does_not_reject() { + let inbox = inbox_at(1); + let a = agent(1); + inbox.enqueue(a, ticket(10, "first")); + inbox.enqueue(a, ticket(11, "second")); // accepted, queues behind + // Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox. + assert_eq!( + inbox.busy_state(a).ticket(), + Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))) + ); + assert_eq!(inbox.mailbox().pending(&a), 2, "forward, never reject"); + } + + #[test] + fn mark_idle_returns_to_idle_so_next_turn_can_start() { + let inbox = inbox_at(1); + let a = agent(1); + inbox.enqueue(a, ticket(10, "t")); + assert!(inbox.busy_state(a).is_busy()); + inbox.mark_idle(a); + assert_eq!(inbox.busy_state(a), AgentBusyState::Idle); + // A subsequent enqueue starts a fresh turn. + inbox.enqueue(a, ticket(11, "t2")); + assert_eq!( + inbox.busy_state(a).ticket(), + Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))) + ); + } + + #[tokio::test] + async fn preempt_is_distinct_from_enqueue_and_resolves_no_ticket() { + let inbox = inbox_at(1); + let a = agent(1); + let pending = inbox.enqueue(a, ticket(10, "t")); + inbox.preempt(a); + // preempt did not pop/resolve the ticket: still pending in the mailbox. + assert_eq!(inbox.mailbox().pending(&a), 1); + // Nothing answered the caller via preempt. + inbox.mailbox().resolve(a, "real".to_owned()).unwrap(); + assert_eq!(pending.await.unwrap(), "real"); + } + + #[test] + fn two_enqueues_same_agent_serialise_in_one_fifo() { + let inbox = inbox_at(1); + let a = agent(1); + inbox.enqueue(a, ticket(10, "first")); + inbox.enqueue(a, ticket(11, "second")); + assert_eq!(inbox.mailbox().pending(&a), 2); + assert_eq!( + inbox.mailbox().head_ticket(&a), + Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))), + "FIFO order preserved" + ); + } + + #[test] + fn different_agents_are_independent_not_blocking() { + let inbox = inbox_at(1); + let a = agent(1); + let b = agent(2); + inbox.enqueue(a, ticket(10, "a")); + inbox.enqueue(b, ticket(20, "b")); + assert!(inbox.busy_state(a).is_busy()); + assert!(inbox.busy_state(b).is_busy()); + // Marking A idle leaves B untouched. + inbox.mark_idle(a); + assert_eq!(inbox.busy_state(a), AgentBusyState::Idle); + assert!(inbox.busy_state(b).is_busy()); + assert_eq!(inbox.mailbox().pending(&a), 1); + assert_eq!(inbox.mailbox().pending(&b), 1); + } + + // ==================================================================== + // Lot C5 — prompt-ready detection on the PTY output stream + // ==================================================================== + + use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle as Handle, SpawnSpec}; + use domain::terminal::PtySize; + use domain::ids::SessionId; + + /// A fake [`PtyPort`] whose `subscribe_output` replays a fixed list of chunks then + /// ends (EOF), so the watcher thread sees a deterministic, finite stream. `write` is + /// recorded; `spawn`/`resize`/`kill`/`scrollback` are unused stubs for these tests. + struct FakePty { + chunks: Mutex>>>, + writes: Mutex>>, + } + impl FakePty { + fn new() -> Self { + Self { + chunks: Mutex::new(HashMap::new()), + writes: Mutex::new(Vec::new()), + } + } + /// Seeds the chunks a later `subscribe_output(handle)` will replay. + fn seed(&self, handle: &Handle, chunks: Vec>) { + self.chunks + .lock() + .unwrap() + .insert(handle.session_id.clone(), chunks); + } + } + #[async_trait::async_trait] + impl PtyPort for FakePty { + async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result { + Ok(Handle { + session_id: SessionId::new_random(), + }) + } + fn write(&self, _handle: &Handle, data: &[u8]) -> Result<(), PtyError> { + self.writes.lock().unwrap().push(data.to_vec()); + Ok(()) + } + fn resize(&self, _handle: &Handle, _size: PtySize) -> Result<(), PtyError> { + Ok(()) + } + fn subscribe_output(&self, handle: &Handle) -> Result { + let chunks = self + .chunks + .lock() + .unwrap() + .get(&handle.session_id) + .cloned() + .unwrap_or_default(); + Ok(Box::new(chunks.into_iter())) + } + fn scrollback(&self, _handle: &Handle) -> Result, PtyError> { + Ok(Vec::new()) + } + async fn kill(&self, _handle: &Handle) -> Result { + Ok(ExitStatus { code: Some(0) }) + } + } + + fn handle(n: u128) -> Handle { + Handle { + session_id: SessionId::from_uuid(uuid::Uuid::from_u128(n)), + } + } + + /// Spins until `cond` holds or the deadline passes (the watcher runs on its own + /// thread, so the transition is observed asynchronously). + fn wait_until(mut cond: impl FnMut() -> bool) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + if cond() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + cond() + } + + fn inbox_with(pty: Arc) -> MediatedInbox { + MediatedInbox::with_pty( + Arc::new(InMemoryMailbox::new()), + Arc::new(FixedClock(1)), + pty as Arc, + ) + } + + #[test] + fn prompt_pattern_present_and_output_contains_it_flips_busy_to_idle() { + let pty = Arc::new(FakePty::new()); + let a = agent(1); + let h = handle(1); + pty.seed(&h, vec![b"working...\n".to_vec(), b"done\n> ".to_vec()]); + let inbox = inbox_with(Arc::clone(&pty)); + + inbox.enqueue(a, ticket(10, "task")); + assert!(inbox.busy_state(a).is_busy(), "enqueue starts a turn"); + + // Arm prompt detection with the literal marker "\n> " (a stable prompt sigil). + inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned())); + + assert!( + wait_until(|| !inbox.busy_state(a).is_busy()), + "prompt marker in output ⇒ Busy→Idle" + ); + } + + #[test] + fn prompt_marker_split_across_two_chunks_still_matches() { + let pty = Arc::new(FakePty::new()); + let a = agent(1); + let h = handle(2); + // The marker "READY" straddles two chunks: "REA" | "DY done". + pty.seed(&h, vec![b"out REA".to_vec(), b"DY done".to_vec()]); + let inbox = inbox_with(Arc::clone(&pty)); + inbox.enqueue(a, ticket(10, "t")); + inbox.bind_handle_with_prompt(a, h, Some("READY".to_owned())); + assert!( + wait_until(|| !inbox.busy_state(a).is_busy()), + "a marker split across chunks must still flip to Idle" + ); + } + + #[test] + fn no_pattern_in_profile_never_marks_idle_even_with_output() { + let pty = Arc::new(FakePty::new()); + let a = agent(1); + let h = handle(3); + pty.seed(&h, vec![b"lots of output\n> $ done\n".to_vec()]); + let inbox = inbox_with(Arc::clone(&pty)); + inbox.enqueue(a, ticket(10, "t")); + // No pattern: bind without arming a watcher (the safe fallback). + inbox.bind_handle_with_prompt(a, h, None); + // Give any (erroneously spawned) watcher a chance to fire — it must not. + std::thread::sleep(std::time::Duration::from_millis(80)); + assert!( + inbox.busy_state(a).is_busy(), + "no pattern ⇒ never a false Idle; the agent stays Busy" + ); + // The FIFO still accepts a second enqueue (forward, never reject). + inbox.enqueue(a, ticket(11, "second")); + assert_eq!(inbox.mailbox().pending(&a), 2, "enqueue accepted while Busy"); + } + + #[test] + fn pattern_absent_from_output_keeps_agent_busy_but_queue_accepts() { + let pty = Arc::new(FakePty::new()); + let a = agent(1); + let h = handle(4); + // Output never contains the marker ⇒ watcher runs to EOF without matching. + pty.seed(&h, vec![b"still thinking, no prompt here\n".to_vec()]); + let inbox = inbox_with(Arc::clone(&pty)); + inbox.enqueue(a, ticket(10, "t")); + inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned())); + // Even after the stream ends, no match ⇒ stays Busy (timeout is the ultimate + // guard, exercised at the orchestrator layer). + std::thread::sleep(std::time::Duration::from_millis(80)); + assert!( + inbox.busy_state(a).is_busy(), + "marker absent from output ⇒ stays Busy (no false Idle)" + ); + inbox.enqueue(a, ticket(11, "queued")); + assert_eq!( + inbox.mailbox().pending(&a), + 2, + "in doubt → keep accepting (forward, never reject)" + ); + } + + #[test] + fn empty_pattern_arms_nothing() { + let pty = Arc::new(FakePty::new()); + let a = agent(1); + let h = handle(5); + pty.seed(&h, vec![b"anything\n> ".to_vec()]); + let inbox = inbox_with(Arc::clone(&pty)); + inbox.enqueue(a, ticket(10, "t")); + inbox.bind_handle_with_prompt(a, h, Some(String::new())); + std::thread::sleep(std::time::Duration::from_millis(60)); + assert!( + inbox.busy_state(a).is_busy(), + "an empty pattern must arm no watcher (treated as no detection)" + ); + } + + #[test] + fn prompt_match_advances_fifo_to_next_ticket() { + let pty = Arc::new(FakePty::new()); + let a = agent(1); + let h = handle(6); + pty.seed(&h, vec![b"done\n> ".to_vec()]); + let inbox = inbox_with(Arc::clone(&pty)); + // Two tickets queued; the turn is on the first. + inbox.enqueue(a, ticket(10, "first")); + inbox.enqueue(a, ticket(11, "second")); + assert_eq!( + inbox.busy_state(a).ticket(), + Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(10))) + ); + inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned())); + // Prompt-ready ⇒ Idle ⇒ the next enqueue can start a fresh turn. + assert!(wait_until(|| !inbox.busy_state(a).is_busy())); + inbox.enqueue(a, ticket(12, "third")); + assert_eq!( + inbox.busy_state(a).ticket(), + Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(12))), + "after prompt-ready Idle, a new enqueue starts the next turn" + ); + } + + #[test] + fn rebinding_same_agent_does_not_spawn_duplicate_watcher() { + let pty = Arc::new(FakePty::new()); + let a = agent(1); + let h = handle(7); + // A stream that never matches and never ends quickly: empty ⇒ immediate EOF. + pty.seed(&h, vec![b"noise".to_vec()]); + let inbox = inbox_with(Arc::clone(&pty)); + inbox.enqueue(a, ticket(10, "t")); + // Arm twice in a row; the second must be a no-op while the first is live (no + // panic, no double-subscribe). After EOF the agent is un-armed and stays Busy. + inbox.bind_handle_with_prompt(a, h.clone(), Some("ZZZ".to_owned())); + inbox.bind_handle_with_prompt(a, h, Some("ZZZ".to_owned())); + std::thread::sleep(std::time::Duration::from_millis(60)); + assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy"); + } +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index f107e82..35cde7c 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -13,11 +13,15 @@ #![warn(missing_docs)] pub mod clock; +pub mod conversation; pub mod eventbus; +pub mod fileguard; pub mod fs; pub mod git; pub mod id; +pub mod input; pub mod inspector; +pub mod mailbox; pub mod orchestrator; pub mod process; pub mod pty; @@ -27,11 +31,15 @@ pub mod session; pub mod store; pub use clock::SystemClock; +pub use conversation::InMemoryConversationRegistry; pub use eventbus::TokioBroadcastEventBus; +pub use fileguard::RwFileGuard; +pub use input::{MediatedInbox, MillisClock, SystemMillisClock}; pub use fs::LocalFileSystem; pub use git::Git2Repository; pub use id::UuidGenerator; pub use inspector::ClaudeTranscriptInspector; +pub use mailbox::InMemoryMailbox; pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport}; pub use orchestrator::{ process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, diff --git a/crates/infrastructure/src/mailbox/mod.rs b/crates/infrastructure/src/mailbox/mod.rs new file mode 100644 index 0000000..2b4a9f4 --- /dev/null +++ b/crates/infrastructure/src/mailbox/mod.rs @@ -0,0 +1,261 @@ +//! [`InMemoryMailbox`] — the [`AgentMailbox`] adapter (Option 1, lot B-1). +//! +//! The driven side of the inter-agent rendezvous: a per-agent FIFO of +//! [`Ticket`]s, each paired with a one-shot reply channel. `enqueue` appends a +//! ticket and hands the caller a [`PendingReply`] over the receiver; `resolve` +//! feeds the target's `idea_reply` result into the **head** ticket's sender. +//! +//! All the concrete machinery the domain refuses to name lives here: the +//! [`std::collections::VecDeque`] queue, its [`std::sync::Mutex`], and the +//! [`tokio::sync::oneshot`] channel that bridges the resolving call to the awaiting +//! one. The domain port ([`domain::mailbox`]) stays I/O-free. +//! +//! ## Concurrency +//! +//! The map is guarded by a **synchronous** [`Mutex`] held only for the O(1) +//! enqueue/resolve/cancel mutations — **never across an `.await`** (the await is the +//! caller's, on the returned [`PendingReply`], outside the lock). Two `ask`s for the +//! **same** target serialise positionally in that target's `VecDeque`; two `ask`s +//! for **different** targets touch different queues and never contend on the data, +//! only briefly on the map mutex. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Mutex; + +use tokio::sync::oneshot; + +use domain::ids::AgentId; +use domain::mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId}; + +/// One queued request plus the sender that resolves its awaiting [`PendingReply`]. +struct Slot { + ticket: Ticket, + reply: oneshot::Sender, +} + +/// In-memory, per-agent FIFO mailbox (the production [`AgentMailbox`]). +#[derive(Default)] +pub struct InMemoryMailbox { + queues: Mutex>>, +} + +impl InMemoryMailbox { + /// Creates an empty mailbox. + #[must_use] + pub fn new() -> Self { + Self { + queues: Mutex::new(HashMap::new()), + } + } + + /// Number of tickets currently queued for `agent` (test/inspection helper). + #[must_use] + pub fn pending(&self, agent: &AgentId) -> usize { + self.queues + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(agent) + .map_or(0, VecDeque::len) + } + + /// The id of the ticket currently at the head of `agent`'s queue, if any + /// (test/inspection helper). + #[must_use] + pub fn head_ticket(&self, agent: &AgentId) -> Option { + self.queues + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(agent) + .and_then(|q| q.front()) + .map(|slot| slot.ticket.id) + } +} + +impl AgentMailbox for InMemoryMailbox { + fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + let (tx, rx) = oneshot::channel::(); + { + let mut queues = self + .queues + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + queues + .entry(agent) + .or_default() + .push_back(Slot { ticket, reply: tx }); + } + // The await happens in the application layer, outside the map lock. A closed + // channel (sender dropped without a value, e.g. the head was cancelled or the + // session ended) maps to a typed `Cancelled` rather than a raw recv error. + PendingReply::new(Box::pin(async move { + rx.await.map_err(|_| MailboxError::Cancelled) + })) + } + + fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError> { + // Pop the head slot under the lock, then send **outside** any await (the send + // is non-blocking). If the receiver already went away (caller timed out), the + // ticket is still correctly retired from the head — the queue advances. + let slot = { + let mut queues = self + .queues + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let queue = queues + .get_mut(&agent) + .filter(|q| !q.is_empty()) + .ok_or(MailboxError::NoPendingRequest(agent))?; + queue.pop_front().expect("non-empty queue has a head") + }; + // A dropped receiver (the awaiting caller timed out and went away) is fine: + // the reply is simply discarded, the head ticket is already retired. + let _ = slot.reply.send(result); + Ok(()) + } + + fn resolve_ticket( + &self, + agent: AgentId, + ticket_id: TicketId, + result: String, + ) -> Result<(), MailboxError> { + // Remove the slot whose ticket id matches, anywhere in the queue (multi-thread + // correlation), then send outside any await. A missing match is a typed + // NoPendingRequest — never a panic. + let slot = { + let mut queues = self + .queues + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let queue = queues + .get_mut(&agent) + .filter(|q| !q.is_empty()) + .ok_or(MailboxError::NoPendingRequest(agent))?; + let pos = queue + .iter() + .position(|s| s.ticket.id == ticket_id) + .ok_or(MailboxError::NoPendingRequest(agent))?; + queue.remove(pos).expect("position just found is in range") + }; + let _ = slot.reply.send(result); + Ok(()) + } + + fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) { + let mut queues = self + .queues + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(queue) = queues.get_mut(&agent) { + // Only retire the head, and only if it is *this* ticket: a head that has + // since changed (already resolved, or someone else's ticket now in front) + // must not be dropped. Idempotent and positional. + if queue.front().map(|s| s.ticket.id) == Some(ticket_id) { + queue.pop_front(); + } + if queue.is_empty() { + queues.remove(&agent); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + fn ticket(n: u128, task: &str) -> Ticket { + Ticket::new(TicketId::from_uuid(uuid::Uuid::from_u128(n)), "Main", task) + } + + #[tokio::test] + async fn enqueue_then_resolve_wakes_the_pending_reply() { + let mb = InMemoryMailbox::new(); + let a = agent(1); + let pending = mb.enqueue(a, ticket(10, "do X")); + + mb.resolve(a, "done X".to_owned()).expect("resolve ok"); + + let reply = pending.await.expect("reply received"); + assert_eq!(reply, "done X"); + assert_eq!(mb.pending(&a), 0, "queue drained after resolve"); + } + + #[tokio::test] + async fn two_asks_same_target_resolve_fifo_head_first() { + let mb = InMemoryMailbox::new(); + let a = agent(1); + let p1 = mb.enqueue(a, ticket(10, "first")); + let p2 = mb.enqueue(a, ticket(11, "second")); + assert_eq!(mb.pending(&a), 2); + assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))); + + // First resolve goes to the FIRST (head) ticket. + mb.resolve(a, "r1".to_owned()).unwrap(); + assert_eq!(p1.await.unwrap(), "r1"); + // Now the second is at the head. + assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))); + mb.resolve(a, "r2".to_owned()).unwrap(); + assert_eq!(p2.await.unwrap(), "r2"); + } + + #[tokio::test] + async fn different_targets_do_not_block_each_other() { + let mb = InMemoryMailbox::new(); + let a = agent(1); + let b = agent(2); + let pa = mb.enqueue(a, ticket(10, "task a")); + let _pb = mb.enqueue(b, ticket(20, "task b")); + + // Resolving B leaves A untouched; resolving A then completes pa. + mb.resolve(b, "rb".to_owned()).unwrap(); + assert_eq!(mb.pending(&a), 1, "A's queue is independent of B's"); + mb.resolve(a, "ra".to_owned()).unwrap(); + assert_eq!(pa.await.unwrap(), "ra"); + } + + #[test] + fn resolve_without_pending_is_a_typed_error() { + let mb = InMemoryMailbox::new(); + let a = agent(1); + assert_eq!( + mb.resolve(a, "orphan".to_owned()), + Err(MailboxError::NoPendingRequest(a)) + ); + } + + #[tokio::test] + async fn cancel_head_retires_the_head_and_advances_the_queue() { + let mb = InMemoryMailbox::new(); + let a = agent(1); + let p1 = mb.enqueue(a, ticket(10, "stuck")); + let p2 = mb.enqueue(a, ticket(11, "next")); + + // Caller of ticket 10 timed out: retire exactly its head ticket. + mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(10))); + assert_eq!(mb.pending(&a), 1); + assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))); + + // The cancelled pending resolves to Cancelled (its sender was dropped). + assert_eq!(p1.await, Err(MailboxError::Cancelled)); + + // The next ticket is now resolvable normally. + mb.resolve(a, "r2".to_owned()).unwrap(); + assert_eq!(p2.await.unwrap(), "r2"); + } + + #[test] + fn cancel_head_is_a_noop_when_head_is_a_different_ticket() { + let mb = InMemoryMailbox::new(); + let a = agent(1); + let _p1 = mb.enqueue(a, ticket(10, "head")); + // Try to cancel a ticket that is NOT the head ⇒ nothing retired. + mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(99))); + assert_eq!(mb.pending(&a), 1); + assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))); + } +} diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 19c2720..a837681 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -225,7 +225,10 @@ impl McpServer { .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)?; + // `idea_reply` needs the connected peer's identity as `from`; every other tool + // ignores `requester`. The handshake-provided requester is the source of truth. + let command = + tools::map_tool_call(&name, &arguments, &self.requester).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 diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 22ee067..99b18ed 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -44,11 +44,15 @@ pub enum ToolMapError { } /// 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). +/// caller: `idea_ask_agent` (the target's reply) and `idea_list_agents` (the agent +/// list as a JSON array). `idea_reply` is an **ACK only** (the result is routed to +/// the awaiting requester, not echoed inline), so it is **not** in this set. #[must_use] pub fn tool_returns_reply(tool: &str) -> bool { - matches!(tool, "idea_ask_agent" | "idea_list_agents") + matches!( + tool, + "idea_ask_agent" | "idea_list_agents" | "idea_context_read" | "idea_memory_read" + ) } /// The full catalogue advertised on `tools/list`. @@ -82,6 +86,23 @@ pub fn catalogue() -> Vec { "additionalProperties": false }), }, + ToolDef { + name: "idea_reply", + description: "Render the result of the task you are currently processing (the one IdeA \ + delegated to you as `[IdeA · tâche … · ticket ]`). Call this — never \ + answer in plain text — so IdeA can hand your answer back to the agent that \ + asked. **Echo the `ticket` id** shown in that prefix so IdeA correlates your \ + reply exactly, even when you handle several requests.", + input_schema: json!({ + "type": "object", + "properties": { + "result": { "type": "string", "description": "The result/answer to deliver to the requester." }, + "ticket": { "type": "string", "description": "The ticket id from the `[IdeA · … · ticket ]` prefix you are answering. Optional, but strongly recommended when you handle more than one request." } + }, + "required": ["result"], + "additionalProperties": false + }), + }, ToolDef { name: "idea_launch_agent", description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \ @@ -124,6 +145,62 @@ pub fn catalogue() -> Vec { "additionalProperties": false }), }, + ToolDef { + name: "idea_context_read", + description: "Read an IdeA-owned context (under IdeA's reader/writer file guard). \ + Omit `target` for the global project context, or pass an agent's display \ + name for that agent's `.md`. Reading is shared (never blocks other readers).", + input_schema: json!({ + "type": "object", + "properties": { + "target": { "type": "string", "description": "Agent display name. Omit for the global project context." } + }, + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_context_propose", + description: "Propose new Markdown content for an IdeA-owned context (under the file \ + guard). For an agent's `.md` this writes directly; for the **global** \ + project context it is only a *proposal* (the global context is \ + single-writer — reserved to the orchestrator — so your change is recorded \ + for validation, not applied).", + input_schema: json!({ + "type": "object", + "properties": { + "target": { "type": "string", "description": "Agent display name. Omit to target the global project context (proposal only)." }, + "content": { "type": "string", "description": "The proposed Markdown body." } + }, + "required": ["content"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_memory_read", + description: "Read project memory (under the file guard). Omit `slug` for the aggregated \ + index, or pass a note slug for one note.", + input_schema: json!({ + "type": "object", + "properties": { + "slug": { "type": "string", "description": "Memory note slug. Omit for the aggregated index." } + }, + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_memory_write", + description: "Write (create or replace) a project memory note under the file guard. \ + Memory is shared across the project's agents.", + input_schema: json!({ + "type": "object", + "properties": { + "slug": { "type": "string", "description": "Memory note slug (kebab-case)." }, + "content": { "type": "string", "description": "The Markdown body to store." } + }, + "required": ["slug", "content"], + "additionalProperties": false + }), + }, ToolDef { name: "idea_create_skill", description: "Create a reusable IdeA skill (Markdown) in the project or global scope.", @@ -146,6 +223,10 @@ pub fn catalogue() -> Vec { /// validation authority. /// /// `arguments` is the raw object an MCP client passes under `params.arguments`. +/// `requester` is the **connected peer's agent id** (from the loopback handshake, +/// cadrage v5 §1.4): it is injected as the `from` of an `idea_reply` so correlation +/// uses the handshake identity, never a model-managed value. Every other tool +/// ignores it. /// /// # Errors /// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`], @@ -154,6 +235,7 @@ pub fn catalogue() -> Vec { pub fn map_tool_call( name: &str, arguments: &Value, + requester: &str, ) -> Result { let args = arguments .as_object() @@ -173,10 +255,25 @@ pub fn map_tool_call( }, "idea_ask_agent" => OrchestratorRequest { request_type: Some("agent.message".to_owned()), + // The **requester** is the connected peer's handshake identity (never a + // tool argument), injected as `requestedBy` so `validate` can route the + // ask on the A↔B conversation and feed the wait-for guard (cadrage C3). + requested_by: Some(requester.to_owned()), target_agent: s("target"), task: s("task"), ..base() }, + "idea_reply" => OrchestratorRequest { + request_type: Some("agent.reply".to_owned()), + // `from` is the handshake identity, not a tool argument: inject the peer's + // requester id as `requestedBy` so `validate` builds `Reply { from, .. }`. + requested_by: Some(requester.to_owned()), + // The agent echoes the `ticket` it received in the `[IdeA · … · ticket + // ]` prefix ⇒ correlate by ticket (multi-thread); optional (cadrage C3 §3.3). + ticket: s("ticket"), + result: s("result"), + ..base() + }, "idea_launch_agent" => OrchestratorRequest { request_type: Some("agent.run".to_owned()), target_agent: s("target"), @@ -197,6 +294,33 @@ pub fn map_tool_call( context: s("context"), ..base() }, + "idea_context_read" => OrchestratorRequest { + request_type: Some("context.read".to_owned()), + // The requester (handshake identity) drives the read lease's `who`. + requested_by: Some(requester.to_owned()), + target_agent: s("target"), + ..base() + }, + "idea_context_propose" => OrchestratorRequest { + request_type: Some("context.propose".to_owned()), + requested_by: Some(requester.to_owned()), + target_agent: s("target"), + content: s("content"), + ..base() + }, + "idea_memory_read" => OrchestratorRequest { + request_type: Some("memory.read".to_owned()), + requested_by: Some(requester.to_owned()), + slug: s("slug"), + ..base() + }, + "idea_memory_write" => OrchestratorRequest { + request_type: Some("memory.write".to_owned()), + requested_by: Some(requester.to_owned()), + slug: s("slug"), + content: s("content"), + ..base() + }, "idea_create_skill" => OrchestratorRequest { request_type: Some("skill.create".to_owned()), name: s("name"), @@ -225,6 +349,10 @@ fn base() -> OrchestratorRequest { node_id: None, attach_to_cell: None, scope: None, + result: None, + ticket: None, + content: None, + slug: None, } } @@ -241,9 +369,17 @@ mod tests { use super::*; use domain::OrchestratorVisibility; + /// A well-formed handshake requester id for the tests (parsed as an `AgentId`). + const REQ: &str = "11111111-1111-1111-1111-111111111111"; + + /// Maps a tool call with an empty requester (the default for tools that ignore it). + fn map(name: &str, args: &Value) -> Result { + map_tool_call(name, args, "") + } + #[test] fn ask_agent_maps_to_ask_command() { - let cmd = map_tool_call( + let cmd = map( "idea_ask_agent", &json!({ "target": "Architect", "task": "Analyse §17" }), ) @@ -253,13 +389,15 @@ mod tests { OrchestratorCommand::AskAgent { target: "Architect".to_owned(), task: "Analyse §17".to_owned(), + // `map` passes an empty requester ⇒ no machine requester carried. + requester: None, } ); } #[test] fn launch_agent_maps_to_spawn_background_by_default() { - let cmd = map_tool_call("idea_launch_agent", &json!({ "target": "Dev" })).unwrap(); + let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap(); assert_eq!( cmd, OrchestratorCommand::SpawnAgent { @@ -274,11 +412,11 @@ mod tests { #[test] fn stop_update_and_skill_map_to_their_commands() { assert_eq!( - map_tool_call("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(), + map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(), OrchestratorCommand::StopAgent { name: "Dev".to_owned() } ); assert_eq!( - map_tool_call( + map( "idea_update_context", &json!({ "target": "Dev", "context": "# body" }) ) @@ -289,7 +427,7 @@ mod tests { } ); assert_eq!( - map_tool_call( + map( "idea_create_skill", &json!({ "name": "deploy", "context": "# steps" }) ) @@ -304,19 +442,19 @@ mod tests { #[test] fn missing_required_field_surfaces_validation_error() { - let err = map_tool_call("idea_ask_agent", &json!({ "target": "Architect" })); + let err = map("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(); + let cmd = map("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!({})); + let err = map("idea_made_up_tool", &json!({})); assert_eq!( err, Err(ToolMapError::UnknownTool("idea_made_up_tool".to_owned())) @@ -325,10 +463,155 @@ mod tests { #[test] fn non_object_arguments_are_rejected() { - let err = map_tool_call("idea_ask_agent", &json!("nope")); + let err = map("idea_ask_agent", &json!("nope")); assert_eq!( err, Err(ToolMapError::BadArguments("idea_ask_agent".to_owned())) ); } + + #[test] + fn reply_maps_with_handshake_requester_as_from() { + // `from` comes from the handshake requester, NOT a tool argument. + let cmd = map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ).unwrap(); + assert_eq!( + cmd, + OrchestratorCommand::Reply { + from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()), + ticket: None, + result: "the answer".to_owned(), + } + ); + } + + #[test] + fn reply_echoes_ticket_when_provided() { + let tkt = "22222222-2222-2222-2222-222222222222"; + let cmd = map_tool_call( + "idea_reply", + &json!({ "result": "done", "ticket": tkt }), + REQ, + ) + .unwrap(); + assert_eq!( + cmd, + OrchestratorCommand::Reply { + from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()), + ticket: Some(domain::mailbox::TicketId::from_uuid( + uuid::Uuid::parse_str(tkt).unwrap() + )), + result: "done".to_owned(), + } + ); + } + + #[test] + fn ask_agent_injects_handshake_requester() { + let cmd = map_tool_call( + "idea_ask_agent", + &json!({ "target": "B", "task": "go" }), + REQ, + ) + .unwrap(); + assert_eq!( + cmd, + OrchestratorCommand::AskAgent { + target: "B".to_owned(), + task: "go".to_owned(), + requester: Some(domain::AgentId::from_uuid( + uuid::Uuid::parse_str(REQ).unwrap() + )), + } + ); + } + + #[test] + fn reply_without_result_is_a_validation_error() { + let err = map_tool_call("idea_reply", &json!({}), REQ); + assert!(matches!(err, Err(ToolMapError::Invalid(_)))); + } + + #[test] + fn reply_with_blank_or_missing_requester_is_a_validation_error() { + // No handshake identity ⇒ no `from` ⇒ typed validation error (never a panic). + let err = map_tool_call("idea_reply", &json!({ "result": "x" }), ""); + assert!(matches!(err, Err(ToolMapError::Invalid(_)))); + // A non-uuid requester is rejected too. + let err2 = map_tool_call("idea_reply", &json!({ "result": "x" }), "not-a-uuid"); + assert!(matches!(err2, Err(ToolMapError::Invalid(_)))); + } + + #[test] + fn context_read_maps_with_requester_party() { + // Global (no target) with a uuid requester ⇒ Agent party. + let cmd = map_tool_call("idea_context_read", &json!({}), REQ).unwrap(); + assert_eq!( + cmd, + OrchestratorCommand::ReadContext { + target: None, + requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( + uuid::Uuid::parse_str(REQ).unwrap() + )), + } + ); + } + + #[test] + fn context_propose_requires_content() { + let ok = map_tool_call( + "idea_context_propose", + &json!({ "target": "Dev", "content": "# body" }), + REQ, + ) + .unwrap(); + assert_eq!( + ok, + OrchestratorCommand::ProposeContext { + target: Some("Dev".to_owned()), + content: "# body".to_owned(), + requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( + uuid::Uuid::parse_str(REQ).unwrap() + )), + } + ); + let err = map_tool_call("idea_context_propose", &json!({ "target": "Dev" }), REQ); + assert!(matches!(err, Err(ToolMapError::Invalid(_)))); + } + + #[test] + fn memory_read_and_write_map_to_their_commands() { + assert_eq!( + map_tool_call("idea_memory_read", &json!({ "slug": "note-a" }), REQ).unwrap(), + OrchestratorCommand::ReadMemory { + slug: Some("note-a".to_owned()), + requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( + uuid::Uuid::parse_str(REQ).unwrap() + )), + } + ); + assert_eq!( + map_tool_call( + "idea_memory_write", + &json!({ "slug": "note-a", "content": "hi" }), + REQ + ) + .unwrap(), + OrchestratorCommand::WriteMemory { + slug: "note-a".to_owned(), + content: "hi".to_owned(), + requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( + uuid::Uuid::parse_str(REQ).unwrap() + )), + } + ); + // memory.write without content ⇒ validation error. + let err = map_tool_call("idea_memory_write", &json!({ "slug": "note-a" }), REQ); + assert!(matches!(err, Err(ToolMapError::Invalid(_)))); + } + + #[test] + fn reply_is_not_an_inline_reply_tool() { + // ACK only: the result is routed to the awaiting requester, not echoed. + assert!(!tool_returns_reply("idea_reply")); + } } diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 3bc4933..7430439 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -32,12 +32,15 @@ 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, + AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, + ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, + PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, + StoreError, +}; +use domain::profile::{ + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + StructuredAdapter, }; -use domain::profile::{AgentProfile, ContextInjection}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; @@ -47,9 +50,12 @@ use uuid::Uuid; use application::{ CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, - OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext, + OrchestratorService, TerminalSessions, UpdateAgentContext, +}; +use infrastructure::{ + InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport, + SystemMillisClock, }; -use infrastructure::{McpServer, MemoryTransport}; use infrastructure::orchestrator::mcp::jsonrpc::error_codes; use serde_json::{json, Value}; @@ -290,35 +296,6 @@ impl PtyPort for FakePty { } } -/// A structured session whose turn deterministically ends on a `Final` carrying a -/// fixed reply — lets us exercise `idea_ask_agent` without a real CLI. -struct FakeSession { - id: SessionId, - reply: String, -} -#[async_trait] -impl AgentSession for FakeSession { - fn id(&self) -> SessionId { - self.id - } - fn conversation_id(&self) -> Option { - None - } - async fn send(&self, _prompt: &str) -> Result { - let events = vec![ - ReplyEvent::TextDelta { - text: "thinking…".to_owned(), - }, - ReplyEvent::Final { - content: self.reply.clone(), - }, - ]; - Ok(Box::new(events.into_iter())) - } - async fn shutdown(&self) -> Result<(), AgentSessionError> { - Ok(()) - } -} #[derive(Default, Clone)] struct NoopBus; @@ -354,6 +331,9 @@ fn project() -> Project { /// registry). Returns the shared `TerminalSessions` so a test can pre-bind a live /// PTY session for an agent (needed to make `stop_agent` succeed). fn build_service(contexts: FakeContexts) -> (Arc, Arc) { + // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) : + // seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible + // d'`idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`. let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( ProfileId::from_uuid(Uuid::from_u128(9)), "Claude Code", @@ -364,7 +344,12 @@ fn build_service(contexts: FakeContexts) -> (Arc, Arc (Arc, Arc (Arc, Arc) { - let structured = Arc::new(StructuredSessions::new()); - let (base, _sessions) = build_service(contexts); +) -> ( + Arc, + Arc, + Arc, +) { + let mailbox = Arc::new(InMemoryMailbox::new()); + let (base, sessions) = build_service(contexts); + let input = Arc::new(MediatedInbox::with_pty( + Arc::clone(&mailbox), + Arc::new(SystemMillisClock), + Arc::new(FakePty) as Arc, + )) as Arc; + let conversations = Arc::new(InMemoryConversationRegistry::new()) + as Arc; 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) + .with_input_mediator( + input, + Arc::clone(&mailbox) as Arc, + ) + .with_conversations(conversations); + (Arc::new(service), mailbox, sessions) +} + +/// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it. +fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) { + sessions.insert( + PtyHandle { session_id }, + TerminalSession::starting( + session_id, + NodeId::from_uuid(Uuid::from_u128(7)), + ProjectPath::new("/home/me/proj").unwrap(), + SessionKind::Agent { agent_id }, + PtySize { rows: 24, cols: 80 }, + ), + ); } fn server(service: Arc) -> McpServer { @@ -461,7 +476,7 @@ fn result_text(result: &Value) -> &str { // --------------------------------------------------------------------------- #[tokio::test] -async fn tools_list_advertises_the_six_idea_tools_with_schemas() { +async fn tools_list_advertises_the_seven_idea_tools_with_schemas() { let (service, _s) = build_service(FakeContexts::new()); let server = server(service); @@ -483,14 +498,24 @@ async fn tools_list_advertises_the_six_idea_tools_with_schemas() { for expected in [ "idea_list_agents", "idea_ask_agent", + "idea_reply", "idea_launch_agent", "idea_stop_agent", "idea_update_context", "idea_create_skill", + // FileGuard-mediated context/memory tools (cadrage C7). + "idea_context_read", + "idea_context_propose", + "idea_memory_read", + "idea_memory_write", ] { assert!(names.contains(&expected), "missing tool {expected}; got {names:?}"); } - assert_eq!(tools.len(), 6, "exactly the six idea_* tools; got {names:?}"); + assert_eq!( + tools.len(), + 11, + "exactly the eleven idea_* tools (7 base + 4 FileGuard C7); got {names:?}" + ); // Every tool advertises an object input schema. for t in tools { @@ -598,28 +623,47 @@ async fn update_context_and_create_skill_calls_succeed() { #[tokio::test] async fn ask_agent_returns_target_reply_inline() { + // Option 1 (B-3/B-4): `idea_ask_agent` writes the task into the target's live + // terminal and blocks on the mailbox; the target's `idea_reply` (carrying its + // handshake identity as requester) resolves it, and the ask returns the result + // inline. We drive both over the MCP server, the ask on a spawned task. 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 (service, mailbox, sessions) = build_service_with_mailbox(contexts); + seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242))); - 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"); + let ask_server = server(Arc::clone(&service)); + let ask = tokio::spawn(async move { + let raw = tools_call( + 7, + "idea_ask_agent", + json!({ "target": "architect", "task": "What is the answer?" }), + ); + ask_server.handle_raw(&raw).await.expect("reply owed") + }); + + // Wait until the ask has enqueued its ticket (it is now blocked awaiting a reply). + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while mailbox.pending(&agent_id) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("ask must enqueue a ticket"); + + // The target replies via idea_reply — its identity comes from the handshake + // requester, which `for_requester` injects as the `from` of the Reply command. + let reply_server = server(Arc::clone(&service)).for_requester(agent_id.to_string()); + let reply_raw = tools_call(8, "idea_reply", json!({ "result": "the answer is 42" })); + let reply_resp = reply_server.handle_raw(&reply_raw).await.expect("reply owed"); + assert_eq!(reply_resp.result.expect("result")["isError"], json!(false)); + + let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask) + .await + .expect("ask completes after reply") + .expect("join ok"); 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!( @@ -629,6 +673,22 @@ async fn ask_agent_returns_target_reply_inline() { ); } +/// `idea_reply` with no in-flight ask for the connected peer ⇒ the IdeA command +/// fails, surfaced as a tool execution error (`isError: true`), never a panic. +#[tokio::test] +async fn reply_without_pending_ask_is_a_tool_error() { + let contexts = FakeContexts::new(); + let agent_id = contexts.seed_agent("architect"); + let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts); + let reply_server = server(service).for_requester(agent_id.to_string()); + + let raw = tools_call(9, "idea_reply", json!({ "result": "orphan" })); + let resp = reply_server.handle_raw(&raw).await.expect("reply owed"); + // Mapped + dispatched, but the command failed ⇒ tool error, transport intact. + assert!(resp.error.is_none(), "no transport error: {:?}", resp.error); + assert_eq!(resp.result.expect("result")["isError"], json!(true)); +} + // --------------------------------------------------------------------------- // 4. idea_list_agents returns the agent list inline (JSON array) // --------------------------------------------------------------------------- @@ -785,11 +845,10 @@ async fn initialize_answers_minimal_handshake() { 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 + // 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, _mailbox, _sessions) = build_service_with_mailbox(contexts); let server = server(service); let raw = tools_call(5, "idea_ask_agent", json!({ "target": "architect" })); diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 1ea8022..75b4e8a 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -21,23 +21,29 @@ use domain::ids::{AgentId, ProfileId, ProjectId}; use domain::markdown::MarkdownDoc; use domain::ids::NodeId; 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, + AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, + ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, + PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, + StoreError, +}; +use domain::profile::{ + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + StructuredAdapter, }; -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, + OrchestratorService, TerminalSessions, UpdateAgentContext, +}; +use infrastructure::{ + process_request_file, FsOrchestratorWatcher, InMemoryMailbox, OrchestratorResponse, }; -use infrastructure::{process_request_file, FsOrchestratorWatcher, OrchestratorResponse}; // --- temp dir (mirror local_fs.rs) --- struct TempDir(PathBuf); @@ -289,36 +295,6 @@ impl PtyPort for FakePty { } } -/// A structured [`AgentSession`] whose turn deterministically ends on a `Final` -/// carrying a fixed reply. Lets us exercise the `ask`/`agent.message` path -/// (`OrchestratorOutcome.reply = Some(..)`) without a real CLI. -struct FakeSession { - id: SessionId, - reply: String, -} -#[async_trait] -impl AgentSession for FakeSession { - fn id(&self) -> SessionId { - self.id - } - fn conversation_id(&self) -> Option { - None - } - async fn send(&self, _prompt: &str) -> Result { - let events = vec![ - ReplyEvent::TextDelta { - text: "thinking…".to_owned(), - }, - ReplyEvent::Final { - content: self.reply.clone(), - }, - ]; - Ok(Box::new(events.into_iter())) - } - async fn shutdown(&self) -> Result<(), AgentSessionError> { - Ok(()) - } -} #[derive(Default, Clone)] struct NoopBus; @@ -351,6 +327,9 @@ fn project() -> Project { } fn build_service(contexts: FakeContexts) -> Arc { + // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) : + // seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible + // d'`idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`. let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( ProfileId::from_uuid(Uuid::from_u128(9)), "Claude Code", @@ -361,7 +340,12 @@ fn build_service(contexts: FakeContexts) -> Arc { "{agentRunDir}", None, ) - .unwrap()]))); + .unwrap() + .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").unwrap(), + McpTransport::Stdio, + ))]))); let sessions = Arc::new(TerminalSessions::new()); let bus = Arc::new(NoopBus); let create = Arc::new(CreateAgentFromScratch::new( @@ -401,24 +385,107 @@ fn build_service(contexts: FakeContexts) -> Arc { )) } -/// Like [`build_service`] but with the **structured sessions** registry wired -/// (`with_structured`), so `agent.message`/`ask` can find a live structured target. -/// Returns the service and the shared registry so a test can pre-insert a session. -fn build_service_with_structured( +/// Like [`build_service`] but with the **inter-agent mailbox** + PTY wired (Option 1 +/// « Terminal + MCP », B-3/B-4), so an `agent.message` blocks on the mailbox and an +/// `agent.reply` resolves it. Returns the service, the shared mailbox (to observe +/// pending tickets) and the PTY registry (to pre-seed a live target). +fn build_service_with_mailbox( contexts: FakeContexts, -) -> (Arc, Arc) { - let structured = Arc::new(StructuredSessions::new()); - // `build_service` already assembles every use case over the same fakes; we only - // need to fold the structured registry onto the resulting service. - let base = build_service(contexts); - // `OrchestratorService` is not `Clone`; rebuild via the builder on a fresh one is - // overkill, so we reconstruct minimally is not possible — instead, the service - // exposes `with_structured` as a consuming builder. We rely on `Arc::try_unwrap` - // since `build_service` returns a freshly-created `Arc` with refcount 1. - 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) +) -> ( + Arc, + Arc, + Arc, +) { + // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) : + // seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible + // d'`idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`. + 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() + .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").unwrap(), + McpTransport::Stdio, + ))]))); + let sessions = Arc::new(TerminalSessions::new()); + let mailbox = Arc::new(InMemoryMailbox::new()); + let bus = Arc::new(NoopBus); + let create = Arc::new(CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds(Mutex::new(1))), + bus.clone(), + )); + let launch = Arc::new(LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(FakeFs), + Arc::new(FakePty), + Arc::new(FakeSkills), + Arc::clone(&sessions), + bus.clone(), + Arc::new(SeqIds(Mutex::new(1))), + Arc::new(FakeRecall), + None, + )); + let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); + let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); + let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(FakeSkills) as Arc, + Arc::new(SeqIds(Mutex::new(1))), + )); + let service = Arc::new( + OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + ) + .with_input_mediator( + Arc::new(infrastructure::MediatedInbox::with_pty( + Arc::clone(&mailbox), + Arc::new(infrastructure::SystemMillisClock), + Arc::new(FakePty) as Arc, + )) as Arc, + Arc::clone(&mailbox) as Arc, + ) + .with_conversations( + Arc::new(infrastructure::InMemoryConversationRegistry::new()) + as Arc, + ), + ); + (service, mailbox, sessions) +} + +/// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it. +fn seed_live_pty( + sessions: &TerminalSessions, + agent_id: AgentId, + session_id: SessionId, +) { + sessions.insert( + PtyHandle { session_id }, + TerminalSession::starting( + session_id, + NodeId::from_uuid(Uuid::from_u128(7)), + ProjectPath::new("/home/me/proj").unwrap(), + SessionKind::Agent { agent_id }, + PtySize { rows: 24, cols: 80 }, + ), + ); } fn read_response(request_path: &std::path::Path) -> OrchestratorResponse { @@ -573,19 +640,17 @@ async fn unknown_action_yields_error_response() { /// alongside `ok: true`. #[tokio::test] async fn ask_request_surfaces_reply_alongside_detail() { + // Option 1 (B-3/B-4): an `agent.message` request blocks awaiting the target's + // `idea_reply`. Over the file protocol we drive the ask request on a task, wait + // for the mailbox to hold a ticket, then process a separate `agent.reply` request + // (carrying the target's id as `requestedBy`, the handshake identity) which + // resolves it. The ask's `*.response.json` then carries reply + detail. let tmp = TempDir::new(); let contexts = FakeContexts::new(); - // Seed an existing target agent and a live structured session that replies. let agent_id = contexts.seed_agent("architect"); - let (service, structured) = build_service_with_structured(contexts.clone()); - 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 (service, mailbox, sessions) = build_service_with_mailbox(contexts.clone()); + // Target already live in the PTY registry, so the ask reuses its terminal. + seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242))); let req = tmp.0.join("ask-req.json"); std::fs::write( @@ -594,11 +659,42 @@ async fn ask_request_surfaces_reply_alongside_detail() { ) .unwrap(); - let response = process_request_file(&req, &project(), &service).await; + let svc = Arc::clone(&service); + let proj = project(); + let ask_req = req.clone(); + let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await }); + + // Wait until the ask has enqueued its ticket (blocked awaiting the reply). + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while mailbox.pending(&agent_id) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("ask must enqueue a ticket"); + + // The target renders its result via an `agent.reply` request whose `requestedBy` + // is its own id (the handshake identity the MCP server would inject as `from`). + let reply_req = tmp.0.join("reply-req.json"); + std::fs::write( + &reply_req, + format!( + r#"{{ "type": "agent.reply", "requestedBy": "{agent_id}", "result": "the answer is 42" }}"# + ) + .as_bytes(), + ) + .unwrap(); + let reply_resp = process_request_file(&reply_req, &project(), &service).await; + assert!(reply_resp.ok, "reply request ok: {reply_resp:?}"); + + let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask) + .await + .expect("ask completes after reply") + .expect("join ok"); assert!(response.ok, "expected ok, got {response:?}"); assert_eq!(response.action.as_deref(), Some("agent.message")); - // reply carries the target's Final content... + // reply carries the target's rendered result... assert_eq!(response.reply.as_deref(), Some("the answer is 42")); // ...and detail still summarises what IdeA did (the two coexist). assert!( diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 09c088a..7b2a6be 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -16,9 +16,6 @@ import { Channel, invoke } from "@tauri-apps/api/core"; import type { Agent, - CellKind, - ReattachChatDto, - ReplyChunk, ResumableAgent, TerminalSession, } from "@/domain"; @@ -41,8 +38,6 @@ interface LaunchAgentResponse { cols: number; /** Conversation id minted by this launch (omitted when nothing was assigned). */ assignedConversationId?: string; - /** How the hosting cell should render (`"chat"` ⇒ structured AI cell, §17.6). */ - cellKind: CellKind; } export class TauriAgentGateway implements AgentGateway { @@ -155,11 +150,9 @@ export class TauriAgentGateway implements AgentGateway { const base = makeTerminalHandle(res.sessionId, channel); // Surface the id assigned by this launch (so the caller persists it on the - // leaf and resumes next time) and the derived `cellKind` (so the leaf routes - // to `AgentChatView` vs `TerminalView`, §17.6). + // leaf and resumes next time). return { ...base, - cellKind: res.cellKind, ...(res.assignedConversationId ? { assignedConversationId: res.assignedConversationId } : {}), @@ -197,40 +190,4 @@ export class TauriAgentGateway implements AgentGateway { request: { projectId, agentId, conversationId }, }); } - - async sendPrompt( - sessionId: string, - prompt: string, - onReply: (chunk: ReplyChunk) => void, - ): Promise { - // Per-turn reply channel. The backend streams typed `ReplyChunk`s (tagged on - // `kind`); the pump runs to the `final` chunk then detaches its generation. - const channel = new Channel(); - channel.onmessage = (chunk) => onReply(chunk); - - // `agent_send` takes top-level args (Tauri renames the snake_case command - // params to camelCase): `sessionId`, `prompt`, `onReply`. - await invoke("agent_send", { sessionId, prompt, onReply: channel }); - } - - async reattachChat( - sessionId: string, - onReply: (chunk: ReplyChunk) => void, - ): Promise { - // Re-bind to a still-living structured session without re-spawning it. The - // returned scrollback repaints prior turns; subsequent chunks arrive over the - // freshly-registered channel. - const channel = new Channel(); - channel.onmessage = (chunk) => onReply(chunk); - - const res = await invoke("reattach_agent_chat", { - sessionId, - onReply: channel, - }); - return res.scrollback; - } - - async closeAgentSession(sessionId: string): Promise { - await invoke("close_agent_session", { sessionId }); - } } diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 1c27d3d..64f45c7 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -14,6 +14,7 @@ import type { } from "@/ports"; import { TauriSystemGateway } from "./system"; import { TauriAgentGateway } from "./agent"; +import { TauriInputGateway } from "./input"; import { TauriProjectGateway } from "./project"; import { TauriTerminalGateway } from "./terminal"; import { TauriLayoutGateway } from "./layout"; @@ -43,6 +44,7 @@ export function createTauriGateways(): Gateways { return { system: new TauriSystemGateway(), agent: new TauriAgentGateway(), + input: new TauriInputGateway(), terminal: new TauriTerminalGateway(), project: new TauriProjectGateway(), layout: new TauriLayoutGateway(), @@ -59,6 +61,7 @@ export function createTauriGateways(): Gateways { export { TauriSystemGateway, TauriAgentGateway, + TauriInputGateway, TauriProjectGateway, TauriTerminalGateway, TauriLayoutGateway, diff --git a/frontend/src/adapters/input.ts b/frontend/src/adapters/input.ts new file mode 100644 index 0000000..be1d132 --- /dev/null +++ b/frontend/src/adapters/input.ts @@ -0,0 +1,33 @@ +/** + * Tauri adapter for {@link InputGateway} (lot F1, cadrage §4.2). + * + * `submit` → `submit_agent_input` (enqueue), `interrupt` → `interrupt_agent` + * (preempt). These app-tauri commands land with lot C4; this adapter is complete + * on the frontend side and the mock covers tests/offline dev meanwhile. + * + * Commands use snake_case (Tauri convention); payload keys are camelCase + * (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent + * with the other adapters in this directory. + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { InputGateway } from "@/ports"; + +export class TauriInputGateway implements InputGateway { + async submit( + projectId: string, + agentId: string, + text: string, + ): Promise { + await invoke("submit_agent_input", { + request: { projectId, agentId, text }, + }); + } + + async interrupt(projectId: string, agentId: string): Promise { + await invoke("interrupt_agent", { + request: { projectId, agentId }, + }); + } +} diff --git a/frontend/src/adapters/mock/agent-chat.test.ts b/frontend/src/adapters/mock/agent-chat.test.ts deleted file mode 100644 index 54466d9..0000000 --- a/frontend/src/adapters/mock/agent-chat.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * D5 — the `MockAgentGateway` chat surface (`_setChatAgents`, `launchAgent` as a - * chat cell, `sendPrompt`, `reattachChat`, `closeAgentSession`). Verifies the - * mock scripts a realistic `ReplyChunk` sequence (deltas… toolActivity… final) - * and that re-attach replays the retained scrollback WITHOUT a new turn — the - * exact behaviours the feature tests rely on. - */ -import { describe, it, expect, vi } from "vitest"; - -import type { ReplyChunk } from "@/domain"; -import { MockAgentGateway } from "./index"; - -const CWD = "/home/me/proj"; - -/** Launches a chat agent and returns its session id. */ -async function launchChat(gw: MockAgentGateway, agentId = "a1", projectId = "p1") { - await gw.createAgent(projectId, { name: agentId, profileId: "claude" }); - // createAgent mints its own id — re-fetch so we mark the real id as chat. - const created = (await gw.listAgents(projectId)).find((a) => a.name === agentId)!; - gw._setChatAgents([created.id]); - const handle = await gw.launchAgent( - projectId, - created.id, - { cwd: CWD, rows: 0, cols: 0, nodeId: "node-1" }, - () => {}, - ); - return { handle, agentId: created.id, projectId }; -} - -describe("MockAgentGateway chat surface", () => { - it("launchAgent reports cellKind 'chat' for a structured agent", async () => { - const gw = new MockAgentGateway(); - const { handle } = await launchChat(gw); - expect(handle.cellKind).toBe("chat"); - }); - - it("launchAgent stays 'pty' for a non-structured agent (no regression)", async () => { - const gw = new MockAgentGateway(); - await gw.createAgent("p1", { name: "plain", profileId: "claude" }); - const plain = (await gw.listAgents("p1")).find((a) => a.name === "plain")!; - const handle = await gw.launchAgent( - "p1", - plain.id, - { cwd: CWD, rows: 24, cols: 80, nodeId: "node-1" }, - () => {}, - ); - expect(handle.cellKind ?? "pty").toBe("pty"); - }); - - it("sendPrompt streams deltas… toolActivity… then a single final", async () => { - const gw = new MockAgentGateway(); - const { handle } = await launchChat(gw); - const chunks: ReplyChunk[] = []; - await gw.sendPrompt(handle.sessionId, "hi", (c) => chunks.push(c)); - - // The send pump runs on a microtask; flush it. - await new Promise((r) => queueMicrotask(() => r(undefined))); - - const kinds = chunks.map((c) => c.kind); - expect(kinds.filter((k) => k === "textDelta").length).toBeGreaterThanOrEqual(1); - expect(kinds).toContain("toolActivity"); - // Exactly one terminal final, and it is the LAST chunk. - expect(kinds.filter((k) => k === "final")).toHaveLength(1); - expect(kinds.at(-1)).toBe("final"); - - // The aggregated final content equals the concatenated deltas (no loss). - const deltas = chunks - .filter((c): c is { kind: "textDelta"; text: string } => c.kind === "textDelta") - .map((c) => c.text) - .join(""); - const final = chunks.find((c): c is { kind: "final"; content: string } => c.kind === "final")!; - expect(final.content).toBe(deltas); - expect(final.content).toBe("echo: hi"); - }); - - it("reattachChat replays the retained scrollback without re-sending", async () => { - const gw = new MockAgentGateway(); - const { handle } = await launchChat(gw); - - // Run one full turn so the session retains a scrollback. - await gw.sendPrompt(handle.sessionId, "remember me", () => {}); - await new Promise((r) => queueMicrotask(() => r(undefined))); - - // Re-attach: returns the prior chunks; the live sink must NOT receive any - // new chunk (no fresh turn is triggered by a re-attach). - const liveSink = vi.fn(); - const scrollback = await gw.reattachChat(handle.sessionId, liveSink); - - expect(scrollback.length).toBeGreaterThan(0); - expect(scrollback.at(-1)?.kind).toBe("final"); - await new Promise((r) => queueMicrotask(() => r(undefined))); - expect(liveSink).not.toHaveBeenCalled(); - }); - - it("reattachChat rejects for a closed session", async () => { - const gw = new MockAgentGateway(); - const { handle } = await launchChat(gw); - await gw.closeAgentSession(handle.sessionId); - await expect(gw.reattachChat(handle.sessionId, () => {})).rejects.toMatchObject({ - code: "NOT_FOUND", - }); - }); - - it("sendPrompt rejects once the session is closed", async () => { - const gw = new MockAgentGateway(); - const { handle } = await launchChat(gw); - await gw.closeAgentSession(handle.sessionId); - await expect(gw.sendPrompt(handle.sessionId, "x", () => {})).rejects.toMatchObject({ - code: "NOT_FOUND", - }); - }); -}); diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 0879d52..3ba28c9 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -27,10 +27,8 @@ import type { MemoryIndexEntry, MemoryLink, MemoryType, - CellKind, Project, ProfileAvailability, - ReplyChunk, ResumableAgent, Skill, SkillScope, @@ -47,6 +45,7 @@ import type { EmbedderGateway, CreateTemplateInput, Gateways, + InputGateway, LiveAgent, MemoryGateway, GitGateway, @@ -161,68 +160,6 @@ class MockPtySession { } } -/** - * A live in-memory mock **chat** session (the structured-AI twin of - * {@link MockPtySession}). It retains a conversation scrollback (every - * {@link ReplyChunk} streamed) and tracks the current reply sink so a view can - * detach (sink cleared, session stays alive) and later re-attach (new sink, - * scrollback replayed by the caller). `sendPrompt` streams a scripted turn: - * a couple of `textDelta`s, an optional `toolActivity`, then one `final`. - */ -class MockChatSession { - /** Accumulated reply chunks (the conversation scrollback). */ - private scrollback: ReplyChunk[] = []; - /** Current reply sink; `null` while detached. */ - private sink: ((chunk: ReplyChunk) => void) | null = null; - /** Whether the session was explicitly closed (shut down). */ - closed = false; - - constructor(readonly sessionId: string) {} - - /** Records a chunk into the scrollback and forwards it to the current sink. */ - private emit(chunk: ReplyChunk): void { - if (this.closed) return; - this.scrollback.push(chunk); - this.sink?.(chunk); - } - - /** Re-attaches a new reply sink, returning the retained scrollback to replay. */ - reattach(sink: (chunk: ReplyChunk) => void): ReplyChunk[] { - this.sink = sink; - return structuredClone(this.scrollback); - } - - /** Detaches the current view: stop delivering, keep the session alive. */ - detach(): void { - this.sink = null; - } - - /** - * Streams a scripted reply turn for `prompt`: two text deltas, a tool-activity - * badge, then the deterministic `final` carrying the aggregated content. Each - * chunk is recorded into the scrollback (so a re-attach replays the whole - * turn) and forwarded live to the current sink. - */ - send(prompt: string, onReply: (chunk: ReplyChunk) => void): void { - if (this.closed) return; - this.sink = onReply; - const content = `echo: ${prompt}`; - const half = Math.ceil(content.length / 2); - queueMicrotask(() => { - this.emit({ kind: "textDelta", text: content.slice(0, half) }); - this.emit({ kind: "toolActivity", label: "thinking" }); - this.emit({ kind: "textDelta", text: content.slice(half) }); - this.emit({ kind: "final", content }); - }); - } - - /** Shuts the session down (idempotent). */ - shutdown(): void { - this.closed = true; - this.sink = null; - } -} - /** * Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`, * `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and @@ -249,19 +186,6 @@ export class MockAgentGateway implements AgentGateway { private liveByAgent = new Map(); /** Live PTY session id per agent (`agentId → sessionId`). */ private liveSessionByAgent = new Map(); - /** Live structured (chat) sessions, kept across detach so reattach finds them. */ - private chatSessions = new Map(); - /** - * Agents that launch as a **chat** cell (`structured_adapter` present). Seeded - * by tests via {@link _setChatAgents}; absent ⇒ the agent launches as a `pty` - * cell (the historical default), so existing tests are unaffected. - */ - private chatAgents = new Set(); - - /** Marks the given agents as structured (chat) cells for `launchAgent`. */ - _setChatAgents(agentIds: string[]): void { - for (const id of agentIds) this.chatAgents.add(id); - } private getAgents(projectId: string): Agent[] { if (!this.agents.has(projectId)) this.agents.set(projectId, []); @@ -450,7 +374,6 @@ export class MockAgentGateway implements AgentGateway { cwd: `/home/user/mock-project`, rows, cols, - cellKind: this.chatAgents.has(agentId) ? "chat" : "pty", }, }; } @@ -535,10 +458,9 @@ export class MockAgentGateway implements AgentGateway { this.sessionSeq += 1; const sessionId = `mock-agent-session-${this.sessionSeq}`; const cwd = options.cwd; - const cellKind: CellKind = this.chatAgents.has(agentId) ? "chat" : "pty"; // Record liveness for `listLiveAgents` + the guard above (only when the - // caller pins a node) — shared by both cell kinds. + // caller pins a node). if (options.nodeId) { this.liveByAgent.set(agentId, options.nodeId); this.liveSessionByAgent.set(agentId, sessionId); @@ -550,37 +472,19 @@ export class MockAgentGateway implements AgentGateway { } }; - let handle: TerminalHandle; - if (cellKind === "chat") { - // Structured AI cell (§17.6): spawn a chat session (no PTY). The reply - // stream is driven by `sendPrompt`; the returned handle is mostly inert - // (no bytes), but its `close` shuts the structured session down. - const chat = new MockChatSession(sessionId); - this.chatSessions.set(sessionId, chat); - handle = { - ...makeInertHandle(sessionId, () => { - chat.shutdown(); - this.chatSessions.delete(sessionId); - clearLive(); - }), - cellKind, - }; - } else { - const enc = new TextEncoder(); - const session = new MockPtySession(sessionId, onData); - this.sessions.set(sessionId, session); - // Greet so something is visible immediately (mirrors MockTerminalGateway). - queueMicrotask(() => - session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)), - ); - handle = { - ...makeMockHandle(session, () => { - this.sessions.delete(sessionId); - clearLive(); - }), - cellKind, - }; - } + // Every agent cell is a raw PTY (Option 1 — Terminal + MCP). The former + // structured "chat" cell routing is retired. + const enc = new TextEncoder(); + const session = new MockPtySession(sessionId, onData); + this.sessions.set(sessionId, session); + // Greet so something is visible immediately (mirrors MockTerminalGateway). + queueMicrotask(() => + session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)), + ); + const handle: TerminalHandle = makeMockHandle(session, () => { + this.sessions.delete(sessionId); + clearLive(); + }); // Simulate session assignment (T4b): a fresh cell (no conversation id) gets a // newly-minted id surfaced on the handle so the caller persists it; a cell @@ -594,58 +498,6 @@ export class MockAgentGateway implements AgentGateway { return handle; } - async sendPrompt( - sessionId: string, - prompt: string, - onReply: (chunk: ReplyChunk) => void, - ): Promise { - const session = this.chatSessions.get(sessionId); - if (!session || session.closed) { - const err: GatewayError = { - code: "NOT_FOUND", - message: `structured session ${sessionId} is not alive`, - }; - throw err; - } - session.send(prompt, onReply); - } - - async reattachChat( - sessionId: string, - onReply: (chunk: ReplyChunk) => void, - ): Promise { - const session = this.chatSessions.get(sessionId); - if (!session || session.closed) { - const err: GatewayError = { - code: "NOT_FOUND", - message: `structured session ${sessionId} is not alive`, - }; - throw err; - } - // Re-attach the reply sink and return the retained conversation scrollback — - // no new turn is started here (mirrors the backend `reattach_agent_chat`). - return session.reattach(onReply); - } - - async closeAgentSession(sessionId: string): Promise { - const session = this.chatSessions.get(sessionId); - if (!session) { - const err: GatewayError = { - code: "NOT_FOUND", - message: `structured session ${sessionId} is not alive`, - }; - throw err; - } - session.shutdown(); - this.chatSessions.delete(sessionId); - for (const [agentId, liveSessionId] of this.liveSessionByAgent) { - if (liveSessionId === sessionId) { - this.liveSessionByAgent.delete(agentId); - this.liveByAgent.delete(agentId); - } - } - } - async reattach( sessionId: string, onData: (bytes: Uint8Array) => void, @@ -730,28 +582,6 @@ function makeMockHandle( }; } -/** - * Builds an inert {@link TerminalHandle} for a structured **chat** session: it - * carries no PTY, so `write`/`resize` are no-ops and `detach` does nothing - * (the chat view manages its own reply subscription). Only `close` is live — - * it shuts the structured session down. The chat cell never feeds bytes through - * this handle; it drives the session via `sendPrompt`/`reattachChat`. - */ -function makeInertHandle( - sessionId: string, - shutdown: () => void, -): TerminalHandle { - return { - sessionId, - async write(): Promise {}, - async resize(): Promise {}, - detach(): void {}, - async close(): Promise { - shutdown(); - }, - }; -} - /** * In-memory fake terminal: a shell-less PTY that **echoes** whatever is written * back to `onData` (so the xterm wrapper renders typed input) and greets on @@ -1703,12 +1533,48 @@ export class MockEmbedderGateway implements EmbedderGateway { } } +/** One recorded mediated-input call (for test assertions). */ +export interface MockInputCall { + projectId: string; + agentId: string; + /** Present for `submit`, absent for `interrupt`. */ + text?: string; +} + +/** + * In-memory {@link InputGateway} (lot F1). Records every `submit`/`interrupt` + * so tests can assert the component routed through the port with the right + * args — no backend. `submit` always resolves (the FIFO never refuses an + * enqueue; the forward/fallback rule lives backend-side, §4.2/§6). + * + * Exported so tests can instantiate it directly (same pattern as the other mocks). + */ +export class MockInputGateway implements InputGateway { + /** All recorded submit calls, in order. */ + readonly submits: MockInputCall[] = []; + /** All recorded interrupt calls, in order. */ + readonly interrupts: MockInputCall[] = []; + + async submit( + projectId: string, + agentId: string, + text: string, + ): Promise { + this.submits.push({ projectId, agentId, text }); + } + + async interrupt(projectId: string, agentId: string): Promise { + this.interrupts.push({ projectId, agentId }); + } +} + /** Builds the full set of mock gateways. */ export function createMockGateways(): Gateways { const agentGateway = new MockAgentGateway(); return { system: new MockSystemGateway(), agent: agentGateway, + input: new MockInputGateway(), terminal: new MockTerminalGateway(), project: new MockProjectGateway(), layout: new MockLayoutGateway(), diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 9ceb13a..93c9ac9 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -12,11 +12,12 @@ import { createMockGateways, MockSystemGateway } from "./index"; const gateways: Gateways = createMockGateways(); describe("createMockGateways", () => { - it("exposes all twelve gateways", () => { + it("exposes all thirteen gateways", () => { expect(Object.keys(gateways).sort()).toEqual([ "agent", "embedder", "git", + "input", "layout", "memory", "profile", diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 4126800..d50f464 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -19,6 +19,7 @@ export type DomainEvent = | { type: "agentLaunched"; agentId: string; sessionId: string } | { type: "agentExited"; agentId: string; code: number } | { type: "agentProfileChanged"; agentId: string; profileId: string } + | { type: "agentBusyChanged"; agentId: string; busy: boolean } | { type: "templateUpdated"; templateId: string; version: number } | { type: "agentDriftDetected"; agentId: string; from: number; to: number } | { type: "agentSynced"; agentId: string; to: number } @@ -283,49 +284,6 @@ export interface TerminalSession { * session assignment and the hosting cell had none yet; absent otherwise. */ assignedConversationId?: string; - /** - * How the frontend should render the cell hosting this session (mirror of the - * backend `TerminalSessionDto.cellKind`, ARCHITECTURE §17.6). `"chat"` ⇒ a - * structured AI session driven by `agent_send`/`reattach_agent_chat` (rendered - * as an `AgentChatView`); `"pty"` ⇒ a raw terminal (xterm). **Derived** - * backend-side from the launch routing — a single source of truth. - */ - cellKind: CellKind; -} - -/** - * Whether a session's hosting cell renders as a structured chat view or a raw - * terminal (mirror of the backend `CellKind`, ARCHITECTURE §17.6). The - * `LayoutGrid` switches `AgentChatView` vs `TerminalView` on this value. - */ -export type CellKind = "pty" | "chat"; - -/** - * One incremental event of an AI agent's reply turn (mirror of the backend - * `ReplyChunk`, ARCHITECTURE §17.7). Tagged on `kind` (camelCase on the wire), - * so the view branches without positional parsing: - * - * - `textDelta`: a text fragment of the current turn (accumulated live); - * - `toolActivity`: a human-readable tool-activity badge (best-effort); - * - `final`: the **deterministic** end-of-turn chunk carrying the aggregated - * final content — after it the turn is frozen and the stream ends. - */ -export type ReplyChunk = - | { kind: "textDelta"; text: string } - | { kind: "toolActivity"; label: string } - | { kind: "final"; content: string }; - -/** - * The retained **conversation scrollback** of a still-live structured session - * (mirror of the backend `ReattachChatDto`, ARCHITECTURE §17.7), returned by - * `reattachChat`. The frontend replays `scrollback` to rebuild the visible turns - * before subsequent chunks arrive over the freshly-registered channel. - */ -export interface ReattachChatDto { - /** The session that was re-attached (echoed back). */ - sessionId: string; - /** The chunks already streamed for this conversation, in order. */ - scrollback: ReplyChunk[]; } /** diff --git a/frontend/src/features/chat/AgentChatView.test.tsx b/frontend/src/features/chat/AgentChatView.test.tsx deleted file mode 100644 index 235af89..0000000 --- a/frontend/src/features/chat/AgentChatView.test.tsx +++ /dev/null @@ -1,268 +0,0 @@ -/** - * D5 — {@link AgentChatView}, the structured-AI chat twin of `TerminalView`. - * - * These are pure-component tests: the view takes its `launch`/`reattach`/`send` - * callbacks as props (the leaf wires them from the `AgentGateway` port), so we - * drive it with hand-rolled spies and assert the visible transcript + the exact - * lifecycle calls. The cardinal invariants under test: - * - * - deltas accumulate into the pending turn; `final` FREEZES it and REPLACES - * the deltas (no double rendering); - * - mounting with an existing session id RE-ATTACHES (replays scrollback) and - * NEVER launches / sends (re-attach ≠ re-spawn); - * - Enter submits the prompt via `send`; Shift+Enter does not. - */ -import { describe, it, expect, vi } from "vitest"; -import { render, screen, waitFor, fireEvent } from "@testing-library/react"; - -import type { ReplyChunk } from "@/domain"; -import { AgentChatView, type AgentChatViewProps } from "./AgentChatView"; - -/** A captured `onReply` sink, so a test can stream chunks live after attach. */ -interface Wired { - props: AgentChatViewProps; - /** Last `onReply` handed to `reattach`/`send` (live stream sink). */ - emit: (chunk: ReplyChunk) => void; - launch: ReturnType; - reattach: ReturnType; - send: ReturnType; - onSessionId: ReturnType; -} - -/** - * Builds the prop set with spy callbacks. `scrollback` is returned by - * `reattach`; `launchId` is the id `launch` resolves with. - */ -function wire(opts: { - sessionId?: string | null; - scrollback?: ReplyChunk[]; - launchId?: string; -}): Wired { - let lastSink: (chunk: ReplyChunk) => void = () => {}; - - const reattach = vi.fn( - async (_sid: string, onReply: (c: ReplyChunk) => void) => { - lastSink = onReply; - return opts.scrollback ?? []; - }, - ); - const launch = vi.fn(async () => opts.launchId ?? "launched-session"); - const send = vi.fn( - async (_sid: string, _prompt: string, onReply: (c: ReplyChunk) => void) => { - lastSink = onReply; - }, - ); - const onSessionId = vi.fn(); - - const props: AgentChatViewProps = { - launch, - reattach, - send, - sessionId: opts.sessionId ?? null, - onSessionId, - }; - return { - props, - emit: (chunk) => lastSink(chunk), - launch, - reattach, - send, - onSessionId, - }; -} - -describe("AgentChatView", () => { - it("mounts the chat view container", () => { - render(); - expect(screen.getByTestId("agent-chat-view")).toBeTruthy(); - }); - - // ── Zone 3: re-attach ≠ re-spawn ────────────────────────────────────────── - it("re-attaches to an existing session and NEVER launches or sends", async () => { - const w = wire({ sessionId: "live-1" }); - render(); - - await waitFor(() => expect(w.reattach).toHaveBeenCalled()); - // Re-attach used the persisted id. - expect(w.reattach.mock.calls[0][0]).toBe("live-1"); - // The cardinal invariant: navigating back must NOT re-spawn nor re-send. - expect(w.launch).not.toHaveBeenCalled(); - expect(w.send).not.toHaveBeenCalled(); - // A reattach (not a launch) must NOT re-persist the session id. - expect(w.onSessionId).not.toHaveBeenCalled(); - }); - - it("launches a fresh session only when the cell has no session yet", async () => { - const w = wire({ sessionId: null, launchId: "fresh-7" }); - render(); - - await waitFor(() => expect(w.launch).toHaveBeenCalled()); - // The launched id is persisted and then attached. - await waitFor(() => expect(w.onSessionId).toHaveBeenCalledWith("fresh-7")); - await waitFor(() => expect(w.reattach).toHaveBeenCalledWith("fresh-7", expect.any(Function))); - }); - - // ── Zone 3 (scrollback): re-attach repaints prior turns ─────────────────── - it("repaints the conversation scrollback returned by re-attach", async () => { - const w = wire({ - sessionId: "live-1", - scrollback: [ - { kind: "textDelta", text: "restored " }, - { kind: "textDelta", text: "answer" }, - { kind: "final", content: "restored answer" }, - ], - }); - render(); - - // The replayed final freezes into a (single) assistant turn. - await waitFor(() => - expect(screen.getByTestId("chat-turn-assistant").textContent).toContain( - "restored answer", - ), - ); - // No prompt was sent to rebuild it. - expect(w.send).not.toHaveBeenCalled(); - }); - - // ── Zone 2: accumulation + non-double-render ────────────────────────────── - it("accumulates textDelta into the pending turn, then final freezes it", async () => { - const w = wire({ sessionId: "s1" }); - render(); - await waitFor(() => expect(w.reattach).toHaveBeenCalled()); - - // Stream two deltas live → they accumulate in the pending bubble. - w.emit({ kind: "textDelta", text: "Hel" }); - w.emit({ kind: "textDelta", text: "lo" }); - await waitFor(() => - expect(screen.getByTestId("chat-turn-pending").textContent).toContain("Hello"), - ); - // Still pending (no assistant turn yet). - expect(screen.queryByTestId("chat-turn-assistant")).toBeNull(); - - // final freezes the turn into an assistant bubble; pending disappears. - w.emit({ kind: "final", content: "Hello" }); - await waitFor(() => - expect(screen.getByTestId("chat-turn-assistant").textContent).toContain("Hello"), - ); - expect(screen.queryByTestId("chat-turn-pending")).toBeNull(); - }); - - it("final content REPLACES the deltas — no double rendering", async () => { - const w = wire({ sessionId: "s1" }); - render(); - await waitFor(() => expect(w.reattach).toHaveBeenCalled()); - - w.emit({ kind: "textDelta", text: "abc" }); - w.emit({ kind: "final", content: "abc" }); - - await waitFor(() => expect(screen.getByTestId("chat-turn-assistant")).toBeTruthy()); - // The frozen turn shows "abc" exactly ONCE — not "abcabc" (delta + final). - const text = screen.getByTestId("chat-turn-assistant").textContent ?? ""; - const occurrences = text.split("abc").length - 1; - expect(occurrences).toBe(1); - // And there is exactly one assistant turn, not two. - expect(screen.getAllByTestId("chat-turn-assistant")).toHaveLength(1); - }); - - // Anti "always-green" guard: prove the non-double assertion would FAIL if the - // component had appended final on top of the deltas (a mutated reducer). Here - // we feed a final whose content DIFFERS from the deltas, then check the visible - // turn is the final alone — if deltas leaked through, "xx" would also appear. - it("the frozen turn is the final content alone (deltas discarded)", async () => { - const w = wire({ sessionId: "s1" }); - render(); - await waitFor(() => expect(w.reattach).toHaveBeenCalled()); - - w.emit({ kind: "textDelta", text: "DELTA" }); - w.emit({ kind: "final", content: "FINAL" }); - - await waitFor(() => expect(screen.getByTestId("chat-turn-assistant")).toBeTruthy()); - const text = screen.getByTestId("chat-turn-assistant").textContent ?? ""; - expect(text).toContain("FINAL"); - expect(text).not.toContain("DELTA"); - }); - - // ── Zone 5: toolActivity → badge ────────────────────────────────────────── - it("renders toolActivity as a tool badge on the turn", async () => { - const w = wire({ sessionId: "s1" }); - render(); - await waitFor(() => expect(w.reattach).toHaveBeenCalled()); - - w.emit({ kind: "textDelta", text: "working" }); - w.emit({ kind: "toolActivity", label: "Read(file.ts)" }); - await waitFor(() => { - const badges = screen.getAllByTestId("chat-tool-badge"); - expect(badges).toHaveLength(1); - expect(badges[0].textContent).toBe("Read(file.ts)"); - }); - - // The tools carry over onto the frozen turn after final. - w.emit({ kind: "final", content: "done" }); - await waitFor(() => expect(screen.getByTestId("chat-turn-assistant")).toBeTruthy()); - expect(screen.getByTestId("chat-tool-badge").textContent).toBe("Read(file.ts)"); - }); - - // ── Zone 4: prompt submission ───────────────────────────────────────────── - it("Enter submits the prompt via send(sessionId, prompt, onReply)", async () => { - const w = wire({ sessionId: "live-9" }); - render(); - await waitFor(() => expect(w.reattach).toHaveBeenCalled()); - - const input = screen.getByTestId("agent-chat-input"); - fireEvent.change(input, { target: { value: "what is 2+2?" } }); - fireEvent.keyDown(input, { key: "Enter" }); - - await waitFor(() => expect(w.send).toHaveBeenCalled()); - expect(w.send.mock.calls[0][0]).toBe("live-9"); - expect(w.send.mock.calls[0][1]).toBe("what is 2+2?"); - expect(typeof w.send.mock.calls[0][2]).toBe("function"); - // The user turn is echoed in the transcript and the input is cleared. - expect(screen.getByTestId("chat-turn-user").textContent).toContain("what is 2+2?"); - expect((input as HTMLTextAreaElement).value).toBe(""); - }); - - it("Shift+Enter does NOT submit (newline, not send)", async () => { - const w = wire({ sessionId: "live-9" }); - render(); - await waitFor(() => expect(w.reattach).toHaveBeenCalled()); - - const input = screen.getByTestId("agent-chat-input"); - fireEvent.change(input, { target: { value: "line one" } }); - fireEvent.keyDown(input, { key: "Enter", shiftKey: true }); - - expect(w.send).not.toHaveBeenCalled(); - }); - - it("the submit button sends and streams the reply turn back over onReply", async () => { - const w = wire({ sessionId: "live-9" }); - render(); - await waitFor(() => expect(w.reattach).toHaveBeenCalled()); - - fireEvent.change(screen.getByTestId("agent-chat-input"), { - target: { value: "ping" }, - }); - fireEvent.click(screen.getByLabelText("send")); - - await waitFor(() => expect(w.send).toHaveBeenCalledWith("live-9", "ping", expect.any(Function))); - // The reply now streams over the same onReply captured by send(). - w.emit({ kind: "final", content: "pong" }); - await waitFor(() => - expect( - screen.getAllByTestId("chat-turn-assistant").some((el) => - (el.textContent ?? "").includes("pong"), - ), - ).toBe(true), - ); - }); - - it("does not send an empty/whitespace prompt", async () => { - const w = wire({ sessionId: "live-9" }); - render(); - await waitFor(() => expect(w.reattach).toHaveBeenCalled()); - - const input = screen.getByTestId("agent-chat-input"); - fireEvent.change(input, { target: { value: " " } }); - fireEvent.keyDown(input, { key: "Enter" }); - expect(w.send).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/features/chat/AgentChatView.tsx b/frontend/src/features/chat/AgentChatView.tsx deleted file mode 100644 index ca81c4a..0000000 --- a/frontend/src/features/chat/AgentChatView.tsx +++ /dev/null @@ -1,431 +0,0 @@ -/** - * Structured AI chat view (§17.6) — the chat twin of {@link TerminalView}. - * - * Where `TerminalView` wires raw PTY bytes into xterm, this view consumes the - * **typed** {@link ReplyChunk} stream of an `AgentSession`: - * - * - `textDelta` → accumulated into the current (in-flight) assistant turn; - * - `toolActivity` → a tool-activity badge on the current turn; - * - `final` → freezes the turn (the aggregated final content is authoritative, - * so it replaces the accumulated deltas — no double rendering). - * - * Input (the prompt box) → {@link AgentChatViewProps.send} (`agent_send`), which - * opens a fresh assistant turn whose chunks stream in over the same `onReply`. - * - * **Session lifecycle is decoupled from the view lifecycle** (same guarantee as - * the PTY): navigating (layout/tab switch) tears the view down but must NOT kill - * the backend structured session. So on mount the view **re-attaches** to the - * still-living session (repainting its conversation scrollback) via - * {@link AgentChatViewProps.reattach}; if no session exists yet it **launches** - * one via {@link AgentChatViewProps.launch}. On unmount it only drops its local - * subscription — it never closes the session (an explicit user action elsewhere). - * - * Pure presentation: it depends only on the callbacks the leaf wires from the - * {@link AgentGateway} port — never on `invoke()`/`Channel` (ARCHITECTURE §1.3). - */ - -import { useCallback, useEffect, useRef, useState } from "react"; - -import type { ReplyChunk } from "@/domain"; - -/** A frozen, completed assistant turn (its `final` content). */ -interface AssistantTurn { - readonly kind: "assistant"; - readonly text: string; - /** Tool-activity labels observed during the turn (in order). */ - readonly tools: readonly string[]; -} - -/** A user prompt turn. */ -interface UserTurn { - readonly kind: "user"; - readonly text: string; -} - -type Turn = AssistantTurn | UserTurn; - -/** - * The live (in-flight) assistant turn being streamed: accumulated text deltas - * plus any tool-activity labels seen so far. `null` between turns. - */ -interface PendingTurn { - text: string; - tools: string[]; -} - -export interface AgentChatViewProps { - /** - * Launches a fresh structured session for this cell and returns its session id. - * Used at mount when the cell has no session yet. Mirrors `TerminalView.open`. - */ - launch: () => Promise; - /** - * Re-attaches to an already-living structured session, replaying its retained - * conversation scrollback. `onReply` then receives subsequent chunks. Mirrors - * `TerminalView.reattach`. Rejects if the session is gone (caller falls back - * to {@link launch}). - */ - reattach: ( - sessionId: string, - onReply: (chunk: ReplyChunk) => void, - ) => Promise; - /** - * Sends a prompt to the live session; its reply turn streams back over - * `onReply`. Mirrors a PTY `write`. Resolves once the turn stream is wired. - */ - send: ( - sessionId: string, - prompt: string, - onReply: (chunk: ReplyChunk) => void, - ) => Promise; - /** Persisted session id for this cell, if a session is already running for it. */ - sessionId?: string | null; - /** - * Called once a session is established (launched) so the caller persists its id - * for this cell and re-attaches on the next mount. Not called on reattach. - */ - onSessionId?: (sessionId: string) => void; -} - -export function AgentChatView({ - launch, - reattach, - send, - sessionId, - onSessionId, -}: AgentChatViewProps) { - /** Frozen turns (history), oldest first. */ - const [turns, setTurns] = useState([]); - /** The in-flight assistant turn, or `null` between turns. */ - const [pending, setPending] = useState(null); - /** The current input value. */ - const [input, setInput] = useState(""); - /** A connection/attach error to surface (non-fatal). */ - const [error, setError] = useState(null); - - // The live session id is held in a ref so the (stable) chunk reducer and the - // submit handler always read the current value without re-subscribing. - const liveSessionId = useRef(sessionId ?? null); - - // Callbacks read through refs so the mount effect does not depend on their - // identity (the leaf re-creates them each render). The view is re-mounted via - // a `key` when the agent changes, so the right callbacks are captured at mount. - const launchRef = useRef(launch); - launchRef.current = launch; - const reattachRef = useRef(reattach); - reattachRef.current = reattach; - const onSessionIdRef = useRef(onSessionId); - onSessionIdRef.current = onSessionId; - - const scrollRef = useRef(null); - - /** - * Folds one {@link ReplyChunk} into the visible state. Stable identity: it - * only touches state via the functional setters, so the same instance serves - * the reattach replay, the live stream, and every subsequent turn. - */ - const apply = useCallback((chunk: ReplyChunk) => { - switch (chunk.kind) { - case "textDelta": - setPending((p) => ({ - text: (p?.text ?? "") + chunk.text, - tools: p?.tools ?? [], - })); - break; - case "toolActivity": - setPending((p) => ({ - text: p?.text ?? "", - tools: [...(p?.tools ?? []), chunk.label], - })); - break; - case "final": - // The aggregated final content is authoritative — it replaces the - // accumulated deltas (no double rendering), carrying over the tools seen. - setPending((p) => { - const tools = p?.tools ?? []; - setTurns((prev) => [ - ...prev, - { kind: "assistant", text: chunk.content, tools }, - ]); - return null; - }); - break; - } - }, []); - - // Mount: re-attach to the existing session (replay scrollback) or launch a new - // one. Decoupled from view lifecycle — unmount never closes the session. - useEffect(() => { - let disposed = false; - const existing = liveSessionId.current; - - const attach = (sid: string) => - reattachRef.current(sid, (chunk) => { - if (!disposed) apply(chunk); - }).then((scrollback) => { - if (disposed) return; - // Replay the retained conversation scrollback to rebuild prior turns, - // then subsequent chunks arrive live over the same `onReply`. - for (const chunk of scrollback) apply(chunk); - }); - - if (existing) { - attach(existing).catch(() => { - // Session gone (explicitly closed / exited): fall back to a fresh launch - // so the cell still works. - if (disposed) return; - launchRef.current() - .then((sid) => { - if (disposed) return; - liveSessionId.current = sid; - onSessionIdRef.current?.(sid); - return attach(sid); - }) - .catch((e) => { - if (!disposed) setError(describe(e)); - }); - }); - } else { - launchRef.current() - .then((sid) => { - if (disposed) return; - liveSessionId.current = sid; - onSessionIdRef.current?.(sid); - return attach(sid); - }) - .catch((e) => { - if (!disposed) setError(describe(e)); - }); - } - - return () => { - // Drop the local subscription only — the backend session keeps running so - // the AI is not cut off (same guarantee as the PTY detach). - disposed = true; - }; - // Re-attach/launch only on (re)mount. Callbacks are read from refs; the agent - // switch re-mounts via `key`, so we must NOT depend on them. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Auto-scroll to the latest content as turns/pending grow. - useEffect(() => { - const el = scrollRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [turns, pending]); - - const submit = () => { - const prompt = input.trim(); - const sid = liveSessionId.current; - if (!prompt || !sid) return; - setInput(""); - setError(null); - setTurns((prev) => [...prev, { kind: "user", text: prompt }]); - void send(sid, prompt, apply).catch((e) => setError(describe(e))); - }; - - return ( -
-
- {turns.map((turn, i) => - turn.kind === "user" ? ( - - ) : ( - - ), - )} - {pending && ( - - )} -
- - {error && ( -

- {error} -

- )} - -
{ - e.preventDefault(); - submit(); - }} - style={{ - display: "flex", - gap: 6, - padding: 6, - borderTop: "1px solid var(--color-border, #3a3a3a)", - }} - > -