feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP

Coeur inter-agents consolidé et surface front réalignée sur la décision
"terminal natif PTY, pas d'UI chat" (Option 1).

Domaine
- nouveaux modules conversation, mailbox, input, fileguard (ports + types)
- orchestrator/profile/events étendus (conversation par paire, FIFO)

Application / Infrastructure
- orchestrator/service + context_guard : sérialisation FIFO par agent,
  garde RW mémoire/contexte, dispatch ask/reply
- adapters in-memory conversation / mailbox / input / fileguard
- registry session + lifecycle agent durcis (1 agent = 1 session vivante)
- outils MCP idea_* alignés sur le nouveau dispatch

Frontend
- MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA,
  terminal = vue sortie inchangée
- suppression de la vue chat structurée (AgentChatView) — abandonnée
- adapter input + ports mis à jour

Divers
- .ideai/ : mémoire projet + briefs de cadrage versionnés ;
  requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA)

Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

3
.gitignore vendored
View File

@ -35,6 +35,9 @@ frontend/coverage/
# Derived vector store for semantic recall (LOT C / §14.5.3): embeddings of the # 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. # memory notes, rebuildable from the `.md` source of truth — not versioned.
.ideai/memory/.index/ .ideai/memory/.index/
# Runtime file-protocol orchestration requests/responses — transient I/O, not
# durable project state (curation .ideai §chantier secondaire).
.ideai/requests/
# ─── Editors / OS ─────────────────────────────────────────────────────────── # ─── Editors / OS ───────────────────────────────────────────────────────────
.idea/ .idea/

View File

@ -14,6 +14,27 @@
"mdPath": "agents/architect.md", "mdPath": "agents/architect.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false "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
} }
] ]
} }

View File

@ -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<String>, // 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<String>);
fn get(&self, id: ConversationId) -> Option<Conversation>;
}
```
- **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<ReadLease, GuardError>;
async fn acquire_write(&self, who: ConversationParty, res: GuardedResource)
-> Result<WriteLease, GuardError>;
}
```
- `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<GuardedResource, RwLock-like>`
(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<ConversationId, Conversation>` + 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 <id>]`)
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<void>; // Envoyer = enqueue
interrupt(projectId: string, agentId: string): Promise<void>; // 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<agentId, boolean>` 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<dyn InputMediator>)` + `with_conversations(Arc<dyn
ConversationRegistry>)`. `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<TicketId>, 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<String>`), 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.*

View File

@ -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<HashMap<AgentId, Arc<AsyncMutex<()>>>>` + `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 <path>` (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<AgentId, VecDeque<(Ticket, oneshot::Sender<String>)>>` + 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<dyn AgentMailbox>` + `Arc<dyn PtyPort>`. 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).

View File

@ -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/<nom>.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/<nom>.json.response.json`.
## Succès attendu
`{ "ok": true, "action": "agent.message", "reply": "<réponse de Codex>" }` — 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`.

View File

@ -1,25 +1,26 @@
{ {
"version": 1, "version": 1,
"activeId": "eed26045-b208-47ff-98ee-ca0d6e5933b3", "activeId": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5",
"layouts": [ "layouts": [
{ {
"id": "eed26045-b208-47ff-98ee-ca0d6e5933b3", "id": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5",
"name": "Default", "name": "Default",
"kind": "terminal", "kind": "terminal",
"tree": { "tree": {
"root": { "root": {
"type": "split", "type": "split",
"node": { "node": {
"id": "8cbe4c7c-357f-49ad-ac20-c96802a84684", "id": "1eb96c70-e954-42d0-907b-f8528d0442bf",
"direction": "row", "direction": "row",
"children": [ "children": [
{ {
"node": { "node": {
"type": "leaf", "type": "leaf",
"node": { "node": {
"id": "e8933dbd-1c53-4342-96ca-020b9a9b7970", "id": "db40e3de-4980-4bed-b6fa-1c136f49d30e",
"session": "51c96008-f875-4515-9b5b-50c4217f64d6", "session": "d7d4c099-eda3-4b33-82e6-e6f8d57b8b97",
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641" "agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
"agentWasRunning": true
} }
}, },
"weight": 1.0 "weight": 1.0
@ -28,8 +29,10 @@
"node": { "node": {
"type": "leaf", "type": "leaf",
"node": { "node": {
"id": "50da405b-18f3-4273-9fe0-57bb242cb2f7", "id": "92826533-1a7c-4d9e-b6b4-f1e904ddc81a",
"session": "5a796d0e-433e-4ded-9955-ed02b389d9c1" "session": "5d20f53a-ac76-4677-9a6f-064d3319cc73",
"agent": "edce8090-4c57-47c5-a319-c08fd172438b",
"agentWasRunning": true
} }
}, },
"weight": 1.0 "weight": 1.0

5
.ideai/memory/MEMORY.md Normal file
View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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".

View File

@ -40,7 +40,8 @@ use crate::dto::{
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
LiveAgentListDto, SubmitAgentInputRequestDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto,
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
@ -1037,8 +1038,10 @@ pub async fn launch_agent(
// M5c handshake guard (`serve_peer`) compares against; the `--requester` is the // M5c handshake guard (`serve_peer`) compares against; the `--requester` is the
// launching agent's id. A missing executable path (should not happen) degrades // launching agent's id. A missing executable path (should not happen) degrades
// to no runtime ⇒ apply_mcp_config writes the minimal declaration. // to no runtime ⇒ apply_mcp_config writes the minimal declaration.
let mcp_runtime = std::env::current_exe().ok().map(|exe| McpRuntime { // L'exe vient de `idea_exe_path()` (privilégie `$APPIMAGE`, sinon `current_exe`)
exe: exe.to_string_lossy().into_owned(), // 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) endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
.as_cli_arg() .as_cli_arg()
.to_owned(), .to_owned(),
@ -1203,6 +1206,55 @@ pub async fn agent_send(
Ok(()) 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 /// `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). /// without re-sending or re-spawning it (ARCHITECTURE §17.6/§17.7).
/// ///

View File

@ -1186,6 +1186,31 @@ impl From<LaunchAgentOutput> 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 /// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime
/// profile, optionally relaunching its live session in place. /// profile, optionally relaunching its live session in place.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]

View File

@ -47,6 +47,15 @@ pub enum DomainEventDto {
/// Exit code. /// Exit code.
code: i32, 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). /// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
AgentReplied { AgentReplied {
@ -212,6 +221,10 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
code: *code, code: *code,
}, },
DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged {
agent_id: agent_id.to_string(),
busy: *busy,
},
DomainEvent::AgentReplied { DomainEvent::AgentReplied {
agent_id, agent_id,
reply_len, reply_len,

View File

@ -166,6 +166,8 @@ pub fn run() {
commands::launch_agent, commands::launch_agent,
commands::change_agent_profile, commands::change_agent_profile,
commands::agent_send, commands::agent_send,
commands::submit_agent_input,
commands::interrupt_agent,
commands::reattach_agent_chat, commands::reattach_agent_chat,
commands::close_agent_session, commands::close_agent_session,
commands::list_resumable_agents, commands::list_resumable_agents,

View File

@ -41,7 +41,8 @@
use std::path::PathBuf; 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`] /// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`]
/// returns. Deterministic per [`ProjectId`]; the single source of truth shared by /// 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 } 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<String> {
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<McpRuntime> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -170,4 +213,77 @@ mod tests {
assert!(path.extension().is_some_and(|e| e == "sock")); assert!(path.extension().is_some_and(|e| e == "sock"));
assert_eq!(path.parent().unwrap(), unix_runtime_dir()); 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é");
}
} }

View File

@ -42,7 +42,8 @@ use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime, embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore,
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, McpServer, Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock,
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory,
SystemClock, TokioBroadcastEventBus, SystemClock, TokioBroadcastEventBus,
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
@ -50,7 +51,7 @@ use infrastructure::{
}; };
use crate::chat::ChatBridge; use crate::chat::ChatBridge;
use crate::mcp_endpoint::{mcp_endpoint, McpEndpoint}; use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
use crate::pty::PtyBridge; use crate::pty::PtyBridge;
use infrastructure::StdioTransport; use infrastructure::StdioTransport;
@ -558,27 +559,29 @@ impl AppState {
)); ));
// LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal // LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
// use cases — indispensable for the PtyBridge to work correctly. // use cases — indispensable for the PtyBridge to work correctly.
let launch_agent = Arc::new( //
LaunchAgent::new( // Option 1 « Terminal + MCP » (lot B-2) : on **ne câble plus** la fabrique
Arc::clone(&contexts_port), // structurée. La vue humaine d'un agent est désormais le **terminal brut
Arc::clone(&profile_store_port), // natif** (PTY interactif) — réflexion live + Échap natifs CLI, zéro parsing —
Arc::clone(&runtime_port), // et la délégation inter-agents passe par les outils MCP (`idea_ask_agent` /
Arc::clone(&fs_port), // `idea_reply`), pas par une `AgentChatView`. Sans `with_structured`, le point
Arc::clone(&pty_port), // de routage §17.4 de `LaunchAgent::execute` retombe **toujours** sur le chemin
Arc::clone(&skill_store_port), // PTH : tout profil (Claude/Codex inclus) ouvre une cellule terminal. Le code
Arc::clone(&terminal_sessions), // `launch_structured` reste en place (mort-code retiré au lot de nettoyage B-6).
Arc::clone(&events_port), let _session_factory = session_factory; // décâblé en B-2 (nettoyage B-6)
Arc::clone(&ids) as Arc<dyn IdGenerator>, let launch_agent = Arc::new(LaunchAgent::new(
Arc::clone(&memory_recall_port), Arc::clone(&contexts_port),
Some(Arc::clone(&check_embedder_suggestion)), Arc::clone(&profile_store_port),
) Arc::clone(&runtime_port),
// Routage structuré §17.4 : un profil avec `structured_adapter` lance une Arc::clone(&fs_port),
// AgentSession (cellule chat) au lieu d'un PTY ; sinon chemin PTY inchangé. Arc::clone(&pty_port),
.with_structured( Arc::clone(&skill_store_port),
Arc::clone(&session_factory), Arc::clone(&terminal_sessions),
Arc::clone(&structured_sessions), Arc::clone(&events_port),
), Arc::clone(&ids) as Arc<dyn IdGenerator>,
); Arc::clone(&memory_recall_port),
Some(Arc::clone(&check_embedder_suggestion)),
));
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ // 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 // 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 // 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 // lifecycle). The per-project watcher that feeds it is started lazily when
// a project is opened (see `ensure_orchestrator_watch`). // 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<dyn domain::mailbox::AgentMailbox>;
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<dyn domain::input::InputMediator>;
// 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<dyn domain::conversation::ConversationRegistry>;
let orchestrator_service = Arc::new( let orchestrator_service = Arc::new(
OrchestratorService::new( OrchestratorService::new(
Arc::clone(&create_agent), Arc::clone(&create_agent),
@ -748,10 +775,17 @@ impl AppState {
Arc::clone(&profile_store_port), Arc::clone(&profile_store_port),
Arc::clone(&terminal_sessions), Arc::clone(&terminal_sessions),
) )
// Messagerie inter-agents §17.4 : registre structuré + bus pour // Messagerie inter-agents (cadrage C3) : médiateur d'entrée (file + livraison
// `agent.message` (AskAgent) rendez-vous synchrone via send_blocking. // PTV sérialisée) + registre de conversations + bus pour AgentReplied.
.with_structured(Arc::clone(&structured_sessions)) .with_input_mediator(Arc::clone(&input_mediator), Arc::clone(&mailbox))
.with_events(Arc::clone(&events_port)), .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<dyn application::McpRuntimeProvider>
),
); );
// --- Windows (L10) --- // --- Windows (L10) ---
@ -982,6 +1016,21 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option<LocalSocketListener> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(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 let name = endpoint
.as_cli_arg() .as_cli_arg()
@ -989,7 +1038,7 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option<LocalSocketListener> {
.ok()?; .ok()?;
ListenerOptions::new() ListenerOptions::new()
.name(name) .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) .reclaim_name(true)
.create_tokio() .create_tokio()
.ok() .ok()
@ -1234,7 +1283,10 @@ mod mcp_serve_peer_tests {
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError, StoreError,
}; };
use domain::profile::{AgentProfile, ContextInjection}; use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
};
use domain::project::{Project, ProjectPath}; use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef; use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope}; use domain::skill::{Skill, SkillScope};
@ -1532,6 +1584,9 @@ mod mcp_serve_peer_tests {
/// Builds an `OrchestratorService` over the in-memory fakes (no structured /// Builds an `OrchestratorService` over the in-memory fakes (no structured
/// registry), mirroring `infrastructure/tests/mcp_server.rs::build_service`. /// registry), mirroring `infrastructure/tests/mcp_server.rs::build_service`.
fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> { fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
// 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( let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)), ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code", "Claude Code",
@ -1542,7 +1597,12 @@ mod mcp_serve_peer_tests {
"{agentRunDir}", "{agentRunDir}",
None, 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 sessions = Arc::new(TerminalSessions::new());
let bus = Arc::new(NoopBus); let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new( let create = Arc::new(CreateAgentFromScratch::new(
@ -1681,14 +1741,24 @@ mod mcp_serve_peer_tests {
for expected in [ for expected in [
"idea_list_agents", "idea_list_agents",
"idea_ask_agent", "idea_ask_agent",
"idea_reply",
"idea_launch_agent", "idea_launch_agent",
"idea_stop_agent", "idea_stop_agent",
"idea_update_context", "idea_update_context",
"idea_create_skill", "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!(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 drop(client); // EOF ⇒ serve loop ends
tokio::time::timeout(TIMEOUT, peer) tokio::time::timeout(TIMEOUT, peer)
@ -2158,19 +2228,22 @@ mod mcp_e2e_loopback_tests {
use application::{ use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext, OrchestratorService, TerminalSessions, UpdateAgentContext,
}; };
use domain::agent::{AgentManifest, ManifestEntry}; use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::{DomainEvent, OrchestrationSource}; use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId, SkillId}; use domain::ids::{AgentId, NodeId, ProfileId, ProjectId, SkillId};
use domain::markdown::MarkdownDoc; use domain::markdown::MarkdownDoc;
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan, AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
}; };
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath}; use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef; use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope}; use domain::skill::{Skill, SkillScope};
@ -2180,7 +2253,9 @@ mod mcp_e2e_loopback_tests {
use super::{bind_endpoint, mcp_endpoint, McpServerHandle}; use super::{bind_endpoint, mcp_endpoint, McpServerHandle};
use crate::mcp_endpoint::McpEndpoint; 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 /// 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. /// 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<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let events = vec![
ReplyEvent::TextDelta {
text: "thinking…".to_owned(),
},
ReplyEvent::Final {
content: self.reply.clone(),
},
];
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[derive(Default, Clone)] #[derive(Default, Clone)]
struct NoopBus; struct NoopBus;
impl EventBus for NoopBus { impl EventBus for NoopBus {
@ -2495,17 +2540,19 @@ mod mcp_e2e_loopback_tests {
} }
/// Builds an `OrchestratorService` over the in-memory fakes, returning the /// Builds an `OrchestratorService` over the in-memory fakes, returning the
/// shared `TerminalSessions` (so a test can pre-bind a live **PTY** session for a /// shared `TerminalSessions` (so a test can pre-bind a live PTY target for an
/// raw-PTY target) and `StructuredSessions` (so a test can pre-insert a replying /// `idea_ask_agent`) and the `InMemoryMailbox` (so a test can observe pending
/// **structured** session for `idea_ask_agent`). Mirrors the established harness; /// tickets). Mirrors the established harness; no use case is re-invented.
/// no use case is re-invented.
fn build_service( fn build_service(
contexts: FakeContexts, contexts: FakeContexts,
) -> ( ) -> (
Arc<OrchestratorService>, Arc<OrchestratorService>,
Arc<TerminalSessions>, Arc<TerminalSessions>,
Arc<StructuredSessions>, Arc<InMemoryMailbox>,
) { ) {
// 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( let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)), ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code", "Claude Code",
@ -2516,9 +2563,14 @@ mod mcp_e2e_loopback_tests {
"{agentRunDir}", "{agentRunDir}",
None, 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 sessions = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new()); let mailbox = Arc::new(InMemoryMailbox::new());
let bus = Arc::new(NoopBus); let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new( let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()), Arc::new(contexts.clone()),
@ -2545,6 +2597,13 @@ mod mcp_e2e_loopback_tests {
Arc::new(FakeSkills) as Arc<dyn SkillStore>, Arc::new(FakeSkills) as Arc<dyn SkillStore>,
Arc::new(SeqIds(Mutex::new(1))), Arc::new(SeqIds(Mutex::new(1))),
)); ));
let input = Arc::new(MediatedInbox::with_pty(
Arc::clone(&mailbox),
Arc::new(SystemMillisClock),
Arc::new(FakePty) as Arc<dyn PtyPort>,
)) as Arc<dyn domain::input::InputMediator>;
let conversations =
Arc::new(InMemoryConversationRegistry::new()) as Arc<dyn domain::conversation::ConversationRegistry>;
let service = OrchestratorService::new( let service = OrchestratorService::new(
create, create,
launch, launch,
@ -2555,8 +2614,26 @@ mod mcp_e2e_loopback_tests {
Arc::clone(&profiles) as Arc<dyn ProfileStore>, Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::clone(&sessions), Arc::clone(&sessions),
) )
.with_structured(Arc::clone(&structured)); .with_input_mediator(
(Arc::new(service), sessions, structured) input,
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
)
.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 --------------------------------------------- // --- real loopback harness ---------------------------------------------
@ -2648,7 +2725,7 @@ mod mcp_e2e_loopback_tests {
let contexts = FakeContexts::new(); let contexts = FakeContexts::new();
contexts.seed_agent("architect"); contexts.seed_agent("architect");
contexts.seed_agent("dev-backend"); contexts.seed_agent("dev-backend");
let (service, _pty, _structured) = build_service(contexts); let (service, _sessions, _mailbox) = build_service(contexts);
let proj = project(); let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None); 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] #[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 contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect"); let agent_id = contexts.seed_agent("architect");
let (service, _pty, structured) = build_service(contexts); let (service, sessions, mailbox) = build_service(contexts);
structured.insert( // Target already live in the PTY registry, so the ask reuses its terminal.
Arc::new(FakeSession { seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
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 proj = project(); let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None); let (handle, endpoint) = start_real_server(service, &proj, None);
let conn = connect_client(&endpoint).await; // Connection A: the asker.
let (read_half, mut write_half) = tokio::io::split(conn); let conn_a = connect_client(&endpoint).await;
let mut reader = BufReader::new(read_half); let (read_a, mut write_a) = tokio::io::split(conn_a);
let mut reader_a = BufReader::new(read_a);
write_half write_a
.write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes()) .write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes())
.await .await
.unwrap(); .unwrap();
write_half write_a
.write_all( .write_all(
tools_call_line( tools_call_line(
7, 7,
@ -2727,9 +2801,41 @@ mod mcp_e2e_loopback_tests {
) )
.await .await
.unwrap(); .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 .await
.expect("an ask response line"); .expect("an ask response line");
assert_eq!(resp["id"], json!(7)); 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}" "ask reply must be returned inline over the real loopback; got {result}"
); );
drop(write_half); drop(write_a);
drop(write_b);
handle.stop(); handle.stop();
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// 3. idea_ask_agent against a raw-PTY target ⇒ typed tool error (isError:true), // 3. idea_reply with no in-flight ask ⇒ typed tool error (isError:true), no
// no panic, connection stays healthy for a follow-up call. // panic, connection stays healthy for a follow-up call.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
#[tokio::test] #[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 contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("dev"); let agent_id = contexts.seed_agent("dev");
let (service, pty, _structured) = build_service(contexts); let (service, _sessions, _mailbox) = 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 proj = project(); let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None); 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 (read_half, mut write_half) = tokio::io::split(conn);
let mut reader = BufReader::new(read_half); 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_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 .await
.unwrap(); .unwrap();
write_half write_half
.write_all( .write_all(
tools_call_line(3, "idea_ask_agent", json!({ "target": "dev", "task": "hi" })) tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes(),
.as_bytes(),
) )
.await .await
.unwrap(); .unwrap();
@ -2791,14 +2886,12 @@ mod mcp_e2e_loopback_tests {
let resp = read_one_response(&mut reader) let resp = read_one_response(&mut reader)
.await .await
.expect("an ask response line"); .expect("a reply response line");
assert_eq!(resp["id"], json!(3)); assert_eq!(resp["id"], json!(3));
// Healthy connection: NO JSON-RPC transport error...
assert!( assert!(
resp["error"].is_null(), 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"]; let result = &resp["result"];
assert_eq!(result["isError"], json!(true), "got {result}"); assert_eq!(result["isError"], json!(true), "got {result}");
assert!( assert!(
@ -2844,7 +2937,7 @@ mod mcp_e2e_loopback_tests {
async fn malformed_jsonrpc_after_handshake_errors_and_server_survives() { async fn malformed_jsonrpc_after_handshake_errors_and_server_survives() {
let contexts = FakeContexts::new(); let contexts = FakeContexts::new();
contexts.seed_agent("architect"); contexts.seed_agent("architect");
let (service, _pty, _structured) = build_service(contexts); let (service, _sessions, _mailbox) = build_service(contexts);
let proj = project(); let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None); let (handle, endpoint) = start_real_server(service, &proj, None);
@ -2906,7 +2999,7 @@ mod mcp_e2e_loopback_tests {
#[tokio::test] #[tokio::test]
async fn handshake_requester_propagates_over_real_loopback() { async fn handshake_requester_propagates_over_real_loopback() {
let contexts = FakeContexts::new(); let contexts = FakeContexts::new();
let (service, _pty, _structured) = build_service(contexts); let (service, _sessions, _mailbox) = build_service(contexts);
let (publish, captured) = capturing_events(); let (publish, captured) = capturing_events();
let proj = project(); let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, Some(publish)); let (handle, endpoint) = start_real_server(service, &proj, Some(publish));
@ -2991,3 +3084,78 @@ mod mcp_e2e_loopback_tests {
handle.stop(); 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)"
);
}
}

View File

@ -1750,6 +1750,17 @@ pub(crate) fn compose_convention_file(
(lister les agents du projet). IdeA lancera ou réattachera l'agent cible \ (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", 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 { } else {
out.push_str( out.push_str(
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \ "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("- [");
out.push_str(&entry.title); out.push_str(&entry.title);
out.push_str("]("); out.push_str("](");
out.push_str(".ideai/memory/");
out.push_str(entry.slug.as_str()); out.push_str(entry.slug.as_str());
out.push_str(".md) — "); out.push_str(".md) — ");
out.push_str(&entry.hook); out.push_str(&entry.hook);
@ -1998,9 +2010,13 @@ mod tests {
let section_at = doc.find("# Mémoire projet").unwrap(); let section_at = doc.find("# Mémoire projet").unwrap();
assert!(persona_at < section_at, "memory comes after the persona"); assert!(persona_at < section_at, "memory comes after the persona");
// Exact line format: `- [Title](slug.md) — hook (type)`. // Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`.
assert!(doc.contains("- [Alpha](alpha-note.md) — the first hook (user)")); assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)"));
assert!(doc.contains("- [Beta](beta-note.md) — the second hook (reference)")); assert!(
doc.contains(
"- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"
)
);
// Deterministic order: first entry precedes the second. // Deterministic order: first entry precedes the second.
let alpha_at = doc.find("[Alpha]").unwrap(); let alpha_at = doc.find("[Alpha]").unwrap();
@ -2033,7 +2049,7 @@ mod tests {
assert!(doc.contains("# Skills")); assert!(doc.contains("# Skills"));
assert!(doc.contains("REFAC_BODY")); assert!(doc.contains("REFAC_BODY"));
assert!(doc.contains("# Mémoire projet")); 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). // Skills section precedes the memory section (persona → skills → memory).
let skills_at = doc.find("# Skills").unwrap(); 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] #[test]
fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() { fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
// mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged, // 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] #[test]
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() { fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
let json = claude_settings_seed("/home/me/proj"); let json = claude_settings_seed("/home/me/proj");

View File

@ -71,7 +71,7 @@ pub use memory::{
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
}; };
pub use orchestrator::{OrchestratorOutcome, OrchestratorService}; pub use orchestrator::{McpRuntimeProvider, OrchestratorOutcome, OrchestratorService};
pub use project::{ pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject, CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,

View File

@ -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/<who>-<ts>.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<dyn AgentContextStore>,
project: &Project,
name: &str,
) -> Result<AgentId, AppError> {
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<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
}
/// 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<String>,
/// 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<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
) -> 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<MarkdownDoc, AppError> {
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<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
clock: Arc<dyn Clock>,
}
/// 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<String>,
/// 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<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
clock: Arc<dyn Clock>,
) -> 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<ProposeOutcome, AppError> {
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/<who>-<ts>.md`, returning its path.
async fn file_proposal(
&self,
project: &Project,
who: ConversationParty,
content: &str,
) -> Result<String, AppError> {
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<dyn FileGuard>,
memory: Arc<dyn MemoryStore>,
}
/// Input for [`ReadMemory`].
pub struct ReadMemoryInput {
/// The project to read within.
pub project: Project,
/// Target note slug; `None` ⇒ the aggregated index.
pub slug: Option<String>,
/// The reading party.
pub requester: ConversationParty,
}
impl ReadMemory {
/// Builds the use case from its ports.
#[must_use]
pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> 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<String, AppError> {
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<String> = 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<dyn FileGuard>,
memory: Arc<dyn MemoryStore>,
}
/// 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<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> 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, &note).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<HashMap<GuardedResource, StdArc<RwLock<()>>>>,
}
impl TestGuard {
fn lock_for(&self, res: &GuardedResource) -> StdArc<RwLock<()>> {
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<ReadLease, GuardError> {
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<WriteLease, GuardError> {
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<HashMap<String, Vec<u8>>>,
dirs: Mutex<Vec<String>>,
}
#[async_trait]
impl FileSystem for FakeFs {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, 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<bool, FsError> {
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<Vec<domain::ports::DirEntry>, FsError> {
Ok(Vec::new())
}
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
Ok(())
}
}
struct FakeContexts {
manifest: AgentManifest,
contexts: Mutex<HashMap<AgentId, String>>,
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
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<AgentManifest, StoreError> {
Ok(self.manifest.clone())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
struct FakeMemory {
notes: Mutex<HashMap<String, String>>,
}
#[async_trait]
impl MemoryStore for FakeMemory {
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
Ok(Vec::new())
}
async fn get(
&self,
_root: &ProjectPath,
slug: &MemorySlug,
) -> Result<Memory, MemoryError> {
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<Vec<domain::memory::MemoryIndexEntry>, MemoryError> {
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<domain::memory::MemoryLink>, MemoryError> {
Ok(Vec::new())
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now_millis(&self) -> i64 {
42
}
}
fn guard() -> Arc<dyn FileGuard> {
Arc::new(TestGuard::default())
}
fn contexts_with(name: &str, agent: AgentId, body: &str) -> Arc<dyn AgentContextStore> {
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<dyn FileSystem>,
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<dyn FileSystem>,
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<dyn MemoryStore>);
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<dyn MemoryStore>);
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);
}
}

View File

@ -4,6 +4,11 @@
//! use-case calls the UI makes, so an orchestrator agent can drive IdeA without //! use-case calls the UI makes, so an orchestrator agent can drive IdeA without
//! ever spawning a process itself. See [`service::OrchestratorService`]. //! ever spawning a process itself. See [`service::OrchestratorService`].
mod context_guard;
mod service; 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};

View File

@ -19,16 +19,25 @@ use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex; 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 domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
use crate::agent::{ use crate::agent::{
send_blocking, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
ListAgents, ListAgentsInput, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
}; };
use crate::error::AppError; use crate::error::AppError;
use crate::orchestrator::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput,
};
use crate::skill::{CreateSkill, CreateSkillInput}; 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 /// 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 /// 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. /// pleins d'attente.
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600); 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<McpRuntime>;
}
/// Dispatches validated orchestrator commands to the agent/terminal use cases. /// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService { pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>, create_agent: Arc<CreateAgentFromScratch>,
@ -71,11 +93,27 @@ pub struct OrchestratorService {
create_skill: Arc<CreateSkill>, create_skill: Arc<CreateSkill>,
profiles: Arc<dyn ProfileStore>, profiles: Arc<dyn ProfileStore>,
sessions: Arc<TerminalSessions>, sessions: Arc<TerminalSessions>,
/// Registre des sessions **structurées** (§17.5) — la cible d'un `agent.message` /// Médiateur d'entrée (cadrage C3 §5.2) — point de convergence unique de l'entrée
/// y est cherchée pour le rendez-vous synchrone. Injecté au câblage via /// d'un agent. La cible d'un `agent.message`/`idea_ask_agent` y reçoit un ticket
/// [`Self::with_structured`] ; `None` ⇒ `AskAgent` ne peut pas être servi (les /// (`enqueue`) dont on **attend** la résolution (`idea_reply` ⇒
/// call sites/tests legacy qui n'utilisent pas la messagerie restent verts). /// [`OrchestratorCommand::Reply`]) ; son impl écrit aussi le tour dans le PTY de la
structured: Option<Arc<StructuredSessions>>, /// 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<Arc<dyn InputMediator>>,
/// 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<Arc<dyn domain::mailbox::AgentMailbox>>,
/// 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<Arc<dyn ConversationRegistry>>,
/// 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<WaitForGraph>,
/// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un /// 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 /// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de
/// publication (l'`ask` fonctionne quand même). /// 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 /// Croissance bornée en pratique au nombre d'agents du projet ; une entrée
/// morte ne coûte qu'un `Arc<Mutex<()>>` vide (pas de session, pas de process). /// morte ne coûte qu'un `Arc<Mutex<()>>` vide (pas de session, pas de process).
ask_locks: StdMutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>, ask_locks: StdMutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>,
/// 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<Arc<dyn McpRuntimeProvider>>,
/// 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<Arc<ContextGuardUseCases>>,
}
/// 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<ReadContext>,
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
pub propose_context: Arc<ProposeContext>,
/// Lecture mémoire sous read-lease.
pub read_memory: Arc<ReadMemory>,
/// Écriture mémoire sous write-lease.
pub write_memory: Arc<WriteMemory>,
} }
/// Outcome of dispatching a command — a short, human-readable success summary the /// Outcome of dispatching a command — a short, human-readable success summary the
@ -137,12 +199,26 @@ impl OrchestratorService {
create_skill, create_skill,
profiles, profiles,
sessions, sessions,
structured: None, input: None,
mailbox: None,
conversations: None,
wait_for: StdMutex::new(WaitForGraph::new()),
events: None, events: None,
ask_locks: StdMutex::new(HashMap::new()), 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<ContextGuardUseCases>) -> Self {
self.context_guard = Some(guard);
self
}
/// Returns the per-agent **turn lock**, creating it on first use. /// Returns the per-agent **turn lock**, creating it on first use.
/// ///
/// Get-or-create under the synchronous map mutex (held only for this lookup, /// 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()) Arc::clone(locks.entry(*agent_id).or_default())
} }
/// Branche le registre des sessions **structurées** (§17.5) pour servir /// Branche le **médiateur d'entrée** (cadrage C3 §5.2) pour servir
/// `agent.message`/[`OrchestratorCommand::AskAgent`]. Builder additif façon D3 : /// `agent.message`/[`OrchestratorCommand::AskAgent`] et
/// signature de [`Self::new`] **inchangée** (les tests/call sites legacy restent /// `agent.reply`/[`OrchestratorCommand::Reply`]. Le `mailbox` est le moteur de
/// verts), le câblage fait `OrchestratorService::new(...).with_structured(reg)`. /// 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] #[must_use]
pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self { pub fn with_input_mediator(
self.structured = Some(structured); mut self,
input: Arc<dyn InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
) -> 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<dyn ConversationRegistry>) -> Self {
self.conversations = Some(conversations);
self self
} }
@ -175,6 +268,17 @@ impl OrchestratorService {
self 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<dyn McpRuntimeProvider>) -> Self {
self.mcp_runtime_provider = Some(provider);
self
}
/// Dispatches a validated command against `project`. /// Dispatches a validated command against `project`.
/// ///
/// # Errors /// # Errors
@ -196,9 +300,16 @@ impl OrchestratorService {
self.spawn_agent(project, name, profile, context, visibility) self.spawn_agent(project, name, profile, context, visibility)
.await .await
} }
OrchestratorCommand::AskAgent { target, task } => { OrchestratorCommand::AskAgent {
self.ask_agent(project, target, task).await 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::ListAgents => self.list_agents(project).await,
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await, OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
OrchestratorCommand::UpdateAgentContext { name, context } => { OrchestratorCommand::UpdateAgentContext { name, context } => {
@ -209,9 +320,135 @@ impl OrchestratorService {
content, content,
scope, scope,
} => self.create_skill(project, name, content, scope).await, } => 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<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
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<String>,
content: String,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
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<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
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<OrchestratorOutcome, AppError> {
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 /// `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 /// that name, then launch it (which publishes `AgentLaunched` → the UI opens a
/// cell + the Agents tab). /// 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** /// The target's human-facing view is now a **raw native terminal** (PTY REPL), and
/// (launching it in structured mode if needed), sends `task` and **waits for the /// delegation flows through the terminal's single FIFO input plus the MCP mailbox:
/// turn's `Final`** via [`send_blocking`], then returns its content as
/// [`OrchestratorOutcome::reply`] and publishes [`DomainEvent::AgentReplied`].
/// ///
/// Invariants: /// 1. Resolve the target by name and acquire its **per-agent turn lock** so two
/// - **1 session vivante/agent** : on réutilise la session structurée vivante si /// `ask`s for the same target serialise FIFO (1 agent = 1 employee).
/// elle existe ; sinon `LaunchAgent` (gardé sur les deux registres) la crée. /// 2. Ensure the target is **live in the PTY registry** — reusing its terminal if
/// - **Timeout ne tue pas la session** : [`send_blocking`] remonte /// it is already running, otherwise launching it in the background (a normal
/// [`AppError::Process`] (via [`domain::ports::AgentSessionError::Timeout`]) en /// PTH launch: a live PTY *is* the channel now, not an error as before).
/// laissant la session vivante dans le registre (retry possible). /// 3. **Enqueue a ticket** in the [`AgentMailbox`] (registering the reply slot)
/// - **Cible PTY-only** (profil sans `structured_adapter`, ou agent déjà vivant en /// **then write** the task into the target's terminal, prefixed with the asking
/// PTY) ⇒ [`AppError::Invalid`] explicite, **jamais** un ACK trompeur. /// 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 /// # Errors
/// - [`AppError::NotFound`] si l'agent cible est inconnu ; /// - [`AppError::NotFound`] if the target agent is unknown;
/// - [`AppError::Invalid`] si la messagerie structurée n'est pas câblée, ou si la /// - [`AppError::Invalid`] if the mailbox/PTY channel is not wired;
/// cible n'est pas pilotable en mode structuré ; /// - [`AppError::Process`] on a launch/PTY-write failure, or on the await timeout
/// - [`AppError::Process`] sur échec/timeout du tour structuré. /// (turn timeout *or* queue-wait timeout — same typed error).
async fn ask_agent( async fn ask_agent(
&self, &self,
project: &Project, project: &Project,
target: String, target: String,
task: String, task: String,
requester: Option<AgentId>,
) -> Result<OrchestratorOutcome, AppError> { ) -> Result<OrchestratorOutcome, AppError> {
let structured = self.structured.as_ref().ok_or_else(|| { let (input, mailbox) = match (&self.input, &self.mailbox) {
AppError::Invalid( (Some(i), Some(m)) => (i, m),
"la messagerie inter-agents (agent.message) n'est pas disponible : \ _ => {
registre des sessions structurées non câblé" return Err(AppError::Invalid(
.to_owned(), "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 let agent = self
.find_agent_id_by_name(project, &target) .find_agent_by_name(project, &target)
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("agent {target}")))?; .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 // F2 — garde profil : refuser **immédiatement** une cible dont le profil ne
// de tour de la **cible** avant tout `send_blocking`, et on le tient pour TOUT // sait pas consommer le pont `idea_*` matérialisé via `.mcp.json`, plutôt que
// le tour réel (rendez-vous direct *ou* lancement-puis-envoi sur cible morte). // de laisser le round-trip échouer en timeout muet (300s).
// Le garde `_turn` est RAII : il tombe en fin de portée, y compris sur chaque self.guard_mcp_bridge_supported(&agent.profile_id, &target)
// early-return d'erreur ci-dessous ⇒ le tour suivant en file démarre alors. .await?;
//
// Verrou par `agent_id` ⇒ un `ask` vers A et un vers B ne se bloquent jamais ; // Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la
// un seul verrou tenu (celui de la cible) ⇒ pas d'inter-verrouillage/deadlock. // cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle
// Le timeout de TOUR ([`ASK_AGENT_TIMEOUT`]) borne `send_blocking`, **pas** // d'attente (B attend déjà …→A). Pur, sans I/O ⇒ jamais de deadlock.
// l'attente du verrou ; cette attente a son **propre** plafond if let Some(from) = requester {
// ([`ASK_QUEUE_WAIT_CAP`]) pour éviter l'inanition. 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 lock = self.ask_lock_for(&agent_id);
let _turn = match tokio::time::timeout(ASK_QUEUE_WAIT_CAP, lock.lock_owned()).await { let _turn = match tokio::time::timeout(ASK_QUEUE_WAIT_CAP, lock.lock_owned()).await {
Ok(guard) => guard, Ok(guard) => guard,
Err(_elapsed) => { Err(_elapsed) => {
// Réutilise le **même** type de timeout que le tour (cadrage v5 §4) : return Err(AppError::from(domain::ports::AgentSessionError::Timeout));
// `AgentSessionError::Timeout` ⇒ `AppError::Process`.
return Err(AppError::from(
domain::ports::AgentSessionError::Timeout,
));
} }
}; };
// 1. Session structurée déjà vivante ? ⇒ rendez-vous direct. // Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`).
if let Some(session) = structured.session_for_agent(&agent_id) { let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id));
let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?;
return Ok(self.reply_outcome(agent_id, &target, content)); // 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<OrchestratorOutcome, AppError> {
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<OrchestratorOutcome, AppError> {
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 input.preempt(agent_id);
// brut), il n'est pas adressable en `ask` ⇒ erreur typée explicite (pas
// d'ACK « launched »). Ok(OrchestratorOutcome {
if self.sessions.session_for_agent(&agent_id).is_some() { detail: format!("interrupted agent {agent_id}"),
return Err(AppError::Invalid(format!( reply: None,
"agent {target} n'est pas pilotable en mode structuré \ })
(session terminal brut, pas de canal de réponse)" }
)));
/// 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<AgentId>,
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<TicketId>,
result: String,
) -> Result<OrchestratorOutcome, AppError> {
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<PtyHandle, AppError> {
// «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. // Dead target: launch it in the background (PTY). On injecte ici la déclaration
let launched = self // MCP **réelle** via le [`McpRuntimeProvider`] câblé (app-tauri détient l'exe et
.launch_agent // 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 { .execute(LaunchAgentInput {
project: project.clone(), project: project.clone(),
agent_id, agent_id,
@ -417,30 +912,71 @@ impl OrchestratorService {
cols: DEFAULT_COLS, cols: DEFAULT_COLS,
node_id: None, node_id: None,
conversation_id: None, conversation_id: None,
// ask_agent revival launch (inside `application`): MCP runtime not mcp_runtime: self
// injected here (see the `spawn_agent` note above). .mcp_runtime_provider
mcp_runtime: None, .as_ref()
.and_then(|p| p.runtime_for(project, agent_id)),
}) })
.await?; .await?;
// Si le lancement n'a pas produit de session structurée, le profil de la cible let session_id = self
// est PTY-only (pas de `structured_adapter`) ⇒ non adressable en `ask`. .sessions
if launched.structured.is_none() { .session_for_agent(&agent_id)
return Err(AppError::Invalid(format!( .ok_or_else(|| {
"agent {target} n'est pas pilotable en mode structuré \ AppError::Process(format!(
(profil sans adaptateur structuré)" "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 /// Binds `conversation` to `session` in both the terminal registry (fast
// agent (invariant « 1 session/agent » ⇒ non ambigu). /// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the
let session = structured.session_for_agent(&agent_id).ok_or_else(|| { /// latter is wired. Idempotent.
AppError::Process(format!( fn bind_conversation_session(
"session structurée de l'agent {target} introuvable après lancement" &self,
)) conversation: domain::conversation::ConversationId,
})?; session: domain::SessionId,
let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?; ) {
Ok(self.reply_outcome(agent_id, &target, content)) 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<AgentId>) -> 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<String> {
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`] /// Builds the success outcome of an `ask` and publishes [`DomainEvent::AgentReplied`]
@ -590,6 +1126,104 @@ impl OrchestratorService {
.map(|a| a.id)) .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<Option<domain::Agent>, 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<String> {
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`, /// Resolves a human-friendly profile reference (slug like `claude-code`,
/// command like `claude`, or display name like `Claude Code`) to a configured /// command like `claude`, or display name like `Claude Code`) to a configured
/// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by /// [`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<WaitForGraph>,
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, /// Normalises a profile reference for tolerant matching: lowercased, with spaces,
/// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"` /// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"`
/// → comparable forms; `claude` ⊂ ... handled by the command match above). /// → comparable forms; `claude` ⊂ ... handled by the command match above).

View File

@ -9,6 +9,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use domain::conversation::ConversationId;
use domain::ports::{AgentSession, PtyHandle}; use domain::ports::{AgentSession, PtyHandle};
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession}; use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
@ -44,6 +45,11 @@ pub trait LiveAgentRegistry: Send + Sync {
#[derive(Default)] #[derive(Default)]
pub struct TerminalSessions { pub struct TerminalSessions {
entries: Mutex<HashMap<SessionId, Entry>>, entries: Mutex<HashMap<SessionId, Entry>>,
/// 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<HashMap<ConversationId, SessionId>>,
} }
impl LiveAgentRegistry for TerminalSessions { impl LiveAgentRegistry for TerminalSessions {
@ -65,9 +71,50 @@ impl TerminalSessions {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
entries: Mutex::new(HashMap::new()), 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<SessionId> {
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<SessionId> {
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. /// Inserts a freshly-opened session.
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) { pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
if let Ok(mut map) = self.entries.lock() { if let Ok(mut map) = self.entries.lock() {
@ -184,8 +231,12 @@ impl TerminalSessions {
.unwrap_or_default() .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<PtyHandle> { pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
if let Ok(mut c) = self.conversations.lock() {
c.retain(|_, sid| sid != id);
}
self.entries self.entries
.lock() .lock()
.ok() .ok()

View File

@ -1312,11 +1312,11 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
"memory section present: {doc}" "memory section present: {doc}"
); );
assert!( 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}" "first memory line exact format: {doc}"
); );
assert!( 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}" "second memory line exact format: {doc}"
); );
// Recalled order preserved; section after the persona. // Recalled order preserved; section after the persona.

File diff suppressed because it is too large Load Diff

View File

@ -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<AgentId> {
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<String>,
}
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<Self, ConversationError> {
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<dyn ConversationRegistry>`.
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<String>);
/// Returns the current snapshot of `id`, if it exists.
fn get(&self, id: ConversationId) -> Option<Conversation>;
}
#[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());
}
}

View File

@ -59,6 +59,17 @@ pub enum DomainEvent {
/// Exit code. /// Exit code.
code: i32, 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). /// An agent's runtime profile was changed (hot-swap of the AI engine).
AgentProfileChanged { AgentProfileChanged {
/// The agent. /// The agent.

View File

@ -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<dyn Send + Sync>,
}
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<dyn Send + Sync>) -> 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<dyn Send + Sync>,
}
impl WriteLease {
/// Wraps an adapter-provided release guard into a domain [`WriteLease`].
#[must_use]
pub fn new(guard: Box<dyn Send + Sync>) -> 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<dyn FileGuard>`); 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<ReadLease, GuardError>;
/// 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<WriteLease, GuardError>;
}
/// 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())
);
}
}

203
crates/domain/src/input.rs Normal file
View File

@ -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<AgentId> {
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<TicketId> {
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<dyn InputMediator>`). 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<String>,
) {
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);
}
}

View File

@ -31,11 +31,15 @@
#![warn(missing_docs)] #![warn(missing_docs)]
pub mod agent; pub mod agent;
pub mod conversation;
pub mod error; pub mod error;
pub mod events; pub mod events;
pub mod fileguard;
pub mod git; pub mod git;
pub mod ids; pub mod ids;
pub mod input;
pub mod layout; pub mod layout;
pub mod mailbox;
pub mod markdown; pub mod markdown;
pub mod memory; pub mod memory;
pub mod orchestrator; pub mod orchestrator;
@ -72,6 +76,20 @@ pub use profile::{
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy, 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 markdown::MarkdownDoc;
pub use memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType}; pub use memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};

View File

@ -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<String>, task: impl Into<String>) -> 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<String>,
task: impl Into<String>,
) -> 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<String>,
task: impl Into<String>,
) -> 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<Box<dyn Future<Output = Result<String, MailboxError>> + 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<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
) -> Self {
Self { inner }
}
}
impl Future for PendingReply {
type Output = Result<String, MailboxError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
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<dyn AgentMailbox>`; 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 <id>]` 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
);
}
}

View File

@ -13,7 +13,9 @@
use serde::{Deserialize, Serialize}; 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; use crate::skill::SkillScope;
/// Errors raised while validating a raw [`OrchestratorRequest`]. /// Errors raised while validating a raw [`OrchestratorRequest`].
@ -46,6 +48,14 @@ pub enum OrchestratorError {
/// The offending visibility value. /// The offending visibility value.
visibility: String, 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. /// The raw, wire-level orchestrator request as deserialised from a request file.
@ -103,6 +113,25 @@ pub struct OrchestratorRequest {
/// other actions. /// other actions.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>, pub scope: Option<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
} }
/// A validated orchestrator command — the only thing the application layer acts on. /// A validated orchestrator command — the only thing the application layer acts on.
@ -149,6 +178,31 @@ pub enum OrchestratorCommand {
target: String, target: String,
/// The task/message to send and await a reply for. /// The task/message to send and await a reply for.
task: String, 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<AgentId>,
},
/// 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 <id>]` 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<TicketId>,
/// The result content the requester awaits.
result: String,
}, },
/// List the project's agents (discovery) — exactly the data the UI reads from /// List the project's agents (discovery) — exactly the data the UI reads from
/// the manifest. Carries no argument: the dispatch resolves the agents against /// the 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 the skill is created in (defaults to [`SkillScope::Project`]).
scope: SkillScope, 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<String>,
/// 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<String>,
/// 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<String>,
/// 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. /// Where IdeA should place a launched agent session.
@ -230,6 +325,30 @@ impl OrchestratorRequest {
"agent.message" => Ok(OrchestratorCommand::AskAgent { "agent.message" => Ok(OrchestratorCommand::AskAgent {
target: self.require_target_agent(action)?, target: self.require_target_agent(action)?,
task: self.require("task", action, self.task.as_deref())?, 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), "list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill { "create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
@ -284,6 +403,90 @@ impl OrchestratorRequest {
self.require("name", action, self.name.as_deref()) 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<AgentId, OrchestratorError> {
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<Option<AgentId>, 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<Option<TicketId>, 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<String> {
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<String> {
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<String, OrchestratorError> { fn require_target_agent(&self, action: &str) -> Result<String, OrchestratorError> {
self.require( self.require(
"targetAgent", "targetAgent",
@ -447,6 +650,56 @@ mod tests {
OrchestratorCommand::AskAgent { OrchestratorCommand::AskAgent {
target: "Architect".to_owned(), target: "Architect".to_owned(),
task: "Analyse §17".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] #[test]
fn unknown_action_is_rejected() { fn unknown_action_is_rejected() {
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#); let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);

View File

@ -283,6 +283,30 @@ pub struct AgentProfile {
/// sérialisation : un profil sans MCP sérialise exactement comme avant. /// sérialisation : un profil sans MCP sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpCapability>, pub mcp: Option<McpCapability>,
/// 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<String>,
} }
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel). /// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
@ -418,6 +442,7 @@ impl AgentProfile {
session, session,
structured_adapter: None, structured_adapter: None,
mcp: None, mcp: None,
prompt_ready_pattern: None,
}) })
} }
@ -439,6 +464,15 @@ impl AgentProfile {
self 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<String>) -> Self {
self.prompt_ready_pattern = Some(pattern.into());
self
}
/// Indique si ce profil peut être **proposé à la sélection/création** d'un /// 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 /// 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 /// 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"); let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid");
assert_eq!(s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() }); 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> "));
}
} }

View File

@ -0,0 +1,194 @@
//! [`InMemoryConversationRegistry`] — the [`ConversationRegistry`] adapter (lot C2).
//!
//! The driven side of conversation-by-pair: a `HashMap<ConversationId, Conversation>`
//! 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<Inner>,
}
#[derive(Default)]
struct Inner {
by_id: HashMap<ConversationId, Conversation>,
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<String>) {
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<Conversation> {
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());
}
}

View File

@ -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<RwLock<()>>` 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<dyn FileGuard>`; cloneable bookkeeping is
/// interior (the registry is a shared `Mutex<HashMap<…>>`).
#[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<HashMap<GuardedResource, Arc<RwLock<()>>>>,
}
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<RwLock<()>> {
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<ReadLease, GuardError> {
// 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<WriteLease, GuardError> {
// 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);
}
}

View File

@ -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<HashMap<AgentId, AgentBusyState>>,
events: Option<Arc<dyn EventBus>>,
}
impl BusyTracker {
fn new(events: Option<Arc<dyn EventBus>>) -> Self {
Self {
busy: Mutex::new(HashMap::new()),
events,
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, AgentBusyState>> {
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<InMemoryMailbox>,
/// Shared busy/idle authority (also handed to prompt-ready watcher threads, C5).
tracker: Arc<BusyTracker>,
clock: Arc<dyn MillisClock>,
/// 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<Arc<dyn PtyPort>>,
/// Per-agent live input handle (one stream per agent), fed by `bind_handle`.
handles: Mutex<HashMap<AgentId, PtyHandle>>,
/// 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<Mutex<HashSet<AgentId>>>,
}
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<InMemoryMailbox>, clock: Arc<dyn MillisClock>) -> 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<dyn EventBus>) -> 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<InMemoryMailbox>,
clock: Arc<dyn MillisClock>,
pty: Arc<dyn PtyPort>,
) -> 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<AgentId, PtyHandle>> {
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<InMemoryMailbox> {
Arc::clone(&self.mailbox)
}
fn watched(&self) -> std::sync::MutexGuard<'_, HashSet<AgentId>> {
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<u8> = 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<String>,
) {
// 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<Vec<DomainEvent>>);
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<dyn EventBus>);
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<dyn EventBus>);
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<HashMap<SessionId, Vec<Vec<u8>>>>,
writes: Mutex<Vec<Vec<u8>>>,
}
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<Vec<u8>>) {
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<Handle, PtyError> {
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<OutputStream, PtyError> {
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<Vec<u8>, PtyError> {
Ok(Vec::new())
}
async fn kill(&self, _handle: &Handle) -> Result<ExitStatus, PtyError> {
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<FakePty>) -> MediatedInbox {
MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
pty as Arc<dyn PtyPort>,
)
}
#[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");
}
}

View File

@ -13,11 +13,15 @@
#![warn(missing_docs)] #![warn(missing_docs)]
pub mod clock; pub mod clock;
pub mod conversation;
pub mod eventbus; pub mod eventbus;
pub mod fileguard;
pub mod fs; pub mod fs;
pub mod git; pub mod git;
pub mod id; pub mod id;
pub mod input;
pub mod inspector; pub mod inspector;
pub mod mailbox;
pub mod orchestrator; pub mod orchestrator;
pub mod process; pub mod process;
pub mod pty; pub mod pty;
@ -27,11 +31,15 @@ pub mod session;
pub mod store; pub mod store;
pub use clock::SystemClock; pub use clock::SystemClock;
pub use conversation::InMemoryConversationRegistry;
pub use eventbus::TokioBroadcastEventBus; pub use eventbus::TokioBroadcastEventBus;
pub use fileguard::RwFileGuard;
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
pub use fs::LocalFileSystem; pub use fs::LocalFileSystem;
pub use git::Git2Repository; pub use git::Git2Repository;
pub use id::UuidGenerator; pub use id::UuidGenerator;
pub use inspector::ClaudeTranscriptInspector; pub use inspector::ClaudeTranscriptInspector;
pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport}; pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
pub use orchestrator::{ pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,

View File

@ -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<String>,
}
/// In-memory, per-agent FIFO mailbox (the production [`AgentMailbox`]).
#[derive(Default)]
pub struct InMemoryMailbox {
queues: Mutex<HashMap<AgentId, VecDeque<Slot>>>,
}
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<TicketId> {
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::<String>();
{
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))));
}
}

View File

@ -225,7 +225,10 @@ impl McpServer {
.to_owned(); .to_owned();
let arguments = params.get("arguments").cloned().unwrap_or(json!({})); 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; let result = self.service.dispatch(&self.project, command).await;
// Surface the processed delegation on the bus, tagged as the MCP door — the // Surface the processed delegation on the bus, tagged as the MCP door — the

View File

@ -44,11 +44,15 @@ pub enum ToolMapError {
} }
/// Whether a successful call of `tool` carries an inline reply payload back to the /// 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 /// caller: `idea_ask_agent` (the target's reply) and `idea_list_agents` (the agent
/// agent list as a JSON array). /// 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] #[must_use]
pub fn tool_returns_reply(tool: &str) -> bool { 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`. /// The full catalogue advertised on `tools/list`.
@ -82,6 +86,23 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false "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 <id>]`). 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 <id>]` prefix you are answering. Optional, but strongly recommended when you handle more than one request." }
},
"required": ["result"],
"additionalProperties": false
}),
},
ToolDef { ToolDef {
name: "idea_launch_agent", name: "idea_launch_agent",
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \ description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
@ -124,6 +145,62 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false "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 { ToolDef {
name: "idea_create_skill", name: "idea_create_skill",
description: "Create a reusable IdeA skill (Markdown) in the project or global scope.", description: "Create a reusable IdeA skill (Markdown) in the project or global scope.",
@ -146,6 +223,10 @@ pub fn catalogue() -> Vec<ToolDef> {
/// validation authority. /// validation authority.
/// ///
/// `arguments` is the raw object an MCP client passes under `params.arguments`. /// `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 /// # Errors
/// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`], /// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`],
@ -154,6 +235,7 @@ pub fn catalogue() -> Vec<ToolDef> {
pub fn map_tool_call( pub fn map_tool_call(
name: &str, name: &str,
arguments: &Value, arguments: &Value,
requester: &str,
) -> Result<OrchestratorCommand, ToolMapError> { ) -> Result<OrchestratorCommand, ToolMapError> {
let args = arguments let args = arguments
.as_object() .as_object()
@ -173,10 +255,25 @@ pub fn map_tool_call(
}, },
"idea_ask_agent" => OrchestratorRequest { "idea_ask_agent" => OrchestratorRequest {
request_type: Some("agent.message".to_owned()), 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"), target_agent: s("target"),
task: s("task"), task: s("task"),
..base() ..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
// <id>]` prefix ⇒ correlate by ticket (multi-thread); optional (cadrage C3 §3.3).
ticket: s("ticket"),
result: s("result"),
..base()
},
"idea_launch_agent" => OrchestratorRequest { "idea_launch_agent" => OrchestratorRequest {
request_type: Some("agent.run".to_owned()), request_type: Some("agent.run".to_owned()),
target_agent: s("target"), target_agent: s("target"),
@ -197,6 +294,33 @@ pub fn map_tool_call(
context: s("context"), context: s("context"),
..base() ..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 { "idea_create_skill" => OrchestratorRequest {
request_type: Some("skill.create".to_owned()), request_type: Some("skill.create".to_owned()),
name: s("name"), name: s("name"),
@ -225,6 +349,10 @@ fn base() -> OrchestratorRequest {
node_id: None, node_id: None,
attach_to_cell: None, attach_to_cell: None,
scope: None, scope: None,
result: None,
ticket: None,
content: None,
slug: None,
} }
} }
@ -241,9 +369,17 @@ mod tests {
use super::*; use super::*;
use domain::OrchestratorVisibility; 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<OrchestratorCommand, ToolMapError> {
map_tool_call(name, args, "")
}
#[test] #[test]
fn ask_agent_maps_to_ask_command() { fn ask_agent_maps_to_ask_command() {
let cmd = map_tool_call( let cmd = map(
"idea_ask_agent", "idea_ask_agent",
&json!({ "target": "Architect", "task": "Analyse §17" }), &json!({ "target": "Architect", "task": "Analyse §17" }),
) )
@ -253,13 +389,15 @@ mod tests {
OrchestratorCommand::AskAgent { OrchestratorCommand::AskAgent {
target: "Architect".to_owned(), target: "Architect".to_owned(),
task: "Analyse §17".to_owned(), task: "Analyse §17".to_owned(),
// `map` passes an empty requester ⇒ no machine requester carried.
requester: None,
} }
); );
} }
#[test] #[test]
fn launch_agent_maps_to_spawn_background_by_default() { 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!( assert_eq!(
cmd, cmd,
OrchestratorCommand::SpawnAgent { OrchestratorCommand::SpawnAgent {
@ -274,11 +412,11 @@ mod tests {
#[test] #[test]
fn stop_update_and_skill_map_to_their_commands() { fn stop_update_and_skill_map_to_their_commands() {
assert_eq!( 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() } OrchestratorCommand::StopAgent { name: "Dev".to_owned() }
); );
assert_eq!( assert_eq!(
map_tool_call( map(
"idea_update_context", "idea_update_context",
&json!({ "target": "Dev", "context": "# body" }) &json!({ "target": "Dev", "context": "# body" })
) )
@ -289,7 +427,7 @@ mod tests {
} }
); );
assert_eq!( assert_eq!(
map_tool_call( map(
"idea_create_skill", "idea_create_skill",
&json!({ "name": "deploy", "context": "# steps" }) &json!({ "name": "deploy", "context": "# steps" })
) )
@ -304,19 +442,19 @@ mod tests {
#[test] #[test]
fn missing_required_field_surfaces_validation_error() { 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(_)))); assert!(matches!(err, Err(ToolMapError::Invalid(_))));
} }
#[test] #[test]
fn list_agents_maps_to_list_command() { 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); assert_eq!(cmd, OrchestratorCommand::ListAgents);
} }
#[test] #[test]
fn unknown_tool_is_rejected() { 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!( assert_eq!(
err, err,
Err(ToolMapError::UnknownTool("idea_made_up_tool".to_owned())) Err(ToolMapError::UnknownTool("idea_made_up_tool".to_owned()))
@ -325,10 +463,155 @@ mod tests {
#[test] #[test]
fn non_object_arguments_are_rejected() { 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!( assert_eq!(
err, err,
Err(ToolMapError::BadArguments("idea_ask_agent".to_owned())) 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"));
}
} }

View File

@ -32,12 +32,15 @@ use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::ids::NodeId; use domain::ids::NodeId;
use domain::markdown::MarkdownDoc; use domain::markdown::MarkdownDoc;
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan, AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
}; };
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath}; use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef; use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope}; use domain::skill::{Skill, SkillScope};
@ -47,9 +50,12 @@ use uuid::Uuid;
use application::{ use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, 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 infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use serde_json::{json, Value}; 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<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let events = vec![
ReplyEvent::TextDelta {
text: "thinking…".to_owned(),
},
ReplyEvent::Final {
content: self.reply.clone(),
},
];
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[derive(Default, Clone)] #[derive(Default, Clone)]
struct NoopBus; struct NoopBus;
@ -354,6 +331,9 @@ fn project() -> Project {
/// registry). Returns the shared `TerminalSessions` so a test can pre-bind a live /// registry). Returns the shared `TerminalSessions` so a test can pre-bind a live
/// PTY session for an agent (needed to make `stop_agent` succeed). /// PTY session for an agent (needed to make `stop_agent` succeed).
fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<TerminalSessions>) { fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<TerminalSessions>) {
// 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( let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)), ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code", "Claude Code",
@ -364,7 +344,12 @@ fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<Termi
"{agentRunDir}", "{agentRunDir}",
None, 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 sessions = Arc::new(TerminalSessions::new());
let bus = Arc::new(NoopBus); let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new( let create = Arc::new(CreateAgentFromScratch::new(
@ -405,18 +390,48 @@ fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<Termi
(service, sessions) (service, sessions)
} }
/// Like [`build_service`] but with the **structured sessions** registry wired, so /// Like [`build_service`] but with the **inter-agent mailbox** + PTY wired (Option 1
/// `idea_ask_agent` can find a live structured target. Returns the service and the /// « Terminal + MCP », B-3/B-4), so `idea_ask_agent` blocks on the mailbox and
/// shared registry so a test can pre-insert a replying session. /// `idea_reply` resolves it. Returns the service, the shared mailbox (to observe
fn build_service_with_structured( /// pending tickets) and the PTY session registry (to pre-seed a live target).
fn build_service_with_mailbox(
contexts: FakeContexts, contexts: FakeContexts,
) -> (Arc<OrchestratorService>, Arc<StructuredSessions>) { ) -> (
let structured = Arc::new(StructuredSessions::new()); Arc<OrchestratorService>,
let (base, _sessions) = build_service(contexts); Arc<InMemoryMailbox>,
Arc<TerminalSessions>,
) {
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<dyn PtyPort>,
)) as Arc<dyn domain::input::InputMediator>;
let conversations = Arc::new(InMemoryConversationRegistry::new())
as Arc<dyn domain::conversation::ConversationRegistry>;
let service = Arc::try_unwrap(base) let service = Arc::try_unwrap(base)
.unwrap_or_else(|_| panic!("freshly built service must be uniquely owned")) .unwrap_or_else(|_| panic!("freshly built service must be uniquely owned"))
.with_structured(Arc::clone(&structured)); .with_input_mediator(
(Arc::new(service), structured) input,
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
)
.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<OrchestratorService>) -> McpServer { fn server(service: Arc<OrchestratorService>) -> McpServer {
@ -461,7 +476,7 @@ fn result_text(result: &Value) -> &str {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[tokio::test] #[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 (service, _s) = build_service(FakeContexts::new());
let server = server(service); let server = server(service);
@ -483,14 +498,24 @@ async fn tools_list_advertises_the_six_idea_tools_with_schemas() {
for expected in [ for expected in [
"idea_list_agents", "idea_list_agents",
"idea_ask_agent", "idea_ask_agent",
"idea_reply",
"idea_launch_agent", "idea_launch_agent",
"idea_stop_agent", "idea_stop_agent",
"idea_update_context", "idea_update_context",
"idea_create_skill", "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!(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. // Every tool advertises an object input schema.
for t in tools { for t in tools {
@ -598,28 +623,47 @@ async fn update_context_and_create_skill_calls_succeed() {
#[tokio::test] #[tokio::test]
async fn ask_agent_returns_target_reply_inline() { 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 contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect"); let agent_id = contexts.seed_agent("architect");
let (service, structured) = build_service_with_structured(contexts); let (service, mailbox, sessions) = build_service_with_mailbox(contexts);
structured.insert( seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
Arc::new(FakeSession {
id: SessionId::from_uuid(Uuid::from_u128(4242)),
reply: "the answer is 42".to_owned(),
}),
agent_id,
NodeId::from_uuid(Uuid::from_u128(7)),
);
let server = server(service);
let raw = tools_call( let ask_server = server(Arc::clone(&service));
7, let ask = tokio::spawn(async move {
"idea_ask_agent", let raw = tools_call(
json!({ "target": "architect", "task": "What is the answer?" }), 7,
); "idea_ask_agent",
let response = server.handle_raw(&raw).await.expect("reply owed"); 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_eq!(response.id, json!(7), "id must be echoed");
assert!(response.error.is_none(), "transport error: {:?}", response.error); assert!(response.error.is_none(), "transport error: {:?}", response.error);
let result = response.result.expect("result"); let result = response.result.expect("result");
assert_eq!(result["isError"], json!(false), "got {result}"); assert_eq!(result["isError"], json!(false), "got {result}");
assert_eq!( 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) // 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() { async fn ask_agent_missing_task_is_rejected_by_shared_validation_no_dispatch() {
let contexts = FakeContexts::new(); let contexts = FakeContexts::new();
contexts.seed_agent("architect"); contexts.seed_agent("architect");
// No structured session inserted: if validation wrongly let this through to // If validation wrongly let this through to dispatch, ask_agent would try to
// dispatch, ask_agent would try to launch/contact the target and the error // launch/contact the target and the error would differ. A validation rejection
// would differ. A validation rejection here is INVALID_PARAMS, before dispatch. // here is INVALID_PARAMS, before dispatch.
let (service, structured) = build_service_with_structured(contexts); let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
let _ = &structured; // intentionally empty
let server = server(service); let server = server(service);
let raw = tools_call(5, "idea_ask_agent", json!({ "target": "architect" })); let raw = tools_call(5, "idea_ask_agent", json!({ "target": "architect" }));

View File

@ -21,23 +21,29 @@ use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc; use domain::markdown::MarkdownDoc;
use domain::ids::NodeId; use domain::ids::NodeId;
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan, AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
}; };
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath}; use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef; use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope}; use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{PtySize, SessionId}; use domain::{PtySize, SessionId};
use uuid::Uuid; use uuid::Uuid;
use application::{ use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, 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) --- // --- temp dir (mirror local_fs.rs) ---
struct TempDir(PathBuf); 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<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let events = vec![
ReplyEvent::TextDelta {
text: "thinking…".to_owned(),
},
ReplyEvent::Final {
content: self.reply.clone(),
},
];
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[derive(Default, Clone)] #[derive(Default, Clone)]
struct NoopBus; struct NoopBus;
@ -351,6 +327,9 @@ fn project() -> Project {
} }
fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> { fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
// 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( let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)), ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code", "Claude Code",
@ -361,7 +340,12 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
"{agentRunDir}", "{agentRunDir}",
None, 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 sessions = Arc::new(TerminalSessions::new());
let bus = Arc::new(NoopBus); let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new( let create = Arc::new(CreateAgentFromScratch::new(
@ -401,24 +385,107 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
)) ))
} }
/// Like [`build_service`] but with the **structured sessions** registry wired /// Like [`build_service`] but with the **inter-agent mailbox** + PTY wired (Option 1
/// (`with_structured`), so `agent.message`/`ask` can find a live structured target. /// « Terminal + MCP », B-3/B-4), so an `agent.message` blocks on the mailbox and an
/// Returns the service and the shared registry so a test can pre-insert a session. /// `agent.reply` resolves it. Returns the service, the shared mailbox (to observe
fn build_service_with_structured( /// pending tickets) and the PTY registry (to pre-seed a live target).
fn build_service_with_mailbox(
contexts: FakeContexts, contexts: FakeContexts,
) -> (Arc<OrchestratorService>, Arc<StructuredSessions>) { ) -> (
let structured = Arc::new(StructuredSessions::new()); Arc<OrchestratorService>,
// `build_service` already assembles every use case over the same fakes; we only Arc<InMemoryMailbox>,
// need to fold the structured registry onto the resulting service. Arc<TerminalSessions>,
let base = build_service(contexts); ) {
// `OrchestratorService` is not `Clone`; rebuild via the builder on a fresh one is // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// overkill, so we reconstruct minimally is not possible — instead, the service // seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible
// exposes `with_structured` as a consuming builder. We rely on `Arc::try_unwrap` // d'`idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`.
// since `build_service` returns a freshly-created `Arc` with refcount 1. let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
let service = Arc::try_unwrap(base) ProfileId::from_uuid(Uuid::from_u128(9)),
.unwrap_or_else(|_| panic!("freshly built service must be uniquely owned")) "Claude Code",
.with_structured(Arc::clone(&structured)); "claude",
(Arc::new(service), structured) 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<dyn ProfileStore>,
Arc::new(FakeRuntime),
Arc::new(FakeFs),
Arc::new(FakePty),
Arc::new(FakeSkills),
Arc::clone(&sessions),
bus.clone(),
Arc::new(SeqIds(Mutex::new(1))),
Arc::new(FakeRecall),
None,
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
let create_skill = Arc::new(CreateSkill::new(
Arc::new(FakeSkills) as Arc<dyn SkillStore>,
Arc::new(SeqIds(Mutex::new(1))),
));
let service = Arc::new(
OrchestratorService::new(
create,
launch,
list,
close,
update,
create_skill,
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::clone(&sessions),
)
.with_input_mediator(
Arc::new(infrastructure::MediatedInbox::with_pty(
Arc::clone(&mailbox),
Arc::new(infrastructure::SystemMillisClock),
Arc::new(FakePty) as Arc<dyn PtyPort>,
)) as Arc<dyn domain::input::InputMediator>,
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
)
.with_conversations(
Arc::new(infrastructure::InMemoryConversationRegistry::new())
as Arc<dyn domain::conversation::ConversationRegistry>,
),
);
(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 { fn read_response(request_path: &std::path::Path) -> OrchestratorResponse {
@ -573,19 +640,17 @@ async fn unknown_action_yields_error_response() {
/// alongside `ok: true`. /// alongside `ok: true`.
#[tokio::test] #[tokio::test]
async fn ask_request_surfaces_reply_alongside_detail() { 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 tmp = TempDir::new();
let contexts = FakeContexts::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 agent_id = contexts.seed_agent("architect");
let (service, structured) = build_service_with_structured(contexts.clone()); let (service, mailbox, sessions) = build_service_with_mailbox(contexts.clone());
structured.insert( // Target already live in the PTY registry, so the ask reuses its terminal.
Arc::new(FakeSession { seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
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 req = tmp.0.join("ask-req.json"); let req = tmp.0.join("ask-req.json");
std::fs::write( std::fs::write(
@ -594,11 +659,42 @@ async fn ask_request_surfaces_reply_alongside_detail() {
) )
.unwrap(); .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!(response.ok, "expected ok, got {response:?}");
assert_eq!(response.action.as_deref(), Some("agent.message")); 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")); assert_eq!(response.reply.as_deref(), Some("the answer is 42"));
// ...and detail still summarises what IdeA did (the two coexist). // ...and detail still summarises what IdeA did (the two coexist).
assert!( assert!(

View File

@ -16,9 +16,6 @@ import { Channel, invoke } from "@tauri-apps/api/core";
import type { import type {
Agent, Agent,
CellKind,
ReattachChatDto,
ReplyChunk,
ResumableAgent, ResumableAgent,
TerminalSession, TerminalSession,
} from "@/domain"; } from "@/domain";
@ -41,8 +38,6 @@ interface LaunchAgentResponse {
cols: number; cols: number;
/** Conversation id minted by this launch (omitted when nothing was assigned). */ /** Conversation id minted by this launch (omitted when nothing was assigned). */
assignedConversationId?: string; assignedConversationId?: string;
/** How the hosting cell should render (`"chat"` ⇒ structured AI cell, §17.6). */
cellKind: CellKind;
} }
export class TauriAgentGateway implements AgentGateway { export class TauriAgentGateway implements AgentGateway {
@ -155,11 +150,9 @@ export class TauriAgentGateway implements AgentGateway {
const base = makeTerminalHandle(res.sessionId, channel); const base = makeTerminalHandle(res.sessionId, channel);
// Surface the id assigned by this launch (so the caller persists it on the // 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 // leaf and resumes next time).
// to `AgentChatView` vs `TerminalView`, §17.6).
return { return {
...base, ...base,
cellKind: res.cellKind,
...(res.assignedConversationId ...(res.assignedConversationId
? { assignedConversationId: res.assignedConversationId } ? { assignedConversationId: res.assignedConversationId }
: {}), : {}),
@ -197,40 +190,4 @@ export class TauriAgentGateway implements AgentGateway {
request: { projectId, agentId, conversationId }, request: { projectId, agentId, conversationId },
}); });
} }
async sendPrompt(
sessionId: string,
prompt: string,
onReply: (chunk: ReplyChunk) => void,
): Promise<void> {
// 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<ReplyChunk>();
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<ReplyChunk[]> {
// 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<ReplyChunk>();
channel.onmessage = (chunk) => onReply(chunk);
const res = await invoke<ReattachChatDto>("reattach_agent_chat", {
sessionId,
onReply: channel,
});
return res.scrollback;
}
async closeAgentSession(sessionId: string): Promise<void> {
await invoke("close_agent_session", { sessionId });
}
} }

View File

@ -14,6 +14,7 @@ import type {
} from "@/ports"; } from "@/ports";
import { TauriSystemGateway } from "./system"; import { TauriSystemGateway } from "./system";
import { TauriAgentGateway } from "./agent"; import { TauriAgentGateway } from "./agent";
import { TauriInputGateway } from "./input";
import { TauriProjectGateway } from "./project"; import { TauriProjectGateway } from "./project";
import { TauriTerminalGateway } from "./terminal"; import { TauriTerminalGateway } from "./terminal";
import { TauriLayoutGateway } from "./layout"; import { TauriLayoutGateway } from "./layout";
@ -43,6 +44,7 @@ export function createTauriGateways(): Gateways {
return { return {
system: new TauriSystemGateway(), system: new TauriSystemGateway(),
agent: new TauriAgentGateway(), agent: new TauriAgentGateway(),
input: new TauriInputGateway(),
terminal: new TauriTerminalGateway(), terminal: new TauriTerminalGateway(),
project: new TauriProjectGateway(), project: new TauriProjectGateway(),
layout: new TauriLayoutGateway(), layout: new TauriLayoutGateway(),
@ -59,6 +61,7 @@ export function createTauriGateways(): Gateways {
export { export {
TauriSystemGateway, TauriSystemGateway,
TauriAgentGateway, TauriAgentGateway,
TauriInputGateway,
TauriProjectGateway, TauriProjectGateway,
TauriTerminalGateway, TauriTerminalGateway,
TauriLayoutGateway, TauriLayoutGateway,

View File

@ -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<void> {
await invoke("submit_agent_input", {
request: { projectId, agentId, text },
});
}
async interrupt(projectId: string, agentId: string): Promise<void> {
await invoke("interrupt_agent", {
request: { projectId, agentId },
});
}
}

View File

@ -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",
});
});
});

View File

@ -27,10 +27,8 @@ import type {
MemoryIndexEntry, MemoryIndexEntry,
MemoryLink, MemoryLink,
MemoryType, MemoryType,
CellKind,
Project, Project,
ProfileAvailability, ProfileAvailability,
ReplyChunk,
ResumableAgent, ResumableAgent,
Skill, Skill,
SkillScope, SkillScope,
@ -47,6 +45,7 @@ import type {
EmbedderGateway, EmbedderGateway,
CreateTemplateInput, CreateTemplateInput,
Gateways, Gateways,
InputGateway,
LiveAgent, LiveAgent,
MemoryGateway, MemoryGateway,
GitGateway, 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`, * Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and * `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
@ -249,19 +186,6 @@ export class MockAgentGateway implements AgentGateway {
private liveByAgent = new Map<string, string>(); private liveByAgent = new Map<string, string>();
/** Live PTY session id per agent (`agentId → sessionId`). */ /** Live PTY session id per agent (`agentId → sessionId`). */
private liveSessionByAgent = new Map<string, string>(); private liveSessionByAgent = new Map<string, string>();
/** Live structured (chat) sessions, kept across detach so reattach finds them. */
private chatSessions = new Map<string, MockChatSession>();
/**
* 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<string>();
/** 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[] { private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []); if (!this.agents.has(projectId)) this.agents.set(projectId, []);
@ -450,7 +374,6 @@ export class MockAgentGateway implements AgentGateway {
cwd: `/home/user/mock-project`, cwd: `/home/user/mock-project`,
rows, rows,
cols, cols,
cellKind: this.chatAgents.has(agentId) ? "chat" : "pty",
}, },
}; };
} }
@ -535,10 +458,9 @@ export class MockAgentGateway implements AgentGateway {
this.sessionSeq += 1; this.sessionSeq += 1;
const sessionId = `mock-agent-session-${this.sessionSeq}`; const sessionId = `mock-agent-session-${this.sessionSeq}`;
const cwd = options.cwd; const cwd = options.cwd;
const cellKind: CellKind = this.chatAgents.has(agentId) ? "chat" : "pty";
// Record liveness for `listLiveAgents` + the guard above (only when the // 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) { if (options.nodeId) {
this.liveByAgent.set(agentId, options.nodeId); this.liveByAgent.set(agentId, options.nodeId);
this.liveSessionByAgent.set(agentId, sessionId); this.liveSessionByAgent.set(agentId, sessionId);
@ -550,37 +472,19 @@ export class MockAgentGateway implements AgentGateway {
} }
}; };
let handle: TerminalHandle; // Every agent cell is a raw PTY (Option 1 — Terminal + MCP). The former
if (cellKind === "chat") { // structured "chat" cell routing is retired.
// Structured AI cell (§17.6): spawn a chat session (no PTY). The reply const enc = new TextEncoder();
// stream is driven by `sendPrompt`; the returned handle is mostly inert const session = new MockPtySession(sessionId, onData);
// (no bytes), but its `close` shuts the structured session down. this.sessions.set(sessionId, session);
const chat = new MockChatSession(sessionId); // Greet so something is visible immediately (mirrors MockTerminalGateway).
this.chatSessions.set(sessionId, chat); queueMicrotask(() =>
handle = { session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
...makeInertHandle(sessionId, () => { );
chat.shutdown(); const handle: TerminalHandle = makeMockHandle(session, () => {
this.chatSessions.delete(sessionId); this.sessions.delete(sessionId);
clearLive(); 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,
};
}
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a // 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 // 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; return handle;
} }
async sendPrompt(
sessionId: string,
prompt: string,
onReply: (chunk: ReplyChunk) => void,
): Promise<void> {
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<ReplyChunk[]> {
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<void> {
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( async reattach(
sessionId: string, sessionId: string,
onData: (bytes: Uint8Array) => void, 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<void> {},
async resize(): Promise<void> {},
detach(): void {},
async close(): Promise<void> {
shutdown();
},
};
}
/** /**
* In-memory fake terminal: a shell-less PTY that **echoes** whatever is written * 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 * 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<void> {
this.submits.push({ projectId, agentId, text });
}
async interrupt(projectId: string, agentId: string): Promise<void> {
this.interrupts.push({ projectId, agentId });
}
}
/** Builds the full set of mock gateways. */ /** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways { export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway(); const agentGateway = new MockAgentGateway();
return { return {
system: new MockSystemGateway(), system: new MockSystemGateway(),
agent: agentGateway, agent: agentGateway,
input: new MockInputGateway(),
terminal: new MockTerminalGateway(), terminal: new MockTerminalGateway(),
project: new MockProjectGateway(), project: new MockProjectGateway(),
layout: new MockLayoutGateway(), layout: new MockLayoutGateway(),

View File

@ -12,11 +12,12 @@ import { createMockGateways, MockSystemGateway } from "./index";
const gateways: Gateways = createMockGateways(); const gateways: Gateways = createMockGateways();
describe("createMockGateways", () => { describe("createMockGateways", () => {
it("exposes all twelve gateways", () => { it("exposes all thirteen gateways", () => {
expect(Object.keys(gateways).sort()).toEqual([ expect(Object.keys(gateways).sort()).toEqual([
"agent", "agent",
"embedder", "embedder",
"git", "git",
"input",
"layout", "layout",
"memory", "memory",
"profile", "profile",

View File

@ -19,6 +19,7 @@ export type DomainEvent =
| { type: "agentLaunched"; agentId: string; sessionId: string } | { type: "agentLaunched"; agentId: string; sessionId: string }
| { type: "agentExited"; agentId: string; code: number } | { type: "agentExited"; agentId: string; code: number }
| { type: "agentProfileChanged"; agentId: string; profileId: string } | { type: "agentProfileChanged"; agentId: string; profileId: string }
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
| { type: "templateUpdated"; templateId: string; version: number } | { type: "templateUpdated"; templateId: string; version: number }
| { type: "agentDriftDetected"; agentId: string; from: number; to: number } | { type: "agentDriftDetected"; agentId: string; from: number; to: number }
| { type: "agentSynced"; agentId: string; 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. * session assignment and the hosting cell had none yet; absent otherwise.
*/ */
assignedConversationId?: string; 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[];
} }
/** /**

View File

@ -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<typeof vi.fn>;
reattach: ReturnType<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
onSessionId: ReturnType<typeof vi.fn>;
}
/**
* 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(<AgentChatView {...wire({ sessionId: "s1" }).props} />);
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(<AgentChatView {...w.props} />);
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(<AgentChatView {...w.props} />);
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(<AgentChatView {...w.props} />);
// 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(<AgentChatView {...w.props} />);
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(<AgentChatView {...w.props} />);
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(<AgentChatView {...w.props} />);
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(<AgentChatView {...w.props} />);
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(<AgentChatView {...w.props} />);
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(<AgentChatView {...w.props} />);
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(<AgentChatView {...w.props} />);
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(<AgentChatView {...w.props} />);
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();
});
});

View File

@ -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<string>;
/**
* 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<ReplyChunk[]>;
/**
* 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<void>;
/** 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<Turn[]>([]);
/** The in-flight assistant turn, or `null` between turns. */
const [pending, setPending] = useState<PendingTurn | null>(null);
/** The current input value. */
const [input, setInput] = useState("");
/** A connection/attach error to surface (non-fatal). */
const [error, setError] = useState<string | null>(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<string | null>(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<HTMLDivElement | null>(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 (
<div
data-testid="agent-chat-view"
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
minHeight: "16rem",
boxSizing: "border-box",
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
fontSize: 13,
}}
>
<div
ref={scrollRef}
data-testid="agent-chat-transcript"
style={{
flex: "1 1 auto",
minHeight: 0,
overflowY: "auto",
padding: "2rem 0.75rem 0.5rem",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{turns.map((turn, i) =>
turn.kind === "user" ? (
<UserBubble key={i} text={turn.text} />
) : (
<AssistantBubble key={i} text={turn.text} tools={turn.tools} />
),
)}
{pending && (
<AssistantBubble
text={pending.text}
tools={pending.tools}
pending
/>
)}
</div>
{error && (
<p
role="alert"
style={{
margin: 0,
padding: "2px 8px",
fontSize: 11,
color: "crimson",
background: "var(--color-surface, #1e1e1e)",
}}
>
{error}
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
style={{
display: "flex",
gap: 6,
padding: 6,
borderTop: "1px solid var(--color-border, #3a3a3a)",
}}
>
<textarea
aria-label="prompt"
data-testid="agent-chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
// Enter submits; Shift+Enter inserts a newline.
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder="Message à l'agent…"
style={{
flex: "1 1 auto",
resize: "none",
fontSize: 13,
fontFamily: "inherit",
background: "var(--color-bg, #141414)",
color: "var(--color-content, #e0e0e0)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
padding: "6px 8px",
}}
/>
<button
type="submit"
aria-label="send"
disabled={input.trim() === ""}
style={{
fontSize: 12,
padding: "0 12px",
borderRadius: 4,
border: "1px solid var(--color-border, #3a3a3a)",
background: "var(--color-accent, #3a6ea5)",
color: "#fff",
cursor: input.trim() === "" ? "default" : "pointer",
opacity: input.trim() === "" ? 0.5 : 1,
}}
>
Envoyer
</button>
</form>
</div>
);
}
function UserBubble({ text }: { text: string }) {
return (
<div
data-testid="chat-turn-user"
style={{
alignSelf: "flex-end",
maxWidth: "85%",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
background: "var(--color-accent, #3a6ea5)",
color: "#fff",
padding: "6px 10px",
borderRadius: 8,
}}
>
{text}
</div>
);
}
function AssistantBubble({
text,
tools,
pending,
}: {
text: string;
tools: readonly string[];
pending?: boolean;
}) {
return (
<div
data-testid={pending ? "chat-turn-pending" : "chat-turn-assistant"}
style={{
alignSelf: "flex-start",
maxWidth: "85%",
background: "var(--color-bg, #141414)",
border: "1px solid var(--color-border, #3a3a3a)",
padding: "6px 10px",
borderRadius: 8,
}}
>
{tools.length > 0 && (
<div
data-testid="chat-tool-activity"
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
marginBottom: text ? 6 : 0,
}}
>
{tools.map((label, i) => (
<span
key={i}
data-testid="chat-tool-badge"
style={{
fontSize: 10,
padding: "1px 6px",
borderRadius: 10,
background: "var(--color-surface, #2a2a2a)",
color: "var(--color-content-muted, #9a9a9a)",
}}
>
{label}
</span>
))}
</div>
)}
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{text}
{pending && (
<span data-testid="chat-cursor" aria-hidden style={{ opacity: 0.6 }}>
</span>
)}
</span>
</div>
);
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}

View File

@ -1,4 +0,0 @@
/** Chat feature (§17.6): structured AI conversation view bound to the `AgentGateway`. */
export { AgentChatView } from "./AgentChatView";
export type { AgentChatViewProps } from "./AgentChatView";

View File

@ -1,26 +1,27 @@
/** /**
* D5 — `LayoutGrid` cell-kind routing (§17.6): an agent cell whose launch reports * F-1 — `LayoutGrid` cell routing (Option 1, Terminal + MCP): **every** agent
* `cellKind:"chat"` must render an {@link AgentChatView}; every other cell (plain * cell renders the raw {@link TerminalView}; no structured chat view is ever
* or a `pty` agent) keeps the unchanged {@link TerminalView}. Wired through the * mounted. This replaces the former §17.6 `cellKind:"chat"` routing — the human
* real {@link DIProvider} with the in-memory mocks, exactly like * view is now the native interactive PTY, and cross-model delegation flows
* `LayoutGrid.test.tsx`. * through MCP tools, not a chat view. Wired through the real {@link DIProvider}
* with the in-memory mocks, exactly like `LayoutGrid.test.tsx`.
* *
* Routing to chat is *derived from the launch* (the leaf starts as a terminal * The decisive case: an agent cell always renders the terminal and never swaps
* and swaps once the handle's `cellKind` arrives). Under jsdom xterm's `open` * to a chat view (the structured chat surface was removed in the F-2 cleanup).
* may bail, so the terminal opener that triggers the launch might not run; these *
* tests therefore assert the *reachable* outcomes without depending on xterm: * Under jsdom xterm's `open` may bail, so the opener that triggers the launch
* - a plain cell renders the terminal view and NEVER a chat view (hard); * might not run; we therefore stub xterm (as in the original test) so the launch
* - when the launch does run, a chat agent ends up rendering the chat view. * does fire and we genuinely exercise the post-launch routing — which must stay
* on the terminal regardless of the reported kind.
*/ */
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor as rtlWaitFor } from "@testing-library/react"; import { render, screen, waitFor as rtlWaitFor } from "@testing-library/react";
// Make xterm "wire up" under jsdom: the real `Terminal.open` throws without a // Make xterm "wire up" under jsdom: the real `Terminal.open` throws without a
// layout engine, which makes `TerminalView`'s effect bail before it ever calls // layout engine, which makes `TerminalView`'s effect bail before it ever calls
// the opener — so the launch (and thus the chat-routing swap) would never fire. // the opener — so the launch would never fire. A minimal stub lets `term.open`
// A minimal stub lets `term.open` succeed, the opener run, and the leaf derive // succeed and the opener run, so the launch (and any routing it could trigger)
// its `cellKind` from the launch exactly as in the app. We do NOT stub the chat // is genuinely exercised. We do NOT stub the routing — only xterm.
// view or the routing — only xterm, the headless-only obstacle.
vi.mock("@xterm/xterm", () => ({ vi.mock("@xterm/xterm", () => ({
Terminal: class { Terminal: class {
loadAddon() {} loadAddon() {}
@ -64,16 +65,17 @@ import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid"; import { LayoutGrid } from "./LayoutGrid";
/** Seeds an agent in the gateway and pins it onto the (single) leaf cell. */ /** Seeds an agent in the gateway and pins it onto the (single) leaf cell. */
async function seedPinnedAgent(opts: { async function seedPinnedAgent(): Promise<{
chat: boolean; gateways: Gateways;
}): Promise<{ gateways: Gateways; layout: MockLayoutGateway; agentGateway: MockAgentGateway }> { layout: MockLayoutGateway;
agentGateway: MockAgentGateway;
}> {
const layout = new MockLayoutGateway(); const layout = new MockLayoutGateway();
const agentGateway = new MockAgentGateway(); const agentGateway = new MockAgentGateway();
const terminal = new MockTerminalGateway(); const terminal = new MockTerminalGateway();
const system = new MockSystemGateway(); const system = new MockSystemGateway();
const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude" }); const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude" });
if (opts.chat) agentGateway._setChatAgents([agent.id]);
// Pin the agent onto the single leaf. // Pin the agent onto the single leaf.
const tree = await layout.loadLayout("p1"); const tree = await layout.loadLayout("p1");
@ -92,7 +94,7 @@ function renderGrid(gateways: Gateways) {
); );
} }
describe("LayoutGrid cell-kind routing (§17.6)", () => { describe("LayoutGrid cell routing (F-1, Terminal + MCP)", () => {
it("a plain (agent-less) cell renders the terminal view, never a chat view", async () => { it("a plain (agent-less) cell renders the terminal view, never a chat view", async () => {
const layout = new MockLayoutGateway(); const layout = new MockLayoutGateway();
const gateways = { const gateways = {
@ -105,34 +107,49 @@ describe("LayoutGrid cell-kind routing (§17.6)", () => {
renderGrid(gateways); renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy()); await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
// Non-regression: the plain path is the terminal, and there is no chat view.
expect(screen.getByTestId("terminal-view")).toBeTruthy(); expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull(); expect(screen.queryByTestId("agent-chat-view")).toBeNull();
}); });
it("a pty agent cell renders the terminal view, never a chat view", async () => { it("a pty agent cell renders the terminal view, never a chat view", async () => {
const { gateways } = await seedPinnedAgent({ chat: false }); const { gateways } = await seedPinnedAgent();
renderGrid(gateways); renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy()); await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
// A pty agent keeps the unchanged terminal path; the chat view never appears.
expect(screen.getByTestId("terminal-view")).toBeTruthy(); expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull(); expect(screen.queryByTestId("agent-chat-view")).toBeNull();
}); });
it("a chat agent cell routes to the chat view once its launch reports cellKind 'chat'", async () => { it("re-mounting a known agent cell (persisted session) repaints as a terminal, never chat", async () => {
const { gateways } = await seedPinnedAgent({ chat: true }); const layout = new MockLayoutGateway();
const agentGateway = new MockAgentGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude" });
// Seed a persisted session on the leaf — the pre-F-1 path would have re-mounted
// such a known agent cell as a chat view; now it must always be a terminal.
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agent.id });
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "running-session",
});
const gateways = { layout, agent: agentGateway, terminal, system } as unknown as Gateways;
// First mount.
const { unmount } = renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
unmount();
// Re-mount (as after a tab/layout navigation): the known agent cell with its
// persisted session must repaint as a terminal — never a chat view.
renderGrid(gateways); renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy()); expect(screen.queryByTestId("agent-chat-view")).toBeNull();
// The leaf starts as a terminal and swaps to the chat view once the derived
// `cellKind` ("chat") arrives from the launch (xterm is stubbed so the
// opener actually runs). This is the real §17.6 routing.
await rtlWaitFor(() =>
expect(screen.queryByTestId("agent-chat-view")).toBeTruthy(),
);
// Once routed to chat, the terminal view is gone for that cell.
expect(screen.queryByTestId("terminal-view")).toBeNull();
}); });
}); });

View File

@ -0,0 +1,239 @@
/**
* F2 — mediated input for agent cells (cadrage §4.2/§7).
*
* Two contracts are exercised here, with the real {@link DIProvider} and the
* in-memory mocks:
*
* 1. **Keystrokes are mediated in agent mode.** A cell that pins an agent puts
* `TerminalView` in `agentMode`: human keystrokes (`term.onData`) MUST NOT be
* written to the PTY (input flows through {@link MediatedInput}). A plain
* (agent-less) cell keeps the raw-shell behaviour (keystrokes → PTY).
* 2. **The PTY *output* is always painted** (both modes) — xterm stays the raw
* output view, unchanged by F2.
* 3. **`MediatedInput` is mounted under the terminal** for an agent cell, and
* **never** an `AgentChatView` (the structured chat surface stays removed).
*
* xterm is stubbed (as in the sibling chat test) so `term.open` succeeds under
* jsdom and the opener/keystroke wiring genuinely runs. The stub captures the
* `onData` keystroke handler so the test can fire a keystroke and assert whether
* it reached the PTY handle (`handle.write`).
*/
import { beforeEach, describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
// Capture the keystroke handler xterm's `onData` is given, and the output sink
// `term.write` so we can assert PTY output is painted. A single shared slot is
// enough: each test renders exactly one terminal cell.
const xtermState: {
keyHandler: ((data: string) => void) | null;
writes: Uint8Array[];
} = { keyHandler: null, writes: [] };
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
open() {}
onData(cb: (data: string) => void) {
xtermState.keyHandler = cb;
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
write(bytes: Uint8Array) {
xtermState.writes.push(bytes);
}
dispose() {}
get cols() {
return 80;
}
get rows() {
return 24;
}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import type { Gateways, TerminalHandle } from "@/ports";
import {
MockAgentGateway,
MockInputGateway,
MockLayoutGateway,
MockSystemGateway,
MockTerminalGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
/** Fresh mock set (input gateway included so MediatedInput is fully wired). */
function makeGateways(agentGateway: MockAgentGateway): Gateways {
return {
layout: new MockLayoutGateway(),
agent: agentGateway,
terminal: new MockTerminalGateway(),
system: new MockSystemGateway(),
input: new MockInputGateway(),
} as unknown as Gateways;
}
function renderGrid(gateways: Gateways) {
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
}
/**
* Spy on the handle the agent gateway hands out, so we can detect whether a
* keystroke reached the PTY (`handle.write`). Returns the spy.
*/
function spyAgentWrite(gw: MockAgentGateway): ReturnType<typeof vi.fn> {
const writeSpy = vi.fn();
const original = gw.launchAgent.bind(gw);
vi.spyOn(gw, "launchAgent").mockImplementation(async (p, a, o, onData) => {
const handle: TerminalHandle = await original(p, a, o, onData);
return { ...handle, write: writeSpy };
});
return writeSpy;
}
/** Spy on the terminal gateway handle write (plain-cell PTY path). */
function spyTerminalWrite(gw: MockTerminalGateway): ReturnType<typeof vi.fn> {
const writeSpy = vi.fn();
const original = gw.openTerminal.bind(gw);
vi.spyOn(gw, "openTerminal").mockImplementation(async (o, onData) => {
const handle: TerminalHandle = await original(o, onData);
return { ...handle, write: writeSpy };
});
return writeSpy;
}
beforeEach(() => {
xtermState.keyHandler = null;
xtermState.writes = [];
});
describe("LayoutGrid F2 — mediated input for agent cells", () => {
it("an AGENT cell does NOT write keystrokes to the PTY (input is mediated)", async () => {
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
// Pin the agent onto the leaf in this run's layout gateway.
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
const writeSpy = spyAgentWrite(agentGateway);
renderGrid(gateways);
// Wait for the launch to settle (the opener ran and adopted a handle).
await waitFor(() => expect(agentGateway.launchAgent).toHaveBeenCalled());
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
// Simulate a human keystroke into xterm.
xtermState.keyHandler!("h");
xtermState.keyHandler!("i");
// In agent mode the keystroke must be swallowed — never forwarded to the PTY.
expect(writeSpy).not.toHaveBeenCalled();
});
it("a PLAIN cell still writes keystrokes to the PTY (raw shell, unchanged)", async () => {
const agentGateway = new MockAgentGateway();
const gateways = makeGateways(agentGateway);
const writeSpy = spyTerminalWrite(gateways.terminal as MockTerminalGateway);
renderGrid(gateways);
await waitFor(() =>
expect((gateways.terminal as MockTerminalGateway).openTerminal).toHaveBeenCalled(),
);
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
xtermState.keyHandler!("l");
xtermState.keyHandler!("s");
// Plain shell: keystrokes flow straight to the PTY (current behaviour).
await waitFor(() => expect(writeSpy).toHaveBeenCalled());
});
it("paints PTY output in BOTH modes (xterm output view unchanged)", async () => {
// Agent cell: the mock greets on open → output must reach term.write.
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
renderGrid(gateways);
// The mock agent greets on open; that output must be painted by xterm even
// though keystroke input is mediated.
await waitFor(() => expect(xtermState.writes.length).toBeGreaterThan(0));
});
it("mounts MediatedInput under an agent cell, never an AgentChatView", async () => {
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.getByTestId("mediated-input")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
it("a PLAIN cell has NO mediated input strip", async () => {
const agentGateway = new MockAgentGateway();
const gateways = makeGateways(agentGateway);
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("mediated-input")).toBeNull();
});
});

View File

@ -18,7 +18,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { Agent, CellKind } from "@/domain"; import type { Agent } from "@/domain";
import type { LayoutNode } from "@/domain"; import type { LayoutNode } from "@/domain";
import type { import type {
ConversationDetails, ConversationDetails,
@ -26,8 +26,12 @@ import type {
OpenTerminalOptions, OpenTerminalOptions,
TerminalHandle, TerminalHandle,
} from "@/ports"; } from "@/ports";
import { ResumeConversationPopup, TerminalView } from "@/features/terminals"; import {
import { AgentChatView } from "@/features/chat"; MediatedInput,
ResumeConversationPopup,
TerminalView,
useAgentBusy,
} from "@/features/terminals";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout"; import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout"; import { useLayout, type LayoutViewModel } from "./useLayout";
@ -144,17 +148,6 @@ interface LeafViewProps {
visibleNodeIds: Set<string>; visibleNodeIds: Set<string>;
} }
/**
* Remembers each live session's {@link CellKind} (`"pty"` | `"chat"`) by session
* id, so that after a navigation/layout change re-mounts a leaf — when only the
* persisted session id is known and a re-attach (not a re-launch) is due — the
* grid can still route to the right view (`AgentChatView` vs `TerminalView`)
* without re-spawning. Populated at launch from the handle's derived `cellKind`
* (ARCHITECTURE §17.6). Module-scoped (survives unmount); a closed session's
* id is never reused, so stale entries are harmless.
*/
const cellKindBySession = new Map<string, CellKind>();
/** /**
* A launch deferred by the resume popup (T7): TerminalView asked the opener to * A launch deferred by the resume popup (T7): TerminalView asked the opener to
* launch (fresh open or reattach-failed fallback) for a cell that carries a * launch (fresh open or reattach-failed fallback) for a cell that carries a
@ -210,6 +203,11 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0; const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
const { agent: agentGateway, system } = useGateways(); const { agent: agentGateway, system } = useGateways();
// Per-agent busy map (lot F1) feeds the mediated input strip: while the agent
// is processing a turn, "Envoyer" is dimmed (but the enqueue stays open) and
// "Interrompre" is active. Absent key ⇒ idle.
const busyMap = useAgentBusy();
// Load the project's agents for the dropdown. // Load the project's agents for the dropdown.
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
useEffect(() => { useEffect(() => {
@ -330,35 +328,13 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
const [pendingResume, setPendingResume] = useState<PendingResume | null>(null); const [pendingResume, setPendingResume] = useState<PendingResume | null>(null);
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(null); const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(null);
// ── Cell kind routing (§17.6) ───────────────────────────────────────────── // ── Cell routing (Option 1 — Terminal + MCP) ──────────────────────────────
// Which view this agent cell renders: a structured chat (`AgentChatView`) or a // Every agent cell renders the raw {@link TerminalView}: the human view is the
// raw terminal (`TerminalView`). `null` until known. It is derived from the // native interactive PTY (live reasoning + Échap are native CLI behaviours,
// launched session's `cellKind`; on a re-mount with a persisted session we // zero model parsing), and cross-model delegation flows through MCP tools, not
// recover it from the module-level cache so a chat cell repaints as chat // a structured chat view. The former `AgentChatView`/`cellKind:"chat"` routing
// (re-attach, not re-spawn). Plain (agent-less) cells are always terminals. // has been removed (F-2 cleanup); any `cellKind` field still emitted by the
const [cellKind, setCellKind] = useState<CellKind | null>(() => // backend on the wire is simply ignored.
agent && session ? (cellKindBySession.get(session) ?? null) : null,
);
// The chat session id once a chat launch resolved — passed to `AgentChatView`
// immediately on the view swap, independent of the layout-persistence round
// trip (so the chat re-attaches to the just-spawned session, never re-spawns).
const [chatSessionId, setChatSessionId] = useState<string | null>(
agent && session && cellKindBySession.get(session) === "chat"
? session
: null,
);
// Reset the derived routing when the pinned agent changes (a different agent —
// or unpinning to a plain cell — may be a different cell kind). We re-seed from
// the persisted session's cached kind so a remount of a known chat cell stays a
// chat (re-attach, not re-spawn); otherwise routing falls back to a terminal
// until the next launch reports the kind.
useEffect(() => {
const known = agent && session ? cellKindBySession.get(session) : undefined;
setCellKind(known ?? null);
setChatSessionId(known === "chat" ? session : null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent]);
/** Performs the actual launch and persists any assigned id (T4b loop). */ /** Performs the actual launch and persists any assigned id (T4b loop). */
const doLaunch = async ( const doLaunch = async (
@ -398,12 +374,6 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
if (handle.assignedConversationId) { if (handle.assignedConversationId) {
void vm.setCellConversation(id, handle.assignedConversationId); void vm.setCellConversation(id, handle.assignedConversationId);
} }
// Record the derived cell kind so re-mounts (after navigation) route to the
// right view without re-spawning (§17.6).
const kind = handle.cellKind ?? "pty";
cellKindBySession.set(handle.sessionId, kind);
if (kind === "chat") setChatSessionId(handle.sessionId);
setCellKind(kind);
return handle; return handle;
}; };
@ -455,21 +425,6 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
agentGateway.reattach(sessionId, onData) agentGateway.reattach(sessionId, onData)
: undefined; : undefined;
// ── Chat cell callbacks (§17.6) ───────────────────────────────────────────
// Only used when this cell renders as a structured chat. `launch` spawns the
// session (reusing the same `doLaunch` as the terminal path, so the singleton
// invariant and conversation persistence are shared); `reattach`/`send` map to
// the chat-specific gateway methods.
const chatLaunch = (): Promise<string> =>
// A chat launch produces no PTY output; the byte sink is a no-op.
doLaunch({ cwd, rows: 0, cols: 0 }, () => {}, conversationId ?? undefined).then(
(handle) => {
setChatSessionId(handle.sessionId);
void vm.setSession(id, handle.sessionId);
return handle.sessionId;
},
);
return ( return (
<div <div
data-testid="layout-leaf" data-testid="layout-leaf"
@ -596,35 +551,47 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
</button> </button>
)} )}
</div> </div>
{/* Route by derived cell kind (§17.6): a structured AI cell renders the {/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the
chat view, every other cell (plain terminal or a TUI/PTY agent) keeps raw xterm {@link TerminalView}. Agent cells are interactive PTYs; their
the unchanged xterm path. Re-key on the agent so the right view/opener cross-model delegation happens through MCP tools, not a chat view.
is captured at mount; the persisted session drives re-attach (never a Re-key on the agent so the right opener is captured at mount; the
re-spawn) when navigating. */} persisted session drives re-attach (never a re-spawn) when navigating.
{agentGateway && agentId && cellKind === "chat" ? (
<AgentChatView Lot F2 (cadrage §4.2): in an **agent** cell the terminal is output-only
key={`${id}-${agentId}-chat`} for the human (`agentMode`) and the {@link MediatedInput} strip is
launch={chatLaunch} mounted **under** it — keystrokes route through IdeA, not the PTY. A
reattach={(sid, onReply) => agentGateway.reattachChat(sid, onReply)} **plain** cell keeps the raw-shell behaviour (keystrokes → PTY) and has
send={(sid, prompt, onReply) => no input strip. We stack terminal + strip in a column so the strip sits
agentGateway.sendPrompt(sid, prompt, onReply) beneath the live output. */}
} <div
sessionId={chatSessionId ?? session} style={{
onSessionId={(sid) => { display: "flex",
setChatSessionId(sid); flexDirection: "column",
void vm.setSession(id, sid); width: "100%",
}} height: "100%",
/> minHeight: 0,
) : ( minWidth: 0,
<TerminalView }}
key={`${id}-${agentId ?? "plain"}`} >
cwd={cwd} <div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0 }}>
open={terminalOpener} <TerminalView
reattach={reattachOpener} key={`${id}-${agentId ?? "plain"}`}
sessionId={session} cwd={cwd}
onSessionId={(sid) => void vm.setSession(id, sid)} open={terminalOpener}
/> reattach={reattachOpener}
)} sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
agentMode={agentId != null}
/>
</div>
{agentId != null && (
<MediatedInput
projectId={projectId}
agentId={agentId}
busy={busyMap[agentId] ?? false}
/>
)}
</div>
{busyNotice && ( {busyNotice && (
<div <div
role="status" role="status"

View File

@ -0,0 +1,108 @@
/**
* F1 — {@link MediatedInput} wired to {@link MockInputGateway} through the real
* {@link DIProvider}. Asserts the mediated-input contract (cadrage §4.2/§6):
*
* - "Envoyer" routes to `InputGateway.submit` with the right args;
* - "Interrompre" routes to `InputGateway.interrupt`;
* - while busy, "Envoyer" is dimmed/aria-disabled but the enqueue STILL goes
* through (forward/fallback — never block the user), and "Interrompre" stays
* active.
*
* Fully offline: gateways are mocks, no backend.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockInputGateway, MockSystemGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { MediatedInput } from "./MediatedInput";
function renderInput(
input: MockInputGateway,
props: Partial<React.ComponentProps<typeof MediatedInput>> = {},
) {
const gateways = {
input,
system: new MockSystemGateway(),
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<MediatedInput projectId="p1" agentId="a1" {...props} />
</DIProvider>,
);
}
describe("MediatedInput (with MockInputGateway)", () => {
it("mounts and renders the input strip", () => {
renderInput(new MockInputGateway());
expect(screen.getByTestId("mediated-input")).toBeTruthy();
});
it("Envoyer routes to gateway.submit with project/agent/text", async () => {
const input = new MockInputGateway();
renderInput(input);
fireEvent.change(screen.getByLabelText("Message à l'agent"), {
target: { value: "hello agent" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => {
expect(input.submits).toEqual([
{ projectId: "p1", agentId: "a1", text: "hello agent" },
]);
});
// No interrupt was triggered.
expect(input.interrupts).toEqual([]);
});
it("does not submit blank/whitespace-only text", async () => {
const input = new MockInputGateway();
renderInput(input);
fireEvent.change(screen.getByLabelText("Message à l'agent"), {
target: { value: " " },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
expect(input.submits).toEqual([]);
});
it("Interrompre routes to gateway.interrupt (not an enqueue)", async () => {
const input = new MockInputGateway();
renderInput(input);
fireEvent.click(screen.getByRole("button", { name: "Interrompre" }));
await waitFor(() => {
expect(input.interrupts).toEqual([{ projectId: "p1", agentId: "a1" }]);
});
expect(input.submits).toEqual([]);
});
it("while busy: Envoyer is aria-disabled but the enqueue still goes through, Interrompre stays active", async () => {
const input = new MockInputGateway();
renderInput(input, { busy: true });
const send = screen.getByRole("button", { name: "Envoyer" });
const interrupt = screen.getByRole("button", { name: "Interrompre" });
// Visually disabled (forward/fallback: dimmed, not hard-disabled).
expect(send.getAttribute("aria-disabled")).toBe("true");
// Interrompre is never disabled.
expect(interrupt.hasAttribute("disabled")).toBe(false);
// The enqueue path is NOT blocked while busy.
fireEvent.change(screen.getByLabelText("Message à l'agent"), {
target: { value: "queued while busy" },
});
fireEvent.click(send);
await waitFor(() => {
expect(input.submits).toEqual([
{ projectId: "p1", agentId: "a1", text: "queued while busy" },
]);
});
});
});

View File

@ -0,0 +1,93 @@
/**
* MediatedInput (lot F1, cadrage §4.2/§4.3).
*
* The mediated input strip rendered **under** an agent cell's terminal. Human
* keystrokes for an agent no longer go straight to the PTY — they are typed here
* and routed through the {@link InputGateway} port:
*
* - **Envoyer** → `submit` (enqueue in the agent's single FIFO).
* - **Interrompre** → `interrupt` (preempt the current turn — NOT an enqueue).
*
* Busy semantics (forward/fallback, §4.2/§6): while the agent is `busy`,
* "Envoyer" is **disabled visually** but the enqueue path is never blocked — the
* backend FIFO is the single point that serialises. So `busy` only dims the
* button; "Interrompre" stays active so the user can always preempt.
*
* Hexagonal: this component talks to {@link InputGateway} via the DI provider,
* never to `invoke()` directly. The busy flag is supplied by the caller (fed by
* {@link useAgentBusy}); this keeps the component pure and easy to test.
*/
import { useState, type FormEvent } from "react";
import { Button, Input } from "@/shared";
import { useGateways } from "@/app/di";
export interface MediatedInputProps {
/** Owning project. */
projectId: string;
/** The agent this input strip targets. */
agentId: string;
/**
* Whether the agent is currently processing a turn. Dims "Envoyer" (without
* blocking the enqueue) and is irrelevant to "Interrompre" (always active).
* Defaults to `false`.
*/
busy?: boolean;
}
/** The mediated input strip for an agent cell. */
export function MediatedInput({
projectId,
agentId,
busy = false,
}: MediatedInputProps) {
const { input } = useGateways();
const [text, setText] = useState("");
const send = async () => {
const value = text;
if (value.trim() === "") return;
// Clear optimistically: the enqueue is fire-and-forward; never block the
// user (forward/fallback). Even while busy the submit goes through.
setText("");
await input.submit(projectId, agentId, value);
};
const onSubmit = (e: FormEvent) => {
e.preventDefault();
void send();
};
const onInterrupt = () => {
void input.interrupt(projectId, agentId);
};
return (
<form
data-testid="mediated-input"
className="flex items-center gap-2 p-2 border-t border-border bg-surface"
onSubmit={onSubmit}
>
<Input
aria-label="Message à l'agent"
placeholder={busy ? "Agent occupé — sera mis en file…" : "Message…"}
value={text}
onChange={(e) => setText(e.target.value)}
/>
<Button
type="submit"
variant="primary"
// Visually disabled while busy, but the enqueue path stays open: the
// user can still press Enter to forward into the FIFO (§4.2/§6).
aria-disabled={busy || undefined}
className={busy ? "opacity-50" : undefined}
>
Envoyer
</Button>
<Button type="button" variant="danger" onClick={onInterrupt}>
Interrompre
</Button>
</form>
);
}

View File

@ -3,7 +3,11 @@
* {@link TerminalGateway} port (or a custom opener), and fits it to its container: * {@link TerminalGateway} port (or a custom opener), and fits it to its container:
* *
* - PTY output (gateway `onData`) → `term.write(bytes)`. * - PTY output (gateway `onData`) → `term.write(bytes)`.
* - xterm `onData` (keystrokes) → `handle.write(bytes)`. * - xterm `onData` (keystrokes) → `handle.write(bytes)` **only for a plain
* (non-agent) cell** (a raw shell). In **agent mode** (`agentMode`, lot F2,
* cadrage §4.2) keystrokes are NOT written to the PTY: input is mediated by
* IdeA through {@link MediatedInput}. The PTY **output** path stays live and
* INCHANGED in both modes (xterm is always the raw output view).
* - container resize (fit addon) → `handle.resize(rows, cols)`. * - container resize (fit addon) → `handle.resize(rows, cols)`.
* *
* Pure presentation: it only knows the port, never `invoke()`/`Channel` * Pure presentation: it only knows the port, never `invoke()`/`Channel`
@ -71,6 +75,15 @@ interface TerminalViewProps {
* reattach (the id is already known). * reattach (the id is already known).
*/ */
onSessionId?: (sessionId: string) => void; onSessionId?: (sessionId: string) => void;
/**
* Agent mode (lot F2, cadrage §4.2). When `true` the cell hosts an agent, so
* keystrokes (`term.onData`) are **not** forwarded to the PTY — input is
* mediated by IdeA through {@link MediatedInput} rendered under the terminal.
* The PTY **output** stays live and unchanged (xterm remains the raw output
* view). When `false`/absent the cell is a plain shell: keystrokes go straight
* to the PTY (current behaviour). Defaults to `false`.
*/
agentMode?: boolean;
} }
export function TerminalView({ export function TerminalView({
@ -79,6 +92,7 @@ export function TerminalView({
reattach, reattach,
sessionId, sessionId,
onSessionId, onSessionId,
agentMode = false,
}: TerminalViewProps) { }: TerminalViewProps) {
const { terminal } = useGateways(); const { terminal } = useGateways();
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@ -100,6 +114,8 @@ export function TerminalView({
onSessionIdRef.current = onSessionId; onSessionIdRef.current = onSessionId;
const terminalRef = useRef(terminal); const terminalRef = useRef(terminal);
terminalRef.current = terminal; terminalRef.current = terminal;
const agentModeRef = useRef(agentMode);
agentModeRef.current = agentMode;
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@ -135,9 +151,14 @@ export function TerminalView({
let handle: TerminalHandle | null = null; let handle: TerminalHandle | null = null;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
// Buffer keystrokes that arrive before the PTY finished opening. // Keystroke → PTY path. In **agent mode** (F2) keystrokes are mediated by
// IdeA (MediatedInput) and must NOT reach the PTY here; we drop them so the
// raw terminal is output-only for the human. In **plain mode** keystrokes go
// straight to the PTY (raw shell), buffering any that arrive before the PTY
// finished opening. The output path below is unchanged in both modes.
let pending = ""; let pending = "";
const onKey = term.onData((data) => { const onKey = term.onData((data) => {
if (agentModeRef.current) return;
if (handle) void handle.write(encoder.encode(data)); if (handle) void handle.write(encoder.encode(data));
else pending += data; else pending += data;
}); });

View File

@ -2,3 +2,7 @@
export { TerminalView } from "./TerminalView"; export { TerminalView } from "./TerminalView";
export { ResumeConversationPopup } from "./ResumeConversationPopup"; export { ResumeConversationPopup } from "./ResumeConversationPopup";
export { MediatedInput } from "./MediatedInput";
export type { MediatedInputProps } from "./MediatedInput";
export { useAgentBusy } from "./useAgentBusy";
export type { AgentBusyMap } from "./useAgentBusy";

View File

@ -0,0 +1,80 @@
/**
* F1 — {@link useAgentBusy} fed by `agentBusyChanged` domain events through the
* mock {@link MockSystemGateway}. Asserts the busy store tracks per-agent state
* and that a busy agent drives the {@link MediatedInput} button states.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockInputGateway, MockSystemGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { MediatedInput } from "./MediatedInput";
import { useAgentBusy } from "./useAgentBusy";
/** Tiny harness: renders the input with `busy` derived from the store. */
function BusyHarness({ agentId }: { agentId: string }) {
const busy = useAgentBusy();
return (
<MediatedInput projectId="p1" agentId={agentId} busy={busy[agentId] ?? false} />
);
}
function renderHarness(system: MockSystemGateway, agentId = "a1") {
const gateways = {
input: new MockInputGateway(),
system,
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<BusyHarness agentId={agentId} />
</DIProvider>,
);
}
describe("useAgentBusy (with MockSystemGateway events)", () => {
it("starts idle: Envoyer is not aria-disabled", () => {
renderHarness(new MockSystemGateway());
const send = screen.getByRole("button", { name: "Envoyer" });
expect(send.getAttribute("aria-disabled")).toBeNull();
});
it("an agentBusyChanged(true) event dims Envoyer; (false) re-enables it", async () => {
const system = new MockSystemGateway();
renderHarness(system, "a1");
act(() => {
system.emit({ type: "agentBusyChanged", agentId: "a1", busy: true });
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Envoyer" }).getAttribute("aria-disabled"),
).toBe("true");
});
act(() => {
system.emit({ type: "agentBusyChanged", agentId: "a1", busy: false });
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Envoyer" }).getAttribute("aria-disabled"),
).toBeNull();
});
});
it("a busy event for another agent does not affect this cell", async () => {
const system = new MockSystemGateway();
renderHarness(system, "a1");
act(() => {
system.emit({ type: "agentBusyChanged", agentId: "other", busy: true });
});
// Give the effect a tick; this cell (a1) must stay idle.
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Envoyer" }).getAttribute("aria-disabled"),
).toBeNull();
});
});
});

View File

@ -0,0 +1,58 @@
/**
* Light per-agent busy store (lot F1, cadrage §4.2/§4.3).
*
* Keeps `agentBusy: Record<agentId, boolean>` fed by the discrete
* `agentBusyChanged` domain event relayed from the backend (a real event, not a
* high-frequency channel). The component consumes this to *disable* the "Envoyer"
* button visually while still allowing the enqueue (forward/fallback, §6) and to
* keep "Interrompre" active.
*
* For F1 the backend event (lot C4) may not yet be emitted; until then the store
* simply stays all-idle, and tests drive it through the mock `SystemGateway`.
*/
import { useEffect, useState } from "react";
import type { DomainEvent } from "@/domain";
import { useGateways } from "@/app/di";
/** A read-only map of agentId → busy. Absent key ⇒ idle. */
export type AgentBusyMap = Readonly<Record<string, boolean>>;
/**
* Subscribes to `agentBusyChanged` domain events and returns the current
* busy map. Unsubscribes on unmount.
*/
export function useAgentBusy(): AgentBusyMap {
const { system } = useGateways();
const [busy, setBusy] = useState<Record<string, boolean>>({});
useEffect(() => {
// The system gateway may be absent in some compositions/tests; stay all-idle
// rather than throw (the busy map is a best-effort overlay).
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event: DomainEvent) => {
if (event.type !== "agentBusyChanged") return;
setBusy((prev) =>
prev[event.agentId] === event.busy
? prev
: { ...prev, [event.agentId]: event.busy },
);
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [system]);
return busy;
}

View File

@ -31,7 +31,6 @@ import type {
MemoryType, MemoryType,
Project, Project,
ProfileAvailability, ProfileAvailability,
ReplyChunk,
ResumableAgent, ResumableAgent,
Skill, Skill,
SkillScope, SkillScope,
@ -166,40 +165,6 @@ export interface AgentGateway {
agentId: string, agentId: string,
conversationId: string, conversationId: string,
): Promise<ConversationDetails>; ): Promise<ConversationDetails>;
/**
* Sends a prompt to a live **structured** (chat) session and streams the
* reply turn back through `onReply` (ARCHITECTURE §17.7, command `agent_send`).
* Each {@link ReplyChunk} is delivered as it arrives: `textDelta`s accumulate
* into the current turn, `toolActivity` shows tool badges, and the terminal
* `final` chunk freezes the turn. The chat twin of a PTY `write`. Resolves once
* the turn stream is wired (not when the turn completes).
*/
sendPrompt(
sessionId: string,
prompt: string,
onReply: (chunk: ReplyChunk) => void,
): Promise<void>;
/**
* Re-attaches a chat view to an **already-living** structured session without
* re-sending or re-spawning it (ARCHITECTURE §17.7, command
* `reattach_agent_chat`). Returns the retained conversation scrollback (the
* chunks already streamed) so the view repaints its prior turns; subsequent
* chunks then arrive over `onReply`. The chat twin of {@link reattach}.
*
* Rejects if no live structured session owns the id (the caller then opens
* fresh).
*/
reattachChat(
sessionId: string,
onReply: (chunk: ReplyChunk) => void,
): Promise<ReplyChunk[]>;
/**
* Shuts a live structured session down and tears its transport (channel +
* conversation scrollback) down (ARCHITECTURE §17.7, command
* `close_agent_session`). The chat twin of {@link TerminalGateway.closeTerminal};
* reserved for an explicit user action — navigation must never call this.
*/
closeAgentSession(sessionId: string): Promise<void>;
} }
/** Options for opening a terminal. */ /** Options for opening a terminal. */
@ -257,14 +222,6 @@ export interface TerminalHandle {
* leaf so the next open resumes instead of re-assigning. * leaf so the next open resumes instead of re-assigning.
*/ */
readonly assignedConversationId?: string; readonly assignedConversationId?: string;
/**
* How the cell hosting this session should render (ARCHITECTURE §17.6).
* Present on handles returned by {@link AgentGateway.launchAgent}: `"chat"`
* routes the leaf to an `AgentChatView`, `"pty"` (the default) to a raw
* {@link TerminalView}. `undefined` on plain-terminal handles ⇒ treated as
* `"pty"`.
*/
readonly cellKind?: import("@/domain").CellKind;
/** Sends bytes (xterm keystrokes) to the PTY. */ /** Sends bytes (xterm keystrokes) to the PTY. */
write(data: Uint8Array): Promise<void>; write(data: Uint8Array): Promise<void>;
/** Resizes the PTY. */ /** Resizes the PTY. */
@ -529,6 +486,25 @@ export interface MemoryGateway {
): Promise<MemoryIndexEntry[]>; ): Promise<MemoryIndexEntry[]>;
} }
/**
* Mediated agent input (lot F1, cadrage §4.2). All human input to an *agent*
* cell flows through this port instead of being written straight to the PTY:
* "Envoyer" enqueues a task in the agent's single FIFO (`submit`), "Interrompre"
* preempts the current turn (`interrupt`). The component talks to this gateway,
* never to `invoke()` directly.
*
* Note (forward/fallback, §4.2/§6): `submit` must never be blocked client-side
* when the agent is busy — the UI may *disable the button visually* but the
* enqueue path stays available; the backend FIFO is the single point that
* serialises. So callers may still call `submit` while busy.
*/
export interface InputGateway {
/** Envoyer = enqueue: appends `text` to the agent's input FIFO. */
submit(projectId: string, agentId: string, text: string): Promise<void>;
/** Interrompre = preempt: signals the current turn to stop (not an enqueue). */
interrupt(projectId: string, agentId: string): Promise<void>;
}
/** /**
* AI profiles & first-run (L5). Drives the first-run wizard and profile * AI profiles & first-run (L5). Drives the first-run wizard and profile
* management: the pre-filled reference catalogue, detection of installed CLIs, * management: the pre-filled reference catalogue, detection of installed CLIs,
@ -581,6 +557,7 @@ export interface EmbedderGateway {
export interface Gateways { export interface Gateways {
system: SystemGateway; system: SystemGateway;
agent: AgentGateway; agent: AgentGateway;
input: InputGateway;
terminal: TerminalGateway; terminal: TerminalGateway;
project: ProjectGateway; project: ProjectGateway;
layout: LayoutGateway; layout: LayoutGateway;