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:
3
.gitignore
vendored
3
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
515
.ideai/briefs/conversation-pair-cadrage.md
Normal file
515
.ideai/briefs/conversation-pair-cadrage.md
Normal 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.*
|
||||||
48
.ideai/briefs/option1-terminal-mcp-design.md
Normal file
48
.ideai/briefs/option1-terminal-mcp-design.md
Normal 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).
|
||||||
35
.ideai/briefs/validation-reelle-inter-agents.md
Normal file
35
.ideai/briefs/validation-reelle-inter-agents.md
Normal 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`.
|
||||||
@ -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
5
.ideai/memory/MEMORY.md
Normal 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.
|
||||||
106
.ideai/memory/agent-context-memory-and-profile-handoff.md
Normal file
106
.ideai/memory/agent-context-memory-and-profile-handoff.md
Normal 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.
|
||||||
206
.ideai/memory/idea-product-directives-main-handoff.md
Normal file
206
.ideai/memory/idea-product-directives-main-handoff.md
Normal 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.
|
||||||
273
.ideai/memory/remaining-work-idea-agent-control-ide.md
Normal file
273
.ideai/memory/remaining-work-idea-agent-control-ide.md
Normal 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".
|
||||||
@ -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).
|
||||||
///
|
///
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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é");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,8 +559,17 @@ 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
|
||||||
|
// structurée. La vue humaine d'un agent est désormais le **terminal brut
|
||||||
|
// natif** (PTY interactif) — réflexion live + Échap natifs CLI, zéro parsing —
|
||||||
|
// et la délégation inter-agents passe par les outils MCP (`idea_ask_agent` /
|
||||||
|
// `idea_reply`), pas par une `AgentChatView`. Sans `with_structured`, le point
|
||||||
|
// de routage §17.4 de `LaunchAgent::execute` retombe **toujours** sur le chemin
|
||||||
|
// PTH : tout profil (Claude/Codex inclus) ouvre une cellule terminal. Le code
|
||||||
|
// `launch_structured` reste en place (mort-code retiré au lot de nettoyage B-6).
|
||||||
|
let _session_factory = session_factory; // décâblé en B-2 (nettoyage B-6)
|
||||||
|
let launch_agent = Arc::new(LaunchAgent::new(
|
||||||
Arc::clone(&contexts_port),
|
Arc::clone(&contexts_port),
|
||||||
Arc::clone(&profile_store_port),
|
Arc::clone(&profile_store_port),
|
||||||
Arc::clone(&runtime_port),
|
Arc::clone(&runtime_port),
|
||||||
@ -571,14 +581,7 @@ impl AppState {
|
|||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
Arc::clone(&memory_recall_port),
|
Arc::clone(&memory_recall_port),
|
||||||
Some(Arc::clone(&check_embedder_suggestion)),
|
Some(Arc::clone(&check_embedder_suggestion)),
|
||||||
)
|
));
|
||||||
// Routage structuré §17.4 : un profil avec `structured_adapter` lance une
|
|
||||||
// AgentSession (cellule chat) au lieu d'un PTY ; sinon chemin PTY inchangé.
|
|
||||||
.with_structured(
|
|
||||||
Arc::clone(&session_factory),
|
|
||||||
Arc::clone(&structured_sessions),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
819
crates/application/src/orchestrator/context_guard.rs
Normal file
819
crates/application/src/orchestrator/context_guard.rs
Normal 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, ¬e).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a [`GuardError`] onto the application error shape. `Forbidden` is an invariant
|
||||||
|
/// violation ([`AppError::Invalid`]); `Busy` is a transient contention the cooperative
|
||||||
|
/// blocking adapter never returns, but is mapped for completeness.
|
||||||
|
fn map_guard_err(e: GuardError) -> AppError {
|
||||||
|
match e {
|
||||||
|
GuardError::Forbidden => AppError::Invalid(
|
||||||
|
"writing the global project context is reserved to the orchestrator; propose instead"
|
||||||
|
.to_owned(),
|
||||||
|
),
|
||||||
|
GuardError::Busy => AppError::Invalid("guarded resource is busy".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::agent::{AgentManifest, ManifestEntry};
|
||||||
|
use domain::conversation::ConversationParty;
|
||||||
|
use domain::fileguard::{may_write_directly, ReadLease, WriteLease};
|
||||||
|
use domain::ports::{FsError, MemoryError, StoreError};
|
||||||
|
use domain::project::ProjectPath;
|
||||||
|
use domain::{ProfileId, ProjectId, RemoteRef};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc as StdArc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
fn project() -> Project {
|
||||||
|
Project::new(
|
||||||
|
ProjectId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||||
|
"demo",
|
||||||
|
ProjectPath::new("/tmp/demo").unwrap(),
|
||||||
|
RemoteRef::local(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-test [`FileGuard`] mirroring `infrastructure::RwFileGuard` (one tokio
|
||||||
|
/// `RwLock` per resource + the single-writer rule), so the application crate
|
||||||
|
/// stays free of any dependency on infrastructure (no dependency cycle).
|
||||||
|
#[derive(Default)]
|
||||||
|
struct TestGuard {
|
||||||
|
locks: Mutex<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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};
|
||||||
|
|||||||
@ -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,7 +320,133 @@ 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
|
||||||
@ -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(
|
||||||
|
"la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \
|
||||||
|
médiateur d'entrée non câblé"
|
||||||
.to_owned(),
|
.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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Pas de session structurée. Si l'agent est vivant en **PTY** (terminal
|
/// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path.
|
||||||
// brut), il n'est pas adressable en `ask` ⇒ erreur typée explicite (pas
|
///
|
||||||
// d'ACK « launched »).
|
/// The operator types into IdeA's mediated input; this resolves the `User↔Agent`
|
||||||
if self.sessions.session_for_agent(&agent_id).is_some() {
|
/// thread, ensures the target is live in the PTY registry, binds its handle on the
|
||||||
return Err(AppError::Invalid(format!(
|
/// mediator, and **enqueues** a `Ticket::from_human` into the **same FIFO** the
|
||||||
"agent {target} n'est pas pilotable en mode structuré \
|
/// inter-agent delegations use (`InputMediator::enqueue`) — so a human submit and a
|
||||||
(session terminal brut, pas de canal de réponse)"
|
/// 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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Cible morte : on la lance en mode structuré (background) puis on envoie.
|
/// Interrupt (cadrage C4 §5.3) — the **Interrompre** path (Échap/stop).
|
||||||
let launched = self
|
///
|
||||||
.launch_agent
|
/// 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}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
input.preempt(agent_id);
|
||||||
|
|
||||||
|
Ok(OrchestratorOutcome {
|
||||||
|
detail: format!("interrupted agent {agent_id}"),
|
||||||
|
reply: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the conversation thread id for an ask: `A↔B` when an agent requests,
|
||||||
|
/// else `User↔B` (cadrage C3 §5.2). Without a wired registry, falls back to a
|
||||||
|
/// stable per-agent id derived from the target (legacy routing — never panics).
|
||||||
|
fn resolve_conversation(
|
||||||
|
&self,
|
||||||
|
requester: Option<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead target: launch it in the background (PTY). On injecte ici la déclaration
|
||||||
|
// MCP **réelle** via le [`McpRuntimeProvider`] câblé (app-tauri détient l'exe et
|
||||||
|
// l'endpoint) — c'est ce qui permet au pont MCP de la cible de se spawner et donc
|
||||||
|
// à la cible d'appeler `idea_reply`. Provider absent (ou `runtime_for` → `None`)
|
||||||
|
// ⇒ déclaration minimale comme avant (dégradation gracieuse).
|
||||||
|
self.launch_agent
|
||||||
.execute(LaunchAgentInput {
|
.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é \
|
|
||||||
(profil sans adaptateur structuré)"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// La session vient d'être enregistrée par LaunchAgent : on la récupère par
|
|
||||||
// agent (invariant « 1 session/agent » ⇒ non ambigu).
|
|
||||||
let session = structured.session_for_agent(&agent_id).ok_or_else(|| {
|
|
||||||
AppError::Process(format!(
|
AppError::Process(format!(
|
||||||
"session structurée de l'agent {target} introuvable après lancement"
|
"agent {target} n'a pas de session terminal vivante après lancement"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?;
|
// Lier la session fraîchement lancée à CE fil (registre terminal + registre de
|
||||||
Ok(self.reply_outcome(agent_id, &target, content))
|
// 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"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binds `conversation` to `session` in both the terminal registry (fast
|
||||||
|
/// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the
|
||||||
|
/// latter is wired. Idempotent.
|
||||||
|
fn bind_conversation_session(
|
||||||
|
&self,
|
||||||
|
conversation: domain::conversation::ConversationId,
|
||||||
|
session: domain::SessionId,
|
||||||
|
) {
|
||||||
|
self.sessions.bind_conversation(conversation, session);
|
||||||
|
if let Some(reg) = &self.conversations {
|
||||||
|
reg.bind_session(conversation, SessionRef::new(session));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a human-friendly label for the **requesting** agent to prefix the
|
||||||
|
/// delegated task with. Best-effort: there is no requester id threaded into
|
||||||
|
/// [`OrchestratorCommand::AskAgent`] today, so this falls back to a stable
|
||||||
|
/// `"un autre agent"` label. Kept as a seam so a future requester-aware ask can
|
||||||
|
/// surface the real name without touching the call sites.
|
||||||
|
async fn requester_label(&self, project: &Project, requester: Option<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).
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
431
crates/domain/src/conversation.rs
Normal file
431
crates/domain/src/conversation.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|||||||
236
crates/domain/src/fileguard.rs
Normal file
236
crates/domain/src/fileguard.rs
Normal 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
203
crates/domain/src/input.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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};
|
||||||
|
|||||||
330
crates/domain/src/mailbox.rs
Normal file
330
crates/domain/src/mailbox.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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" }"#);
|
||||||
|
|||||||
@ -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> "));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
194
crates/infrastructure/src/conversation/mod.rs
Normal file
194
crates/infrastructure/src/conversation/mod.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
230
crates/infrastructure/src/fileguard/mod.rs
Normal file
230
crates/infrastructure/src/fileguard/mod.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
789
crates/infrastructure/src/input/mod.rs
Normal file
789
crates/infrastructure/src/input/mod.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
261
crates/infrastructure/src/mailbox/mod.rs
Normal file
261
crates/infrastructure/src/mailbox/mod.rs
Normal 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))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ask_server = server(Arc::clone(&service));
|
||||||
|
let ask = tokio::spawn(async move {
|
||||||
let raw = tools_call(
|
let raw = tools_call(
|
||||||
7,
|
7,
|
||||||
"idea_ask_agent",
|
"idea_ask_agent",
|
||||||
json!({ "target": "architect", "task": "What is the answer?" }),
|
json!({ "target": "architect", "task": "What is the answer?" }),
|
||||||
);
|
);
|
||||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
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" }));
|
||||||
|
|||||||
@ -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!(
|
||||||
|
|||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
33
frontend/src/adapters/input.ts
Normal file
33
frontend/src/adapters/input.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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,22 +472,8 @@ 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
|
|
||||||
// stream is driven by `sendPrompt`; the returned handle is mostly inert
|
|
||||||
// (no bytes), but its `close` shuts the structured session down.
|
|
||||||
const chat = new MockChatSession(sessionId);
|
|
||||||
this.chatSessions.set(sessionId, chat);
|
|
||||||
handle = {
|
|
||||||
...makeInertHandle(sessionId, () => {
|
|
||||||
chat.shutdown();
|
|
||||||
this.chatSessions.delete(sessionId);
|
|
||||||
clearLive();
|
|
||||||
}),
|
|
||||||
cellKind,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
const session = new MockPtySession(sessionId, onData);
|
const session = new MockPtySession(sessionId, onData);
|
||||||
this.sessions.set(sessionId, session);
|
this.sessions.set(sessionId, session);
|
||||||
@ -573,14 +481,10 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
queueMicrotask(() =>
|
queueMicrotask(() =>
|
||||||
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
||||||
);
|
);
|
||||||
handle = {
|
const handle: TerminalHandle = makeMockHandle(session, () => {
|
||||||
...makeMockHandle(session, () => {
|
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
clearLive();
|
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(),
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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";
|
|
||||||
@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
239
frontend/src/features/layout/LayoutGrid.f2.test.tsx
Normal file
239
frontend/src/features/layout/LayoutGrid.f2.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,26 +551,29 @@ 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,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
) : (
|
<div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0 }}>
|
||||||
<TerminalView
|
<TerminalView
|
||||||
key={`${id}-${agentId ?? "plain"}`}
|
key={`${id}-${agentId ?? "plain"}`}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
@ -623,8 +581,17 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
|||||||
reattach={reattachOpener}
|
reattach={reattachOpener}
|
||||||
sessionId={session}
|
sessionId={session}
|
||||||
onSessionId={(sid) => void vm.setSession(id, sid)}
|
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"
|
||||||
|
|||||||
108
frontend/src/features/terminals/MediatedInput.test.tsx
Normal file
108
frontend/src/features/terminals/MediatedInput.test.tsx
Normal 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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
93
frontend/src/features/terminals/MediatedInput.tsx
Normal file
93
frontend/src/features/terminals/MediatedInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
80
frontend/src/features/terminals/useAgentBusy.test.tsx
Normal file
80
frontend/src/features/terminals/useAgentBusy.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
58
frontend/src/features/terminals/useAgentBusy.ts
Normal file
58
frontend/src/features/terminals/useAgentBusy.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user