chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le support de la délégation inter-agents pour les profils Codex. Le round-trip inter-agent question/réponse est couvert sans tokens par les tests loopback existants (state::mcp_e2e_loopback_tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -35,6 +35,20 @@
|
||||
"mdPath": "agents/qa.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
|
||||
"name": "TestConversation",
|
||||
"mdPath": "agents/testconversation.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "484eff91-60a1-459f-9ebe-c9552cc70447",
|
||||
"name": "NewTest",
|
||||
"mdPath": "agents/newtest.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
0
.ideai/agents/newtest.md
Normal file
0
.ideai/agents/newtest.md
Normal file
0
.ideai/agents/testconversation.md
Normal file
0
.ideai/agents/testconversation.md
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,9 @@
|
||||
---
|
||||
upTo: 88f405da-f9c9-44bb-96c9-7e1b0e5af557
|
||||
objective: Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
---
|
||||
**Objectif :** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
|
||||
- **Prompt:** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
- **Prompt:** Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA.
|
||||
- **Response:** (1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅
|
||||
@ -0,0 +1,3 @@
|
||||
{"id":"20dd8bdc-f858-46a6-9c8a-6afde591a6bd","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781367769591,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?"}
|
||||
{"id":"0b8fcc51-c692-4928-9d90-3823857dad5c","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368607174,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA."}
|
||||
{"id":"88f405da-f9c9-44bb-96c9-7e1b0e5af557","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368615582,"source":{"kind":"agent","agentId":"c932c770-cf36-4fb2-a966-71bb1644e4b4"},"role":"response","text":"(1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅"}
|
||||
@ -1,37 +1,56 @@
|
||||
{
|
||||
"version": 1,
|
||||
"activeId": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5",
|
||||
"activeId": "1af250f0-65ef-4b78-8905-b1746673aee0",
|
||||
"layouts": [
|
||||
{
|
||||
"id": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5",
|
||||
"id": "1af250f0-65ef-4b78-8905-b1746673aee0",
|
||||
"name": "Default",
|
||||
"kind": "terminal",
|
||||
"tree": {
|
||||
"root": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "1eb96c70-e954-42d0-907b-f8528d0442bf",
|
||||
"id": "56ffe1e4-636c-458d-9ab2-7e278fd45897",
|
||||
"direction": "row",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "db40e3de-4980-4bed-b6fa-1c136f49d30e",
|
||||
"session": "d7d4c099-eda3-4b33-82e6-e6f8d57b8b97",
|
||||
"id": "c3319a9a-1345-4fa2-b64e-5f3fe00d13d8",
|
||||
"session": "4965c71a-f69f-4c06-90de-ecb81acff710",
|
||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "8cf1c06e-2654-45a3-bf17-a9c2507da935",
|
||||
"direction": "column",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "69dc2e23-86f5-4770-84c4-b1b4b2c25299",
|
||||
"session": "11fbf6b4-eb62-4420-95e9-feb2ff667c43",
|
||||
"agent": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "92826533-1a7c-4d9e-b6b4-f1e904ddc81a",
|
||||
"session": "5d20f53a-ac76-4677-9a6f-064d3319cc73",
|
||||
"agent": "edce8090-4c57-47c5-a319-c08fd172438b",
|
||||
"id": "b9251e74-3bd5-43ee-90e5-e6bb87faab38",
|
||||
"session": "69a3bf52-05ef-45c0-badf-26b0d8224f0e",
|
||||
"agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
@ -39,6 +58,11 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,3 +3,4 @@
|
||||
- [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.
|
||||
- [mcp-bridge-and-delegation-runtime-notes](mcp-bridge-and-delegation-runtime-notes.md) — Pieges runtime du pont MCP/delegation et regle de rebuild de l'AppImage (binaire qui tourne = AppImage, pas les sources).
|
||||
|
||||
24
.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md
Normal file
24
.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md
Normal file
@ -0,0 +1,24 @@
|
||||
---
|
||||
name: mcp-bridge-and-delegation-runtime-notes
|
||||
description: Pieges runtime du pont MCP et de la delegation inter-agents IdeA, et la regle de rebuild de l'AppImage.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Pont MCP & delegation : pieges runtime
|
||||
|
||||
Deux bugs trouves le 2026-06-13 en testant la conversation inter-agents (`idea_ask_agent` vers TestConversation), tous deux corriges. Notes utiles pour ne pas reperdre du temps :
|
||||
|
||||
## Le binaire qui tourne = AppImage installee, pas les sources
|
||||
L'IdeA en cours d'utilisation est `/home/anthony/Documents/IdeA_0.1.0_amd64.AppImage`. **Ce meme binaire sert a la fois de serveur orchestrateur (il tient `OrchestratorService`/`InputMediator` et compose les evenements) ET de binaire-pont** (`<exe> mcp-server …` declare dans chaque `.ideai/run/<id>/.mcp.json`). Donc **tout correctif cote serveur ou cote pont n'est actif dans l'app que apres rebuild + reinstall de l'AppImage et relance d'IdeA**. Un binaire `target/debug` fraichement compile ne valide que le pont (qui parle au serveur via le socket) ; il ne valide pas la composition cote serveur.
|
||||
**Comment appliquer :** apres une correction backend, rebuild AppImage (`npm --prefix frontend run build` puis `frontend/node_modules/.bin/tauri build --bundles appimage`), remplacer l'AppImage, relancer IdeA, puis retester.
|
||||
|
||||
## Env AppImage pollue le shell
|
||||
La session shell herite des variables de l'AppImage montee (`APPDIR`, `LD_LIBRARY_PATH`, `PYTHONHOME` -> `/tmp/.mount_IdeA_*`). Consequences : `python3` casse (`No module named 'encodings'`) et lancer un binaire app-tauri fraichement compile tente de booter WebKit et crash. **Workaround :** lancer avec un env propre (`env -i PATH=/usr/bin:/bin HOME=$HOME XDG_RUNTIME_DIR=/run/user/1000 ...`), utiliser `jq` plutot que python.
|
||||
|
||||
## Bug 1 — pont MCP en lockstep (corrige)
|
||||
`mcp_bridge.rs::relay` lisait 1 ligne client -> attendait 1 reponse loopback, en boucle. MCP n'est pas 1:1 : `notifications/initialized` n'a pas de reponse => deadlock juste apres `initialize`, et `tools/list` n'etait jamais relaye => les outils `idea_*` ne se chargeaient jamais (`claude mcp list` affichait quand meme "Connected" car `initialize` repond). Corrige en relay **full-duplex** (deux pompes concurrentes) + drain borne (`DRAIN_GRACE`) a la fermeture stdin. Symptome cote utilisateur : 3 jours de "outils MCP pas charges".
|
||||
|
||||
## Bug 2 — prefixe de delegation perdu (corrige)
|
||||
Le signal `[IdeA · tâche de <demandeur> · ticket <id>]` qui dit a l'agent cible "reponds via `idea_reply`, jamais en texte" n'etait plus compose nulle part : supprime du backend (`service.rs` C3 §5.1) lors du passage de l'ecriture PTY au frontend, mais le frontend (`useWritePortal.ts`) ecrit `head.text` verbatim et ne l'ajoutait pas. La cible recevait la tache brute, repondait en texte => `idea_ask_agent` timeout. Corrige en composant le prefixe dans `infrastructure/src/input/mod.rs` (`delegation_preamble`) a l'emission de `DomainEvent::DelegationReady` ; la tache brute reste dans le `Ticket`/historique. Rappel : la correlation `idea_reply` marche par ticket OU par tete de FIFO (fallback), donc le ticket echo est recommande mais pas strictement requis.
|
||||
|
||||
See also [[remaining-work-idea-agent-control-ide]], [[agent-context-memory-and-profile-handoff]].
|
||||
133
ARCHITECTURE.md
133
ARCHITECTURE.md
@ -2027,4 +2027,137 @@ Avant correction, `launch_structured` persistait l'**id moteur (2)** sur `LeafCe
|
||||
|
||||
---
|
||||
|
||||
## 20. Terminal natif + portail d'écriture unique (cadrage — remplace la barre `MediatedInput`)
|
||||
|
||||
> **Cadrage architecture** d'une feature **déjà décidée** (cf. décision produit « terminal natif »). Produit la frontière, les contrats (ports/events/DTO/profil), le découpage en lots testables et les risques. **Aucun code applicatif ici.**
|
||||
|
||||
### 20.1 Problème & décision produit (rappel, ne pas rediscuter)
|
||||
- **Bug.** En mode agent, l'humain tape dans une barre IdeA séparée (`MediatedInput.tsx`) ; la livraison d'une délégation écrit dans le PTY un texte terminé par `\n` (`MediatedInbox::enqueue`, `infrastructure/src/input/mod.rs:307-311`). Résultat : le texte se dépose dans le prompt de la TUI mais **n'est jamais soumis** (`\n` ≠ Entrée en raw-mode ; la détection de paste absorbe le `\n`). De plus barre IdeA + prompt natif = « double chat ».
|
||||
- **Décision.** La cellule agent héberge la CLI comme **vrai terminal** : **toutes** les frappes (Entrée comprise) vont à la CLI ; on garde le chrome natif. On **supprime** `MediatedInput`. **Pas d'interception d'Entrée** (ambiguë en TUI). IdeA n'**observe** qu'un compteur « ligne humaine en cours » (+1 sur imprimable, reset sur Entrée/Ctrl-C). **Sérialisation par un portail d'écriture unique** vers le PTY : deux écrivains (frappes humaines natives + médiateur IdeA pour les délégations). Une délégation n'est livrée qu'à une **frontière propre = prompt-ready ET ligne humaine vide** ; sinon elle **patiente** (jamais de refus). Invariant inchangé : **1 agent = 1 employé = 1 session CLI** (la « conversation par paire » reste un cloisonnement **logique**, pas des sessions séparées).
|
||||
|
||||
### 20.2 Décision d'architecture — où vit le portail, qui écrit
|
||||
|
||||
**Le portail d'écriture unique vit côté FRONTEND** (le détenteur du terminal). Le backend **décide quand** une délégation est prête et **publie l'intention** ; le frontend, seul détenteur de xterm + des frappes + du compteur de ligne + de l'overlay, **exécute le handshake** (b→e) et **écrit le texte + `\r`** via la **même** `TerminalHandle.write` que les frappes humaines. Ainsi il n'existe **qu'un seul écrivain effectif du PTY** (le front) : pas de course entre deux écrivains physiques, le portail est un mutex **logique** dans la cellule. Le backend conserve son rôle d'**autorité de file/busy** (mailbox FIFO, prompt-ready watcher, `AgentBusyChanged`) mais **n'écrit plus le tour dans le PTY** — c'est le point dur tranché.
|
||||
|
||||
Justification hexagonale : la frontière reste nette. L'**application** (`OrchestratorService`) reste l'autorité métier de l'orchestration (file, corrélation par ticket, cycle, timeout) ; elle parle **ports** (`InputMediator`, `EventBus`) sans connaître le terminal. La **livraison physique** (octets vers le PTY) est un **détail d'I/O** qui appartient à l'adapter sortant — ici l'adapter **frontend** (la cellule), exactement comme les frappes humaines y vivent déjà. On **retire** au `MediatedInbox` la responsabilité d'écrire le PTY (violation SRP : il était à la fois moteur de file ET écrivain d'I/O brut) ; il redevient pur moteur de file/busy/corrélation. Le « double signal OR » prompt-ready/`idea_reply` et le `wait_for`/timeout restent **inchangés**.
|
||||
|
||||
```
|
||||
ask_agent / idea_ask_agent (MCP) idea_reply (MCP)
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────── OrchestratorService (application) ─────────────┐
|
||||
│ enqueue(ticket) → mailbox FIFO (corrélation) resolve_ticket → réveille ask │
|
||||
│ busy: Idle→Busy → AgentBusyChanged mark_idle (OR signal) │
|
||||
│ PLUS: publie DelegationReady{agent, ticket, text} ◄── NOUVEAU (ne PTY-écrit │
|
||||
└───────────────────────────────┬─────────────────────────────── plus le tour)┘
|
||||
│ EventBus → relais Tauri (event)
|
||||
▼
|
||||
┌──────────────────────── Cellule agent (frontend, détient le terminal) ───────┐
|
||||
│ TerminalView (agent natif): term.onData → handle.write [frappes humaines] │
|
||||
│ writePortal (mutex logique de la cellule): │
|
||||
│ • frappes humaines: write direct + maj compteur ligne (imprimable / reset) │
|
||||
│ • DelegationReady reçue → si prompt-ready & ligne vide → HANDSHAKE b→e: │
|
||||
│ (b) couper le relais frappes + overlay grisé « un agent parle » │
|
||||
│ (c) revérif compteur K ; si K>0 → \x7f ×K (backspaces) │
|
||||
│ (d) write(texte) puis write(submitSequence) après submitDelayMs │
|
||||
│ (e) réactiver + retirer overlay, PLANCHER 2 s depuis (b) │
|
||||
│ • sinon (busy / ligne non vide) → la délégation PATIENTE (file native CLI │
|
||||
│ empile ; la prochaine frontière propre la libère) │
|
||||
│ ack: input.deliveredDelegation(ticket) ── confirme la livraison au backend │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Pourquoi pas le backend ?** Le backend ne connaît ni l'état « ligne humaine en cours » (détenu par xterm côté front) ni l'overlay. Lui faire écrire le PTY impose un round-trip fragile (front→back « ligne vide ? », back→front « j'écris ») et **réintroduit deux écrivains physiques** du même PTY (le back via `PtyPort.write` + le front via `handle.write`) → exactement la classe de course que `terminal-input-accents-ordering` a déjà coûtée. Centraliser l'écriture côté front supprime la race par construction.
|
||||
|
||||
### 20.3 Contrats
|
||||
|
||||
**Profil (`crates/domain/src/profile.rs`) — fix Bug 1, déclaratif & model-agnostic.** Deux champs additifs sur `AgentProfile`, à côté de `prompt_ready_pattern`, sérialisés camelCase, `skip_serializing_if` ⇒ zéro régression :
|
||||
```rust
|
||||
/// Séquence de soumission écrite APRÈS le texte d'une délégation pour la
|
||||
/// faire valider par la CLI (esquive la détection de paste : texte sans `\n`,
|
||||
/// puis cette séquence seule après un court délai). Défaut `"\r"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_sequence: Option<String>, // None ⇒ défaut "\r" appliqué au point d'usage
|
||||
/// Délai (ms) entre l'écriture du texte et celle de `submit_sequence`.
|
||||
/// Défaut ~50–80 ms. Évite que la TUI absorbe la soumission comme un paste.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_delay_ms: Option<u32>, // None ⇒ défaut (p.ex. 60) au point d'usage
|
||||
```
|
||||
Withers additifs `with_submit_sequence`/`with_submit_delay_ms` (comme `with_prompt_ready_pattern`). Le **défaut** (`"\r"`, ~60 ms) est appliqué côté front (DTO `Option` → valeur effective), jamais codé en dur dans le domaine.
|
||||
|
||||
**Port domaine `InputMediator` (`crates/domain/src/input.rs`) — recentrage.** On **retire la sémantique d'écriture PTY** de `enqueue`/`bind_handle*` (la doc de `enqueue` ne promet plus la livraison physique) ; le médiateur reste autorité **file + busy + corrélation + prompt-watcher**. Pas de nouvelle méthode obligatoire : la livraison passe désormais par un **event** (ci-dessous). `bind_handle_with_prompt` **reste** (le prompt-ready watcher observe toujours le flux de sortie côté infra). `delivers_turn` devient inutile (toujours « le front délivre ») → marqué déprécié/`false`.
|
||||
|
||||
**Adapter infra `MediatedInbox` (`crates/infrastructure/src/input/mod.rs`).** `enqueue` **ne fait plus** le `pty.write(line)` (suppression du bloc 305-313, donc du `\n` band-aid). À la place, sur la transition qui **démarre le tour** (Idle→Busy) il publie un **nouvel event** `DelegationReady` portant le **texte de la tâche** + ticket + agent (le `BusyTracker`/`enqueue` a déjà l'`EventBus`). Le `preempt` (ESC) **reste** (interruption = octet de contrôle, légitime côté back ; il ne concourt pas avec une écriture de ligne). Le prompt-ready watcher **reste** identique.
|
||||
|
||||
**Nouvel event domaine `DomainEvent` (`crates/domain/src/events.rs`).**
|
||||
```rust
|
||||
/// Une délégation est prête à être injectée dans le terminal natif de l'agent
|
||||
/// (le front exécute le handshake b→e et écrit texte + submit_sequence). Le
|
||||
/// backend reste l'autorité de file/busy ; il NE PTY-écrit PLUS le tour.
|
||||
DelegationReady {
|
||||
agent_id: AgentId,
|
||||
ticket: TicketId,
|
||||
text: String,
|
||||
/// Profil de la cible : la cellule applique submit_sequence/submit_delay_ms.
|
||||
submit_sequence: Option<String>,
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
```
|
||||
Relayé au front comme les autres `DomainEvent` (mapping DTO camelCase déjà en place). Discret, basse fréquence (1/délégation).
|
||||
|
||||
**Commande Tauri (ack de livraison) — `crates/app-tauri/src/commands.rs` + `dto.rs`.** Le front confirme qu'il a **effectivement écrit** la délégation (clôt la boucle « le tour est parti »), pour distinguer « en file car ligne occupée » d'« écrit » côté observabilité/persistance :
|
||||
```rust
|
||||
// DTO
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveredDelegationRequestDto { pub project_id: String, pub agent_id: String, pub ticket: String }
|
||||
// commande
|
||||
#[tauri::command] pub async fn delegation_delivered(request: DeliveredDelegationRequestDto, state: …) -> Result<(), ErrorDto>
|
||||
```
|
||||
Mappé vers une méthode applicative best-effort (`OrchestratorService::note_delegation_delivered`) — **ne change pas** la corrélation (le réveil de l'`ask` reste `idea_reply`→`resolve_ticket`) ; sert l'observabilité/log. Les commandes existantes `submit_agent_input`/`reattach_agent_chat` deviennent **mortes** pour les agents (retirées en L5) ; `interrupt_agent` **reste** (Échap → `preempt`), mais l'UI l'invoque désormais depuis le terminal (raccourci), plus depuis la barre.
|
||||
|
||||
**Ports/adapter frontend (`frontend/src/ports/index.ts`, `adapters/input.ts`).** `InputGateway` perd `submit` (plus de barre) et **gagne** :
|
||||
```ts
|
||||
export interface InputGateway {
|
||||
/** Interrompre = preempt (Échap/stop). Reste. */
|
||||
interrupt(projectId: string, agentId: string): Promise<void>;
|
||||
/** Ack : la cellule a écrit la délégation `ticket` dans le PTY natif. */
|
||||
delegationDelivered(projectId: string, agentId: string, ticket: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
Un nouveau port d'**abonnement** aux `DelegationReady` n'est **pas** nécessaire : `SystemGateway.onDomainEvent` les relaie déjà (filtrer `event.type === "delegationReady"`), à l'image de `useAgentBusy`. Côté domaine front, ajouter la variante `DelegationReady` au type `DomainEvent`.
|
||||
|
||||
**Frontend — le portail.** Un hook `useWritePortal(handle, agentId)` détient : (1) le **compteur de ligne** (incrément/`reset` branchés sur `term.onData` dans `TerminalView`), (2) la **file locale** des `DelegationReady` reçues, (3) l'exécution du **handshake** (b→e) avec **plancher 2 s** et **revérif K + backspaces**, (4) l'**overlay** (état booléen rendu par la cellule). `TerminalView` (agent) écrit les frappes **et** notifie le portail (imprimable/Entrée/Ctrl-C) ; quand le portail injecte, il **coupe** le relais des frappes (flag `suspended`) et écrit via `handle.write`.
|
||||
|
||||
### 20.4 Comment le backend connaît « ligne humaine vide » — il ne la connaît PAS
|
||||
La décision frontière l'évite : **la frontière propre est jugée côté front** (seul détenteur du compteur). Le backend publie `DelegationReady` **dès** que le tour démarre ; c'est le **front** qui retient l'injection jusqu'à `prompt-ready ET ligne vide`. Le « prompt-ready » est connu des **deux** : le watcher backend le détecte sur le flux de sortie pour le **busy** ; le front peut soit ré-utiliser un signal (un futur `AgentPromptReady` event, optionnel) soit, plus simplement, considérer la **ligne vide** comme condition front suffisante et s'appuyer sur le fait que la CLI **empile** nativement les soumissions (robustesse : même injectée « tôt », la CLI met en file). **Choix retenu (sobre)** : le front conditionne sur **ligne humaine vide** uniquement ; la nativité de la CLI gère le reste ; aucun round-trip. Si l'expérience montre des injections trop précoces, on ajoute l'event `AgentPromptReady` (additif, sans changer le portail). Aucune des deux variantes ne crée deux écrivains.
|
||||
|
||||
### 20.5 Découpage en lots (dev/test séquencés)
|
||||
|
||||
| Lot | Couche | Contenu | Fichiers | Testable (vert) |
|
||||
|---|---|---|---|---|
|
||||
| **L1** | domaine+infra | Profil `submit_sequence`/`submit_delay_ms` (+ withers) ; `MediatedInbox::enqueue` **cesse** d'écrire le PTY (suppr. bloc `\n`) et **publie** `DelegationReady` ; `DomainEvent::DelegationReady` | `domain/src/profile.rs`, `domain/src/events.rs`, `infrastructure/src/input/mod.rs` | round-trip serde (clés omises si `None`, legacy→`None`) ; `enqueue` ne fait **aucun** `pty.write` ; publie 1 `DelegationReady{text,ticket}` sur Idle→Busy, **0** sur 2ᵉ enqueue busy ; `preempt` inchangé ; prompt-watcher tests intacts |
|
||||
| **L2** | app+app-tauri | `OrchestratorService` : `ask_agent`/`submit_human_input` n'attendent plus du médiateur l'écriture (déjà le cas) ; ajout `note_delegation_delivered` ; commande `delegation_delivered` + DTO ; relais `DelegationReady` au front | `application/src/orchestrator/service.rs`, `app-tauri/src/commands.rs`, `dto.rs`, `lib.rs` (register) | `ask_agent` toujours réveillé par `idea_reply` (timeout/cycle inchangés) ; `delegation_delivered` best-effort (no-op si non câblé) ne casse pas la corrélation ; event mappé camelCase `delegationReady` |
|
||||
| **L3** | frontend | `TerminalView` agent **natif** : frappes → PTY **inconditionnellement** (retrait du drop `agentMode`) ; compteur de ligne (imprimable +1 / Entrée|Ctrl-C reset) exposé au portail ; `InputGateway` (retrait `submit`, ajout `delegationDelivered`) + adapter ; type front `DomainEvent.DelegationReady` | `frontend/src/features/terminals/TerminalView.tsx`, `ports/index.ts`, `adapters/input.ts`, `domain/*`, `app/di.tsx`, mocks | Vitest : en agent, une frappe écrit le PTY ; compteur +1 sur `a`, reset sur `\r` et `\x03` ; multiligne natif n'altère pas le compteur de façon erronée ; adapter appelle `delegation_delivered` avec `{projectId,agentId,ticket}` |
|
||||
| **L4** | frontend | `useWritePortal` + overlay : réception `DelegationReady` → file ; injection **ssi ligne vide** ; handshake (b) couper relais+overlay, (c) revérif K→`\x7f`×K, (d) `write(text)` puis `write(submitSequence??"\r")` après `submitDelayMs??60`, (e) réactiver+overlay off **plancher 2 s** ; ack `delegationDelivered` | `frontend/src/features/terminals/useWritePortal.ts` (nouveau), `LayoutGrid.tsx` (overlay + montage), `features/terminals/index.ts` | Vitest (faux timers + faux handle) : injecte **rien** tant que ligne non vide ; à ligne vide → écrit `text` **sans** `\n` puis `\r` après le délai ; course « K=2 lettres dans le micro-intervalle » → 2 `\x7f` avant le texte ; **plancher 2 s** : overlay maintenu si (e) < 2 s après (b) ; relais frappes coupé pendant l'overlay ; `delegationDelivered` appelé une fois après (d) |
|
||||
| **L5** | frontend+app-tauri | **Retrait** de `MediatedInput` (suppr. composant + montage `LayoutGrid`), de `useAgentBusy` si plus utilisé (ou conservé pour un badge), des commandes mortes `submit_agent_input`/`reattach_agent_chat`/DTO afférents ; `interrupt` rebranché sur un raccourci terminal | `LayoutGrid.tsx`, `MediatedInput.tsx` (suppr.), `useAgentBusy.ts`, `app-tauri/src/commands.rs`/`dto.rs`/`lib.rs`, tests | suite front verte sans `MediatedInput` ; aucune cellule **plain** modifiée (régression nulle) ; `cargo build` sans les commandes retirées ; `interrupt_agent` toujours appelable |
|
||||
|
||||
**Ordre** : L1→L2 (back prêt) ∥ L3 (front natif) → L4 (portail, dépend L1/L3) → L5 (nettoyage). L1 est le pivot (supprime le `\n`, source du bug, et bascule la livraison sur event).
|
||||
|
||||
### 20.6 Plan de tests — cas limites explicites
|
||||
- **Course 1–2 lettres** (étape c) : entre (a) « ligne vide constatée » et (b) « relais coupé », l'humain tape K∈{1,2} ⇒ le portail écrit **exactement** K `\x7f` puis le texte ; **jamais** Ctrl-U. *(Vitest, faux handle enregistrant les writes.)*
|
||||
- **Plancher 2 s** (étape e) : (d) se termine à t<2 s ⇒ overlay + relais coupés **maintenus** jusqu'à t=2 s ; (e) à t≥2 s ⇒ pas d'attente résiduelle. *(faux timers.)*
|
||||
- **Multiligne natif** : l'humain compose une commande multiligne (la CLI gère ses propres `\n` internes) ⇒ le compteur **n'injecte pas** au milieu (ligne « non vide » tant que la frappe est en cours) ; on ne réécrit jamais le contenu humain.
|
||||
- **Ligne non vide à la frontière** : `DelegationReady` reçue alors que compteur>0 ⇒ **mise en file**, **aucune** écriture ; libérée à la prochaine ligne vide. *(pas de refus, pas de perte.)*
|
||||
- **Préemption pendant overlay** : Échap (`interrupt`) pendant l'overlay ⇒ `preempt` (ESC) côté back inchangé ; le portail lève l'overlay au plancher ; la délégation en cours d'écriture n'est pas dupliquée (ack idempotent).
|
||||
- **Back (Rust)** : `enqueue` ne PTY-écrit plus ; 1 `DelegationReady` sur démarrage de tour, 0 en re-enqueue busy ; `preempt`/prompt-watcher/`mark_idle` inchangés ; `ask_agent` réveillé par `idea_reply`, timeout/cycle intacts ; profil serde zéro régression.
|
||||
|
||||
### 20.7 Risques résiduels & vigilance
|
||||
- **Frontière « tôt »** : conditionner sur « ligne vide » seule peut injecter avant le tout premier prompt-ready. Mitigation : la CLI empile nativement ; si insuffisant, event additif `AgentPromptReady` (déjà détecté côté back) sans toucher le portail.
|
||||
- **`submit_sequence` par CLI** : `"\r"` + délai esquive la paste-detection de Claude Code ; d'autres TUI pourraient exiger une autre séquence/délai → c'est précisément pourquoi c'est **déclaratif** (profil), pas codé en dur.
|
||||
- **Compteur de ligne vs séquences ANSI** : ne compter que les **imprimables** issus de `term.onData` (frappes), pas la sortie ; ignorer les séquences de contrôle (flèches/échap) pour éviter un faux « ligne non vide » qui bloquerait toute injection.
|
||||
- **Reattach** : à la réouverture d'une cellule, le portail repart d'un **compteur=0** et d'une file vide ; une `DelegationReady` perdue pendant la navigation est rejouée par le `ask` en attente (le back retient le ticket jusqu'au reply/timeout) → pas de perte de corrélation.
|
||||
- **Plain shell strictement inchangé** : tout le mécanisme est gardé par `agentMode`/présence d'agent ; aucune cellule plain ne voit overlay, portail, ni compteur.
|
||||
|
||||
---
|
||||
|
||||
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*
|
||||
|
||||
@ -9,19 +9,16 @@ use tauri::State;
|
||||
|
||||
use crate::dto::DismissEmbedderSuggestionRequestDto;
|
||||
use application::{
|
||||
AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput, CreateAgentInput,
|
||||
CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput,
|
||||
DeleteEmbedderProfileInput,
|
||||
DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput,
|
||||
DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput,
|
||||
GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
||||
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LiveSessions,
|
||||
McpRuntime,
|
||||
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput,
|
||||
OpenProjectInput,
|
||||
ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||
ReconcileLayoutsInput, RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput,
|
||||
SnapshotRunningAgentsInput,
|
||||
AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput,
|
||||
CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput,
|
||||
DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput,
|
||||
DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput,
|
||||
GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
||||
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput,
|
||||
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput,
|
||||
McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadMemoryIndexInput,
|
||||
ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, RenameLayoutInput,
|
||||
ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
||||
SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput,
|
||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateSkillInput,
|
||||
};
|
||||
@ -30,27 +27,27 @@ use domain::ports::PtyHandle;
|
||||
use crate::dto::{
|
||||
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug,
|
||||
parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id,
|
||||
parse_template_id, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto,
|
||||
AttachLiveAgentRequestDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
||||
AssignSkillRequestDto, AttachLiveAgentRequestDto, ChangeAgentProfileDto,
|
||||
ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, SubmitAgentInputRequestDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
|
||||
ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto,
|
||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResumableAgentListDto,
|
||||
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto,
|
||||
SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateSkillRequestDto,
|
||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
||||
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
|
||||
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto,
|
||||
MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto,
|
||||
ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto, ReattachResultDto,
|
||||
RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveProfileRequestDto,
|
||||
SetActiveLayoutRequestDto, SkillDto, SkillListDto, SyncAgentWithTemplateRequestDto,
|
||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
||||
UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateMemoryRequestDto,
|
||||
UpdateProjectContextRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
WriteTerminalRequestDto,
|
||||
};
|
||||
use crate::pty::{PtyBridge, PtyChunk};
|
||||
use crate::state::AppState;
|
||||
@ -122,6 +119,7 @@ pub async fn open_project(
|
||||
.await;
|
||||
// (Re)start the orchestrator watcher for this project (idempotent, §14.3).
|
||||
state.ensure_orchestrator_watch(&output.project);
|
||||
state.reconcile_claude_run_dirs(&output.project).await;
|
||||
Ok(ProjectDto::from(output))
|
||||
}
|
||||
|
||||
@ -1049,6 +1047,11 @@ pub async fn launch_agent(
|
||||
requester: agent_id.to_string(),
|
||||
});
|
||||
|
||||
// Functional migration seam: before any launch/reattach/idempotent early-return,
|
||||
// repair the target Claude run dir so stale `.mcp.json` / `.claude/settings.local.json`
|
||||
// artefacts from previous AppImages do not survive into this activation.
|
||||
state.reconcile_claude_run_dirs(&project).await;
|
||||
|
||||
let output = state
|
||||
.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
@ -1206,31 +1209,6 @@ pub async fn agent_send(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `submit_agent_input` — the human **Envoyer** path (cadrage C4 §4.2).
|
||||
///
|
||||
/// Routes the operator's text to [`OrchestratorService::submit_human_input`], which
|
||||
/// enqueues a `from_human` ticket in the **same FIFO** the inter-agent delegations
|
||||
/// use (serialised per agent). Fire-and-forget: the human watches the terminal for
|
||||
/// the answer. The mediator emits `AgentBusyChanged` at the source.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator,
|
||||
/// `NOT_FOUND` if the project or agent is unknown, `PROCESS` on a launch/PTY failure).
|
||||
#[tauri::command]
|
||||
pub async fn submit_agent_input(
|
||||
request: SubmitAgentInputRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.orchestrator_service
|
||||
.submit_human_input(&project, agent_id, request.text)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2).
|
||||
///
|
||||
/// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's
|
||||
@ -1255,6 +1233,32 @@ pub async fn interrupt_agent(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `delegation_delivered` — the frontend write-portal's **ack** (ARCHITECTURE §20.3).
|
||||
///
|
||||
/// Called once the cell has physically written a delegation `ticket` into the agent's
|
||||
/// native PTY (text + submit sequence). Routes to the best-effort
|
||||
/// [`OrchestratorService::note_delegation_delivered`] (observability/log only): it does
|
||||
/// **not** change correlation — the requester's `ask` is still woken by `idea_reply`,
|
||||
/// and timeouts/cycle guards are untouched. Infallible on the application side; only a
|
||||
/// malformed id (project/agent/ticket) yields an `INVALID` error here.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the project is
|
||||
/// unknown).
|
||||
#[tauri::command]
|
||||
pub async fn delegation_delivered(
|
||||
request: DeliveredDelegationRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
let ticket = parse_ticket_id(&request.ticket)?;
|
||||
state
|
||||
.orchestrator_service
|
||||
.note_delegation_delivered(&project, agent_id, ticket);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `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).
|
||||
///
|
||||
|
||||
@ -1197,20 +1197,6 @@ 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)]
|
||||
@ -1222,6 +1208,22 @@ pub struct InterruptAgentRequestDto {
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `delegation_delivered` (ARCHITECTURE §20.3): the frontend write-
|
||||
/// portal acks that it **physically wrote** a delegation `ticket` into the agent's
|
||||
/// native PTY. Best-effort observability — it never changes correlation (the `ask` is
|
||||
/// still woken by `idea_reply`). The frontend sends `{ request: { projectId, agentId,
|
||||
/// ticket } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveredDelegationRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Id of the agent whose terminal received the delegation.
|
||||
pub agent_id: String,
|
||||
/// Id of the delivered mailbox ticket.
|
||||
pub ticket: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime
|
||||
/// profile, optionally relaunching its live session in place.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@ -1435,6 +1437,19 @@ pub fn parse_agent_id(raw: &str) -> Result<AgentId, ErrorDto> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
|
||||
pub fn parse_ticket_id(raw: &str) -> Result<domain::TicketId, ErrorDto> {
|
||||
uuid::Uuid::parse_str(raw)
|
||||
.map(domain::TicketId::from_uuid)
|
||||
.map_err(|_| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: format!("invalid ticket id: {raw}"),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resumable agents (§15.2 — Chantier B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -56,6 +56,26 @@ pub enum DomainEventDto {
|
||||
/// `true` when a turn is in flight, `false` when idle.
|
||||
busy: bool,
|
||||
},
|
||||
/// A delegation is ready to be injected into the agent's **native terminal**
|
||||
/// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence`
|
||||
/// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty,
|
||||
/// then acks via the `delegation_delivered` command. The backend no longer PTY-writes
|
||||
/// the turn.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DelegationReady {
|
||||
/// Target agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`.
|
||||
ticket: String,
|
||||
/// Task text to inject (written without a trailing newline by the portal).
|
||||
text: String,
|
||||
/// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
submit_sequence: Option<String>,
|
||||
/// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
/// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentReplied {
|
||||
@ -225,6 +245,19 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
busy: *busy,
|
||||
},
|
||||
DomainEvent::DelegationReady {
|
||||
agent_id,
|
||||
ticket,
|
||||
text,
|
||||
submit_sequence,
|
||||
submit_delay_ms,
|
||||
} => Self::DelegationReady {
|
||||
agent_id: agent_id.to_string(),
|
||||
ticket: ticket.to_string(),
|
||||
text: text.clone(),
|
||||
submit_sequence: submit_sequence.clone(),
|
||||
submit_delay_ms: *submit_delay_ms,
|
||||
},
|
||||
DomainEvent::AgentReplied {
|
||||
agent_id,
|
||||
reply_len,
|
||||
|
||||
@ -44,15 +44,10 @@ pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
|
||||
#[must_use]
|
||||
pub fn dispatch() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
if args
|
||||
.next()
|
||||
.is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND)
|
||||
{
|
||||
if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) {
|
||||
// Headless bridge: bypass Tauri entirely. Forward the remaining args
|
||||
// (`--endpoint`, `--project`, `--requester`) to the bridge parser.
|
||||
let rest: Vec<String> = args
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
return mcp_bridge::run_mcp_bridge(rest);
|
||||
}
|
||||
|
||||
@ -166,8 +161,8 @@ pub fn run() {
|
||||
commands::launch_agent,
|
||||
commands::change_agent_profile,
|
||||
commands::agent_send,
|
||||
commands::submit_agent_input,
|
||||
commands::interrupt_agent,
|
||||
commands::delegation_delivered,
|
||||
commands::reattach_agent_chat,
|
||||
commands::close_agent_session,
|
||||
commands::list_resumable_agents,
|
||||
|
||||
@ -49,14 +49,18 @@ use std::time::Duration;
|
||||
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
|
||||
use interprocess::local_socket::traits::tokio::Stream as _;
|
||||
use interprocess::local_socket::{GenericFilePath, ToFsName as _};
|
||||
use tokio::io::{
|
||||
AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader,
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
/// How long the bridge waits to connect to the project loopback before giving up.
|
||||
/// Bounded so an absent/unreachable endpoint fails fast instead of hanging.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// After the CLI closes stdin, how long to keep draining the loopback so an
|
||||
/// in-flight response still reaches the CLI. Bounded so the bridge never blocks
|
||||
/// waiting on the server: when stdin closes the CLI is gone, and the OS closes the
|
||||
/// loopback fd at process exit anyway.
|
||||
const DRAIN_GRACE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Parsed `mcp-server` invocation arguments.
|
||||
///
|
||||
/// `--endpoint` is **required** (without it the bridge has nowhere to relay).
|
||||
@ -222,18 +226,26 @@ async fn connect_loopback(endpoint: &str) -> Result<LocalSocketStream, String> {
|
||||
///
|
||||
/// Sequence:
|
||||
/// 1. Write the **handshake line** (`project`+`requester`) to the loopback.
|
||||
/// 2. Loop: read one **line** from the CLI (`cli_in`) → write it to the loopback;
|
||||
/// read one **line** from the loopback → write it to the CLI (`cli_out`).
|
||||
/// 3. **CLI stdin EOF** ⇒ stop, return `Ok(())` (clean code 0). Dropping the
|
||||
/// loopback writer closes that direction of the connection.
|
||||
/// 2. Run **two independent directional pumps concurrently** (full duplex):
|
||||
/// - `CLI → loopback`: every line from `cli_in` is forwarded to the loopback;
|
||||
/// - `loopback → CLI`: every line from the loopback is forwarded to `cli_out`.
|
||||
/// 3. **CLI stdin EOF** ⇒ half-close the loopback write side (so the server sees
|
||||
/// EOF), **drain** any responses still in flight, then return `Ok(())`.
|
||||
///
|
||||
/// The loop is request/response paced (one CLI line in, one loopback line back),
|
||||
/// matching the JSON Lines `StdioTransport` the server speaks. A loopback that
|
||||
/// closes mid-exchange surfaces as an error (non-zero exit).
|
||||
/// ## Why full duplex (and not lockstep)
|
||||
///
|
||||
/// JSON-RPC over MCP is **not** one-response-per-request. **Notifications** (e.g.
|
||||
/// `notifications/initialized` sent right after `initialize`) carry no `id` and get
|
||||
/// **no response**, and the server may push messages unsolicited. A lockstep pump
|
||||
/// that reads one CLI line then *blocks* for exactly one loopback line **deadlocks**
|
||||
/// on the first notification: it waits forever for a reply that never comes, and
|
||||
/// never reads the client's next request (e.g. `tools/list`) — so the CLI never
|
||||
/// receives its tool list. Two decoupled pumps let notifications and asynchronous
|
||||
/// server messages flow freely in both directions.
|
||||
async fn relay<CIn, COut, LIn, LOut>(
|
||||
args: &BridgeArgs,
|
||||
cli_in: CIn,
|
||||
mut cli_out: COut,
|
||||
cli_out: COut,
|
||||
lp_read: LIn,
|
||||
mut lp_write: LOut,
|
||||
) -> Result<(), String>
|
||||
@ -253,48 +265,66 @@ where
|
||||
.await
|
||||
.map_err(|e| format!("handshake flush failed: {e}"))?;
|
||||
|
||||
let mut cli_reader = BufReader::new(cli_in);
|
||||
let mut lp_reader = BufReader::new(lp_read);
|
||||
let mut cli_line = String::new();
|
||||
let mut lp_line = String::new();
|
||||
// 2. Two decoupled pumps. Each owns its streams so neither blocks the other.
|
||||
let cli_to_lp = pump_lines(BufReader::new(cli_in), lp_write, "stdin", "loopback");
|
||||
let lp_to_cli = pump_lines(BufReader::new(lp_read), cli_out, "loopback", "stdout");
|
||||
tokio::pin!(cli_to_lp);
|
||||
tokio::pin!(lp_to_cli);
|
||||
|
||||
tokio::select! {
|
||||
// CLI closed stdin (or errored): half-close the loopback writer to nudge
|
||||
// the server, then drain briefly so an in-flight response still reaches the
|
||||
// CLI — but never block on the server (bounded by DRAIN_GRACE).
|
||||
r = &mut cli_to_lp => {
|
||||
let mut lp_write = r?;
|
||||
let _ = lp_write.shutdown().await;
|
||||
let _ = tokio::time::timeout(DRAIN_GRACE, &mut lp_to_cli).await;
|
||||
Ok(())
|
||||
}
|
||||
// Loopback closed first (server hangup): nothing left to relay. The CLI's
|
||||
// stdin EOF is no longer needed to exit.
|
||||
r = &mut lp_to_cli => r.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards every newline-delimited line from `reader` to `writer` until `reader`
|
||||
/// hits EOF, flushing after each line so a peer blocked on a read sees it promptly.
|
||||
///
|
||||
/// On clean EOF it returns the (now-drained) `writer` so the caller can half-close
|
||||
/// it. `src`/`dst` name the two ends for error messages only.
|
||||
async fn pump_lines<R, W>(
|
||||
mut reader: BufReader<R>,
|
||||
mut writer: W,
|
||||
src: &str,
|
||||
dst: &str,
|
||||
) -> Result<W, String>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
// 2a. CLI → loopback (one line). EOF ⇒ clean shutdown.
|
||||
cli_line.clear();
|
||||
let n = cli_reader
|
||||
.read_line(&mut cli_line)
|
||||
line.clear();
|
||||
let n = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| format!("stdin read failed: {e}"))?;
|
||||
.map_err(|e| format!("{src} read failed: {e}"))?;
|
||||
if n == 0 {
|
||||
// CLI closed stdin: nothing more to forward. Clean exit.
|
||||
return Ok(());
|
||||
}
|
||||
lp_write
|
||||
.write_all(cli_line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("loopback write failed: {e}"))?;
|
||||
lp_write
|
||||
// Source closed: drain and hand the writer back for half-close.
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("loopback flush failed: {e}"))?;
|
||||
|
||||
// 2b. loopback → CLI (one line). EOF here is a server hangup mid-exchange.
|
||||
lp_line.clear();
|
||||
let m = lp_reader
|
||||
.read_line(&mut lp_line)
|
||||
.await
|
||||
.map_err(|e| format!("loopback read failed: {e}"))?;
|
||||
if m == 0 {
|
||||
return Err("loopback closed before answering".to_string());
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
return Ok(writer);
|
||||
}
|
||||
cli_out
|
||||
.write_all(lp_line.as_bytes())
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("stdout write failed: {e}"))?;
|
||||
cli_out
|
||||
.map_err(|e| format!("{dst} write failed: {e}"))?;
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("stdout flush failed: {e}"))?;
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
@ -313,7 +343,12 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_all_three_flags() {
|
||||
let a = BridgeArgs::parse([
|
||||
"--endpoint", "/tmp/x.sock", "--project", "p1", "--requester", "agent-7",
|
||||
"--endpoint",
|
||||
"/tmp/x.sock",
|
||||
"--project",
|
||||
"p1",
|
||||
"--requester",
|
||||
"agent-7",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(a.endpoint, "/tmp/x.sock");
|
||||
@ -355,8 +390,7 @@ mod tests {
|
||||
};
|
||||
let line = a.handshake_line();
|
||||
assert_eq!(*line.last().unwrap(), b'\n');
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(&line[..line.len() - 1]).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
|
||||
assert_eq!(v["project"], "proj");
|
||||
assert_eq!(v["requester"], "rq");
|
||||
}
|
||||
@ -393,24 +427,19 @@ mod tests {
|
||||
reader.read_line(&mut request).await.unwrap();
|
||||
|
||||
let mut w = srv_write;
|
||||
w.write_all(b"{\"result\":\"ok\",\"id\":1}\n").await.unwrap();
|
||||
w.write_all(b"{\"result\":\"ok\",\"id\":1}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
(handshake, request)
|
||||
});
|
||||
|
||||
relay(
|
||||
&args,
|
||||
&cli_in[..],
|
||||
&mut cli_out,
|
||||
lp_read,
|
||||
lp_write,
|
||||
)
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (handshake, request) = server.await.unwrap();
|
||||
let hs: serde_json::Value =
|
||||
serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-1");
|
||||
assert_eq!(hs["requester"], "agent-9");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"tools/call\",\"id\":1}");
|
||||
@ -420,6 +449,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (the bug that hid the tool list for days): a **notification**
|
||||
/// (no `id`, no response) sent between two requests must NOT stall the relay.
|
||||
/// A lockstep pump deadlocks here — after forwarding the notification it blocks
|
||||
/// waiting for a reply that never comes, and never reads `tools/list`. The
|
||||
/// full-duplex relay forwards all three lines and delivers the one response.
|
||||
#[tokio::test]
|
||||
async fn relay_does_not_block_on_notification_without_response() {
|
||||
// initialize result, then an unanswered notification, then tools/list.
|
||||
let cli_in = b"{\"method\":\"initialize\",\"id\":1}\n\
|
||||
{\"method\":\"notifications/initialized\"}\n\
|
||||
{\"method\":\"tools/list\",\"id\":2}\n"
|
||||
.to_vec();
|
||||
let mut cli_out: Vec<u8> = Vec::new();
|
||||
|
||||
let (lp_read, srv_write) = tokio::io::duplex(4096); // server → bridge
|
||||
let (srv_read, lp_write) = tokio::io::duplex(4096); // bridge → server
|
||||
|
||||
let args = BridgeArgs {
|
||||
endpoint: "unused".into(),
|
||||
project: "p".into(),
|
||||
requester: "agent".into(),
|
||||
};
|
||||
|
||||
// Server: handshake, then read all three forwarded lines, answering only
|
||||
// the two that carry an `id`. The notification gets no reply — exactly the
|
||||
// case that used to wedge the relay.
|
||||
let server = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(srv_read);
|
||||
let mut w = srv_write;
|
||||
for _ in 0..4 {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).await.unwrap() == 0 {
|
||||
break;
|
||||
}
|
||||
let v: serde_json::Value = match serde_json::from_str(line.trim_end()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // handshake line
|
||||
};
|
||||
if let Some(id) = v.get("id") {
|
||||
w.write_all(format!("{{\"result\":\"ok\",\"id\":{id}}}\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write),
|
||||
)
|
||||
.await
|
||||
.expect("relay must not deadlock on a notification")
|
||||
.expect("relay ok");
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
let out = String::from_utf8(cli_out).unwrap();
|
||||
// Both request responses arrived; the notification produced none.
|
||||
assert!(out.contains("\"id\":1"), "missing initialize response: {out}");
|
||||
assert!(out.contains("\"id\":2"), "missing tools/list response: {out}");
|
||||
}
|
||||
|
||||
/// stdin EOF with no request ⇒ relay returns `Ok` (code 0), having still sent
|
||||
/// the handshake.
|
||||
#[tokio::test]
|
||||
@ -485,10 +577,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn connect_to_absent_endpoint_errors_fast() {
|
||||
let endpoint = temp_endpoint("absent");
|
||||
let res = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connect_loopback(&endpoint),
|
||||
)
|
||||
let res = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
|
||||
.await
|
||||
.expect("connect_loopback must not hang");
|
||||
assert!(res.is_err(), "absent endpoint must yield an error");
|
||||
@ -545,10 +634,7 @@ mod tests {
|
||||
|
||||
// Drive the whole bridge_over_loopback path (real connect + split + relay),
|
||||
// but feed our own stdio streams via `relay` to avoid touching process std.
|
||||
let conn = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connect_loopback(&endpoint),
|
||||
)
|
||||
let conn = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
|
||||
.await
|
||||
.expect("no hang")
|
||||
.expect("connect to live endpoint");
|
||||
@ -565,8 +651,7 @@ mod tests {
|
||||
server.await.unwrap();
|
||||
|
||||
let (handshake, request) = captured.lock().await.clone();
|
||||
let hs: serde_json::Value =
|
||||
serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-e2e");
|
||||
assert_eq!(hs["requester"], "agent-e2e");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"ping\",\"id\":42}");
|
||||
|
||||
@ -136,9 +136,11 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
|
||||
/// 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")
|
||||
std::env::var("APPIMAGE").ok().or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.or_else(|| std::env::current_exe().ok().map(|p| p.to_string_lossy().into_owned()))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à
|
||||
@ -209,7 +211,9 @@ mod tests {
|
||||
fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() {
|
||||
let p = pid("11111111-1111-1111-1111-111111111111");
|
||||
let ep = mcp_endpoint(&p);
|
||||
let path = ep.socket_path().expect("unix endpoint exposes a socket path");
|
||||
let path = ep
|
||||
.socket_path()
|
||||
.expect("unix endpoint exposes a socket path");
|
||||
assert!(path.extension().is_some_and(|e| e == "sock"));
|
||||
assert_eq!(path.parent().unwrap(), unix_runtime_dir());
|
||||
}
|
||||
@ -252,9 +256,9 @@ mod tests {
|
||||
/// `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;
|
||||
use domain::{AgentId, Project, ProjectId};
|
||||
|
||||
let project = Project::new(
|
||||
ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
|
||||
|
||||
@ -5,31 +5,29 @@
|
||||
//! `Arc<dyn Port>` into the use cases (ARCHITECTURE §1.1, §10). The use cases
|
||||
//! are then exposed through `tauri::State<AppState>` to the command handlers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab,
|
||||
CloseTerminal,
|
||||
ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory,
|
||||
CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout,
|
||||
DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines,
|
||||
DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory,
|
||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||
GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents, ListEmbedderProfiles,
|
||||
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
|
||||
ListTemplates,
|
||||
LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
|
||||
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||
RenameLayout, ResizeTerminal,
|
||||
ResolveMemoryLinks,
|
||||
SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
|
||||
SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
|
||||
UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate,
|
||||
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
|
||||
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
FirstRunState, GetMemory, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog,
|
||||
GitStage, GitStatus, GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents,
|
||||
ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LoadLayout, McpRuntime,
|
||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext, RecallMemory,
|
||||
ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
||||
ResizeTerminal, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, SetActiveLayout,
|
||||
SnapshotRunningAgents, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||
TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, UpdateMemory,
|
||||
UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
||||
@ -37,19 +35,25 @@ use domain::ports::{
|
||||
MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore,
|
||||
TemplateStore,
|
||||
};
|
||||
use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
};
|
||||
use domain::remote::RemoteKind;
|
||||
use domain::{AgentId, DomainEvent, EmbedderProfile, Project, ProjectId};
|
||||
use serde_json::{json, Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use infrastructure::{
|
||||
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
|
||||
EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsHandoffStore, FsMemoryStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
|
||||
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
|
||||
Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock,
|
||||
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory,
|
||||
SystemClock, TokioBroadcastEventBus,
|
||||
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
FsHandoffStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore,
|
||||
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock,
|
||||
SystemMillisClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
use crate::chat::ChatBridge;
|
||||
@ -646,14 +650,15 @@ impl AppState {
|
||||
// porte une conversation et qu'un handoff existe (`<root>/.ideai/conversations/`),
|
||||
// son résumé est réinjecté dans le convention file. Best-effort, additif :
|
||||
// un handoff absent/illisible ⇒ lancement normal sans section.
|
||||
.with_handoff_provider(Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>)
|
||||
.with_handoff_provider(
|
||||
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>
|
||||
)
|
||||
// Resumable moteur par provider (lot P8b) : après un lancement structuré
|
||||
// exposant un id de session moteur, rangé sous la clé de paire dans
|
||||
// `<root>/.ideai/conversations/providers.json`. Best-effort, additif : une
|
||||
// écriture en échec / pas d'id moteur ⇒ lancement normal, aucune écriture.
|
||||
.with_provider_session_provider(
|
||||
Arc::new(AppProviderSessionProvider) as Arc<dyn application::ProviderSessionProvider>,
|
||||
),
|
||||
.with_provider_session_provider(Arc::new(AppProviderSessionProvider)
|
||||
as Arc<dyn application::ProviderSessionProvider>),
|
||||
);
|
||||
|
||||
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
|
||||
@ -821,8 +826,7 @@ impl AppState {
|
||||
// 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 mailbox = Arc::clone(&inmemory_mailbox) as Arc<dyn domain::mailbox::AgentMailbox>;
|
||||
let input_mediator = Arc::new(
|
||||
MediatedInbox::with_pty(
|
||||
Arc::clone(&inmemory_mailbox),
|
||||
@ -835,8 +839,8 @@ impl AppState {
|
||||
) 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 conversation_registry = Arc::new(InMemoryConversationRegistry::new())
|
||||
as Arc<dyn domain::conversation::ConversationRegistry>;
|
||||
let orchestrator_service = Arc::new(
|
||||
OrchestratorService::new(
|
||||
Arc::clone(&create_agent),
|
||||
@ -1056,6 +1060,16 @@ impl AppState {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Repairs the persisted Claude run-dir artefacts of `project` so a freshly
|
||||
/// launched AppImage does not inherit stale MCP/settings state from older runs.
|
||||
///
|
||||
/// Best-effort and local-only: remote SSH/WSL projects are skipped because this
|
||||
/// migration rewrites the local run-dir files the AppImage owns. Launch-time
|
||||
/// repair still remains available on the normal activation path.
|
||||
pub async fn reconcile_claude_run_dirs(&self, project: &Project) {
|
||||
self.migrate_claude_run_dirs(project).await;
|
||||
}
|
||||
|
||||
/// Stops and removes the orchestrator watcher for `project_id`, if any.
|
||||
/// Called from `close_project` so a closed project stops consuming requests.
|
||||
/// Symmetrically stops the project's MCP server (its twin) so both entry doors
|
||||
@ -1078,6 +1092,556 @@ impl AppState {
|
||||
handle.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort migration of persisted Claude run dirs at project-open time:
|
||||
/// repairs stale `.mcp.json` declarations and missing
|
||||
/// `enabledMcpjsonServers` entries in `.claude/settings.local.json`.
|
||||
pub async fn migrate_claude_run_dirs(&self, project: &Project) {
|
||||
if project.remote.kind() != RemoteKind::Local {
|
||||
return;
|
||||
}
|
||||
let agents = match self
|
||||
.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(output) => output.agents,
|
||||
Err(_) => return,
|
||||
};
|
||||
let profiles = match self.list_profiles.execute().await {
|
||||
Ok(output) => output.profiles,
|
||||
Err(_) => return,
|
||||
};
|
||||
let profile_by_id: HashMap<_, _> = profiles.into_iter().map(|p| (p.id, p)).collect();
|
||||
for agent in agents {
|
||||
let Some(profile) = profile_by_id.get(&agent.profile_id) else {
|
||||
continue;
|
||||
};
|
||||
if !is_claude_mcp_profile(profile) {
|
||||
continue;
|
||||
}
|
||||
let _ = migrate_claude_run_dir(project, &agent.id, profile).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn migrate_claude_run_dir(
|
||||
project: &Project,
|
||||
agent_id: &AgentId,
|
||||
profile: &AgentProfile,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let run_dir = project
|
||||
.root
|
||||
.as_str()
|
||||
.trim_end_matches(['/', '\\'])
|
||||
.to_owned()
|
||||
+ &format!("/.ideai/run/{agent_id}");
|
||||
let run_dir_path = Path::new(&run_dir);
|
||||
if tokio::fs::metadata(run_dir_path).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
migrate_claude_settings(run_dir_path, project.root.as_str()).await?;
|
||||
|
||||
let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
|
||||
exe,
|
||||
endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
|
||||
.as_cli_arg()
|
||||
.to_owned(),
|
||||
project_id: project.id.as_uuid().simple().to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
});
|
||||
migrate_claude_mcp_config(run_dir_path, profile, runtime.as_ref()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_claude_settings(run_dir: &Path, project_root: &str) -> Result<(), std::io::Error> {
|
||||
let claude_dir = run_dir.join(".claude");
|
||||
let settings_path = claude_dir.join("settings.local.json");
|
||||
let next = match tokio::fs::read_to_string(&settings_path).await {
|
||||
Ok(existing) => merge_claude_settings_json(&existing, project_root),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
Some(claude_settings_seed_value(project_root))
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let Some(next) = next else {
|
||||
return Ok(());
|
||||
};
|
||||
tokio::fs::create_dir_all(&claude_dir).await?;
|
||||
let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?;
|
||||
body.push('\n');
|
||||
write_atomically(&settings_path, &body)
|
||||
}
|
||||
|
||||
async fn migrate_claude_mcp_config(
|
||||
run_dir: &Path,
|
||||
profile: &AgentProfile,
|
||||
runtime: Option<&McpRuntime>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let Some(target) = claude_mcp_config_target(profile) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(runtime) = runtime else {
|
||||
return Ok(());
|
||||
};
|
||||
let mcp_path = run_dir.join(target);
|
||||
let desired_server = mcp_server_entry(profile, Some(runtime));
|
||||
|
||||
let next = match tokio::fs::read_to_string(&mcp_path).await {
|
||||
Ok(existing) => merge_mcp_json(&existing, desired_server),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
Some(wrap_idea_mcp_server(desired_server))
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let Some(next) = next else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(parent) = mcp_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?;
|
||||
body.push('\n');
|
||||
write_atomically(&mcp_path, &body)
|
||||
}
|
||||
|
||||
fn is_claude_mcp_profile(profile: &AgentProfile) -> bool {
|
||||
let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude)
|
||||
|| matches!(
|
||||
&profile.context_injection,
|
||||
ContextInjection::ConventionFile { target }
|
||||
if target
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(target)
|
||||
.eq_ignore_ascii_case("CLAUDE.md")
|
||||
);
|
||||
is_claude && claude_mcp_config_target(profile).is_some()
|
||||
}
|
||||
|
||||
fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> {
|
||||
match profile.mcp.as_ref().map(|mcp| &mcp.config) {
|
||||
Some(McpConfigStrategy::ConfigFile { target }) => Some(target.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option<Value> {
|
||||
let mut doc = match serde_json::from_str::<Value>(existing) {
|
||||
Ok(Value::Object(map)) => Value::Object(map),
|
||||
Ok(_) | Err(_) => return Some(claude_settings_seed_value(project_root)),
|
||||
};
|
||||
let before = doc.clone();
|
||||
let root = doc.as_object_mut().expect("object preserved above");
|
||||
let permissions = root
|
||||
.entry("permissions".to_owned())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !permissions.is_object() {
|
||||
*permissions = Value::Object(Map::new());
|
||||
}
|
||||
let permissions = permissions
|
||||
.as_object_mut()
|
||||
.expect("permissions forced to object");
|
||||
permissions.insert(
|
||||
"defaultMode".to_owned(),
|
||||
Value::String("bypassPermissions".to_owned()),
|
||||
);
|
||||
permissions.insert(
|
||||
"additionalDirectories".to_owned(),
|
||||
Value::Array(merge_string_array(
|
||||
permissions.get("additionalDirectories"),
|
||||
[project_root],
|
||||
)),
|
||||
);
|
||||
permissions.insert(
|
||||
"allow".to_owned(),
|
||||
Value::Array(merge_string_array(
|
||||
permissions.get("allow"),
|
||||
["Read", "Edit", "Write", "Bash"],
|
||||
)),
|
||||
);
|
||||
permissions.insert(
|
||||
"deny".to_owned(),
|
||||
Value::Array(merge_string_array(
|
||||
permissions.get("deny"),
|
||||
[
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)",
|
||||
],
|
||||
)),
|
||||
);
|
||||
root.insert(
|
||||
"skipDangerousModePermissionPrompt".to_owned(),
|
||||
Value::Bool(true),
|
||||
);
|
||||
root.insert(
|
||||
"enabledMcpjsonServers".to_owned(),
|
||||
Value::Array(merge_string_array(
|
||||
root.get("enabledMcpjsonServers"),
|
||||
["idea"],
|
||||
)),
|
||||
);
|
||||
let sandbox = root
|
||||
.entry("sandbox".to_owned())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !sandbox.is_object() {
|
||||
*sandbox = Value::Object(Map::new());
|
||||
}
|
||||
let sandbox = sandbox.as_object_mut().expect("sandbox forced to object");
|
||||
sandbox.insert("enabled".to_owned(), Value::Bool(false));
|
||||
|
||||
if doc != before {
|
||||
Some(doc)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_mcp_json(existing: &str, desired_server: Value) -> Option<Value> {
|
||||
let mut doc = match serde_json::from_str::<Value>(existing) {
|
||||
Ok(Value::Object(map)) => map,
|
||||
Ok(_) | Err(_) => return Some(wrap_idea_mcp_server(desired_server)),
|
||||
};
|
||||
let mcp_servers = doc
|
||||
.entry("mcpServers".to_owned())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !mcp_servers.is_object() {
|
||||
*mcp_servers = Value::Object(Map::new());
|
||||
}
|
||||
let changed = mcp_servers.get("idea") != Some(&desired_server);
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
match mcp_servers {
|
||||
Value::Object(servers) => {
|
||||
servers.insert("idea".to_owned(), desired_server);
|
||||
Some(Value::Object(doc))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_idea_mcp_server(server: Value) -> Value {
|
||||
let mut servers = Map::new();
|
||||
servers.insert("idea".to_owned(), server);
|
||||
let mut root = Map::new();
|
||||
root.insert("mcpServers".to_owned(), Value::Object(servers));
|
||||
Value::Object(root)
|
||||
}
|
||||
|
||||
fn mcp_server_entry(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> Value {
|
||||
let transport = match profile.mcp.as_ref().map(|mcp| mcp.transport) {
|
||||
Some(McpTransport::Socket) => "socket",
|
||||
Some(McpTransport::Stdio) | None => "stdio",
|
||||
};
|
||||
let (command, args) = match runtime {
|
||||
Some(rt) => (
|
||||
rt.exe.clone(),
|
||||
vec![
|
||||
Value::String("mcp-server".to_owned()),
|
||||
Value::String("--endpoint".to_owned()),
|
||||
Value::String(rt.endpoint.clone()),
|
||||
Value::String("--project".to_owned()),
|
||||
Value::String(rt.project_id.clone()),
|
||||
Value::String("--requester".to_owned()),
|
||||
Value::String(rt.requester.clone()),
|
||||
],
|
||||
),
|
||||
None => (
|
||||
"idea".to_owned(),
|
||||
vec![Value::String("mcp-server".to_owned())],
|
||||
),
|
||||
};
|
||||
let mut server = Map::new();
|
||||
server.insert("command".to_owned(), Value::String(command));
|
||||
server.insert("args".to_owned(), Value::Array(args));
|
||||
server.insert("transport".to_owned(), Value::String(transport.to_owned()));
|
||||
Value::Object(server)
|
||||
}
|
||||
|
||||
fn claude_settings_seed_value(project_root: &str) -> Value {
|
||||
json!({
|
||||
"permissions": {
|
||||
"defaultMode": "bypassPermissions",
|
||||
"additionalDirectories": [project_root],
|
||||
"allow": ["Read", "Edit", "Write", "Bash"],
|
||||
"deny": [
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)"
|
||||
]
|
||||
},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"enabledMcpjsonServers": ["idea"],
|
||||
"sandbox": { "enabled": false }
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_string_array<'a>(
|
||||
existing: Option<&Value>,
|
||||
required: impl IntoIterator<Item = &'a str>,
|
||||
) -> Vec<Value> {
|
||||
let mut seen = HashSet::<String>::new();
|
||||
let mut out = Vec::new();
|
||||
|
||||
if let Some(existing) = existing.and_then(Value::as_array) {
|
||||
for item in existing.iter().filter_map(Value::as_str) {
|
||||
if seen.insert(item.to_owned()) {
|
||||
out.push(Value::String(item.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for item in required {
|
||||
if seen.insert(item.to_owned()) {
|
||||
out.push(Value::String(item.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn write_atomically(path: &Path, content: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(OsString::from)
|
||||
.unwrap_or_else(|| OsString::from("tmp"));
|
||||
let tmp_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4());
|
||||
let tmp_path = path.with_file_name(tmp_name);
|
||||
|
||||
std::fs::write(&tmp_path, content.as_bytes())?;
|
||||
#[cfg(windows)]
|
||||
if path.exists() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod run_dir_migration_tests {
|
||||
use super::{
|
||||
claude_settings_seed_value, is_claude_mcp_profile, mcp_server_entry,
|
||||
merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir,
|
||||
};
|
||||
use application::McpRuntimeProvider;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
StructuredAdapter,
|
||||
};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn claude_profile() -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(Uuid::from_u128(9)),
|
||||
"Claude Code",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
None,
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
McpTransport::Stdio,
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime(agent_id: AgentId) -> application::McpRuntime {
|
||||
application::McpRuntime {
|
||||
exe: "/opt/IdeA.AppImage".to_owned(),
|
||||
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
|
||||
project_id: ProjectId::from_uuid(Uuid::from_u128(1234))
|
||||
.as_uuid()
|
||||
.simple()
|
||||
.to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_claude_settings_adds_idea_and_preserves_existing_entries() {
|
||||
let merged = merge_claude_settings_json(
|
||||
r#"{
|
||||
"permissions": {
|
||||
"additionalDirectories": ["/tmp/custom"],
|
||||
"allow": ["Read"],
|
||||
"deny": ["Bash(custom)"]
|
||||
},
|
||||
"enabledMcpjsonServers": ["other"],
|
||||
"extra": true
|
||||
}"#,
|
||||
"/home/me/proj",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let parsed = merged;
|
||||
assert_eq!(parsed["extra"], json!(true));
|
||||
assert_eq!(
|
||||
parsed["permissions"]["defaultMode"],
|
||||
json!("bypassPermissions")
|
||||
);
|
||||
assert!(parsed["permissions"]["additionalDirectories"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("/tmp/custom")));
|
||||
assert!(parsed["permissions"]["additionalDirectories"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("/home/me/proj")));
|
||||
assert!(parsed["enabledMcpjsonServers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("other")));
|
||||
assert!(parsed["enabledMcpjsonServers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("idea")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_idea_mcp_json_rewrites_idea_and_preserves_other_servers() {
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(77));
|
||||
let desired = mcp_server_entry(&claude_profile(), Some(&runtime(agent_id)));
|
||||
let merged = merge_mcp_json(
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"idea": { "command": "idea", "args": ["mcp-server"], "transport": "stdio" },
|
||||
"other": { "command": "keep-me", "args": [] }
|
||||
}
|
||||
}"#,
|
||||
desired.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let parsed = merged;
|
||||
assert_eq!(parsed["mcpServers"]["other"]["command"], json!("keep-me"));
|
||||
assert_eq!(parsed["mcpServers"]["idea"], desired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_claude_run_dir_repairs_legacy_files_on_disk() {
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(88));
|
||||
let temp = std::env::temp_dir().join(format!("idea-run-migrate-{}", Uuid::new_v4()));
|
||||
let project_root = temp.join("project");
|
||||
let run_dir = project_root.join(".ideai/run").join(agent_id.to_string());
|
||||
std::fs::create_dir_all(run_dir.join(".claude")).unwrap();
|
||||
std::fs::write(
|
||||
run_dir.join(".claude/settings.local.json"),
|
||||
r#"{"permissions":{"additionalDirectories":["/tmp/old"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
run_dir.join(".mcp.json"),
|
||||
r#"{"mcpServers":{"idea":{"command":"idea","args":["mcp-server"],"transport":"stdio"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let project = domain::Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1234)),
|
||||
"demo",
|
||||
domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
|
||||
domain::remote::RemoteRef::local(),
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
let profile = claude_profile();
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
migrate_claude_run_dir(&project, &agent_id, &profile)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let settings: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(run_dir.join(".claude/settings.local.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(settings["enabledMcpjsonServers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("idea")));
|
||||
assert!(settings["permissions"]["additionalDirectories"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!(project.root.as_str())));
|
||||
|
||||
let mcp: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(run_dir.join(".mcp.json")).unwrap())
|
||||
.unwrap();
|
||||
let expected_runtime = crate::mcp_endpoint::AppMcpRuntimeProvider
|
||||
.runtime_for(&project, agent_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
mcp["mcpServers"]["idea"],
|
||||
mcp_server_entry(&claude_profile(), Some(&expected_runtime))
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(temp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_profile_guard_matches_only_claude_mcp_profiles() {
|
||||
assert!(is_claude_mcp_profile(&claude_profile()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_settings_fall_back_to_seed() {
|
||||
let merged = merge_claude_settings_json("{not-json", "/home/me/proj").unwrap();
|
||||
assert_eq!(merged, claude_settings_seed_value("/home/me/proj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_typed_settings_are_repaired_without_panicking() {
|
||||
let merged = merge_claude_settings_json(
|
||||
r#"{
|
||||
"permissions": [],
|
||||
"sandbox": false,
|
||||
"enabledMcpjsonServers": "idea"
|
||||
}"#,
|
||||
"/home/me/proj",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
merged["permissions"]["defaultMode"],
|
||||
json!("bypassPermissions")
|
||||
);
|
||||
assert_eq!(merged["sandbox"]["enabled"], json!(false));
|
||||
assert_eq!(merged["enabledMcpjsonServers"], json!(["idea"]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds the project's loopback endpoint listener (M5a). Best-effort: returns the
|
||||
@ -1113,10 +1677,7 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option<LocalSocketListener> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = endpoint
|
||||
.as_cli_arg()
|
||||
.to_fs_name::<GenericFilePath>()
|
||||
.ok()?;
|
||||
let name = endpoint.as_cli_arg().to_fs_name::<GenericFilePath>().ok()?;
|
||||
ListenerOptions::new()
|
||||
.name(name)
|
||||
// Reclaim (unlink) on drop so a clean close leaves no socket behind.
|
||||
@ -1808,7 +2369,10 @@ mod mcp_serve_peer_tests {
|
||||
.write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client.write_all(tools_list_line(1).as_bytes()).await.unwrap();
|
||||
client
|
||||
.write_all(tools_list_line(1).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client.flush().await.unwrap();
|
||||
|
||||
let resp = tokio::time::timeout(TIMEOUT, read_one_response(&mut client))
|
||||
@ -1833,7 +2397,10 @@ mod mcp_serve_peer_tests {
|
||||
"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(),
|
||||
@ -1888,7 +2455,11 @@ mod mcp_serve_peer_tests {
|
||||
.iter()
|
||||
.filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
|
||||
.collect();
|
||||
assert_eq!(processed.len(), 1, "exactly one processed event; got {events:?}");
|
||||
assert_eq!(
|
||||
processed.len(),
|
||||
1,
|
||||
"exactly one processed event; got {events:?}"
|
||||
);
|
||||
match processed[0] {
|
||||
DomainEvent::OrchestratorRequestProcessed {
|
||||
requester_id,
|
||||
@ -1976,7 +2547,11 @@ mod mcp_serve_peer_tests {
|
||||
let resp = read_one_response(&mut client)
|
||||
.await
|
||||
.expect("the glued tools/list must still be served");
|
||||
assert_eq!(resp["id"], json!(7), "the buffered request id must come through");
|
||||
assert_eq!(
|
||||
resp["id"],
|
||||
json!(7),
|
||||
"the buffered request id must come through"
|
||||
);
|
||||
assert!(
|
||||
resp["result"]["tools"].is_array(),
|
||||
"buffered request produced a real tools/list result; got {resp}"
|
||||
@ -2053,7 +2628,10 @@ mod mcp_serve_peer_tests {
|
||||
.write_all(handshake_line("", "agent-1").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client.write_all(tools_list_line(1).as_bytes()).await.unwrap();
|
||||
client
|
||||
.write_all(tools_list_line(1).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client.flush().await.unwrap();
|
||||
|
||||
let resp = read_one_response(&mut client)
|
||||
@ -2091,7 +2669,10 @@ mod mcp_serve_peer_tests {
|
||||
.write_all(handshake_line(&project_id_arg(&proj), "agent-b").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client_b.write_all(tools_list_line(2).as_bytes()).await.unwrap();
|
||||
client_b
|
||||
.write_all(tools_list_line(2).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client_b.flush().await.unwrap();
|
||||
|
||||
let resp = read_one_response(&mut client_b)
|
||||
@ -2304,7 +2885,7 @@ mod mcp_e2e_loopback_tests {
|
||||
use async_trait::async_trait;
|
||||
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
|
||||
use interprocess::local_socket::traits::tokio::Stream as _;
|
||||
use tokio::io::{AsyncWriteExt, BufReader, AsyncBufReadExt};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
@ -2683,8 +3264,8 @@ mod mcp_e2e_loopback_tests {
|
||||
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 conversations = Arc::new(InMemoryConversationRegistry::new())
|
||||
as Arc<dyn domain::conversation::ConversationRegistry>;
|
||||
let service = OrchestratorService::new(
|
||||
create,
|
||||
launch,
|
||||
@ -2743,12 +3324,8 @@ mod mcp_e2e_loopback_tests {
|
||||
if let Some(publish) = events {
|
||||
server = server.with_events(publish);
|
||||
}
|
||||
let handle = McpServerHandle::start(
|
||||
server,
|
||||
endpoint.clone(),
|
||||
listener,
|
||||
project_id_arg(project),
|
||||
);
|
||||
let handle =
|
||||
McpServerHandle::start(server, endpoint.clone(), listener, project_id_arg(project));
|
||||
(handle, endpoint)
|
||||
}
|
||||
|
||||
@ -2859,7 +3436,11 @@ mod mcp_e2e_loopback_tests {
|
||||
let agent_id = contexts.seed_agent("architect");
|
||||
let (service, sessions, mailbox) = build_service(contexts);
|
||||
// Target already live in the PTY registry, so the ask reuses its terminal.
|
||||
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
|
||||
seed_live_pty(
|
||||
&sessions,
|
||||
agent_id,
|
||||
SessionId::from_uuid(Uuid::from_u128(4242)),
|
||||
);
|
||||
let proj = project();
|
||||
let (handle, endpoint) = start_real_server(service, &proj, None);
|
||||
|
||||
@ -2913,7 +3494,11 @@ mod mcp_e2e_loopback_tests {
|
||||
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}");
|
||||
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)
|
||||
@ -2958,9 +3543,7 @@ mod mcp_e2e_loopback_tests {
|
||||
.await
|
||||
.unwrap();
|
||||
write_half
|
||||
.write_all(
|
||||
tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes(),
|
||||
)
|
||||
.write_all(tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
write_half.flush().await.unwrap();
|
||||
@ -3031,20 +3614,23 @@ mod mcp_e2e_loopback_tests {
|
||||
.await
|
||||
.unwrap();
|
||||
// A malformed JSON-RPC line (not valid JSON) — must NOT crash the serve loop.
|
||||
write_half
|
||||
.write_all(b"{ this is not json\n")
|
||||
.await
|
||||
.unwrap();
|
||||
write_half.write_all(b"{ this is not json\n").await.unwrap();
|
||||
write_half.flush().await.unwrap();
|
||||
|
||||
let resp = read_one_response(&mut reader)
|
||||
.await
|
||||
.expect("a parse error still owes a response");
|
||||
let error = &resp["error"];
|
||||
assert!(!error.is_null(), "malformed input must yield a JSON-RPC error: {resp}");
|
||||
assert!(
|
||||
!error.is_null(),
|
||||
"malformed input must yield a JSON-RPC error: {resp}"
|
||||
);
|
||||
// JSON-RPC mandates a null id when the request could not be correlated.
|
||||
assert_eq!(resp["id"], Value::Null, "got {resp}");
|
||||
assert!(resp["result"].is_null(), "no result on parse error; got {resp}");
|
||||
assert!(
|
||||
resp["result"].is_null(),
|
||||
"no result on parse error; got {resp}"
|
||||
);
|
||||
|
||||
// The server survived: a subsequent valid call still answers.
|
||||
write_half
|
||||
@ -3200,7 +3786,9 @@ mod bind_endpoint_d1_tests {
|
||||
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");
|
||||
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);
|
||||
@ -3214,7 +3802,10 @@ mod bind_endpoint_d1_tests {
|
||||
let corpse = UnixListener::bind(&path).expect("lay corpse socket");
|
||||
drop(corpse);
|
||||
}
|
||||
assert!(path.exists(), "corpse socket inode remains (no live listener)");
|
||||
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.
|
||||
@ -3239,4 +3830,3 @@ mod bind_endpoint_d1_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -126,7 +126,10 @@ fn pump_delivers_deltas_then_exactly_one_final_in_order() {
|
||||
"deltas in order, then the single Final"
|
||||
);
|
||||
// Exactly one Final, and it is last (deterministic terminal chunk).
|
||||
let finals = got.iter().filter(|c| matches!(c, ReplyChunk::Final { .. })).count();
|
||||
let finals = got
|
||||
.iter()
|
||||
.filter(|c| matches!(c, ReplyChunk::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(finals, 1, "exactly one Final per turn");
|
||||
assert!(matches!(got.last(), Some(ReplyChunk::Final { .. })));
|
||||
}
|
||||
@ -227,8 +230,12 @@ fn scrollback_accumulates_every_routed_chunk_in_order() {
|
||||
gen,
|
||||
vec![
|
||||
ReplyEvent::TextDelta { text: "x".into() },
|
||||
ReplyEvent::ToolActivity { label: "runs".into() },
|
||||
ReplyEvent::Final { content: "x".into() },
|
||||
ReplyEvent::ToolActivity {
|
||||
label: "runs".into(),
|
||||
},
|
||||
ReplyEvent::Final {
|
||||
content: "x".into(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
@ -236,7 +243,9 @@ fn scrollback_accumulates_every_routed_chunk_in_order() {
|
||||
bridge.scrollback(&session),
|
||||
vec![
|
||||
delta("x"),
|
||||
ReplyChunk::ToolActivity { label: "runs".into() },
|
||||
ReplyChunk::ToolActivity {
|
||||
label: "runs".into()
|
||||
},
|
||||
final_chunk("x"),
|
||||
],
|
||||
"scrollback retains the full conversation, in order"
|
||||
|
||||
@ -148,7 +148,8 @@ fn orchestration_source_dto_serialises_lowercase() {
|
||||
use domain::events::OrchestrationSource;
|
||||
|
||||
// File → "file"; Mcp → "mcp" (camelCase rename on a unit enum = lowercase).
|
||||
let file = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap();
|
||||
let file =
|
||||
serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap();
|
||||
assert_eq!(file, json!("file"));
|
||||
|
||||
let mcp = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::Mcp)).unwrap();
|
||||
|
||||
@ -8,9 +8,9 @@
|
||||
|
||||
use app_tauri_lib::dto::{CellKind, ReattachChatDto, ReplyChunk, TerminalSessionDto};
|
||||
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
|
||||
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::SessionId;
|
||||
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -49,8 +49,12 @@ fn reply_chunk_final_serialises_exact_camel_case() {
|
||||
fn reply_chunk_round_trips_through_json_for_every_variant() {
|
||||
for chunk in [
|
||||
ReplyChunk::TextDelta { text: "x".into() },
|
||||
ReplyChunk::ToolActivity { label: "runs".into() },
|
||||
ReplyChunk::Final { content: "y".into() },
|
||||
ReplyChunk::ToolActivity {
|
||||
label: "runs".into(),
|
||||
},
|
||||
ReplyChunk::Final {
|
||||
content: "y".into(),
|
||||
},
|
||||
] {
|
||||
let v = serde_json::to_value(&chunk).unwrap();
|
||||
let back: ReplyChunk = serde_json::from_value(v).unwrap();
|
||||
@ -84,7 +88,9 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
|
||||
session_id: "sess-1".into(),
|
||||
scrollback: vec![
|
||||
ReplyChunk::TextDelta { text: "Hi".into() },
|
||||
ReplyChunk::Final { content: "Hi".into() },
|
||||
ReplyChunk::Final {
|
||||
content: "Hi".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
@ -16,9 +16,7 @@ use async_trait::async_trait;
|
||||
use app_tauri_lib::dto::LiveAgentListDto;
|
||||
use application::{LiveSessions, StructuredSessions, TerminalSessions};
|
||||
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream};
|
||||
use domain::{
|
||||
AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession,
|
||||
};
|
||||
use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- petits constructeurs déterministes ------------------------------------
|
||||
@ -160,7 +158,11 @@ fn same_agent_in_both_registries_is_deduplicated() {
|
||||
structured.insert(fake(sid(2)), a, nid(200));
|
||||
|
||||
let dto = dto_for(&pty, &structured);
|
||||
assert_eq!(dto.0.len(), 1, "un même agent ne doit apparaître qu'une fois");
|
||||
assert_eq!(
|
||||
dto.0.len(),
|
||||
1,
|
||||
"un même agent ne doit apparaître qu'une fois"
|
||||
);
|
||||
assert_eq!(dto.0[0].agent_id, a.to_string());
|
||||
// On garde la première occurrence (PTY, listé en premier par l'agrégateur).
|
||||
assert_eq!(dto.0[0].node_id, nid(100).to_string());
|
||||
|
||||
@ -164,10 +164,7 @@ async fn stop_watch_unregisters_the_mcp_server() {
|
||||
assert!(has_mcp(&state, &project.id));
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
assert!(
|
||||
!has_mcp(&state, &project.id),
|
||||
"MCP server removed on close"
|
||||
);
|
||||
assert!(!has_mcp(&state, &project.id), "MCP server removed on close");
|
||||
assert_eq!(mcp_count(&state), 0);
|
||||
|
||||
// Stopping an unknown project is a no-op (does not panic) for the MCP twin too.
|
||||
@ -274,7 +271,10 @@ async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
|
||||
// socket) — it returns early. One endpoint, still bound, no panic.
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert_eq!(mcp_count(&state), 1, "one endpoint per project");
|
||||
assert!(socket_exists(&project), "endpoint still bound after re-open");
|
||||
assert!(
|
||||
socket_exists(&project),
|
||||
"endpoint still bound after re-open"
|
||||
);
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
}
|
||||
|
||||
@ -19,7 +19,8 @@
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
StructuredAdapter,
|
||||
};
|
||||
|
||||
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||
@ -161,7 +162,9 @@ mod mcp_tests {
|
||||
let mcp = profile(slug).mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() },
|
||||
McpConfigStrategy::ConfigFile {
|
||||
target: ".mcp.json".to_owned()
|
||||
},
|
||||
"profile `{slug}` should declare `.mcp.json`"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
|
||||
@ -461,8 +461,8 @@ impl ChangeAgentProfile {
|
||||
mutated_agent = Some(agent);
|
||||
}
|
||||
}
|
||||
let agent = mutated_agent
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
let agent =
|
||||
mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts
|
||||
@ -524,8 +524,10 @@ impl ChangeAgentProfile {
|
||||
// Capture the preserved pair id (stable across all leaves of this
|
||||
// agent) — used to relaunch with handoff re-injection (P7).
|
||||
if pair_id.is_none() {
|
||||
if let Some(cid) =
|
||||
named.tree.leaf(leaf_id).and_then(|l| l.conversation_id.clone())
|
||||
if let Some(cid) = named
|
||||
.tree
|
||||
.leaf(leaf_id)
|
||||
.and_then(|l| l.conversation_id.clone())
|
||||
{
|
||||
pair_id = Some(cid);
|
||||
}
|
||||
@ -1300,8 +1302,7 @@ impl LaunchAgent {
|
||||
// pure partagée avec `resolve_conversation` (aucun couplage à
|
||||
// l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
|
||||
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
|
||||
let pair_conversation_id =
|
||||
input.conversation_id.clone().unwrap_or_else(|| {
|
||||
let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| {
|
||||
ConversationId::for_pair(
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(agent.id),
|
||||
@ -1679,10 +1680,18 @@ impl LaunchAgent {
|
||||
///
|
||||
/// Dispatch by [`McpConfigStrategy`]:
|
||||
/// - `ConfigFile { target }` → write `<run_dir>/<target>` (e.g. `.mcp.json`) with
|
||||
/// the IdeA MCP server declaration. **Non-clobbering and best-effort**, exactly
|
||||
/// like [`Self::seed_cli_permissions`]: an existing file (possibly user-edited)
|
||||
/// is left untouched, and any write/exists failure is swallowed so it **never**
|
||||
/// fails the launch.
|
||||
/// the IdeA MCP server declaration. **Best-effort** (any write/exists failure is
|
||||
/// swallowed so it **never** fails the launch), with two clobber regimes:
|
||||
/// * with an injected [`McpRuntime`] (real app-tauri launch) the file is
|
||||
/// **regenerated and clobbered on every (re)launch**, exactly like the
|
||||
/// convention file. The declaration's `command` carries the **stable** IdeA
|
||||
/// exe path (`$APPIMAGE`) and the project's live endpoint — both drift between
|
||||
/// runs (the AppImage mount path changes at each remount/reboot), so a stale
|
||||
/// `.mcp.json` would point the bridge at a dead binary/socket. `.mcp.json` is
|
||||
/// IdeA-managed (not user-edited), so clobbering is safe;
|
||||
/// * without a runtime (orchestrator / hot-swap / tests) only a degraded minimal
|
||||
/// declaration is available, so the write stays **non-clobbering** (mirrors
|
||||
/// [`Self::seed_cli_permissions`]) — never overwriting a real declaration.
|
||||
/// - `Flag { flag }` → append the flag + run-dir config path to [`SpawnSpec::args`].
|
||||
/// - `Env { var }` → append the variable (run-dir config path) to [`SpawnSpec::env`].
|
||||
///
|
||||
@ -1708,17 +1717,33 @@ impl LaunchAgent {
|
||||
};
|
||||
match &mcp.config {
|
||||
domain::profile::McpConfigStrategy::ConfigFile { target } => {
|
||||
// Non-clobbering, best-effort — mirrors `seed_cli_permissions`.
|
||||
let path = RemotePath::new(join(run_dir, target));
|
||||
match self.fs.exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
let declaration = mcp_server_declaration(mcp.transport, runtime);
|
||||
// Best-effort: a write failure must not fail the launch.
|
||||
match runtime {
|
||||
// Real launch (app-tauri injected the runtime): the declaration
|
||||
// carries the **stable** IdeA executable path (`$APPIMAGE`, resolved
|
||||
// by `idea_exe_path`) and the project's live loopback endpoint. Both
|
||||
// can drift between runs — the AppImage internal mount path changes
|
||||
// at every remount/reboot — so the file MUST be regenerated and
|
||||
// **clobbered** on every (re)launch, exactly like the convention file
|
||||
// (`apply_injection`). `.mcp.json` is IdeA-managed, not user-edited,
|
||||
// so there is nothing to preserve. Best-effort: a write failure must
|
||||
// never fail the launch.
|
||||
Some(_) => {
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
}
|
||||
// Internal launch (orchestrator / hot-swap / tests): we only have a
|
||||
// degraded **minimal** declaration (no endpoint/project/requester).
|
||||
// Stay non-clobbering — mirrors `seed_cli_permissions` — so we never
|
||||
// overwrite a real declaration previously written by an app-tauri
|
||||
// launch with the minimal one.
|
||||
None => match self.fs.exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
}
|
||||
// An exists() probe failure is swallowed too (best-effort).
|
||||
Err(_) => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
domain::profile::McpConfigStrategy::Flag { flag } => {
|
||||
@ -1976,6 +2001,7 @@ fn claude_settings_seed(project_root: &str) -> String {
|
||||
]
|
||||
}},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"enabledMcpjsonServers": ["idea"],
|
||||
"sandbox": {{
|
||||
"enabled": false
|
||||
}}
|
||||
@ -2347,11 +2373,7 @@ mod tests {
|
||||
|
||||
// Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`.
|
||||
assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)"));
|
||||
assert!(
|
||||
doc.contains(
|
||||
"- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"
|
||||
)
|
||||
);
|
||||
assert!(doc.contains("- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"));
|
||||
|
||||
// Deterministic order: first entry precedes the second.
|
||||
let alpha_at = doc.find("[Alpha]").unwrap();
|
||||
@ -2533,15 +2555,7 @@ mod tests {
|
||||
MemoryType::Project,
|
||||
)];
|
||||
let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7"));
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[],
|
||||
&memory,
|
||||
Some(&h),
|
||||
false,
|
||||
);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), false);
|
||||
|
||||
assert!(
|
||||
doc.contains("# Reprise de la conversation"),
|
||||
@ -2636,6 +2650,8 @@ mod tests {
|
||||
// Valid JSON.
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON");
|
||||
assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions");
|
||||
// IdeA MCP server pre-approved so idea_* tools load without a prompt.
|
||||
assert_eq!(parsed["enabledMcpjsonServers"][0], "idea");
|
||||
assert_eq!(
|
||||
parsed["permissions"]["additionalDirectories"][0],
|
||||
"/home/me/proj"
|
||||
|
||||
@ -20,19 +20,17 @@ pub use structured::send_blocking;
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
};
|
||||
pub use lifecycle::{
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
||||
ProviderSessionProvider,
|
||||
LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
||||
UpdateAgentContext,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, McpRuntime, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, StructuredSessionDescriptor, UpdateAgentContext,
|
||||
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
};
|
||||
pub use usecases::{
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
|
||||
@ -35,8 +35,8 @@ pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOut
|
||||
pub use snapshot::{
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
||||
};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub(crate) use store::{persist_doc, resolve_doc};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub use usecases::{
|
||||
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
|
||||
MutateLayoutInput, MutateLayoutOutput,
|
||||
|
||||
@ -109,12 +109,7 @@ impl LayoutOperation {
|
||||
direction,
|
||||
new_leaf,
|
||||
container,
|
||||
} => tree.split(
|
||||
*target,
|
||||
*direction,
|
||||
LeafCell::new(*new_leaf),
|
||||
*container,
|
||||
),
|
||||
} => tree.split(*target, *direction, LeafCell::new(*new_leaf), *container),
|
||||
Self::Merge {
|
||||
container,
|
||||
keep_index,
|
||||
|
||||
@ -28,19 +28,19 @@ pub mod terminal;
|
||||
pub mod window;
|
||||
|
||||
pub use agent::{
|
||||
reference_profile_id, reference_profiles, selectable_reference_profiles, ChangeAgentProfile,
|
||||
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||
HandoffProvider, ProviderSessionProvider,
|
||||
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime,
|
||||
ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile,
|
||||
SaveProfileInput, SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext,
|
||||
UpdateAgentContextInput, send_blocking, AGENT_MEMORY_RECALL_BUDGET,
|
||||
reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking,
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles,
|
||||
ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput,
|
||||
CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput,
|
||||
DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||
HandoffProvider, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||
SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use conversation::RecordTurn;
|
||||
pub use embedder::{
|
||||
|
||||
@ -29,9 +29,7 @@ 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::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, Project};
|
||||
|
||||
use crate::error::AppError;
|
||||
@ -90,7 +88,11 @@ impl ReadContext {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
) -> Self {
|
||||
Self { guard, contexts, fs }
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the requested context, returning its Markdown body.
|
||||
@ -98,7 +100,11 @@ impl ReadContext {
|
||||
/// # 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;
|
||||
let ReadContextInput {
|
||||
project,
|
||||
target,
|
||||
requester,
|
||||
} = input;
|
||||
match target {
|
||||
None => {
|
||||
// Global project context: shared read-lease, then read the root file.
|
||||
@ -109,8 +115,8 @@ impl ReadContext {
|
||||
.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()))?;
|
||||
let text =
|
||||
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
Ok(MarkdownDoc::new(text))
|
||||
}
|
||||
Some(name) => {
|
||||
@ -173,7 +179,12 @@ impl ProposeContext {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self { guard, contexts, fs, clock }
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the proposal: direct write under a write-lease, or a materialised
|
||||
@ -182,7 +193,12 @@ impl ProposeContext {
|
||||
/// # 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;
|
||||
let ProposeContextInput {
|
||||
project,
|
||||
target,
|
||||
content,
|
||||
requester,
|
||||
} = input;
|
||||
match target {
|
||||
Some(name) => {
|
||||
// Per-agent context: direct write under an exclusive write-lease.
|
||||
@ -273,7 +289,11 @@ impl ReadMemory {
|
||||
/// [`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;
|
||||
let ReadMemoryInput {
|
||||
project,
|
||||
slug,
|
||||
requester,
|
||||
} = input;
|
||||
match slug {
|
||||
Some(raw) => {
|
||||
let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
@ -329,7 +349,12 @@ impl WriteMemory {
|
||||
/// [`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 WriteMemoryInput {
|
||||
project,
|
||||
slug,
|
||||
content,
|
||||
requester,
|
||||
} = input;
|
||||
let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let _lease = self
|
||||
.guard
|
||||
@ -527,11 +552,7 @@ mod tests {
|
||||
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
slug: &MemorySlug,
|
||||
) -> Result<Memory, MemoryError> {
|
||||
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
||||
let body = self
|
||||
.notes
|
||||
.lock()
|
||||
@ -556,11 +577,7 @@ mod tests {
|
||||
.insert(memory.slug().to_string(), memory.body.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_slug: &MemorySlug,
|
||||
) -> Result<(), MemoryError> {
|
||||
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn read_index(
|
||||
@ -805,7 +822,10 @@ mod tests {
|
||||
guard.acquire_write(agent_party(2), slug.clone()),
|
||||
)
|
||||
.await;
|
||||
assert!(blocked.is_err(), "a second writer must block while w1 holds");
|
||||
assert!(
|
||||
blocked.is_err(),
|
||||
"a second writer must block while w1 holds"
|
||||
);
|
||||
drop(w1);
|
||||
let w2 = tokio::time::timeout(
|
||||
Duration::from_millis(200),
|
||||
|
||||
@ -19,15 +19,15 @@ use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use domain::conversation::{
|
||||
ConversationParty, ConversationRegistry, SessionRef, WaitForGraph,
|
||||
};
|
||||
use domain::conversation::{ConversationParty, ConversationRegistry, SessionRef, WaitForGraph};
|
||||
use domain::conversation_log::{ConversationTurn, TurnId, TurnRole};
|
||||
use domain::input::{InputMediator, InputSource};
|
||||
use domain::input::{InputMediator, InputSource, SubmitConfig};
|
||||
use domain::mailbox::{Ticket, TicketId};
|
||||
use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
|
||||
use domain::{
|
||||
AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project,
|
||||
};
|
||||
|
||||
use crate::conversation::RecordTurn;
|
||||
|
||||
@ -416,7 +416,10 @@ impl OrchestratorService {
|
||||
target,
|
||||
content,
|
||||
requester,
|
||||
} => self.propose_context(project, target, content, requester).await,
|
||||
} => {
|
||||
self.propose_context(project, target, content, requester)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::ReadMemory { slug, requester } => {
|
||||
self.read_memory(project, slug, requester).await
|
||||
}
|
||||
@ -453,10 +456,7 @@ impl OrchestratorService {
|
||||
})
|
||||
.await?;
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!(
|
||||
"read {} context",
|
||||
target.as_deref().unwrap_or("project")
|
||||
),
|
||||
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
|
||||
reply: Some(md.into_string()),
|
||||
})
|
||||
}
|
||||
@ -481,15 +481,17 @@ impl OrchestratorService {
|
||||
})
|
||||
.await?;
|
||||
let detail = match outcome {
|
||||
ProposeOutcome::Written => format!(
|
||||
"wrote {} context",
|
||||
target.as_deref().unwrap_or("project")
|
||||
),
|
||||
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 })
|
||||
Ok(OrchestratorOutcome {
|
||||
detail,
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
|
||||
@ -755,8 +757,8 @@ impl OrchestratorService {
|
||||
.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);
|
||||
let (prompt_pattern, submit) = self.prompt_and_submit_for_agent(project, agent_id).await;
|
||||
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern, submit);
|
||||
|
||||
// 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
|
||||
@ -866,8 +868,8 @@ impl OrchestratorService {
|
||||
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);
|
||||
let (prompt_pattern, submit) = self.prompt_and_submit_for_agent(project, agent_id).await;
|
||||
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern, submit);
|
||||
|
||||
// Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
|
||||
// forget: we drop the PendingReply (the human reads the terminal). The
|
||||
@ -922,6 +924,34 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// **Ack de livraison** (ARCHITECTURE §20.3) — best-effort, observabilité only.
|
||||
///
|
||||
/// The frontend write-portal calls this (via the `delegation_delivered` Tauri
|
||||
/// command) once it has **physically written** a delegation `ticket` into the agent's
|
||||
/// native PTY, to distinguish "queued because the human line was busy" from "written"
|
||||
/// in logs/observability. It **does not** change correlation: the requester's `ask`
|
||||
/// is still woken by `idea_reply`→`resolve_ticket`, and the per-turn timeout/cycle
|
||||
/// guards are untouched. It is a pure log no-op when nothing is wired, so a missing
|
||||
/// mediator/registry never breaks the rendezvous.
|
||||
pub fn note_delegation_delivered(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
ticket: TicketId,
|
||||
) {
|
||||
// Observability beacon only: never mutates the mailbox/busy state nor resolves
|
||||
// the ticket (that stays `idea_reply`-driven). Kept best-effort by construction —
|
||||
// a pure, infallible hook so a missing mediator/registry never breaks the
|
||||
// rendezvous. The application crate carries no logging framework (zero new dep);
|
||||
// the ack is materialised as an `eprintln!` trace, the same lightweight channel
|
||||
// the orchestrator already uses for best-effort diagnostics.
|
||||
let _ = project;
|
||||
eprintln!(
|
||||
"[orchestrator] delegation delivered into agent {agent_id}'s native terminal \
|
||||
(front ack, ticket {ticket})"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@ -944,7 +974,6 @@ impl OrchestratorService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// `agent.reply` / `idea_reply`: the target agent renders the result of the task
|
||||
/// it is currently processing (Option 1, lot B-4).
|
||||
///
|
||||
@ -1039,10 +1068,7 @@ impl OrchestratorService {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = self
|
||||
.sessions
|
||||
.session_for_agent(&agent_id)
|
||||
.ok_or_else(|| {
|
||||
let session_id = self.sessions.session_for_agent(&agent_id).ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"agent {target} n'a pas de session terminal vivante après lancement"
|
||||
))
|
||||
@ -1051,7 +1077,9 @@ impl OrchestratorService {
|
||||
// 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"))
|
||||
AppError::Process(format!(
|
||||
"handle PTY de l'agent {target} introuvable après lancement"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@ -1318,30 +1346,41 @@ impl OrchestratorService {
|
||||
)))
|
||||
}
|
||||
|
||||
/// 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(
|
||||
/// Resolves the target agent profile's **prompt-ready pattern** (§6, lot C5) **and**
|
||||
/// its **submit config** (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) in
|
||||
/// a single profile lookup. Both are carried into `bind_handle_with_prompt`: the
|
||||
/// pattern arms prompt detection, the submit config is echoed on the next
|
||||
/// `DelegationReady` so the frontend write-portal knows how to submit. Returns
|
||||
/// `(None, default)` when the agent, its profile, or the field is absent — the safe
|
||||
/// fallback (no pattern ⇒ Idle only via explicit signal/timeout; no submit ⇒ the
|
||||
/// front applies its own `"\r"`/~60 ms default).
|
||||
async fn prompt_and_submit_for_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Option<String> {
|
||||
let agent = self
|
||||
) -> (Option<String>, SubmitConfig) {
|
||||
let Some(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)
|
||||
.ok()
|
||||
.and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
|
||||
else {
|
||||
return (None, SubmitConfig::default());
|
||||
};
|
||||
let Some(profile) = self
|
||||
.profiles
|
||||
.list()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
|
||||
else {
|
||||
return (None, SubmitConfig::default());
|
||||
};
|
||||
let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms);
|
||||
(profile.prompt_ready_pattern, submit)
|
||||
}
|
||||
|
||||
/// Resolves a human-friendly profile reference (slug like `claude-code`,
|
||||
|
||||
@ -90,7 +90,12 @@ impl TerminalSessions {
|
||||
/// 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()?;
|
||||
let sid = self
|
||||
.conversations
|
||||
.lock()
|
||||
.ok()?
|
||||
.get(&conversation)
|
||||
.copied()?;
|
||||
// Only return it if the session is still live in `entries`.
|
||||
self.entries
|
||||
.lock()
|
||||
|
||||
@ -1312,7 +1312,9 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
|
||||
"memory section present: {doc}"
|
||||
);
|
||||
assert!(
|
||||
doc.contains("- [Git optionnel](.ideai/memory/git-optional.md) — git reste un simple tool (project)"),
|
||||
doc.contains(
|
||||
"- [Git optionnel](.ideai/memory/git-optional.md) — git reste un simple tool (project)"
|
||||
),
|
||||
"first memory line exact format: {doc}"
|
||||
);
|
||||
assert!(
|
||||
@ -1589,12 +1591,8 @@ async fn launch_without_mcp_writes_no_mcp_config_and_leaves_spec_untouched() {
|
||||
async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() {
|
||||
// Test 2 — ConfigFile { target: ".mcp.json" } ⇒ a file is written at
|
||||
// <run_dir>/.mcp.json with a non-empty, coherent MCP server declaration.
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile_with_mcp(
|
||||
pid(9),
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
),
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
@ -1605,7 +1603,11 @@ async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() {
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
let mcp = writes_ending_with(&fs, "/.mcp.json");
|
||||
assert_eq!(mcp.len(), 1, "exactly one MCP config file written");
|
||||
assert_eq!(mcp[0].0, format!("{run_dir}/.mcp.json"), "written at run dir");
|
||||
assert_eq!(
|
||||
mcp[0].0,
|
||||
format!("{run_dir}/.mcp.json"),
|
||||
"written at run dir"
|
||||
);
|
||||
|
||||
let body = String::from_utf8(mcp[0].1.clone()).unwrap();
|
||||
assert!(!body.trim().is_empty(), "MCP declaration is non-empty");
|
||||
@ -1615,20 +1617,15 @@ async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() {
|
||||
"MCP declaration must declare the IdeA MCP server, got: {body}"
|
||||
);
|
||||
// It is valid JSON.
|
||||
let _: serde_json::Value =
|
||||
serde_json::from_str(&body).expect("MCP declaration is valid JSON");
|
||||
let _: serde_json::Value = serde_json::from_str(&body).expect("MCP declaration is valid JSON");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_config_file_is_non_clobbering() {
|
||||
// Test 3 — if <run_dir>/.mcp.json already exists, the launch must NOT overwrite
|
||||
// it: no write is recorded against that path.
|
||||
let profile = profile_with_mcp(
|
||||
pid(9),
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
);
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
let profile = profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap());
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
@ -1649,12 +1646,8 @@ async fn launch_mcp_config_file_is_non_clobbering() {
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_config_file_write_failure_is_best_effort() {
|
||||
// Test 4 — a write failure on the MCP config file must NOT fail the launch.
|
||||
let profile = profile_with_mcp(
|
||||
pid(9),
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
);
|
||||
let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
let profile = profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap());
|
||||
let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
@ -1676,8 +1669,7 @@ async fn launch_mcp_config_file_write_failure_is_best_effort() {
|
||||
async fn launch_mcp_flag_appends_flag_and_path_to_args() {
|
||||
// Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed
|
||||
// by the run-dir config path.
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
@ -1704,8 +1696,7 @@ async fn launch_mcp_flag_appends_flag_and_path_to_args() {
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_env_appends_var_and_path_to_env() {
|
||||
// Test 6 — Env { var: "IDEA_MCP" } ⇒ spec.env carries (IDEA_MCP, <run_dir>).
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::env("IDEA_MCP").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
@ -1761,8 +1752,7 @@ async fn launch_mcp_runtime_writes_real_declaration_with_ordered_args() {
|
||||
// declaration is the REAL one: command == exe, and args == the ordered
|
||||
// ["mcp-server","--endpoint",endpoint,"--project",project_id,"--requester",
|
||||
// requester]. Structure-asserted via parsed JSON (not a fragile string match).
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
@ -1820,8 +1810,7 @@ async fn launch_mcp_runtime_special_chars_stay_valid_json() {
|
||||
// M5d-2 — an exe/endpoint with a space and a backslash ⇒ the .mcp.json stays
|
||||
// valid JSON (parse OK, asserted by read_single_mcp_json) and the values are
|
||||
// faithfully preserved (correct escaping, no corruption).
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
@ -1867,8 +1856,7 @@ async fn launch_mcp_runtime_special_chars_stay_valid_json() {
|
||||
async fn launch_mcp_runtime_none_writes_minimal_declaration() {
|
||||
// M5d-3 — mcp_runtime = None + an MCP profile ⇒ the minimal declaration is
|
||||
// written: command == "idea", args == ["mcp-server"]. Still valid JSON.
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
@ -1928,22 +1916,28 @@ async fn launch_mcp_runtime_with_no_mcp_profile_writes_nothing() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_runtime_is_non_clobbering() {
|
||||
// M5d-5 — a pre-existing .mcp.json is NOT overwritten, even when a real runtime
|
||||
// is injected (unchanged non-clobbering contract from M1).
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
async fn launch_mcp_runtime_clobbers_stale_config_with_fresh_declaration() {
|
||||
// M5d-5 — with a real injected runtime, a pre-existing .mcp.json MUST be
|
||||
// regenerated and CLOBBERED on every (re)launch: its `command` (the IdeA exe
|
||||
// path) and endpoint drift between runs (the AppImage internal mount path
|
||||
// changes at each remount/reboot), so a stale declaration would point the bridge
|
||||
// at a dead binary/socket. `.mcp.json` is IdeA-managed, so clobbering is safe.
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
// A stale file already on disk (e.g. written by a previous run pointing at a
|
||||
// now-dead AppImage mount).
|
||||
fs.mark_existing(&format!("{run_dir}/.mcp.json"));
|
||||
|
||||
let exe = "/abs/idea";
|
||||
let endpoint = "/run/idea-mcp/abc.sock";
|
||||
let runtime = application::McpRuntime {
|
||||
exe: "/abs/idea".to_owned(),
|
||||
endpoint: "/run/idea-mcp/abc.sock".to_owned(),
|
||||
exe: exe.to_owned(),
|
||||
endpoint: endpoint.to_owned(),
|
||||
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||
requester: "agent-7".to_owned(),
|
||||
};
|
||||
@ -1953,9 +1947,118 @@ async fn launch_mcp_runtime_is_non_clobbering() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The fresh declaration is written despite the pre-existing file, and it carries
|
||||
// the current exe path / endpoint.
|
||||
let doc = read_single_mcp_json(&fs);
|
||||
let server = &doc["mcpServers"]["idea"];
|
||||
assert_eq!(
|
||||
server["command"].as_str(),
|
||||
Some(exe),
|
||||
"a stale .mcp.json must be clobbered with the fresh exe path, got: {doc}"
|
||||
);
|
||||
let args: Vec<&str> = server["args"]
|
||||
.as_array()
|
||||
.expect("args must be a JSON array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
args.get(2).copied(),
|
||||
Some(endpoint),
|
||||
"the clobbered declaration must carry the fresh endpoint, got: {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_runtime_none_is_non_clobbering() {
|
||||
// M5d-5b — WITHOUT a runtime (orchestrator / hot-swap / tests) only the degraded
|
||||
// minimal declaration is available, so a pre-existing .mcp.json must NOT be
|
||||
// overwritten: we never replace a real declaration with the minimal one.
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
fs.mark_existing(&format!("{run_dir}/.mcp.json"));
|
||||
|
||||
// launch_input has mcp_runtime: None.
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
assert!(
|
||||
writes_ending_with(&fs, "/.mcp.json").is_empty(),
|
||||
"an existing .mcp.json must not be clobbered even with an injected runtime"
|
||||
"without a runtime, an existing .mcp.json must not be clobbered with the minimal decl"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_runtime_remount_clobbers_with_run_specific_facts() {
|
||||
// M5d-5c (QA) — direct reproduction of the AppImage-remount bug: across two
|
||||
// independent app sessions (= two reboots, each with a *different* mount path
|
||||
// and a *different* loopback socket), each launch must clobber a pre-existing
|
||||
// stale `.mcp.json` with the facts of THAT run. This proves the file is rebuilt
|
||||
// per-launch from the live runtime — never frozen on a previous run's dead
|
||||
// binary/socket. Two fixtures are used on purpose: relaunching the *same* live
|
||||
// agent would short-circuit through the reattach guard (no respawn), so it
|
||||
// would not exercise a second `apply_mcp_config`.
|
||||
let launch_once = |exe: &'static str, endpoint: &'static str| async move {
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
// A stale declaration left by the previous boot is already on disk.
|
||||
fs.mark_existing(&format!("{run_dir}/.mcp.json"));
|
||||
|
||||
let runtime = application::McpRuntime {
|
||||
exe: exe.to_owned(),
|
||||
endpoint: endpoint.to_owned(),
|
||||
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||
requester: "agent-7".to_owned(),
|
||||
};
|
||||
launch
|
||||
.execute(launch_input_with_runtime(agent.id, runtime))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let doc = read_single_mcp_json(&fs);
|
||||
let server = &doc["mcpServers"]["idea"];
|
||||
let command = server["command"].as_str().unwrap().to_owned();
|
||||
let args: Vec<String> = server["args"]
|
||||
.as_array()
|
||||
.expect("args must be a JSON array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
(command, args)
|
||||
};
|
||||
|
||||
// Boot #1: mount path A + socket A.
|
||||
let exe_a = "/tmp/.mount_IdeA_AAA/idea";
|
||||
let endpoint_a = "/run/idea-mcp/boot-a.sock";
|
||||
let (command_a, args_a) = launch_once(exe_a, endpoint_a).await;
|
||||
assert_eq!(command_a, exe_a, "boot #1 must carry mount path A");
|
||||
assert_eq!(args_a.get(2).map(String::as_str), Some(endpoint_a));
|
||||
|
||||
// Boot #2: a *new* mount path + a *new* socket (the very drift that caused the
|
||||
// bug). The declaration must follow the live facts, not the previous boot's.
|
||||
let exe_b = "/tmp/.mount_IdeA_BBB/idea";
|
||||
let endpoint_b = "/run/idea-mcp/boot-b.sock";
|
||||
let (command_b, args_b) = launch_once(exe_b, endpoint_b).await;
|
||||
assert_eq!(command_b, exe_b, "boot #2 must carry the NEW mount path");
|
||||
assert_eq!(args_b.get(2).map(String::as_str), Some(endpoint_b));
|
||||
|
||||
// And nothing from the stale boot #1 leaks into the boot #2 declaration.
|
||||
assert_ne!(
|
||||
command_b, exe_a,
|
||||
"boot #2 must not reuse boot #1's dead exe"
|
||||
);
|
||||
assert!(
|
||||
!args_b.iter().any(|a| a == endpoint_a),
|
||||
"boot #2 must not reuse boot #1's dead endpoint, got: {args_b:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@ -2122,11 +2225,7 @@ impl HandoffStore for FakeHandoffStore {
|
||||
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().get(&conversation).cloned())
|
||||
}
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().insert(conversation, handoff);
|
||||
Ok(())
|
||||
}
|
||||
@ -2386,11 +2485,12 @@ async fn reopened_cell_does_not_load_handoff_saved_under_a_different_pair_key()
|
||||
// La cellule porte SA clé de paire (celle que P8a aurait persistée)…
|
||||
let leaf_key = ConversationId::for_pair(ConversationParty::User, agent_party);
|
||||
// …mais le handoff est rangé sous la clé d'une AUTRE paire (autre agent).
|
||||
let other_key = ConversationId::for_pair(
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(aid(2)),
|
||||
let other_key =
|
||||
ConversationId::for_pair(ConversationParty::User, ConversationParty::agent(aid(2)));
|
||||
assert_ne!(
|
||||
leaf_key, other_key,
|
||||
"préalable : deux clés de paire distinctes"
|
||||
);
|
||||
assert_ne!(leaf_key, other_key, "préalable : deux clés de paire distinctes");
|
||||
|
||||
let store = FakeHandoffStore::with(other_key, handoff_for("ne doit pas fuiter", Some("X")));
|
||||
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
|
||||
|
||||
@ -46,9 +46,7 @@ use domain::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, TerminalSessions,
|
||||
};
|
||||
use application::{ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, TerminalSessions};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeContexts (AgentContextStore) — manifest + md_path → content
|
||||
@ -675,10 +673,7 @@ impl domain::HandoffStore for FakeHandoffs {
|
||||
}
|
||||
|
||||
impl application::HandoffProvider for FakeHandoffs {
|
||||
fn handoff_store_for(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
) -> Option<Arc<dyn domain::HandoffStore>> {
|
||||
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn domain::HandoffStore>> {
|
||||
Some(Arc::new(self.clone()))
|
||||
}
|
||||
}
|
||||
@ -860,7 +855,11 @@ async fn success_mutates_manifest_to_new_profile() {
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
assert_eq!(out.agent.profile_id, pid(2), "returned agent carries new profile");
|
||||
assert_eq!(
|
||||
out.agent.profile_id,
|
||||
pid(2),
|
||||
"returned agent carries new profile"
|
||||
);
|
||||
assert_eq!(
|
||||
f.contexts.profile_of(&agent.id),
|
||||
Some(pid(2)),
|
||||
@ -1017,7 +1016,10 @@ async fn live_agent_is_killed_and_relaunched_in_same_cell() {
|
||||
assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once");
|
||||
// The relaunched session is returned and pinned on the SAME cell N.
|
||||
let relaunched = out.relaunched.expect("a live agent is relaunched");
|
||||
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
||||
assert_eq!(
|
||||
relaunched.node_id, host,
|
||||
"relaunch reopens in the same cell"
|
||||
);
|
||||
assert_eq!(relaunched.id, sid(777), "relaunch adopts the new PTY id");
|
||||
// The relaunched session is registered and tagged for this agent.
|
||||
assert!(matches!(
|
||||
@ -1111,8 +1113,11 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
|
||||
|
||||
// A UUID-shaped pair id (so resolve_handoff can parse it) and a seeded handoff.
|
||||
let pair = "11111111-1111-1111-1111-111111111111";
|
||||
f.handoffs
|
||||
.seed(pair, "Résumé : on a fini l'étape 2.", Some("Livrer le lot P8d"));
|
||||
f.handoffs.seed(
|
||||
pair,
|
||||
"Résumé : on a fini l'étape 2.",
|
||||
Some("Livrer le lot P8d"),
|
||||
);
|
||||
|
||||
// Live agent on cell N with a foreign engine resumable cache.
|
||||
let host = nid(5);
|
||||
@ -1138,7 +1143,10 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
|
||||
// The old PTY was killed and a single relaunch happened in the SAME cell.
|
||||
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
|
||||
let relaunched = out.relaunched.expect("a live agent is relaunched");
|
||||
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
||||
assert_eq!(
|
||||
relaunched.node_id, host,
|
||||
"relaunch reopens in the same cell"
|
||||
);
|
||||
|
||||
// The pair id is preserved on the persisted leaf; the engine cache is cleared.
|
||||
assert_eq!(
|
||||
@ -1171,7 +1179,10 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
|
||||
// The old engine resumable is NEVER replayed: the relaunch's SessionPlan is
|
||||
// None (providers.json[new provider] is empty ⇒ fresh engine, fidelity carried
|
||||
// by the handoff). It is emphatically NOT Resume{"engine-old"}.
|
||||
let plan = f.runtime.last_plan().expect("the relaunch prepared an invocation");
|
||||
let plan = f
|
||||
.runtime
|
||||
.last_plan()
|
||||
.expect("the relaunch prepared an invocation");
|
||||
assert_eq!(
|
||||
plan,
|
||||
SessionPlan::None,
|
||||
|
||||
@ -61,7 +61,12 @@ impl InMemoryConversationLog {
|
||||
}
|
||||
|
||||
fn thread(&self, conv: ConversationId) -> Vec<ConversationTurn> {
|
||||
self.threads.lock().unwrap().get(&conv).cloned().unwrap_or_default()
|
||||
self.threads
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&conv)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,11 +161,7 @@ impl HandoffStore for InMemoryHandoffStore {
|
||||
Ok(self.store.lock().unwrap().get(&conversation).cloned())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
if let Some(err) = self.fail_save.lock().unwrap().clone() {
|
||||
return Err(err);
|
||||
}
|
||||
@ -219,7 +220,9 @@ async fn single_record_appends_turn_and_advances_handoff() {
|
||||
let conv = conv_id(1);
|
||||
let t1 = turn(conv, turn_id(10), "hello world");
|
||||
|
||||
uc.record(conv, t1.clone()).await.expect("record should succeed");
|
||||
uc.record(conv, t1.clone())
|
||||
.await
|
||||
.expect("record should succeed");
|
||||
|
||||
// Log contains the turn.
|
||||
assert_eq!(log.thread(conv), vec![t1.clone()]);
|
||||
@ -227,7 +230,11 @@ async fn single_record_appends_turn_and_advances_handoff() {
|
||||
// Handoff advanced: up_to == turn id, summary contains the turn text.
|
||||
let h = handoffs.loaded(conv).expect("handoff should be saved");
|
||||
assert_eq!(h.up_to, t1.id);
|
||||
assert!(h.summary_md.contains("hello world"), "summary={:?}", h.summary_md);
|
||||
assert!(
|
||||
h.summary_md.contains("hello world"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
|
||||
// First fold: prev = None, exactly one new turn.
|
||||
assert_eq!(summarizer.calls(), vec![(false, 1)]);
|
||||
@ -254,8 +261,16 @@ async fn two_records_fold_incrementally_without_full_reread() {
|
||||
// Handoff up_to == t2.id; summary contains both texts.
|
||||
let h = handoffs.loaded(conv).expect("handoff saved");
|
||||
assert_eq!(h.up_to, t2.id);
|
||||
assert!(h.summary_md.contains("first-text"), "summary={:?}", h.summary_md);
|
||||
assert!(h.summary_md.contains("second-text"), "summary={:?}", h.summary_md);
|
||||
assert!(
|
||||
h.summary_md.contains("first-text"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
assert!(
|
||||
h.summary_md.contains("second-text"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
|
||||
// Proof of incrementality: prev None then Some, and len == 1 on BOTH calls
|
||||
// (never 2 → the whole log is never re-read into the fold).
|
||||
@ -311,9 +326,9 @@ async fn load_error_propagates_as_store() {
|
||||
#[tokio::test]
|
||||
async fn save_error_propagates_as_store() {
|
||||
let log = Arc::new(InMemoryConversationLog::default());
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::failing_save(StoreError::Serialization(
|
||||
"save boom".to_owned(),
|
||||
)));
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::failing_save(
|
||||
StoreError::Serialization("save boom".to_owned()),
|
||||
));
|
||||
let summarizer = Arc::new(RecordingSummarizer::default());
|
||||
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::layout::Workspace;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, DirEntry, FileSystem, FsError, ProfileStore, ProjectStore, RemotePath,
|
||||
StoreError,
|
||||
@ -26,7 +27,6 @@ use domain::ports::{
|
||||
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::{
|
||||
AgentId, Direction, GridCell, GridContainer, LayoutId, LayoutNode, LayoutTree, LeafCell,
|
||||
NodeId, ProfileId, ProjectId, SplitContainer, WeightedChild,
|
||||
@ -521,12 +521,7 @@ async fn resume_supported_follows_profile() {
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(
|
||||
plain_leaf,
|
||||
Some(plain_agent),
|
||||
Some("c2"),
|
||||
true,
|
||||
)),
|
||||
node: LayoutNode::Leaf(agent_leaf(plain_leaf, Some(plain_agent), Some("c2"), true)),
|
||||
weight: 1.0,
|
||||
},
|
||||
],
|
||||
@ -537,17 +532,16 @@ async fn resume_supported_follows_profile() {
|
||||
entry(supported_agent, "Resumable", pid(1)),
|
||||
entry(plain_agent, "Plain", pid(2)),
|
||||
]);
|
||||
let profiles =
|
||||
FakeProfiles::new(vec![profile_with_session(pid(1)), profile_no_session(pid(2))]);
|
||||
let profiles = FakeProfiles::new(vec![
|
||||
profile_with_session(pid(1)),
|
||||
profile_no_session(pid(2)),
|
||||
]);
|
||||
let uc = make_use_case(&store, &fs, &contexts, &profiles);
|
||||
|
||||
let out = run(&uc).await;
|
||||
assert_eq!(out.len(), 2, "both still listed: {out:?}");
|
||||
|
||||
let supported = out
|
||||
.iter()
|
||||
.find(|r| r.agent_id == supported_agent)
|
||||
.unwrap();
|
||||
let supported = out.iter().find(|r| r.agent_id == supported_agent).unwrap();
|
||||
assert!(supported.resume_supported, "profile pid(1) has a session");
|
||||
|
||||
let plain = out.iter().find(|r| r.agent_id == plain_agent).unwrap();
|
||||
@ -620,7 +614,12 @@ async fn agent_absent_from_manifest_is_ignored() {
|
||||
direction: Direction::Row,
|
||||
children: vec![
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(orphan_leaf, Some(orphan_agent), Some("c1"), true)),
|
||||
node: LayoutNode::Leaf(agent_leaf(
|
||||
orphan_leaf,
|
||||
Some(orphan_agent),
|
||||
Some("c1"),
|
||||
true,
|
||||
)),
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
@ -667,7 +666,11 @@ async fn failing_profile_store_lists_agents_without_resume_support() {
|
||||
let uc = make_use_case(&store, &fs, &contexts, &profiles);
|
||||
|
||||
let out = run(&uc).await;
|
||||
assert_eq!(out.len(), 1, "agent still listed despite profile failure: {out:?}");
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
1,
|
||||
"agent still listed despite profile failure: {out:?}"
|
||||
);
|
||||
assert_eq!(out[0].agent_id, agent);
|
||||
assert!(
|
||||
!out[0].resume_supported,
|
||||
|
||||
@ -343,10 +343,10 @@ impl PtyPort for FakePty {
|
||||
})
|
||||
}
|
||||
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
|
||||
self.writes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((handle.session_id, String::from_utf8_lossy(data).into_owned()));
|
||||
self.writes.lock().unwrap().push((
|
||||
handle.session_id,
|
||||
String::from_utf8_lossy(data).into_owned(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
|
||||
@ -380,7 +380,6 @@ impl EventBus for SpyBus {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl SeqIds {
|
||||
fn new() -> Self {
|
||||
@ -655,13 +654,19 @@ async fn agent_run_visible_other_cell_refuses_when_live_elsewhere() {
|
||||
assert_eq!(err.code(), "AGENT_ALREADY_RUNNING", "got {err:?}");
|
||||
match err {
|
||||
application::AppError::AgentAlreadyRunning { node_id, .. } => {
|
||||
assert_eq!(node_id, first_cell, "reports the live host cell, not target");
|
||||
assert_eq!(
|
||||
node_id, first_cell,
|
||||
"reports the live host cell, not target"
|
||||
);
|
||||
}
|
||||
other => panic!("expected AgentAlreadyRunning, got {other:?}"),
|
||||
}
|
||||
|
||||
let session = fx.sessions.session(&sid(777)).expect("live session");
|
||||
assert_eq!(session.node_id, first_cell, "session stays on its host cell");
|
||||
assert_eq!(
|
||||
session.node_id, first_cell,
|
||||
"session stays on its host cell"
|
||||
);
|
||||
assert_eq!(fx.pty.spawns().len(), 1, "refused launch must not respawn");
|
||||
}
|
||||
|
||||
@ -784,7 +789,6 @@ async fn create_skill_honours_global_scope() {
|
||||
assert_eq!(saved[0].scope, SkillScope::Global);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option 1 « Terminal + MCP » — idea_ask_agent / idea_reply (B-3 / B-4)
|
||||
//
|
||||
@ -826,7 +830,11 @@ impl TestMailbox {
|
||||
}
|
||||
}
|
||||
fn pending(&self, agent: &AgentId) -> usize {
|
||||
self.queues.lock().unwrap().get(agent).map_or(0, VecDeque::len)
|
||||
self.queues
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(agent)
|
||||
.map_or(0, VecDeque::len)
|
||||
}
|
||||
/// Ids of the tickets queued for `agent`, in FIFO order (test inspection).
|
||||
fn ticket_ids(&self, agent: &AgentId) -> Vec<TicketId> {
|
||||
@ -953,7 +961,10 @@ impl InputMediator for TestMediator {
|
||||
self.preempts.lock().unwrap().push(agent);
|
||||
}
|
||||
fn mark_idle(&self, agent: AgentId) {
|
||||
self.busy.lock().unwrap().insert(agent, AgentBusyState::Idle);
|
||||
self.busy
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(agent, AgentBusyState::Idle);
|
||||
}
|
||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
|
||||
self.busy
|
||||
@ -1004,7 +1015,9 @@ impl ConversationRegistry for TestConversations {
|
||||
}
|
||||
fn bind_session(&self, id: ConversationId, session: SessionRef) {
|
||||
if let Some(c) = self.by_id.lock().unwrap().get_mut(&id) {
|
||||
c.session = ConversationSession::Live { handle_ref: session };
|
||||
c.session = ConversationSession::Live {
|
||||
handle_ref: session,
|
||||
};
|
||||
}
|
||||
}
|
||||
fn suspend(&self, id: ConversationId, resumable_id: Option<String>) {
|
||||
@ -1131,8 +1144,7 @@ async fn await_until<F: Fn() -> bool>(cond: F) {
|
||||
.expect("condition never reached within guard (possible hang/deadlock)");
|
||||
}
|
||||
|
||||
const ASK_JSON: &str =
|
||||
r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
|
||||
const ASK_JSON: &str = r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
|
||||
|
||||
/// Builds an `agent.reply` command for `from` with `result` (the handshake-injected
|
||||
/// path, exactly what the MCP server produces from a peer's `idea_reply`).
|
||||
@ -1171,12 +1183,25 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() {
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
// The task was written into the target's terminal, prefixed for idea_reply.
|
||||
let writes = fx.pty.writes_for(sid(800));
|
||||
assert_eq!(writes.len(), 1, "exactly one task write to the live terminal");
|
||||
assert_eq!(
|
||||
writes.len(),
|
||||
1,
|
||||
"exactly one task write to the live terminal"
|
||||
);
|
||||
assert!(writes[0].contains("Analyse §17"), "task body written");
|
||||
assert!(writes[0].contains("[IdeA · tâche"), "delegated-task prefix present");
|
||||
assert!(writes[0].contains("idea_reply") || writes[0].contains("ticket"), "carries ticket/idea_reply cue");
|
||||
assert!(
|
||||
writes[0].contains("[IdeA · tâche"),
|
||||
"delegated-task prefix present"
|
||||
);
|
||||
assert!(
|
||||
writes[0].contains("idea_reply") || writes[0].contains("ticket"),
|
||||
"carries ticket/idea_reply cue"
|
||||
);
|
||||
// No PTY spawned: the live terminal was reused.
|
||||
assert!(fx.pty.spawns().is_empty(), "live target reused, not respawned");
|
||||
assert!(
|
||||
fx.pty.spawns().is_empty(),
|
||||
"live target reused, not respawned"
|
||||
);
|
||||
|
||||
// The target renders its result via idea_reply ⇒ resolves the head ticket.
|
||||
fx.service
|
||||
@ -1242,7 +1267,11 @@ async fn ask_dead_target_launches_pty_then_writes_and_replies() {
|
||||
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
// The dead target was launched as a PTY (spawn recorded, session registered).
|
||||
assert_eq!(fx.pty.spawns(), vec![sid(777)], "dead target launched as PTY");
|
||||
assert_eq!(
|
||||
fx.pty.spawns(),
|
||||
vec![sid(777)],
|
||||
"dead target launched as PTY"
|
||||
);
|
||||
assert_eq!(fx.sessions.session_for_agent(&aid(1)), Some(sid(777)));
|
||||
// The delegated task was written into the freshly-launched terminal (the Stdin
|
||||
// context injection may also write the persona, so we look for the task prefix).
|
||||
@ -1289,7 +1318,10 @@ async fn ask_success_publishes_agent_replied_event() {
|
||||
.events()
|
||||
.into_iter()
|
||||
.find_map(|e| match e {
|
||||
DomainEvent::AgentReplied { agent_id, reply_len } => Some((agent_id, reply_len)),
|
||||
DomainEvent::AgentReplied {
|
||||
agent_id,
|
||||
reply_len,
|
||||
} => Some((agent_id, reply_len)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("AgentReplied must be published");
|
||||
@ -1358,7 +1390,11 @@ async fn reply_without_pending_ask_is_invalid() {
|
||||
.dispatch(&project(), reply_cmd(aid(7), "orphan result"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "orphan reply is typed INVALID: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"orphan reply is typed INVALID: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Reply` resolves the HEAD ticket of the emitting agent's queue (positional
|
||||
@ -1417,8 +1453,12 @@ async fn ask_different_targets_run_in_parallel() {
|
||||
let mut inner = contexts.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&a));
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&b));
|
||||
inner.contents.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner.contents.insert(b.context_path.clone(), "# b".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(b.context_path.clone(), "# b".to_owned());
|
||||
}
|
||||
let fx = ask_fixture(contexts);
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(801));
|
||||
@ -1498,10 +1538,10 @@ impl FileSystem for CapturingFs {
|
||||
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((path.as_str().to_owned(), String::from_utf8_lossy(data).into_owned()));
|
||||
self.0.lock().unwrap().push((
|
||||
path.as_str().to_owned(),
|
||||
String::from_utf8_lossy(data).into_owned(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
||||
@ -1640,8 +1680,7 @@ fn ask_fixture_ex(
|
||||
.with_events(Arc::new(bus.clone()));
|
||||
|
||||
if let Some(p) = provider {
|
||||
service =
|
||||
service.with_mcp_runtime_provider(p as Arc<dyn application::McpRuntimeProvider>);
|
||||
service = service.with_mcp_runtime_provider(p as Arc<dyn application::McpRuntimeProvider>);
|
||||
}
|
||||
|
||||
AskFixtureEx {
|
||||
@ -1677,7 +1716,11 @@ async fn f1_ask_dead_target_injects_provider_runtime_into_mcp_json() {
|
||||
let calls = provider.calls();
|
||||
assert_eq!(calls.len(), 1, "provider interrogé exactement une fois");
|
||||
assert_eq!(calls[0].0, project().id, "interrogé pour le bon projet");
|
||||
assert_eq!(calls[0].1, aid(1), "interrogé pour la cible relancée (= --requester)");
|
||||
assert_eq!(
|
||||
calls[0].1,
|
||||
aid(1),
|
||||
"interrogé pour la cible relancée (= --requester)"
|
||||
);
|
||||
|
||||
// La déclaration `.mcp.json` écrite porte les valeurs sentinelles du runtime.
|
||||
let decl = fx
|
||||
@ -1780,13 +1823,20 @@ async fn f2_ask_codex_target_is_invalid_no_launch() {
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID", "garde F2 = erreur typée Invalid: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"garde F2 = erreur typée Invalid: {err:?}"
|
||||
);
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("idea_*") || msg.contains("pont") || msg.contains(".mcp.json"),
|
||||
"message explicite sur le pont non supporté: {msg}"
|
||||
);
|
||||
assert!(fx.pty.spawns().is_empty(), "aucun lancement sur cible refusée");
|
||||
assert!(
|
||||
fx.pty.spawns().is_empty(),
|
||||
"aucun lancement sur cible refusée"
|
||||
);
|
||||
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "aucun ticket enfilé");
|
||||
}
|
||||
|
||||
@ -1856,7 +1906,10 @@ fn ask_fixture_c3(contexts: FakeContexts) -> AskFixtureC3 {
|
||||
None,
|
||||
));
|
||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||
let close = Arc::new(CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)));
|
||||
let close = Arc::new(CloseTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
));
|
||||
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
|
||||
let create_skill = Arc::new(CreateSkill::new(
|
||||
Arc::new(RecordingSkills::default()) as Arc<dyn SkillStore>,
|
||||
@ -1898,8 +1951,12 @@ fn seed_two_agents(contexts: &FakeContexts) {
|
||||
let mut inner = contexts.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&a));
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&b));
|
||||
inner.contents.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner.contents.insert(b.context_path.clone(), "# b".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(b.context_path.clone(), "# b".to_owned());
|
||||
}
|
||||
|
||||
/// An `agent.message` from agent A (uuid requester) to a named target.
|
||||
@ -1920,15 +1977,19 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "do B")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "do B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
|
||||
// The registry now holds the A↔B thread (agent 1 ↔ agent 2), not User↔B.
|
||||
let a_to_b = fx
|
||||
.conversations
|
||||
.resolve(ConversationParty::agent(aid(1)), ConversationParty::agent(aid(2)));
|
||||
let a_to_b = fx.conversations.resolve(
|
||||
ConversationParty::agent(aid(1)),
|
||||
ConversationParty::agent(aid(2)),
|
||||
);
|
||||
let user_b = fx
|
||||
.conversations
|
||||
.resolve(ConversationParty::User, ConversationParty::agent(aid(2)));
|
||||
@ -1941,7 +2002,10 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
|
||||
"ask A→B resolved the A↔B pair"
|
||||
);
|
||||
// The live B session is bound to the A↔B thread (session keyed by conversation).
|
||||
assert!(a_to_b.session.is_live(), "A↔B thread bound to a live session");
|
||||
assert!(
|
||||
a_to_b.session.is_live(),
|
||||
"A↔B thread bound to a live session"
|
||||
);
|
||||
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(2), "B done"))
|
||||
@ -1964,7 +2028,10 @@ async fn cycle_a_to_b_to_a_is_refused_before_deadlock() {
|
||||
// A→B in flight (held — B never replies during the test): edge A→B is posted.
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let _ask_ab = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
@ -1972,13 +2039,19 @@ async fn cycle_a_to_b_to_a_is_refused_before_deadlock() {
|
||||
// Now B→A would close the cycle ⇒ typed INVALID, no enqueue on A, returns fast.
|
||||
let err = timeout(
|
||||
TEST_GUARD,
|
||||
fx.service
|
||||
.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "back to A"))),
|
||||
fx.service.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(2), "alpha", "back to A")),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("cycle ask must return fast, never deadlock")
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "re-entrant cycle is typed INVALID: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"re-entrant cycle is typed INVALID: {err:?}"
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("cycle") || err.to_string().contains("ré-entrante"),
|
||||
"explicit cycle message: {err}"
|
||||
@ -1999,7 +2072,10 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
|
||||
// A→B completes.
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask_ab = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
@ -2012,7 +2088,10 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
|
||||
// Edge A→B is now gone ⇒ B→A is allowed (would only cycle if A→B still pending).
|
||||
let svc2 = Arc::clone(&fx.service);
|
||||
let ask_ba = tokio::spawn(async move {
|
||||
svc2.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")))
|
||||
svc2.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
@ -2021,7 +2100,11 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
|
||||
.await
|
||||
.expect("reply ok");
|
||||
let out = timeout(TEST_GUARD, ask_ba).await.unwrap().unwrap().unwrap();
|
||||
assert_eq!(out.reply.as_deref(), Some("A ok"), "reverse delegation allowed");
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("A ok"),
|
||||
"reverse delegation allowed"
|
||||
);
|
||||
}
|
||||
|
||||
/// C3 — reply correlates **by ticket** (deterministic id-keyed resolution). The agent
|
||||
@ -2064,7 +2147,11 @@ async fn reply_correlates_by_ticket() {
|
||||
.dispatch(&project(), reply_cmd_ticket(aid(2), bogus, "wrong"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "unknown ticket id ⇒ typed INVALID: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"unknown ticket id ⇒ typed INVALID: {err:?}"
|
||||
);
|
||||
let t2 = fx.mailbox.ticket_ids(&aid(2))[0];
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd_ticket(aid(2), t2, "R2"))
|
||||
@ -2129,7 +2216,11 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() {
|
||||
"closed-channel ask is a typed error: {err:?}"
|
||||
);
|
||||
// Queue freed, and the target B is still live (never killed by the failed ask).
|
||||
assert_eq!(fx.mailbox.pending(&aid(2)), 0, "queue freed after retirement");
|
||||
assert_eq!(
|
||||
fx.mailbox.pending(&aid(2)),
|
||||
0,
|
||||
"queue freed after retirement"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.sessions.session_for_agent(&aid(2)),
|
||||
Some(sid(802)),
|
||||
@ -2232,7 +2323,10 @@ async fn submit_and_ask_share_one_fifo_per_agent() {
|
||||
// A delegation A→B blocks in B's FIFO (it awaits a reply).
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(10), "architect", "deleg")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(10), "architect", "deleg")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
@ -2256,7 +2350,8 @@ async fn submit_and_ask_share_one_fifo_per_agent() {
|
||||
);
|
||||
|
||||
// Unblock the delegation so the spawned task ends cleanly.
|
||||
fx.mailbox.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]);
|
||||
fx.mailbox
|
||||
.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]);
|
||||
let _ = timeout(TEST_GUARD, ask).await;
|
||||
}
|
||||
|
||||
@ -2275,11 +2370,7 @@ async fn interrupt_preempts_without_enqueue_or_resolve() {
|
||||
.expect("interrupt succeeds");
|
||||
|
||||
assert_eq!(fx.mediator.preempts(), vec![aid(1)], "preempt called once");
|
||||
assert_eq!(
|
||||
fx.mailbox.pending(&aid(1)),
|
||||
0,
|
||||
"interrupt enqueues nothing"
|
||||
);
|
||||
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "interrupt enqueues nothing");
|
||||
}
|
||||
|
||||
/// A human submit to an unknown agent id is a typed NotFound (never a panic).
|
||||
@ -2304,7 +2395,10 @@ async fn interrupt_unknown_agent_is_not_found() {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "unknown agent interrupt: {err:?}");
|
||||
assert!(fx.mediator.preempts().is_empty(), "no preempt on unknown agent");
|
||||
assert!(
|
||||
fx.mediator.preempts().is_empty(),
|
||||
"no preempt on unknown agent"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -2323,13 +2417,13 @@ async fn interrupt_unknown_agent_is_not_found() {
|
||||
// in `conversation_record.rs`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::{OrchestratorOutcome, RecordTurn, RecordTurnProvider};
|
||||
use domain::ports::Clock;
|
||||
use domain::InputSource;
|
||||
use domain::{
|
||||
ConversationLog, ConversationTurn as DomainTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
TurnId, TurnRole,
|
||||
};
|
||||
use application::{OrchestratorOutcome, RecordTurn, RecordTurnProvider};
|
||||
use domain::ports::Clock;
|
||||
use domain::InputSource;
|
||||
|
||||
/// A deterministic [`Clock`]: every persisted turn is stamped with this constant.
|
||||
struct FixedClock(i64);
|
||||
@ -2367,7 +2461,9 @@ impl ConversationLog for RecordingLog {
|
||||
turn: DomainTurn,
|
||||
) -> Result<(), StoreError> {
|
||||
if self.fail_append {
|
||||
return Err(StoreError::Io("recording log: forced append failure".to_owned()));
|
||||
return Err(StoreError::Io(
|
||||
"recording log: forced append failure".to_owned(),
|
||||
));
|
||||
}
|
||||
self.appends.lock().unwrap().push(turn);
|
||||
Ok(())
|
||||
@ -2399,11 +2495,7 @@ impl HandoffStore for NoopHandoffStore {
|
||||
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().get(&conversation).cloned())
|
||||
}
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().insert(conversation, handoff);
|
||||
Ok(())
|
||||
}
|
||||
@ -2419,7 +2511,10 @@ impl HandoffSummarizer for ConcatSummarizer {
|
||||
for t in new_turns {
|
||||
summary.push_str(&t.text);
|
||||
}
|
||||
let up_to = new_turns.last().map(|t| t.id).expect("at least one new turn");
|
||||
let up_to = new_turns
|
||||
.last()
|
||||
.map(|t| t.id)
|
||||
.expect("at least one new turn");
|
||||
Handoff::new(summary, up_to, None)
|
||||
}
|
||||
}
|
||||
@ -2564,7 +2659,11 @@ async fn p6b_user_ask_records_prompt_then_response_pair() {
|
||||
assert_eq!(out.reply.as_deref(), Some("the §17 answer"));
|
||||
|
||||
let appends = log.appends();
|
||||
assert_eq!(appends.len(), 2, "exactly two turns persisted (Prompt + Response)");
|
||||
assert_eq!(
|
||||
appends.len(),
|
||||
2,
|
||||
"exactly two turns persisted (Prompt + Response)"
|
||||
);
|
||||
|
||||
let prompt = &appends[0];
|
||||
let response = &appends[1];
|
||||
@ -2575,12 +2674,26 @@ async fn p6b_user_ask_records_prompt_then_response_pair() {
|
||||
);
|
||||
// Order + role.
|
||||
assert_eq!(prompt.role, TurnRole::Prompt, "first turn is the Prompt");
|
||||
assert_eq!(response.role, TurnRole::Response, "second turn is the Response");
|
||||
assert_eq!(
|
||||
response.role,
|
||||
TurnRole::Response,
|
||||
"second turn is the Response"
|
||||
);
|
||||
// Text: task then result.
|
||||
assert_eq!(prompt.text, "Analyse §17", "prompt text is the delegated task");
|
||||
assert_eq!(response.text, "the §17 answer", "response text is the reply result");
|
||||
assert_eq!(
|
||||
prompt.text, "Analyse §17",
|
||||
"prompt text is the delegated task"
|
||||
);
|
||||
assert_eq!(
|
||||
response.text, "the §17 answer",
|
||||
"response text is the reply result"
|
||||
);
|
||||
// Source: Human prompt (no agent requester), target-sourced response.
|
||||
assert_eq!(prompt.source, InputSource::Human, "User ask ⇒ Human prompt source");
|
||||
assert_eq!(
|
||||
prompt.source,
|
||||
InputSource::Human,
|
||||
"User ask ⇒ Human prompt source"
|
||||
);
|
||||
assert_eq!(
|
||||
response.source,
|
||||
InputSource::agent(aid(1)),
|
||||
@ -2603,8 +2716,11 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
|
||||
Arc::new(ConcatSummarizer) as Arc<dyn HandoffSummarizer>,
|
||||
));
|
||||
let provider = Arc::new(SharedRecordProvider(record)) as Arc<dyn RecordTurnProvider>;
|
||||
let fx =
|
||||
ask_fixture_with_record(FakeContexts::with_agent(&architect, "# persona"), provider, 0);
|
||||
let fx = ask_fixture_with_record(
|
||||
FakeContexts::with_agent(&architect, "# persona"),
|
||||
provider,
|
||||
0,
|
||||
);
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
// Requester is agent A (id 7). The orchestrator threads `requester` from the
|
||||
@ -2625,7 +2741,10 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
|
||||
// assert both turns landed on it.
|
||||
let convs = TestConversations::new();
|
||||
let expected = convs
|
||||
.resolve(ConversationParty::agent(a), ConversationParty::agent(aid(1)))
|
||||
.resolve(
|
||||
ConversationParty::agent(a),
|
||||
ConversationParty::agent(aid(1)),
|
||||
)
|
||||
.id;
|
||||
// (Resolution is deterministic per pair within one registry; we instead assert the
|
||||
// turns share a thread and the prompt source carries A — the load-bearing facts.)
|
||||
@ -2655,7 +2774,11 @@ async fn p6b_no_provider_is_silent_no_op() {
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let out = run_ask_roundtrip(&fx, ASK_JSON, aid(1), "ok").await;
|
||||
assert_eq!(out.reply.as_deref(), Some("ok"), "ask succeeds without persistence");
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("ok"),
|
||||
"ask succeeds without persistence"
|
||||
);
|
||||
}
|
||||
|
||||
/// Provider wired but returns `None` for the root ⇒ ask still succeeds (best-effort
|
||||
@ -2668,7 +2791,11 @@ async fn p6b_provider_returns_none_is_silent_no_op() {
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let out = run_ask_roundtrip(&fx, ASK_JSON, aid(1), "ok").await;
|
||||
assert_eq!(out.reply.as_deref(), Some("ok"), "ask succeeds when provider declines");
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("ok"),
|
||||
"ask succeeds when provider declines"
|
||||
);
|
||||
}
|
||||
|
||||
/// `RecordTurn` built on a log whose `append` always fails ⇒ persistence errors are
|
||||
@ -2693,5 +2820,8 @@ async fn p6b_failing_record_does_not_degrade_ask() {
|
||||
"a failing append must not turn a successful delegation into an error"
|
||||
);
|
||||
// The failing log recorded nothing (every append errored).
|
||||
assert!(failing_log.appends().is_empty(), "no turns stored when append fails");
|
||||
assert!(
|
||||
failing_log.appends().is_empty(),
|
||||
"no turns stored when append fails"
|
||||
);
|
||||
}
|
||||
|
||||
@ -218,16 +218,26 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
|
||||
assert!(out.is_first_run);
|
||||
// §17.3/D7: the wizard is seeded only with the *selectable* (structured-
|
||||
// drivable) profiles — Claude + Codex — not the full 4-profile catalogue.
|
||||
assert_eq!(out.reference_profiles.len(), 2, "selectable catalogue seeded");
|
||||
assert_eq!(
|
||||
out.reference_profiles.len(),
|
||||
2,
|
||||
"selectable catalogue seeded"
|
||||
);
|
||||
let commands: Vec<&str> = out
|
||||
.reference_profiles
|
||||
.iter()
|
||||
.map(|p| p.command.as_str())
|
||||
.collect();
|
||||
assert_eq!(commands, vec!["claude", "codex"], "only Claude/Codex offered");
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec!["claude", "codex"],
|
||||
"only Claude/Codex offered"
|
||||
);
|
||||
// Every seeded profile is selectable (the gate the menu relies on).
|
||||
assert!(
|
||||
out.reference_profiles.iter().all(AgentProfile::is_selectable),
|
||||
out.reference_profiles
|
||||
.iter()
|
||||
.all(AgentProfile::is_selectable),
|
||||
"seeded profiles must all be selectable"
|
||||
);
|
||||
}
|
||||
|
||||
@ -99,10 +99,14 @@ fn delta(t: &str) -> ReplyEvent {
|
||||
ReplyEvent::TextDelta { text: t.to_owned() }
|
||||
}
|
||||
fn tool(l: &str) -> ReplyEvent {
|
||||
ReplyEvent::ToolActivity { label: l.to_owned() }
|
||||
ReplyEvent::ToolActivity {
|
||||
label: l.to_owned(),
|
||||
}
|
||||
}
|
||||
fn final_(c: &str) -> ReplyEvent {
|
||||
ReplyEvent::Final { content: c.to_owned() }
|
||||
ReplyEvent::Final {
|
||||
content: c.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -174,8 +178,9 @@ async fn empty_stream_is_io_error() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_decode_error_is_propagated() {
|
||||
let session =
|
||||
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
let session = ScriptedSession::new(Script::Err(AgentSessionError::Decode(
|
||||
"bad json".to_owned(),
|
||||
)));
|
||||
let out = send_blocking(&session, "x", None).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
}
|
||||
|
||||
@ -570,10 +570,10 @@ impl FakeProviderSessionStore {
|
||||
/// so a P8c launch reading via [`ProviderSessionStore::get`] resolves the engine
|
||||
/// resumable for that pair (mirrors what a previous P8b launch would have stored).
|
||||
fn seed_sync(&self, conversation: ConversationId, provider: &str, resumable_id: &str) {
|
||||
self.entries.lock().unwrap().insert(
|
||||
(conversation, provider.to_owned()),
|
||||
resumable_id.to_owned(),
|
||||
);
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((conversation, provider.to_owned()), resumable_id.to_owned());
|
||||
}
|
||||
/// Observed resumable id for a `(conversation, provider)` couple, if any.
|
||||
fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option<String> {
|
||||
@ -606,10 +606,10 @@ impl ProviderSessionStore for FakeProviderSessionStore {
|
||||
if self.fail_set {
|
||||
return Err(StoreError::Io("forced set failure".to_owned()));
|
||||
}
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((conversation, provider_id.to_owned()), resumable_id.to_owned());
|
||||
self.entries.lock().unwrap().insert(
|
||||
(conversation, provider_id.to_owned()),
|
||||
resumable_id.to_owned(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -862,14 +862,21 @@ async fn non_structured_profile_takes_pty_path_unchanged() {
|
||||
|
||||
// PTY spawn appelé, factory jamais sollicitée.
|
||||
assert_eq!(f.pty.spawn_count(), 1, "pty path spawns");
|
||||
assert_eq!(f.factory.start_count(), 0, "factory not called for pty profile");
|
||||
assert_eq!(
|
||||
f.factory.start_count(),
|
||||
0,
|
||||
"factory not called for pty profile"
|
||||
);
|
||||
|
||||
// Session côté registre PTY, rien côté structuré.
|
||||
assert_eq!(f.sessions.session_for_agent(&f.agent.id), Some(sid(777)));
|
||||
assert!(f.structured.session_for_agent(&f.agent.id).is_none());
|
||||
|
||||
// output.structured = None ; session PTY classique.
|
||||
assert!(out.structured.is_none(), "no structured descriptor on pty path");
|
||||
assert!(
|
||||
out.structured.is_none(),
|
||||
"no structured descriptor on pty path"
|
||||
);
|
||||
assert_eq!(out.session.id, sid(777));
|
||||
}
|
||||
|
||||
@ -911,7 +918,11 @@ async fn structured_launch_new_in_other_cell_refuses_when_live_elsewhere() {
|
||||
"no second factory.start on a refused launch"
|
||||
);
|
||||
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
|
||||
assert_eq!(f.structured.len(), 1, "still a single live structured session");
|
||||
assert_eq!(
|
||||
f.structured.len(),
|
||||
1,
|
||||
"still a single live structured session"
|
||||
);
|
||||
assert_eq!(
|
||||
f.structured.node_for_agent(&f.agent.id),
|
||||
Some(host),
|
||||
@ -973,7 +984,11 @@ async fn structured_relaunch_other_cell_with_conversation_id_rebinds() {
|
||||
"no second factory.start on explicit reattach"
|
||||
);
|
||||
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
|
||||
assert_eq!(f.structured.len(), 1, "still a single live structured session");
|
||||
assert_eq!(
|
||||
f.structured.len(),
|
||||
1,
|
||||
"still a single live structured session"
|
||||
);
|
||||
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(target));
|
||||
let desc = out.structured.expect("descriptor on rebind");
|
||||
assert_eq!(desc.session_id, sid(500), "same live session id");
|
||||
@ -1125,7 +1140,11 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
f.pty.kills().is_empty(),
|
||||
"no PTY kill for a structured live session"
|
||||
);
|
||||
assert_eq!(f.pty.spawn_count(), 0, "no PTY spawn (target is structured)");
|
||||
assert_eq!(
|
||||
f.pty.spawn_count(),
|
||||
0,
|
||||
"no PTY spawn (target is structured)"
|
||||
);
|
||||
|
||||
// Relance via la factory : un nouveau start (donc total 2).
|
||||
assert_eq!(
|
||||
@ -1137,12 +1156,25 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
// `ChangeAgentProfileOutput` ne surface qu'un snapshot `relaunched` (TerminalSession)
|
||||
// — on vérifie donc le registre structuré + le snapshot.
|
||||
// Seed consumed id 600; the relaunch's session is the factory's next id (601).
|
||||
let relaunched = out.relaunched.expect("a live structured agent is relaunched");
|
||||
assert_eq!(relaunched.id, sid(601), "relaunch adopts the new structured id");
|
||||
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
||||
let relaunched = out
|
||||
.relaunched
|
||||
.expect("a live structured agent is relaunched");
|
||||
assert_eq!(
|
||||
relaunched.id,
|
||||
sid(601),
|
||||
"relaunch adopts the new structured id"
|
||||
);
|
||||
assert_eq!(
|
||||
relaunched.node_id, host,
|
||||
"relaunch reopens in the same cell"
|
||||
);
|
||||
assert_eq!(f.structured.session_id_for_agent(&agent.id), Some(sid(601)));
|
||||
assert_eq!(f.structured.node_for_agent(&agent.id), Some(host));
|
||||
assert_eq!(f.structured.len(), 1, "single live structured session after swap");
|
||||
assert_eq!(
|
||||
f.structured.len(),
|
||||
1,
|
||||
"single live structured session after swap"
|
||||
);
|
||||
// Manifeste muté vers le nouveau profil.
|
||||
assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
|
||||
}
|
||||
@ -1169,12 +1201,19 @@ async fn swap_pty_live_session_keeps_a1_kill_behaviour() {
|
||||
// A1 d'origine : le PTY vivant est tué.
|
||||
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
|
||||
// Aucune session structurée n'a été créée ni shutdown.
|
||||
assert_eq!(f.factory.start_count(), 0, "no structured start on pty swap");
|
||||
assert_eq!(
|
||||
f.factory.start_count(),
|
||||
0,
|
||||
"no structured start on pty swap"
|
||||
);
|
||||
assert_eq!(f.factory.shutdown_count(), 0, "no structured shutdown");
|
||||
// Relance PTY : un spawn, même cellule.
|
||||
assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once");
|
||||
let relaunched = out.relaunched.expect("a live agent is relaunched");
|
||||
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
||||
assert_eq!(
|
||||
relaunched.node_id, host,
|
||||
"relaunch reopens in the same cell"
|
||||
);
|
||||
assert_eq!(relaunched.id, sid(777));
|
||||
// The relaunched session lives in the PTY registry, not the structured one.
|
||||
assert_eq!(f.sessions.session_for_agent(&agent.id), Some(sid(777)));
|
||||
@ -1321,7 +1360,8 @@ fn launch_fixture_p8b(
|
||||
)
|
||||
.with_structured(Arc::new(factory), Arc::clone(&structured));
|
||||
if let Some(store) = store {
|
||||
let provider = Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
|
||||
let provider =
|
||||
Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
|
||||
launch = launch.with_provider_session_provider(provider);
|
||||
}
|
||||
(Arc::new(launch), agent)
|
||||
@ -1335,8 +1375,11 @@ fn launch_fixture_p8b(
|
||||
async fn p8b_nominal_claude_writes_engine_id_under_pair_and_claude_key() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, Some("engine-xyz"));
|
||||
let (launch, agent) =
|
||||
launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent) = launch_fixture_p8b(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
// La cellule porte un id de paire UUID valide : c'est lui qui sert de clé.
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
@ -1406,7 +1449,10 @@ async fn p8b_no_provider_wired_writes_nothing_launch_ok() {
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("launch ok without provider");
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("launch ok without provider");
|
||||
|
||||
assert_eq!(store.len(), 0, "no provider wired ⇒ nothing written");
|
||||
}
|
||||
@ -1418,14 +1464,20 @@ async fn p8b_no_engine_id_writes_nothing_launch_ok() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
// Factory avec conversation_id None ⇒ session.conversation_id() == None.
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent) =
|
||||
launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent) = launch_fixture_p8b(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("launch ok without engine id");
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("launch ok without engine id");
|
||||
|
||||
assert_eq!(
|
||||
store.len(),
|
||||
@ -1441,8 +1493,11 @@ async fn p8b_no_engine_id_writes_nothing_launch_ok() {
|
||||
async fn p8b_store_set_error_does_not_fail_launch() {
|
||||
let store = Arc::new(FakeProviderSessionStore::failing());
|
||||
let factory = FakeFactory::new(500, Some("engine-xyz"));
|
||||
let (launch, agent) =
|
||||
launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent) = launch_fixture_p8b(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
@ -1498,8 +1553,11 @@ async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
// Le moteur n'attribue pas d'id neuf (None) : le resume vient du store, pas du run.
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) =
|
||||
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
// Pré-remplit le store : (pair, "claude") → "engine-x".
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
@ -1509,7 +1567,10 @@ async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() {
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured launch resumes");
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("structured launch resumes");
|
||||
|
||||
// Le SessionPlan transmis à factory.start est Resume avec l'**engine id du store**.
|
||||
let starts = observed.starts();
|
||||
@ -1552,7 +1613,10 @@ async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() {
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured codex launch");
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("structured codex launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
@ -1572,8 +1636,11 @@ async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() {
|
||||
async fn p8c_provider_key_mismatch_falls_back_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) =
|
||||
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
// Engine id rangé sous une AUTRE clé provider.
|
||||
@ -1600,8 +1667,11 @@ async fn p8c_provider_key_mismatch_falls_back_to_none() {
|
||||
async fn p8c_structured_store_empty_resolves_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) =
|
||||
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
@ -1624,8 +1694,11 @@ async fn p8c_structured_store_empty_resolves_to_none() {
|
||||
async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) =
|
||||
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
// Cellule neuve : conversation_id = None.
|
||||
launch
|
||||
@ -1649,8 +1722,11 @@ async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() {
|
||||
async fn p8c_non_uuid_pair_id_with_store_resolves_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) =
|
||||
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some("not-a-uuid".to_owned());
|
||||
|
||||
@ -21,12 +21,8 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, PtyHandle, ReplyStream,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession,
|
||||
};
|
||||
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream};
|
||||
use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- petits constructeurs déterministes ------------------------------------
|
||||
@ -121,10 +117,7 @@ fn structured_live_agents_lists_triples() {
|
||||
|
||||
assert_eq!(
|
||||
live,
|
||||
vec![
|
||||
(aid(10), nid(100), sid(1)),
|
||||
(aid(20), nid(200), sid(2)),
|
||||
]
|
||||
vec![(aid(10), nid(100), sid(1)), (aid(20), nid(200), sid(2)),]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -366,11 +366,7 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_two_users() {
|
||||
assert_eq!(
|
||||
Conversation::try_new(
|
||||
conv_id(1),
|
||||
ConversationParty::User,
|
||||
ConversationParty::User
|
||||
),
|
||||
Conversation::try_new(conv_id(1), ConversationParty::User, ConversationParty::User),
|
||||
Err(ConversationError::TwoUsers)
|
||||
);
|
||||
}
|
||||
@ -428,10 +424,7 @@ mod tests {
|
||||
// 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"
|
||||
);
|
||||
assert!(!g.would_cycle(agent(2), agent(3)), "A→B→C is acyclic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -31,9 +31,7 @@ use crate::ports::StoreError;
|
||||
/// Newtype autour d'[`uuid::Uuid`], calqué sur [`crate::conversation::ConversationId`]
|
||||
/// / [`crate::mailbox::TicketId`]. Sa monotonie (un id frappé à l'`append`) sert de
|
||||
/// **curseur** à la relecture incrémentale ([`ConversationLog::read`] avec `since`).
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
|
||||
)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct TurnId(pub uuid::Uuid);
|
||||
|
||||
@ -199,11 +197,7 @@ pub struct Handoff {
|
||||
impl Handoff {
|
||||
/// Construit un handoff à partir de ses composants.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
summary_md: impl Into<String>,
|
||||
up_to: TurnId,
|
||||
objective: Option<String>,
|
||||
) -> Self {
|
||||
pub fn new(summary_md: impl Into<String>, up_to: TurnId, objective: Option<String>) -> Self {
|
||||
Self {
|
||||
summary_md: summary_md.into(),
|
||||
up_to,
|
||||
@ -235,11 +229,7 @@ pub trait HandoffStore: Send + Sync {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] en cas d'échec d'écriture ou de sérialisation.
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError>;
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Le résumeur incrémental de handoff (port driving, lot P4).
|
||||
@ -435,7 +425,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn turn_role_serializes_camel_case() {
|
||||
assert_eq!(serde_json::to_string(&TurnRole::Prompt).unwrap(), "\"prompt\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnRole::Prompt).unwrap(),
|
||||
"\"prompt\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnRole::Response).unwrap(),
|
||||
"\"response\""
|
||||
@ -581,7 +574,11 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
let last2 = log.last(c, 2).await.unwrap();
|
||||
assert_eq!(texts(&last2), vec!["c", "d"], "the 2 last, in insertion order");
|
||||
assert_eq!(
|
||||
texts(&last2),
|
||||
vec!["c", "d"],
|
||||
"the 2 last, in insertion order"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -599,7 +596,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c1, None).await.unwrap()),
|
||||
vec!["c1-a", "c1-b"]
|
||||
);
|
||||
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
|
||||
// A cursor from one thread never leaks tours from another.
|
||||
assert_eq!(texts(&log.last(c2, 10).await.unwrap()), vec!["c2-a"]);
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
//! presentation layer (ARCHITECTURE §3.2).
|
||||
|
||||
use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
|
||||
use crate::mailbox::TicketId;
|
||||
use crate::memory::MemorySlug;
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
@ -173,6 +174,28 @@ pub enum DomainEvent {
|
||||
/// Whether the in-process ONNX capability (`localOnnx`) is compiled in.
|
||||
vector_onnx_enabled: bool,
|
||||
},
|
||||
/// A delegation is ready to be injected into the agent's **native terminal**
|
||||
/// (ARCHITECTURE §20.2/§20.3). Published when a turn *starts* (Idle→Busy); the
|
||||
/// backend stays the authority of the FIFO/busy state and **no longer writes the
|
||||
/// turn into the PTY** — the frontend cell runs the write-portal handshake and
|
||||
/// writes `text` + `submit_sequence` through the single PTY writer (the front).
|
||||
/// Discrete, low-frequency (one per delegation). Relayed to the front as
|
||||
/// `delegationReady` like every other [`DomainEvent`].
|
||||
DelegationReady {
|
||||
/// The target agent whose terminal will receive the delegation.
|
||||
agent_id: AgentId,
|
||||
/// The mailbox ticket correlating this turn (acked back via
|
||||
/// `delegation_delivered`); the requester's `ask` is woken by `idea_reply`.
|
||||
ticket: TicketId,
|
||||
/// The task text to inject (written without a trailing `\n` by the portal).
|
||||
text: String,
|
||||
/// Target profile's submit sequence (the cell applies it after the text).
|
||||
/// `None` ⇒ the front applies its default (`"\r"`); never hard-coded in the
|
||||
/// domain.
|
||||
submit_sequence: Option<String>,
|
||||
/// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms).
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
|
||||
@ -97,6 +97,29 @@ impl AgentBusyState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-agent submit configuration (ARCHITECTURE §20.3), resolved from the target
|
||||
/// agent's profile and carried to the [`InputMediator`] at bind time so it can be
|
||||
/// echoed on a [`crate::events::DomainEvent::DelegationReady`].
|
||||
///
|
||||
/// Both fields stay `Option`: the **default** (`"\r"`, ~60 ms) is applied at the
|
||||
/// point of use (the frontend write-portal), never hard-coded in the domain — the
|
||||
/// domain only transports the declared values.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SubmitConfig {
|
||||
/// The profile's `submit_sequence` (e.g. `"\r"`). `None` ⇒ front default.
|
||||
pub sequence: Option<String>,
|
||||
/// The profile's `submit_delay_ms`. `None` ⇒ front default (~60 ms).
|
||||
pub delay_ms: Option<u32>,
|
||||
}
|
||||
|
||||
impl SubmitConfig {
|
||||
/// Builds a submit config from the two optional profile fields.
|
||||
#[must_use]
|
||||
pub const fn new(sequence: Option<String>, delay_ms: Option<u32>) -> Self {
|
||||
Self { sequence, delay_ms }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@ -108,12 +131,14 @@ 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.
|
||||
/// **Delivery** (ARCHITECTURE §20.2/§20.3): the mediator **no longer writes the
|
||||
/// turn into the PTY** (the `\n` band-aid is gone — it never submitted in raw mode
|
||||
/// and produced a "double chat"). Instead, on the enqueue that **starts a turn**
|
||||
/// (Idle→Busy), the adapter publishes a [`crate::events::DomainEvent::DelegationReady`]
|
||||
/// carrying the task text + ticket + the target's [`SubmitConfig`]; the frontend
|
||||
/// cell (sole owner of the terminal) runs the write-portal handshake and writes the
|
||||
/// text + submit sequence through the single PTY writer. The mediator stays the
|
||||
/// authority of the FIFO/busy state and correlation only.
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
|
||||
|
||||
/// Registers (or refreshes) the live input [`PtyHandle`] of `agent` so a later
|
||||
@ -134,21 +159,30 @@ pub trait InputMediator: Send + Sync {
|
||||
/// 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.
|
||||
/// It also records the target's [`SubmitConfig`] (ARCHITECTURE §20.3) so the
|
||||
/// adapter can echo `submit_sequence`/`submit_delay_ms` on the
|
||||
/// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts.
|
||||
/// The bind is the natural carrier: both the prompt pattern and the submit config
|
||||
/// are per-agent profile data the orchestrator resolves at the same time, so they
|
||||
/// travel together (no extra ticket field, no second resolve).
|
||||
///
|
||||
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and
|
||||
/// submit config (a mediator that does not observe the output stream). The infra
|
||||
/// adapter overrides it to arm the watcher and stash the submit config.
|
||||
fn bind_handle_with_prompt(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
handle: PtyHandle,
|
||||
_prompt_ready_pattern: Option<String>,
|
||||
_submit: SubmitConfig,
|
||||
) {
|
||||
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`.
|
||||
/// **Deprecated since ARCHITECTURE §20**: the mediator never writes the turn into
|
||||
/// the PTY anymore — the **frontend** always delivers (via the write-portal). Kept
|
||||
/// for source compatibility; it now always returns `false`. Callers should stop
|
||||
/// branching on it (the orchestrator no longer falls back to its own PTY write).
|
||||
#[must_use]
|
||||
fn delivers_turn(&self, _agent: AgentId) -> bool {
|
||||
false
|
||||
|
||||
@ -40,16 +40,7 @@ use crate::input::InputSource;
|
||||
/// 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,
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct TicketId(pub uuid::Uuid);
|
||||
@ -189,9 +180,7 @@ pub struct PendingReply {
|
||||
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 {
|
||||
pub fn new(inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
@ -286,10 +275,7 @@ mod tests {
|
||||
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())
|
||||
);
|
||||
assert_eq!(t.conversation, ConversationId::from_uuid(uuid::Uuid::nil()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -322,9 +308,6 @@ mod tests {
|
||||
#[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
|
||||
);
|
||||
assert_ne!(MailboxError::NoPendingRequest(a), MailboxError::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
@ -325,6 +325,28 @@ pub struct AgentProfile {
|
||||
/// un profil sans motif sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_ready_pattern: Option<String>,
|
||||
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
|
||||
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
|
||||
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
|
||||
/// la TUI), puis **cette séquence seule** après un court délai.
|
||||
///
|
||||
/// `None` ⇒ défaut `"\r"` appliqué **au point d'usage** (front), jamais codé
|
||||
/// en dur dans le domaine (model-agnostic, déclaratif). Une autre TUI peut
|
||||
/// exiger une séquence différente → c'est précisément pourquoi c'est donnée.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** : un profil
|
||||
/// sans cette clé sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_sequence: Option<String>,
|
||||
/// Délai (ms) entre l'écriture du texte et celle de [`Self::submit_sequence`]
|
||||
/// (§20.3, fix Bug 1). Évite que la TUI absorbe la soumission comme un paste.
|
||||
///
|
||||
/// `None` ⇒ défaut (~60 ms) appliqué **au point d'usage** (front), jamais codé
|
||||
/// en dur dans le domaine.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_delay_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
|
||||
@ -461,6 +483,8 @@ impl AgentProfile {
|
||||
structured_adapter: None,
|
||||
mcp: None,
|
||||
prompt_ready_pattern: None,
|
||||
submit_sequence: None,
|
||||
submit_delay_ms: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -491,6 +515,23 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
|
||||
#[must_use]
|
||||
pub fn with_submit_sequence(mut self, sequence: impl Into<String>) -> Self {
|
||||
self.submit_sequence = Some(sequence.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe le [`Self::submit_delay_ms`] (§20.3, fix Bug 1) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel).
|
||||
#[must_use]
|
||||
pub const fn with_submit_delay_ms(mut self, delay_ms: u32) -> Self {
|
||||
self.submit_delay_ms = Some(delay_ms);
|
||||
self
|
||||
}
|
||||
|
||||
/// Indique si ce profil peut être **proposé à la sélection/création** d'un
|
||||
/// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est
|
||||
/// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il
|
||||
@ -611,9 +652,8 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn mcp_config_strategy_uses_tagged_camel_case() {
|
||||
// The `strategy` tag and camelCase rename are part of the wire contract.
|
||||
let json = serde_json::to_string(
|
||||
&McpConfigStrategy::config_file(".mcp.json").expect("valid"),
|
||||
)
|
||||
let json =
|
||||
serde_json::to_string(&McpConfigStrategy::config_file(".mcp.json").expect("valid"))
|
||||
.expect("serialise");
|
||||
assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}");
|
||||
}
|
||||
@ -651,7 +691,12 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn config_file_accepts_safe_relative_target() {
|
||||
let s = McpConfigStrategy::config_file(".mcp.json").expect("safe relative");
|
||||
assert_eq!(s, McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() });
|
||||
assert_eq!(
|
||||
s,
|
||||
McpConfigStrategy::ConfigFile {
|
||||
target: ".mcp.json".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -663,7 +708,12 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn flag_accepts_non_empty() {
|
||||
let s = McpConfigStrategy::flag("--mcp-config {path}").expect("non-empty");
|
||||
assert_eq!(s, McpConfigStrategy::Flag { flag: "--mcp-config {path}".to_owned() });
|
||||
assert_eq!(
|
||||
s,
|
||||
McpConfigStrategy::Flag {
|
||||
flag: "--mcp-config {path}".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -675,7 +725,12 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn env_accepts_valid_name() {
|
||||
let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid");
|
||||
assert_eq!(s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() });
|
||||
assert_eq!(
|
||||
s,
|
||||
McpConfigStrategy::Env {
|
||||
var: "IDEA_MCP_SERVER".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) --------------
|
||||
@ -721,4 +776,60 @@ mod mcp_tests {
|
||||
assert_eq!(profile, back);
|
||||
assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> "));
|
||||
}
|
||||
|
||||
// -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------
|
||||
|
||||
#[test]
|
||||
fn profile_default_has_no_submit_fields() {
|
||||
let p = profile_without_mcp();
|
||||
assert!(p.submit_sequence.is_none());
|
||||
assert!(p.submit_delay_ms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_submit_fields_omits_keys_in_json() {
|
||||
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("submitSequence"),
|
||||
"no submitSequence key when None (zero regression); got: {json}"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("submitDelayMs"),
|
||||
"no submitDelayMs key when None (zero regression); got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_submit_fields_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 p: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
|
||||
assert!(p.submit_sequence.is_none());
|
||||
assert!(p.submit_delay_ms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_submit_fields_set_and_round_trip_camel_case() {
|
||||
let p = profile_without_mcp()
|
||||
.with_submit_sequence("\r")
|
||||
.with_submit_delay_ms(60);
|
||||
assert_eq!(p.submit_sequence.as_deref(), Some("\r"));
|
||||
assert_eq!(p.submit_delay_ms, Some(60));
|
||||
|
||||
let json = serde_json::to_string(&p).expect("serialise");
|
||||
assert!(json.contains("submitSequence"), "key present: {json}");
|
||||
assert!(json.contains("submitDelayMs"), "key present: {json}");
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(p, back);
|
||||
assert_eq!(back.submit_sequence.as_deref(), Some("\r"));
|
||||
assert_eq!(back.submit_delay_ms, Some(60));
|
||||
}
|
||||
}
|
||||
|
||||
@ -194,13 +194,19 @@ fn leaf_returns_none_for_grid_node_id() {
|
||||
// L'id de la grille n'est pas une feuille.
|
||||
assert!(grid.leaf(node(50)).is_none());
|
||||
// La feuille imbriquée est bien retrouvée.
|
||||
assert_eq!(*grid.leaf(node(3)).expect("nested leaf"), leaf_cell(3, None));
|
||||
assert_eq!(
|
||||
*grid.leaf(node(3)).expect("nested leaf"),
|
||||
leaf_cell(3, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_finds_single_root_leaf() {
|
||||
let tree = LayoutTree::single(leaf_cell(7, Some(70)));
|
||||
assert_eq!(*tree.leaf(node(7)).expect("root leaf"), leaf_cell(7, Some(70)));
|
||||
assert_eq!(
|
||||
*tree.leaf(node(7)).expect("root leaf"),
|
||||
leaf_cell(7, Some(70))
|
||||
);
|
||||
assert!(tree.leaf(node(8)).is_none());
|
||||
}
|
||||
|
||||
|
||||
@ -837,12 +837,7 @@ fn move_session_preserves_resume_fields_on_both_leaves() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Builds an agent-bearing leaf with explicit resume signals.
|
||||
fn agent_leaf_full(
|
||||
id: u128,
|
||||
agent: u128,
|
||||
conv: Option<&str>,
|
||||
running: bool,
|
||||
) -> LeafCell {
|
||||
fn agent_leaf_full(id: u128, agent: u128, conv: Option<&str>, running: bool) -> LeafCell {
|
||||
LeafCell {
|
||||
id: node(id),
|
||||
session: None,
|
||||
|
||||
@ -150,7 +150,9 @@ fn profile_with_adapter_roundtrips_and_uses_camel_case() {
|
||||
#[test]
|
||||
fn with_structured_adapter_sets_only_that_field() {
|
||||
let before = pty_profile();
|
||||
let after = before.clone().with_structured_adapter(StructuredAdapter::Codex);
|
||||
let after = before
|
||||
.clone()
|
||||
.with_structured_adapter(StructuredAdapter::Codex);
|
||||
|
||||
// Le seul champ muté :
|
||||
assert_eq!(after.structured_adapter, Some(StructuredAdapter::Codex));
|
||||
@ -275,7 +277,10 @@ fn agent_session_error_is_std_error_and_equates() {
|
||||
AgentSessionError::Start("a".into()),
|
||||
AgentSessionError::Start("b".into())
|
||||
);
|
||||
assert_ne!(AgentSessionError::Timeout, AgentSessionError::Io("t".into()));
|
||||
assert_ne!(
|
||||
AgentSessionError::Timeout,
|
||||
AgentSessionError::Io("t".into())
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -191,11 +191,7 @@ impl HandoffStore for FsHandoffStore {
|
||||
deserialize(&content).map(Some)
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
let body = serialize(&handoff);
|
||||
|
||||
let dir = self.conversation_dir(conversation);
|
||||
|
||||
@ -25,8 +25,8 @@ 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::input::{AgentBusyState, InputMediator, SubmitConfig};
|
||||
use domain::mailbox::{AgentMailbox, PendingReply, Ticket, TicketId};
|
||||
use domain::ports::{EventBus, PtyHandle, PtyPort};
|
||||
|
||||
use crate::mailbox::InMemoryMailbox;
|
||||
@ -122,10 +122,13 @@ impl MillisClock for SystemMillisClock {
|
||||
|
||||
/// 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).
|
||||
/// Since ARCHITECTURE §20, `enqueue` **no longer writes the turn into the PTY** (the
|
||||
/// `\n` band-aid is gone — it never submitted in raw mode and produced a "double chat").
|
||||
/// On the enqueue that starts a turn (Idle→Busy) it publishes a
|
||||
/// [`DomainEvent::DelegationReady`] carrying the task text + ticket + the target's
|
||||
/// submit config; the **frontend** write-portal owns the single physical PTY write. A
|
||||
/// wired [`PtyPort`] is kept only to observe the output stream for prompt-ready
|
||||
/// detection (lot C5) and to deliver the `preempt` interrupt byte.
|
||||
pub struct MediatedInbox {
|
||||
mailbox: Arc<InMemoryMailbox>,
|
||||
/// Shared busy/idle authority (also handed to prompt-ready watcher threads, C5).
|
||||
@ -136,6 +139,10 @@ pub struct MediatedInbox {
|
||||
pty: Option<Arc<dyn PtyPort>>,
|
||||
/// Per-agent live input handle (one stream per agent), fed by `bind_handle`.
|
||||
handles: Mutex<HashMap<AgentId, PtyHandle>>,
|
||||
/// Per-agent submit config (target profile's `submit_sequence`/`submit_delay_ms`),
|
||||
/// stashed at bind time (§20.3) and echoed on the `DelegationReady` event when a
|
||||
/// turn starts. Absent ⇒ both `None` (the front applies its defaults).
|
||||
submit: Mutex<HashMap<AgentId, SubmitConfig>>,
|
||||
/// 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.
|
||||
@ -153,6 +160,7 @@ impl MediatedInbox {
|
||||
clock,
|
||||
pty: None,
|
||||
handles: Mutex::new(HashMap::new()),
|
||||
submit: Mutex::new(HashMap::new()),
|
||||
watched: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
@ -181,6 +189,7 @@ impl MediatedInbox {
|
||||
clock,
|
||||
pty: Some(pty),
|
||||
handles: Mutex::new(HashMap::new()),
|
||||
submit: Mutex::new(HashMap::new()),
|
||||
watched: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
@ -188,7 +197,10 @@ impl MediatedInbox {
|
||||
/// 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))
|
||||
Self::new(
|
||||
Arc::new(InMemoryMailbox::new()),
|
||||
Arc::new(SystemMillisClock),
|
||||
)
|
||||
}
|
||||
|
||||
fn handles(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, PtyHandle>> {
|
||||
@ -197,6 +209,12 @@ impl MediatedInbox {
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn submit(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, SubmitConfig>> {
|
||||
self.submit
|
||||
.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> {
|
||||
@ -250,10 +268,7 @@ impl MediatedInbox {
|
||||
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())
|
||||
{
|
||||
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;
|
||||
@ -273,6 +288,17 @@ impl MediatedInbox {
|
||||
}
|
||||
}
|
||||
|
||||
/// Composes the line delivered into the target's terminal for a delegated turn.
|
||||
///
|
||||
/// The `[IdeA · tâche de <requester> · ticket <id>]` header is the protocol signal
|
||||
/// (cadrage B-5, agent context) that marks the message as an IdeA delegation the
|
||||
/// target must answer via `idea_reply` — echoing `ticket` for multi-thread
|
||||
/// correlation — rather than as a free-text human prompt. The raw `task` follows on
|
||||
/// the next line so the agent reads the request unchanged.
|
||||
fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String {
|
||||
format!("[IdeA · tâche de {requester} · ticket {ticket}]\n{task}")
|
||||
}
|
||||
|
||||
impl InputMediator for MediatedInbox {
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
||||
let ticket_id = ticket.id;
|
||||
@ -286,29 +312,36 @@ impl InputMediator for MediatedInbox {
|
||||
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).
|
||||
// Publish only on the enqueue that **starts** a turn (Idle→Busy); a second
|
||||
// enqueue while Busy queues behind without re-announcing (cadrage C4 §4.2,
|
||||
// ARCHITECTURE §20). Published outside the busy mutex (the tracker released it
|
||||
// above). On a started turn we emit BOTH the advisory busy beacon AND the new
|
||||
// `DelegationReady` carrying the task text + ticket + the target's submit
|
||||
// config: the backend NO LONGER writes the turn into the PTY (the `\n` band-aid
|
||||
// is gone — it never submitted in raw mode). The frontend write-portal owns the
|
||||
// physical write (text + submit_sequence) through the single PTY writer.
|
||||
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());
|
||||
let submit = self.submit().get(&agent).cloned().unwrap_or_default();
|
||||
events.publish(DomainEvent::DelegationReady {
|
||||
agent_id: agent,
|
||||
ticket: ticket_id,
|
||||
// Préfixe de délégation : c'est le **signal** qui dit à la cible
|
||||
// « ceci est une tâche IdeA, réponds via `idea_reply` (jamais en
|
||||
// texte) en renvoyant ce `ticket` ». Sans lui la cible traite la
|
||||
// ligne comme une invite humaine et répond dans le terminal, et
|
||||
// l'agent demandeur reste bloqué jusqu'au timeout. La tâche brute
|
||||
// reste dans le `Ticket` (mailbox/historique) ; seul le texte livré
|
||||
// au PTY porte le préfixe. Format aligné sur l'instruction injectée
|
||||
// dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`).
|
||||
text: delegation_preamble(&ticket.requester, ticket_id, &ticket.task),
|
||||
submit_sequence: submit.sequence,
|
||||
submit_delay_ms: submit.delay_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.mailbox.enqueue(agent, ticket)
|
||||
@ -323,12 +356,15 @@ impl InputMediator for MediatedInbox {
|
||||
agent: AgentId,
|
||||
handle: PtyHandle,
|
||||
prompt_ready_pattern: Option<String>,
|
||||
submit: SubmitConfig,
|
||||
) {
|
||||
// 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).
|
||||
// Register the input handle exactly like `bind_handle`, stash the target's
|
||||
// submit config (echoed on `DelegationReady` at the next turn start, §20.3),
|
||||
// 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());
|
||||
self.submit().insert(agent, submit);
|
||||
if let Some(pattern) = prompt_ready_pattern {
|
||||
self.arm_prompt_watcher(agent, &handle, pattern);
|
||||
}
|
||||
@ -392,7 +428,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn inbox_at(now_ms: u64) -> MediatedInbox {
|
||||
MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(now_ms)))
|
||||
MediatedInbox::new(
|
||||
Arc::new(InMemoryMailbox::new()),
|
||||
Arc::new(FixedClock(now_ms)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Records every [`DomainEvent`] published, for busy/idle assertions.
|
||||
@ -421,6 +460,32 @@ mod tests {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `(ticket, text, submit_sequence, submit_delay_ms)` of every
|
||||
/// `DelegationReady` published, in order.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn delegation_ready(&self) -> Vec<(TicketId, String, Option<String>, Option<u32>)> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
DomainEvent::DelegationReady {
|
||||
ticket,
|
||||
text,
|
||||
submit_sequence,
|
||||
submit_delay_ms,
|
||||
..
|
||||
} => Some((
|
||||
*ticket,
|
||||
text.clone(),
|
||||
submit_sequence.clone(),
|
||||
*submit_delay_ms,
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -436,7 +501,11 @@ mod tests {
|
||||
|
||||
// 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");
|
||||
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);
|
||||
@ -459,6 +528,123 @@ mod tests {
|
||||
assert_eq!(bus.busy_events(), vec![(a, true)]);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// §20 — enqueue no longer PTY-writes; it publishes DelegationReady
|
||||
// ====================================================================
|
||||
|
||||
#[test]
|
||||
fn enqueue_publishes_exactly_one_delegation_ready_on_idle_to_busy() {
|
||||
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);
|
||||
|
||||
// Idle→Busy ⇒ exactly one DelegationReady carrying the task text + ticket.
|
||||
inbox.enqueue(a, ticket(10, "do the thing"));
|
||||
let ready = bus.delegation_ready();
|
||||
assert_eq!(ready.len(), 1, "exactly one DelegationReady on turn start");
|
||||
let tid = TicketId::from_uuid(uuid::Uuid::from_u128(10));
|
||||
assert_eq!(ready[0].0, tid);
|
||||
// The delivered text carries the delegation prefix (the signal to answer via
|
||||
// `idea_reply`) followed by the raw task on the next line.
|
||||
assert_eq!(ready[0].1, delegation_preamble("User", tid, "do the thing"));
|
||||
assert!(
|
||||
ready[0].1.starts_with("[IdeA · tâche de User · ticket "),
|
||||
"delivered text must carry the delegation prefix: {:?}",
|
||||
ready[0].1
|
||||
);
|
||||
assert!(
|
||||
ready[0].1.ends_with("\ndo the thing"),
|
||||
"raw task must follow the prefix line: {:?}",
|
||||
ready[0].1
|
||||
);
|
||||
|
||||
// A second enqueue while Busy must NOT re-publish (consistent with started_turn).
|
||||
inbox.enqueue(a, ticket(11, "queued"));
|
||||
assert_eq!(
|
||||
bus.delegation_ready().len(),
|
||||
1,
|
||||
"no DelegationReady on a re-enqueue while Busy"
|
||||
);
|
||||
|
||||
// After mark_idle, a fresh enqueue starts a new turn ⇒ a second DelegationReady.
|
||||
inbox.mark_idle(a);
|
||||
inbox.enqueue(a, ticket(12, "next turn"));
|
||||
let ready = bus.delegation_ready();
|
||||
assert_eq!(ready.len(), 2, "new turn ⇒ a new DelegationReady");
|
||||
assert!(
|
||||
ready[1].1.ends_with("\nnext turn"),
|
||||
"second turn delivers its own prefixed task: {:?}",
|
||||
ready[1].1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegation_ready_carries_bound_submit_config() {
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let inbox = MediatedInbox::with_pty(
|
||||
Arc::new(InMemoryMailbox::new()),
|
||||
Arc::new(FixedClock(1)),
|
||||
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
||||
)
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
let a = agent(1);
|
||||
let h = handle(1);
|
||||
|
||||
// Bind the target's submit config (resolved from its profile by the service).
|
||||
inbox.bind_handle_with_prompt(
|
||||
a,
|
||||
h,
|
||||
None,
|
||||
SubmitConfig::new(Some("\r".to_owned()), Some(42)),
|
||||
);
|
||||
inbox.enqueue(a, ticket(10, "task"));
|
||||
|
||||
let ready = bus.delegation_ready();
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].2.as_deref(), Some("\r"), "submit_sequence echoed");
|
||||
assert_eq!(ready[0].3, Some(42), "submit_delay_ms echoed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_without_bound_submit_yields_none_fields() {
|
||||
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, "task"));
|
||||
let ready = bus.delegation_ready();
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(
|
||||
ready[0].2, None,
|
||||
"no bound submit ⇒ None (front applies default)"
|
||||
);
|
||||
assert_eq!(ready[0].3, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_does_not_write_into_the_pty() {
|
||||
// The whole point of §20: the backend stops writing the turn into the PTY.
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let inbox = MediatedInbox::with_pty(
|
||||
Arc::new(InMemoryMailbox::new()),
|
||||
Arc::new(FixedClock(1)),
|
||||
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
||||
);
|
||||
let a = agent(1);
|
||||
let h = handle(1);
|
||||
inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default());
|
||||
|
||||
inbox.enqueue(a, ticket(10, "do X"));
|
||||
inbox.enqueue(a, ticket(11, "do Y")); // even a second enqueue must not write
|
||||
|
||||
assert!(
|
||||
pty.writes.lock().unwrap().is_empty(),
|
||||
"enqueue must perform NO pty.write (the `\\n` band-aid is gone)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
|
||||
let inbox = inbox_at(5);
|
||||
@ -562,9 +748,9 @@ mod tests {
|
||||
// Lot C5 — prompt-ready detection on the PTY output stream
|
||||
// ====================================================================
|
||||
|
||||
use domain::ids::SessionId;
|
||||
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
|
||||
@ -659,7 +845,7 @@ mod tests {
|
||||
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()));
|
||||
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
|
||||
|
||||
assert!(
|
||||
wait_until(|| !inbox.busy_state(a).is_busy()),
|
||||
@ -676,7 +862,7 @@ mod tests {
|
||||
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()));
|
||||
inbox.bind_handle_with_prompt(a, h, Some("READY".to_owned()), SubmitConfig::default());
|
||||
assert!(
|
||||
wait_until(|| !inbox.busy_state(a).is_busy()),
|
||||
"a marker split across chunks must still flip to Idle"
|
||||
@ -692,7 +878,7 @@ mod tests {
|
||||
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);
|
||||
inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default());
|
||||
// Give any (erroneously spawned) watcher a chance to fire — it must not.
|
||||
std::thread::sleep(std::time::Duration::from_millis(80));
|
||||
assert!(
|
||||
@ -701,7 +887,11 @@ mod tests {
|
||||
);
|
||||
// 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");
|
||||
assert_eq!(
|
||||
inbox.mailbox().pending(&a),
|
||||
2,
|
||||
"enqueue accepted while Busy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -713,7 +903,7 @@ mod tests {
|
||||
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()));
|
||||
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
|
||||
// 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));
|
||||
@ -737,7 +927,7 @@ mod tests {
|
||||
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()));
|
||||
inbox.bind_handle_with_prompt(a, h, Some(String::new()), SubmitConfig::default());
|
||||
std::thread::sleep(std::time::Duration::from_millis(60));
|
||||
assert!(
|
||||
inbox.busy_state(a).is_busy(),
|
||||
@ -757,15 +947,19 @@ mod tests {
|
||||
inbox.enqueue(a, ticket(11, "second"));
|
||||
assert_eq!(
|
||||
inbox.busy_state(a).ticket(),
|
||||
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(10)))
|
||||
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(
|
||||
10
|
||||
)))
|
||||
);
|
||||
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()));
|
||||
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
|
||||
// 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))),
|
||||
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(
|
||||
12
|
||||
))),
|
||||
"after prompt-ready Idle, a new enqueue starts the next turn"
|
||||
);
|
||||
}
|
||||
@ -781,8 +975,13 @@ mod tests {
|
||||
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()));
|
||||
inbox.bind_handle_with_prompt(
|
||||
a,
|
||||
h.clone(),
|
||||
Some("ZZZ".to_owned()),
|
||||
SubmitConfig::default(),
|
||||
);
|
||||
inbox.bind_handle_with_prompt(a, h, Some("ZZZ".to_owned()), SubmitConfig::default());
|
||||
std::thread::sleep(std::time::Duration::from_millis(60));
|
||||
assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy");
|
||||
}
|
||||
|
||||
@ -38,10 +38,10 @@ pub use conversation_log::{
|
||||
};
|
||||
pub use eventbus::TokioBroadcastEventBus;
|
||||
pub use fileguard::RwFileGuard;
|
||||
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
|
||||
pub use fs::LocalFileSystem;
|
||||
pub use git::Git2Repository;
|
||||
pub use id::UuidGenerator;
|
||||
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
|
||||
pub use inspector::ClaudeTranscriptInspector;
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
|
||||
|
||||
@ -192,13 +192,19 @@ mod tests {
|
||||
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))));
|
||||
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))));
|
||||
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");
|
||||
}
|
||||
@ -238,7 +244,10 @@ mod tests {
|
||||
// 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))));
|
||||
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));
|
||||
@ -256,6 +265,9 @@ mod tests {
|
||||
// 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))));
|
||||
assert_eq!(
|
||||
mb.head_ticket(&a),
|
||||
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -219,9 +219,7 @@ impl McpServer {
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
JsonRpcError::new(error_codes::INVALID_PARAMS, "missing tool `name`")
|
||||
})?
|
||||
.ok_or_else(|| JsonRpcError::new(error_codes::INVALID_PARAMS, "missing tool `name`"))?
|
||||
.to_owned();
|
||||
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
|
||||
@ -238,7 +236,10 @@ impl McpServer {
|
||||
match result {
|
||||
Ok(outcome) => {
|
||||
// The text the agent sees: the reply for an `ask`, else the detail.
|
||||
let text = outcome.reply.clone().unwrap_or_else(|| outcome.detail.clone());
|
||||
let text = outcome
|
||||
.reply
|
||||
.clone()
|
||||
.unwrap_or_else(|| outcome.detail.clone());
|
||||
Ok(tool_result_text(&text, false))
|
||||
}
|
||||
// A failed IdeA command is reported as a tool execution error
|
||||
|
||||
@ -242,9 +242,8 @@ pub fn map_tool_call(
|
||||
.ok_or_else(|| ToolMapError::BadArguments(name.to_owned()))?;
|
||||
|
||||
// Small helpers to pull optional string fields out of the arguments object.
|
||||
let s = |key: &str| -> Option<String> {
|
||||
args.get(key).and_then(Value::as_str).map(str::to_owned)
|
||||
};
|
||||
let s =
|
||||
|key: &str| -> Option<String> { args.get(key).and_then(Value::as_str).map(str::to_owned) };
|
||||
|
||||
// Translate the tool into the wire-level request shape the watcher already
|
||||
// uses (v2 `type`), then let `validate` enforce the invariants once.
|
||||
@ -361,7 +360,9 @@ fn base() -> OrchestratorRequest {
|
||||
/// missing its node, with a precise field error).
|
||||
fn parse_node_id(value: Option<&Value>) -> Option<domain::NodeId> {
|
||||
let raw = value?.as_str()?;
|
||||
uuid::Uuid::parse_str(raw).ok().map(domain::NodeId::from_uuid)
|
||||
uuid::Uuid::parse_str(raw)
|
||||
.ok()
|
||||
.map(domain::NodeId::from_uuid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -413,7 +414,9 @@ mod tests {
|
||||
fn stop_update_and_skill_map_to_their_commands() {
|
||||
assert_eq!(
|
||||
map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
|
||||
OrchestratorCommand::StopAgent { name: "Dev".to_owned() }
|
||||
OrchestratorCommand::StopAgent {
|
||||
name: "Dev".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
map(
|
||||
|
||||
@ -108,9 +108,7 @@ impl MemoryTransport {
|
||||
/// signal EOF. Returns the transport and a receiver of everything the server
|
||||
/// `send`s back.
|
||||
#[must_use]
|
||||
pub fn scripted(
|
||||
messages: Vec<Vec<u8>>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Vec<u8>>) {
|
||||
pub fn scripted(messages: Vec<Vec<u8>>) -> (Self, mpsc::UnboundedReceiver<Vec<u8>>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(
|
||||
Self {
|
||||
|
||||
@ -165,7 +165,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
parsed.events,
|
||||
vec![
|
||||
ReplyEvent::TextDelta { text: "un".to_owned() },
|
||||
ReplyEvent::TextDelta {
|
||||
text: "un".to_owned()
|
||||
},
|
||||
ReplyEvent::ToolActivity {
|
||||
label: "Read".to_owned()
|
||||
},
|
||||
@ -216,8 +218,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn codex_parse_thread_started_message_and_final() {
|
||||
let sess = codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#)
|
||||
.expect("ok");
|
||||
let sess =
|
||||
codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#).expect("ok");
|
||||
assert_eq!(sess.conversation_id.as_deref(), Some("cx-9"));
|
||||
assert!(sess.events.is_empty());
|
||||
|
||||
@ -371,7 +373,11 @@ mod tests {
|
||||
expected.insert("gemini", false);
|
||||
expected.insert("aider", false);
|
||||
|
||||
assert_eq!(profiles.len(), 4, "catalogue has the four reference profiles");
|
||||
assert_eq!(
|
||||
profiles.len(),
|
||||
4,
|
||||
"catalogue has the four reference profiles"
|
||||
);
|
||||
for profile in &profiles {
|
||||
let selectable = profile.is_selectable();
|
||||
assert_eq!(
|
||||
@ -673,9 +679,8 @@ mod tests {
|
||||
label: "reasoning".into()
|
||||
}]
|
||||
);
|
||||
let c = codex::parse_event(
|
||||
r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#,
|
||||
)
|
||||
let c =
|
||||
codex::parse_event(r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
c.events,
|
||||
@ -707,7 +712,12 @@ mod tests {
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(m.events, vec![ReplyEvent::Final { content: String::new() }]);
|
||||
assert_eq!(
|
||||
m.events,
|
||||
vec![ReplyEvent::Final {
|
||||
content: String::new()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// `thread.started` capte le `thread_id` ; pas d'événement.
|
||||
@ -730,10 +740,12 @@ mod tests {
|
||||
.unwrap()
|
||||
.events
|
||||
.is_empty());
|
||||
assert!(codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#)
|
||||
assert!(
|
||||
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#)
|
||||
.unwrap()
|
||||
.events
|
||||
.is_empty());
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Machinerie process via FakeCli ---------------------------------
|
||||
@ -1160,7 +1172,11 @@ mod tests {
|
||||
assert!(args.contains(&"--skip-git-repo-check"), "vu: {args:?}");
|
||||
assert!(args.contains(&"salut"), "vu: {args:?}");
|
||||
// `exec` est bien la 1re sous-commande (position 0 de l'argv).
|
||||
assert_eq!(args.first(), Some(&"exec"), "exec doit ouvrir l'argv, vu: {args:?}");
|
||||
assert_eq!(
|
||||
args.first(),
|
||||
Some(&"exec"),
|
||||
"exec doit ouvrir l'argv, vu: {args:?}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
@ -71,7 +71,8 @@ impl TempDir {
|
||||
}
|
||||
/// `<root>/.ideai/conversations/<conversationId>/providers.json.tmp` — the atomic-write tmp (P5).
|
||||
fn providers_tmp_path(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.conversation_dir(conversation).join("providers.json.tmp")
|
||||
self.conversation_dir(conversation)
|
||||
.join("providers.json.tmp")
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
@ -119,10 +120,13 @@ async fn append_writes_one_valid_json_line_per_turn() {
|
||||
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
assert_eq!(lines.len(), 3, "one line per append, got: {raw:?}");
|
||||
for line in &lines {
|
||||
let parsed: ConversationTurn =
|
||||
serde_json::from_str(line).unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"));
|
||||
let parsed: ConversationTurn = serde_json::from_str(line)
|
||||
.unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"));
|
||||
// camelCase shape leaks through to the file (sanity on the persisted format).
|
||||
assert!(line.contains("\"atMs\""), "expected camelCase atMs in {line:?}");
|
||||
assert!(
|
||||
line.contains("\"atMs\""),
|
||||
"expected camelCase atMs in {line:?}"
|
||||
);
|
||||
let _ = parsed;
|
||||
}
|
||||
}
|
||||
@ -144,7 +148,9 @@ async fn persists_across_a_fresh_instance_restart() {
|
||||
(2, TurnRole::Response, "b"),
|
||||
(3, TurnRole::Prompt, "c"),
|
||||
] {
|
||||
log.append(c, turn(c, turn_id(id), role, txt)).await.unwrap();
|
||||
log.append(c, turn(c, turn_id(id), role, txt))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@ -184,7 +190,10 @@ async fn conversations_land_in_disjoint_files() {
|
||||
assert_ne!(p1, p2, "the two conversations must use distinct files");
|
||||
|
||||
// No cross-leak through the API, and the c2 file holds only c2's single line.
|
||||
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c1, None).await.unwrap()),
|
||||
vec!["c1-a", "c1-b"]
|
||||
);
|
||||
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
|
||||
let c2_raw = std::fs::read_to_string(&p2).unwrap();
|
||||
assert_eq!(
|
||||
@ -192,7 +201,10 @@ async fn conversations_land_in_disjoint_files() {
|
||||
1,
|
||||
"c2 file must not contain c1's turns"
|
||||
);
|
||||
assert!(!c2_raw.contains("c1-"), "no c1 content leaked into c2's file");
|
||||
assert!(
|
||||
!c2_raw.contains("c1-"),
|
||||
"no c1 content leaked into c2's file"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -228,11 +240,21 @@ async fn corrupted_line_is_skipped_without_panic() {
|
||||
// A fresh instance must skip the junk line and return only the two valid turns.
|
||||
let log = FsConversationLog::new(&tmp.project_path());
|
||||
let all = log.read(c, None).await.unwrap();
|
||||
assert_eq!(texts(&all), vec!["good-1", "good-2"], "garbage line skipped");
|
||||
assert_eq!(
|
||||
texts(&all),
|
||||
vec!["good-1", "good-2"],
|
||||
"garbage line skipped"
|
||||
);
|
||||
// `last` must be just as tolerant.
|
||||
assert_eq!(texts(&log.last(c, 5).await.unwrap()), vec!["good-1", "good-2"]);
|
||||
assert_eq!(
|
||||
texts(&log.last(c, 5).await.unwrap()),
|
||||
vec!["good-1", "good-2"]
|
||||
);
|
||||
// And the cursor still works across the corrupted tail.
|
||||
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["good-2"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c, Some(turn_id(1))).await.unwrap()),
|
||||
vec!["good-2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -260,7 +282,11 @@ async fn good_line_appended_after_corruption_is_still_read() {
|
||||
.unwrap();
|
||||
|
||||
let all = log.read(c, None).await.unwrap();
|
||||
assert_eq!(texts(&all), vec!["before", "after"], "junk in the middle is skipped, both reals survive");
|
||||
assert_eq!(
|
||||
texts(&all),
|
||||
vec!["before", "after"],
|
||||
"junk in the middle is skipped, both reals survive"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -274,7 +300,11 @@ async fn missing_conversation_reads_empty() {
|
||||
|
||||
// Nothing was ever appended: no .ideai dir at all.
|
||||
assert!(log.read(conv_id(99), None).await.unwrap().is_empty());
|
||||
assert!(log.read(conv_id(99), Some(turn_id(1))).await.unwrap().is_empty());
|
||||
assert!(log
|
||||
.read(conv_id(99), Some(turn_id(1)))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert!(log.last(conv_id(99), 5).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
@ -293,8 +323,14 @@ async fn read_cursor_is_strictly_exclusive() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["b", "c"]);
|
||||
assert_eq!(texts(&log.read(c, Some(turn_id(2))).await.unwrap()), vec!["c"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c, Some(turn_id(1))).await.unwrap()),
|
||||
vec!["b", "c"]
|
||||
);
|
||||
assert_eq!(
|
||||
texts(&log.read(c, Some(turn_id(2))).await.unwrap()),
|
||||
vec!["c"]
|
||||
);
|
||||
// Cursor on the last id => nothing after.
|
||||
assert!(log.read(c, Some(turn_id(3))).await.unwrap().is_empty());
|
||||
// Unknown cursor => empty (cohérent avec le double in-memory de P1).
|
||||
@ -317,9 +353,15 @@ async fn last_contract_zero_and_bounds() {
|
||||
// last(n < len) => the n last, in insertion order.
|
||||
assert_eq!(texts(&log.last(c, 2).await.unwrap()), vec!["c", "d"]);
|
||||
// last(n > len) => everything.
|
||||
assert_eq!(texts(&log.last(c, 10).await.unwrap()), vec!["a", "b", "c", "d"]);
|
||||
assert_eq!(
|
||||
texts(&log.last(c, 10).await.unwrap()),
|
||||
vec!["a", "b", "c", "d"]
|
||||
);
|
||||
// last(n == len) => everything.
|
||||
assert_eq!(texts(&log.last(c, 4).await.unwrap()), vec!["a", "b", "c", "d"]);
|
||||
assert_eq!(
|
||||
texts(&log.last(c, 4).await.unwrap()),
|
||||
vec!["a", "b", "c", "d"]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -352,7 +394,11 @@ async fn concurrent_appends_on_same_conversation_keep_both_lines_intact() {
|
||||
// Exactly two non-empty lines, each a fully-parseable turn (no interleaving).
|
||||
let raw = std::fs::read_to_string(tmp.log_path(c)).unwrap();
|
||||
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
assert_eq!(lines.len(), 2, "two concurrent appends => two lines, got: {raw:?}");
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
2,
|
||||
"two concurrent appends => two lines, got: {raw:?}"
|
||||
);
|
||||
for line in &lines {
|
||||
serde_json::from_str::<ConversationTurn>(line)
|
||||
.unwrap_or_else(|e| panic!("interleaved/corrupted line {line:?}: {e}"));
|
||||
@ -391,15 +437,23 @@ async fn handoff_round_trip_multiline_summary_with_objective() {
|
||||
|
||||
// summary_md délibérément multi-ligne, avec un `---` interne et des espaces de fin
|
||||
// pour éprouver la fidélité octet-pour-octet du corps.
|
||||
let summary = "# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n";
|
||||
let summary =
|
||||
"# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n";
|
||||
let handoff = Handoff::new(summary, turn_id(42), Some("livrer le lot P3".to_string()));
|
||||
|
||||
store.save(c, handoff.clone()).await.unwrap();
|
||||
let loaded = store.load(c).await.unwrap();
|
||||
|
||||
assert_eq!(loaded, Some(handoff.clone()), "round-trip doit redonner le Handoff exact");
|
||||
assert_eq!(
|
||||
loaded,
|
||||
Some(handoff.clone()),
|
||||
"round-trip doit redonner le Handoff exact"
|
||||
);
|
||||
let loaded = loaded.unwrap();
|
||||
assert_eq!(loaded.summary_md, summary, "summary_md conservé octet pour octet");
|
||||
assert_eq!(
|
||||
loaded.summary_md, summary,
|
||||
"summary_md conservé octet pour octet"
|
||||
);
|
||||
assert_eq!(loaded.up_to, turn_id(42), "up_to conservé à l'identique");
|
||||
assert_eq!(loaded.objective.as_deref(), Some("livrer le lot P3"));
|
||||
}
|
||||
@ -428,8 +482,14 @@ async fn handoff_round_trip_without_objective() {
|
||||
|
||||
// Sanity sur le format brut : pas de ligne `objective:` quand None.
|
||||
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
|
||||
assert!(!raw.contains("objective:"), "aucune clé objective ne doit être écrite, got: {raw:?}");
|
||||
assert!(raw.contains("upTo: "), "upTo toujours présent, got: {raw:?}");
|
||||
assert!(
|
||||
!raw.contains("objective:"),
|
||||
"aucune clé objective ne doit être écrite, got: {raw:?}"
|
||||
);
|
||||
assert!(
|
||||
raw.contains("upTo: "),
|
||||
"upTo toujours présent, got: {raw:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -464,8 +524,15 @@ async fn handoff_save_is_atomic_no_tmp_left_behind() {
|
||||
"le fichier temporaire handoff.md.tmp ne doit pas subsister après save"
|
||||
);
|
||||
// Le fichier final existe et est complet/relisible.
|
||||
assert!(tmp.handoff_path(c).exists(), "handoff.md final doit exister");
|
||||
assert_eq!(store.load(c).await.unwrap(), Some(handoff), "contenu final lisible et complet");
|
||||
assert!(
|
||||
tmp.handoff_path(c).exists(),
|
||||
"handoff.md final doit exister"
|
||||
);
|
||||
assert_eq!(
|
||||
store.load(c).await.unwrap(),
|
||||
Some(handoff),
|
||||
"contenu final lisible et complet"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -485,10 +552,20 @@ async fn handoff_overwrite_keeps_only_the_last() {
|
||||
store.save(c, second.clone()).await.unwrap();
|
||||
|
||||
// load renvoie le dernier, aucune trace du premier.
|
||||
assert_eq!(store.load(c).await.unwrap(), Some(second), "load doit renvoyer le dernier save");
|
||||
assert_eq!(
|
||||
store.load(c).await.unwrap(),
|
||||
Some(second),
|
||||
"load doit renvoyer le dernier save"
|
||||
);
|
||||
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
|
||||
assert!(!raw.contains("première version"), "le contenu du premier save ne doit pas subsister");
|
||||
assert!(!raw.contains("obj-1"), "l'objective du premier save ne doit pas subsister");
|
||||
assert!(
|
||||
!raw.contains("première version"),
|
||||
"le contenu du premier save ne doit pas subsister"
|
||||
);
|
||||
assert!(
|
||||
!raw.contains("obj-1"),
|
||||
"l'objective du premier save ne doit pas subsister"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -515,11 +592,20 @@ async fn handoff_is_isolated_per_conversation() {
|
||||
// Deux fichiers distincts sur disque.
|
||||
let p1 = tmp.handoff_path(c1);
|
||||
let p2 = tmp.handoff_path(c2);
|
||||
assert_ne!(p1, p2, "deux conversations => deux fichiers handoff distincts");
|
||||
assert_ne!(
|
||||
p1, p2,
|
||||
"deux conversations => deux fichiers handoff distincts"
|
||||
);
|
||||
assert!(p1.exists() && p2.exists());
|
||||
let raw2 = std::fs::read_to_string(&p2).unwrap();
|
||||
assert!(!raw2.contains("résumé c1"), "aucune fuite de c1 dans le handoff de c2");
|
||||
assert!(!raw2.contains("obj-c1"), "aucune fuite de l'objective de c1 dans c2");
|
||||
assert!(
|
||||
!raw2.contains("résumé c1"),
|
||||
"aucune fuite de c1 dans le handoff de c2"
|
||||
);
|
||||
assert!(
|
||||
!raw2.contains("obj-c1"),
|
||||
"aucune fuite de l'objective de c1 dans c2"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -537,11 +623,23 @@ async fn handoff_corruption_yields_serialization_error_no_panic() {
|
||||
// (4) `upTo` non-UUID.
|
||||
// (5) ligne de front-matter sans `:`.
|
||||
let cases: [(u128, &str, &str); 5] = [
|
||||
(101, "pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n", "fence ouvrant absent"),
|
||||
(102, "---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n", "fence fermant absent"),
|
||||
(
|
||||
101,
|
||||
"pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n",
|
||||
"fence ouvrant absent",
|
||||
),
|
||||
(
|
||||
102,
|
||||
"---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n",
|
||||
"fence fermant absent",
|
||||
),
|
||||
(103, "---\nobjective: but\n---\ncorps\n", "upTo absent"),
|
||||
(104, "---\nupTo: pas-un-uuid\n---\ncorps\n", "upTo non-UUID"),
|
||||
(105, "---\nligne sans deux-points\n---\ncorps\n", "ligne front-matter sans `:`"),
|
||||
(
|
||||
105,
|
||||
"---\nligne sans deux-points\n---\ncorps\n",
|
||||
"ligne front-matter sans `:`",
|
||||
),
|
||||
];
|
||||
|
||||
for (n, content, label) in cases {
|
||||
@ -575,7 +673,10 @@ async fn handoff_corruption_yields_serialization_error_no_panic() {
|
||||
|
||||
/// Compte les lignes-tours (`- **…`) dans un `summary_md` rendu.
|
||||
fn turn_lines(summary_md: &str) -> Vec<&str> {
|
||||
summary_md.lines().filter(|l| l.starts_with("- **")).collect()
|
||||
summary_md
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("- **"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `fold(None, turns)` = calcul de base : tours rendus, curseur = dernier id,
|
||||
@ -595,7 +696,8 @@ async fn fold_none_renders_base_window_objective_and_cursor() {
|
||||
// Objectif extrait du 1er Prompt.
|
||||
assert_eq!(h.objective.as_deref(), Some("Implémente la feature X"));
|
||||
assert!(
|
||||
h.summary_md.contains("**Objectif :** Implémente la feature X"),
|
||||
h.summary_md
|
||||
.contains("**Objectif :** Implémente la feature X"),
|
||||
"ligne d'objectif attendue, got:\n{}",
|
||||
h.summary_md
|
||||
);
|
||||
@ -603,7 +705,12 @@ async fn fold_none_renders_base_window_objective_and_cursor() {
|
||||
assert_eq!(h.up_to, turn_id(3));
|
||||
// Les 3 tours rendus, un par ligne, avec le bon label.
|
||||
let lines = turn_lines(&h.summary_md);
|
||||
assert_eq!(lines.len(), 3, "3 lignes-tours attendues, got:\n{}", h.summary_md);
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
3,
|
||||
"3 lignes-tours attendues, got:\n{}",
|
||||
h.summary_md
|
||||
);
|
||||
assert_eq!(lines[0], "- **Prompt:** Implémente la feature X");
|
||||
assert_eq!(lines[1], "- **Tool:** ran grep");
|
||||
assert_eq!(lines[2], "- **Response:** fait");
|
||||
@ -626,11 +733,18 @@ async fn fold_is_incremental_does_not_re_pass_old_turns() {
|
||||
|
||||
// Le 2e appel ne re-fournit QUE t6.
|
||||
let h2 = s
|
||||
.fold(Some(h1.clone()), &[turn(c, turn_id(6), TurnRole::Response, "r6")])
|
||||
.fold(
|
||||
Some(h1.clone()),
|
||||
&[turn(c, turn_id(6), TurnRole::Response, "r6")],
|
||||
)
|
||||
.await;
|
||||
|
||||
let lines = turn_lines(&h2.summary_md);
|
||||
assert_eq!(lines.len(), 6, "t1..t6 reconstitués depuis prev + incrément");
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
6,
|
||||
"t1..t6 reconstitués depuis prev + incrément"
|
||||
);
|
||||
assert_eq!(lines[0], "- **Prompt:** obj un");
|
||||
assert_eq!(lines[5], "- **Response:** r6");
|
||||
assert_eq!(h2.up_to, turn_id(6), "curseur = dernier de l'incrément");
|
||||
@ -686,7 +800,10 @@ async fn fold_keeps_existing_objective_over_new_prompt() {
|
||||
);
|
||||
|
||||
let h = s
|
||||
.fold(Some(prev), &[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")])
|
||||
.fold(
|
||||
Some(prev),
|
||||
&[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(h.objective.as_deref(), Some("but initial"), "objectif figé");
|
||||
@ -752,10 +869,13 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
|
||||
|
||||
// Le tour piégeux est aplati en une seule ligne (whitespace collapsé).
|
||||
let lines = turn_lines(&h1.summary_md);
|
||||
assert_eq!(lines.len(), 2, "2 lignes-tours seulement, pas de ligne fantôme");
|
||||
assert_eq!(
|
||||
lines[1],
|
||||
"- **Response:** ligne une - **Prompt:** faux marqueur ligne trois",
|
||||
lines.len(),
|
||||
2,
|
||||
"2 lignes-tours seulement, pas de ligne fantôme"
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1], "- **Response:** ligne une - **Prompt:** faux marqueur ligne trois",
|
||||
"texte multi-lignes + marqueur aplati en une ligne"
|
||||
);
|
||||
|
||||
@ -765,10 +885,17 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
|
||||
// (préfixée par `- **Response:** ligne une `), le reparse par `starts_with("- **")`
|
||||
// la compte comme UNE seule ligne — la fenêtre reste cohérente.
|
||||
let h2 = s
|
||||
.fold(Some(h1.clone()), &[turn(c, turn_id(3), TurnRole::Response, "suite")])
|
||||
.fold(
|
||||
Some(h1.clone()),
|
||||
&[turn(c, turn_id(3), TurnRole::Response, "suite")],
|
||||
)
|
||||
.await;
|
||||
let lines2 = turn_lines(&h2.summary_md);
|
||||
assert_eq!(lines2.len(), 3, "fenêtre cohérente après repli (pas de split parasite)");
|
||||
assert_eq!(
|
||||
lines2.len(),
|
||||
3,
|
||||
"fenêtre cohérente après repli (pas de split parasite)"
|
||||
);
|
||||
assert_eq!(lines2[2], "- **Response:** suite");
|
||||
assert_eq!(h2.up_to, turn_id(3));
|
||||
}
|
||||
@ -902,8 +1029,16 @@ async fn provider_corrupted_file_yields_serialization_error_no_panic() {
|
||||
|
||||
// Cas de contenus non-désérialisables en map<String,String>.
|
||||
let cases: [(u128, &str, &str); 3] = [
|
||||
(201, "{ ceci n'est pas du json", "JSON syntaxiquement invalide"),
|
||||
(202, "[\"pas\", \"un\", \"objet\"]", "JSON valide mais pas un objet map"),
|
||||
(
|
||||
201,
|
||||
"{ ceci n'est pas du json",
|
||||
"JSON syntaxiquement invalide",
|
||||
),
|
||||
(
|
||||
202,
|
||||
"[\"pas\", \"un\", \"objet\"]",
|
||||
"JSON valide mais pas un objet map",
|
||||
),
|
||||
(203, "{\"claude\": 123}", "valeur non-String"),
|
||||
];
|
||||
|
||||
@ -986,8 +1121,14 @@ async fn provider_is_isolated_per_conversation() {
|
||||
store.set(c2, "claude", "c2-id").await.unwrap();
|
||||
|
||||
// Chacune relit la sienne.
|
||||
assert_eq!(store.get(c1, "claude").await.unwrap(), Some("c1-id".to_string()));
|
||||
assert_eq!(store.get(c2, "claude").await.unwrap(), Some("c2-id".to_string()));
|
||||
assert_eq!(
|
||||
store.get(c1, "claude").await.unwrap(),
|
||||
Some("c1-id".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
store.get(c2, "claude").await.unwrap(),
|
||||
Some("c2-id".to_string())
|
||||
);
|
||||
|
||||
// Deux fichiers distincts sur disque, sans fuite de contenu.
|
||||
let p1 = tmp.providers_path(c1);
|
||||
@ -995,5 +1136,8 @@ async fn provider_is_isolated_per_conversation() {
|
||||
assert_ne!(p1, p2, "deux conversations ⇒ deux providers.json distincts");
|
||||
assert!(p1.exists() && p2.exists());
|
||||
let raw2 = std::fs::read_to_string(&p2).unwrap();
|
||||
assert!(!raw2.contains("c1-id"), "aucune fuite de c1 dans le providers.json de c2");
|
||||
assert!(
|
||||
!raw2.contains("c1-id"),
|
||||
"aucune fuite de c1 dans le providers.json de c2"
|
||||
);
|
||||
}
|
||||
|
||||
@ -27,9 +27,9 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::ids::NodeId;
|
||||
use domain::ids::SkillId;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::ids::NodeId;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
@ -52,11 +52,11 @@ use application::{
|
||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
|
||||
OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||
};
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
use infrastructure::{
|
||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||
SystemMillisClock,
|
||||
};
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@ -296,7 +296,6 @@ impl PtyPort for FakePty {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct NoopBus;
|
||||
impl EventBus for NoopBus {
|
||||
@ -468,7 +467,9 @@ fn tools_call(id: i64, tool: &str, arguments: Value) -> Vec<u8> {
|
||||
|
||||
/// Extracts the single text content block of a successful `tools/call` result.
|
||||
fn result_text(result: &Value) -> &str {
|
||||
result["content"][0]["text"].as_str().expect("text content block")
|
||||
result["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("text content block")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -485,15 +486,15 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let response = server.handle_raw(&raw).await.expect("tools/list owes a reply");
|
||||
let response = server
|
||||
.handle_raw(&raw)
|
||||
.await
|
||||
.expect("tools/list owes a reply");
|
||||
assert!(response.error.is_none(), "got error: {:?}", response.error);
|
||||
let result = response.result.expect("result");
|
||||
|
||||
let tools = result["tools"].as_array().expect("tools array");
|
||||
let names: Vec<&str> = tools
|
||||
.iter()
|
||||
.map(|t| t["name"].as_str().unwrap())
|
||||
.collect();
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
|
||||
for expected in [
|
||||
"idea_list_agents",
|
||||
@ -509,7 +510,10 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
|
||||
"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(),
|
||||
@ -520,7 +524,8 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
|
||||
// Every tool advertises an object input schema.
|
||||
for t in tools {
|
||||
assert_eq!(
|
||||
t["inputSchema"]["type"], json!("object"),
|
||||
t["inputSchema"]["type"],
|
||||
json!("object"),
|
||||
"tool {} must declare an object input schema",
|
||||
t["name"]
|
||||
);
|
||||
@ -544,7 +549,11 @@ async fn launch_agent_call_creates_and_launches_the_agent() {
|
||||
json!({ "target": "dev-backend", "profile": "claude-code" }),
|
||||
);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "transport error: {:?}", response.error);
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"transport error: {:?}",
|
||||
response.error
|
||||
);
|
||||
let result = response.result.expect("result");
|
||||
assert_eq!(result["isError"], json!(false), "got {result}");
|
||||
|
||||
@ -630,7 +639,11 @@ async fn ask_agent_returns_target_reply_inline() {
|
||||
let contexts = FakeContexts::new();
|
||||
let agent_id = contexts.seed_agent("architect");
|
||||
let (service, mailbox, sessions) = build_service_with_mailbox(contexts);
|
||||
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
|
||||
seed_live_pty(
|
||||
&sessions,
|
||||
agent_id,
|
||||
SessionId::from_uuid(Uuid::from_u128(4242)),
|
||||
);
|
||||
|
||||
let ask_server = server(Arc::clone(&service));
|
||||
let ask = tokio::spawn(async move {
|
||||
@ -655,7 +668,10 @@ async fn ask_agent_returns_target_reply_inline() {
|
||||
// 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");
|
||||
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)
|
||||
@ -663,7 +679,11 @@ async fn ask_agent_returns_target_reply_inline() {
|
||||
.expect("ask completes after reply")
|
||||
.expect("join ok");
|
||||
assert_eq!(response.id, json!(7), "id must be echoed");
|
||||
assert!(response.error.is_none(), "transport error: {:?}", response.error);
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"transport error: {:?}",
|
||||
response.error
|
||||
);
|
||||
let result = response.result.expect("result");
|
||||
assert_eq!(result["isError"], json!(false), "got {result}");
|
||||
assert_eq!(
|
||||
@ -751,7 +771,10 @@ async fn failed_command_is_tool_error_not_transport_error_and_server_survives()
|
||||
}))
|
||||
.unwrap();
|
||||
let again = server.handle_raw(&raw).await.expect("server still serves");
|
||||
assert!(again.result.is_some(), "server must survive a failed command");
|
||||
assert!(
|
||||
again.result.is_some(),
|
||||
"server must survive a failed command"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -898,7 +921,10 @@ async fn scripted_session_over_memory_transport_round_trips() {
|
||||
// Exactly two responses (init + list); the notification produced none.
|
||||
let first = rx.recv().await.expect("init response");
|
||||
let second = rx.recv().await.expect("list response");
|
||||
assert!(rx.recv().await.is_none(), "notification must not be answered");
|
||||
assert!(
|
||||
rx.recv().await.is_none(),
|
||||
"notification must not be answered"
|
||||
);
|
||||
|
||||
let first: Value = serde_json::from_slice(&first).unwrap();
|
||||
let second: Value = serde_json::from_slice(&second).unwrap();
|
||||
@ -927,7 +953,11 @@ async fn processed_tools_call_publishes_event_tagged_mcp_source() {
|
||||
json!({ "target": "dev-backend", "profile": "claude-code" }),
|
||||
);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "transport error: {:?}", response.error);
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"transport error: {:?}",
|
||||
response.error
|
||||
);
|
||||
assert_eq!(response.result.expect("result")["isError"], json!(false));
|
||||
|
||||
// Exactly one OrchestratorRequestProcessed event, tagged as the MCP door.
|
||||
@ -951,7 +981,10 @@ async fn processed_tools_call_publishes_event_tagged_mcp_source() {
|
||||
assert_eq!(*source, OrchestrationSource::Mcp, "MCP door must tag Mcp");
|
||||
assert_eq!(action, "idea_launch_agent", "action mirrors the tool name");
|
||||
assert!(*ok, "the launch succeeded");
|
||||
assert_eq!(requester_id, "mcp", "stable mcp label until per-session bind");
|
||||
assert_eq!(
|
||||
requester_id, "mcp",
|
||||
"stable mcp label until per-session bind"
|
||||
);
|
||||
}
|
||||
other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
|
||||
}
|
||||
@ -976,9 +1009,15 @@ async fn failed_tools_call_still_publishes_event_with_ok_false_and_mcp_source()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
|
||||
.collect();
|
||||
assert_eq!(processed.len(), 1, "a failed call still beacons; got {events:?}");
|
||||
assert_eq!(
|
||||
processed.len(),
|
||||
1,
|
||||
"a failed call still beacons; got {events:?}"
|
||||
);
|
||||
match processed[0] {
|
||||
DomainEvent::OrchestratorRequestProcessed { ok, source, action, .. } => {
|
||||
DomainEvent::OrchestratorRequestProcessed {
|
||||
ok, source, action, ..
|
||||
} => {
|
||||
assert!(!*ok, "the dispatch failed → ok = false");
|
||||
assert_eq!(*source, OrchestrationSource::Mcp);
|
||||
assert_eq!(action, "idea_stop_agent");
|
||||
@ -1001,7 +1040,11 @@ async fn server_without_event_sink_emits_nothing_and_does_not_panic() {
|
||||
json!({ "target": "dev-backend", "profile": "claude-code" }),
|
||||
);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "transport error: {:?}", response.error);
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"transport error: {:?}",
|
||||
response.error
|
||||
);
|
||||
assert_eq!(response.result.expect("result")["isError"], json!(false));
|
||||
// The command really ran (agent created) — proof the no-op sink path is live.
|
||||
assert_eq!(contexts.entries().len(), 1);
|
||||
|
||||
@ -16,10 +16,10 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::ids::NodeId;
|
||||
use domain::ids::SkillId;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ids::NodeId;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
||||
@ -295,7 +295,6 @@ impl PtyPort for FakePty {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct NoopBus;
|
||||
impl EventBus for NoopBus {
|
||||
@ -471,11 +470,7 @@ fn build_service_with_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,
|
||||
) {
|
||||
fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) {
|
||||
sessions.insert(
|
||||
PtyHandle { session_id },
|
||||
TerminalSession::starting(
|
||||
@ -650,7 +645,11 @@ async fn ask_request_surfaces_reply_alongside_detail() {
|
||||
let agent_id = contexts.seed_agent("architect");
|
||||
let (service, mailbox, sessions) = build_service_with_mailbox(contexts.clone());
|
||||
// Target already live in the PTY registry, so the ask reuses its terminal.
|
||||
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
|
||||
seed_live_pty(
|
||||
&sessions,
|
||||
agent_id,
|
||||
SessionId::from_uuid(Uuid::from_u128(4242)),
|
||||
);
|
||||
|
||||
let req = tmp.0.join("ask-req.json");
|
||||
std::fs::write(
|
||||
@ -770,7 +769,8 @@ async fn non_ask_request_omits_reply_key() {
|
||||
#[test]
|
||||
fn legacy_response_without_reply_round_trips_without_reintroducing_key() {
|
||||
// A response payload exactly as a pre-D6b IdeA would have written it.
|
||||
let legacy = r#"{"ok":true,"action":"spawn_agent","detail":"launched agent dev-backend in background"}"#;
|
||||
let legacy =
|
||||
r#"{"ok":true,"action":"spawn_agent","detail":"launched agent dev-backend in background"}"#;
|
||||
|
||||
let parsed: OrchestratorResponse =
|
||||
serde_json::from_str(legacy).expect("legacy response must still parse");
|
||||
@ -873,7 +873,12 @@ async fn watcher_publishes_processed_event_tagged_file_source() {
|
||||
|
||||
let event = found.expect("watcher must publish a processed beacon within the poll budget");
|
||||
match event {
|
||||
DomainEvent::OrchestratorRequestProcessed { source, ok, requester_id, .. } => {
|
||||
DomainEvent::OrchestratorRequestProcessed {
|
||||
source,
|
||||
ok,
|
||||
requester_id,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(source, OrchestrationSource::File, "fs door must tag File");
|
||||
assert!(ok, "the spawn request succeeded");
|
||||
assert_eq!(requester_id, "Main", "requester id = request subdirectory");
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Tauri adapter for {@link InputGateway} (lot F1, cadrage §4.2).
|
||||
* Tauri adapter for {@link InputGateway} (ARCHITECTURE §20.3).
|
||||
*
|
||||
* `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.
|
||||
* `interrupt` → `interrupt_agent` (preempt). `delegationDelivered` →
|
||||
* `delegation_delivered` (the native write-portal's ack that a delegation's
|
||||
* text was effectively written into the PTY). The former `submit` →
|
||||
* `submit_agent_input` path is gone: the agent cell is a native terminal, so
|
||||
* human keystrokes reach the PTY directly (no mediated-input strip).
|
||||
*
|
||||
* Commands use snake_case (Tauri convention); payload keys are camelCase
|
||||
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent
|
||||
@ -15,19 +17,19 @@ 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 },
|
||||
});
|
||||
}
|
||||
|
||||
async delegationDelivered(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
ticket: string,
|
||||
): Promise<void> {
|
||||
await invoke("delegation_delivered", {
|
||||
request: { projectId, agentId, ticket },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1533,39 +1533,38 @@ export class MockEmbedderGateway implements EmbedderGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/** One recorded mediated-input call (for test assertions). */
|
||||
/** One recorded agent-input control call (for test assertions). */
|
||||
export interface MockInputCall {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
/** Present for `submit`, absent for `interrupt`. */
|
||||
text?: string;
|
||||
/** Present for `delegationDelivered`, absent for `interrupt`. */
|
||||
ticket?: 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).
|
||||
* In-memory {@link InputGateway} (ARCHITECTURE §20.3). Records every
|
||||
* `interrupt`/`delegationDelivered` so tests can assert the component routed
|
||||
* through the port with the right args — no backend.
|
||||
*
|
||||
* 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 });
|
||||
}
|
||||
/** All recorded delegation-delivered acks, in order. */
|
||||
readonly delivered: MockInputCall[] = [];
|
||||
|
||||
async interrupt(projectId: string, agentId: string): Promise<void> {
|
||||
this.interrupts.push({ projectId, agentId });
|
||||
}
|
||||
|
||||
async delegationDelivered(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
ticket: string,
|
||||
): Promise<void> {
|
||||
this.delivered.push({ projectId, agentId, ticket });
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the full set of mock gateways. */
|
||||
|
||||
@ -20,6 +20,22 @@ export type DomainEvent =
|
||||
| { type: "agentExited"; agentId: string; code: number }
|
||||
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
||||
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
|
||||
| {
|
||||
/**
|
||||
* A delegation is ready to be injected into the agent's native terminal
|
||||
* (ARCHITECTURE §20). The backend is the queue/busy authority but no longer
|
||||
* PTY-writes the turn: the frontend write-portal runs the handshake (b→e)
|
||||
* and writes `text` + `submitSequence`. `submitSequence`/`submitDelayMs`
|
||||
* come from the target's profile; absent ⇒ the portal applies its defaults
|
||||
* (`"\r"`, ~60 ms).
|
||||
*/
|
||||
type: "delegationReady";
|
||||
agentId: string;
|
||||
ticket: string;
|
||||
text: string;
|
||||
submitSequence?: string;
|
||||
submitDelayMs?: number;
|
||||
}
|
||||
| { type: "templateUpdated"; templateId: string; version: number }
|
||||
| { type: "agentDriftDetected"; agentId: string; from: number; to: number }
|
||||
| { type: "agentSynced"; agentId: string; to: number }
|
||||
|
||||
@ -1,22 +1,20 @@
|
||||
/**
|
||||
* F2 — mediated input for agent cells (cadrage §4.2/§7).
|
||||
* Agent cell = NATIVE terminal (ARCHITECTURE §20) — supersedes the F2 mediated
|
||||
* input contract.
|
||||
*
|
||||
* Two contracts are exercised here, with the real {@link DIProvider} and the
|
||||
* in-memory mocks:
|
||||
* The agent cell no longer mediates keystrokes through a separate strip. It is a
|
||||
* real terminal:
|
||||
*
|
||||
* 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).
|
||||
* 1. **Keystrokes reach the PTY in BOTH modes.** An agent cell and a plain cell
|
||||
* alike forward `term.onData` keystrokes straight to `handle.write`.
|
||||
* 2. **The PTY *output* is always painted** — xterm stays the raw output view.
|
||||
* 3. **No `MediatedInput` strip and no `AgentChatView`** are ever mounted; the
|
||||
* agent cell renders only the raw {@link TerminalView}.
|
||||
*
|
||||
* 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`).
|
||||
* `onData` keystroke handler so the test can fire a keystroke and assert it
|
||||
* reached the PTY handle (`handle.write`).
|
||||
*/
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
@ -79,7 +77,7 @@ import { DIProvider } from "@/app/di";
|
||||
import { leaves } from "./layout";
|
||||
import { LayoutGrid } from "./LayoutGrid";
|
||||
|
||||
/** Fresh mock set (input gateway included so MediatedInput is fully wired). */
|
||||
/** Fresh mock set (input gateway included so the write-portal is fully wired). */
|
||||
function makeGateways(agentGateway: MockAgentGateway): Gateways {
|
||||
return {
|
||||
layout: new MockLayoutGateway(),
|
||||
@ -123,42 +121,43 @@ function spyTerminalWrite(gw: MockTerminalGateway): ReturnType<typeof vi.fn> {
|
||||
return writeSpy;
|
||||
}
|
||||
|
||||
async function pinAgent(gateways: Gateways, agentId: string): Promise<void> {
|
||||
const layout = gateways.layout as MockLayoutGateway;
|
||||
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: agentId,
|
||||
});
|
||||
}
|
||||
|
||||
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 () => {
|
||||
describe("LayoutGrid — agent cell is a native terminal (§20)", () => {
|
||||
it("an AGENT cell DOES write keystrokes to the PTY (native terminal)", 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,
|
||||
});
|
||||
await pinAgent(gateways, 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.
|
||||
// Simulate human keystrokes into xterm; in native mode they reach the PTY.
|
||||
xtermState.keyHandler!("h");
|
||||
xtermState.keyHandler!("i");
|
||||
|
||||
// In agent mode the keystroke must be swallowed — never forwarded to the PTY.
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(writeSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("a PLAIN cell still writes keystrokes to the PTY (raw shell, unchanged)", async () => {
|
||||
@ -176,64 +175,48 @@ describe("LayoutGrid F2 — mediated input for agent cells", () => {
|
||||
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,
|
||||
});
|
||||
await pinAgent(gateways, 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 () => {
|
||||
it("renders only the raw TerminalView — no MediatedInput, no 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);
|
||||
await pinAgent(gateways, agent.id);
|
||||
|
||||
renderGrid(gateways);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
expect(screen.queryByTestId("mediated-input")).toBeNull();
|
||||
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
|
||||
});
|
||||
|
||||
it("a PLAIN cell has no portal overlay", 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("write-portal-overlay")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -27,10 +27,9 @@ import type {
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
import {
|
||||
MediatedInput,
|
||||
ResumeConversationPopup,
|
||||
TerminalView,
|
||||
useAgentBusy,
|
||||
useWritePortal,
|
||||
} from "@/features/terminals";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
|
||||
@ -203,10 +202,12 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
|
||||
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();
|
||||
// The single write-portal of this cell (ARCHITECTURE §20). It owns the human
|
||||
// line counter, the local delegation FIFO, the handshake (b→e) and the overlay
|
||||
// shown while a delegation is being injected. For a plain (agent-less) cell
|
||||
// `agent` is null: the portal stays inert (no subscription, no overlay) and is
|
||||
// simply not passed to the terminal.
|
||||
const { portal, overlay } = useWritePortal(projectId, agent ?? null);
|
||||
|
||||
// Load the project's agents for the dropdown.
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
@ -552,28 +553,23 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
)}
|
||||
</div>
|
||||
{/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the
|
||||
raw xterm {@link TerminalView}. Agent cells are interactive PTYs; their
|
||||
cross-model delegation happens through MCP tools, not a chat view.
|
||||
Re-key on the agent so the right opener is captured at mount; the
|
||||
persisted session drives re-attach (never a re-spawn) when navigating.
|
||||
|
||||
Lot F2 (cadrage §4.2): in an **agent** cell the terminal is output-only
|
||||
for the human (`agentMode`) and the {@link MediatedInput} strip is
|
||||
mounted **under** it — keystrokes route through IdeA, not the PTY. A
|
||||
**plain** cell keeps the raw-shell behaviour (keystrokes → PTY) and has
|
||||
no input strip. We stack terminal + strip in a column so the strip sits
|
||||
beneath the live output. */}
|
||||
raw xterm {@link TerminalView}. Agent cells are **native terminals**
|
||||
(ARCHITECTURE §20): every human keystroke (Enter included) reaches the
|
||||
PTY. There is no mediated-input strip; cross-model delegation flows
|
||||
through the write-portal (which injects at a clean line boundary) and
|
||||
MCP tools. Re-key on the agent so the right opener is captured at mount;
|
||||
the persisted session drives re-attach (never a re-spawn) when
|
||||
navigating. The agent cell passes its {@link WritePortal} so keystroke
|
||||
counting + injection suspension are wired; a plain cell passes none. */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0 }}>
|
||||
<TerminalView
|
||||
key={`${id}-${agentId ?? "plain"}`}
|
||||
cwd={cwd}
|
||||
@ -582,14 +578,41 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
sessionId={session}
|
||||
onSessionId={(sid) => void vm.setSession(id, sid)}
|
||||
agentMode={agentId != null}
|
||||
portal={agentId != null ? portal : undefined}
|
||||
/>
|
||||
{/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation
|
||||
is being injected into the agent's PTY, a grey veil with a centred
|
||||
message sits above the terminal. Only ever shown for an agent cell. */}
|
||||
{agentId != null && overlay && (
|
||||
<div
|
||||
data-testid="write-portal-overlay"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0, 0, 0, 0.45)",
|
||||
color: "var(--color-content, #e0e0e0)",
|
||||
fontSize: 13,
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 4,
|
||||
padding: "6px 12px",
|
||||
}}
|
||||
>
|
||||
Un agent est en train de parler…
|
||||
</span>
|
||||
</div>
|
||||
{agentId != null && (
|
||||
<MediatedInput
|
||||
projectId={projectId}
|
||||
agentId={agentId}
|
||||
busy={busyMap[agentId] ?? false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{busyNotice && (
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
/**
|
||||
* 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,93 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
174
frontend/src/features/terminals/TerminalView.portal.test.tsx
Normal file
174
frontend/src/features/terminals/TerminalView.portal.test.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* L3 — TerminalView agent cell is a **native terminal** wired to a
|
||||
* {@link WritePortal} (ARCHITECTURE §20).
|
||||
*
|
||||
* Contracts exercised (xterm stubbed so `term.open` succeeds under jsdom and the
|
||||
* keystroke wiring genuinely runs; the stub captures the `onData` handler):
|
||||
* - In agent mode a keystroke is written to the PTY (native), AND reported to
|
||||
* the portal for line counting.
|
||||
* - The portal's `isSuspended()` gates the relay: while suspended, keystrokes
|
||||
* are NOT forwarded to the PTY (but are still reported for counting).
|
||||
* - The live handle is bound to the portal on adopt, unbound on unmount.
|
||||
*/
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
|
||||
const xtermState: { keyHandler: ((data: string) => void) | null } = {
|
||||
keyHandler: null,
|
||||
};
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: class {
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
onData(cb: (data: string) => void) {
|
||||
xtermState.keyHandler = cb;
|
||||
return { dispose() {} };
|
||||
}
|
||||
onResize() {
|
||||
return { dispose() {} };
|
||||
}
|
||||
write() {}
|
||||
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, WritePortal } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
|
||||
function makeHandle(write = vi.fn().mockResolvedValue(undefined)): TerminalHandle {
|
||||
return {
|
||||
sessionId: "s1",
|
||||
write,
|
||||
resize: vi.fn().mockResolvedValue(undefined),
|
||||
detach: vi.fn(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function makePortal(overrides: Partial<WritePortal> = {}): WritePortal {
|
||||
return {
|
||||
onHumanData: vi.fn(),
|
||||
isSuspended: vi.fn(() => false),
|
||||
bindHandle: vi.fn(),
|
||||
unbindHandle: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function decode(b: Uint8Array): string {
|
||||
return new TextDecoder().decode(b);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
xtermState.keyHandler = null;
|
||||
});
|
||||
|
||||
describe("TerminalView native agent terminal + portal (§20)", () => {
|
||||
it("writes keystrokes to the PTY in agent mode and reports them to the portal", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const handle = makeHandle(write);
|
||||
const open = vi.fn(async () => handle);
|
||||
const portal = makePortal();
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
|
||||
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalledWith(handle));
|
||||
|
||||
xtermState.keyHandler!("a");
|
||||
|
||||
expect(portal.onHumanData).toHaveBeenCalledWith("a");
|
||||
await waitFor(() => expect(write).toHaveBeenCalled());
|
||||
expect(decode(write.mock.calls[0][0])).toBe("a");
|
||||
});
|
||||
|
||||
it("does NOT forward keystrokes while the portal is suspended (but still counts them)", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const handle = makeHandle(write);
|
||||
const open = vi.fn(async () => handle);
|
||||
const onHumanData = vi.fn();
|
||||
const portal = makePortal({ isSuspended: () => true, onHumanData });
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
|
||||
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalled());
|
||||
|
||||
xtermState.keyHandler!("x");
|
||||
|
||||
// Reported for counting, but the relay is suspended ⇒ not written.
|
||||
expect(onHumanData).toHaveBeenCalledWith("x");
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unbinds the handle from the portal on unmount", async () => {
|
||||
const handle = makeHandle();
|
||||
const open = vi.fn(async () => handle);
|
||||
const portal = makePortal();
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
const { unmount } = render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalled());
|
||||
unmount();
|
||||
expect(portal.unbindHandle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a PLAIN cell ignores the portal and writes keystrokes natively", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const handle = makeHandle(write);
|
||||
const open = vi.fn(async () => handle);
|
||||
const portal = makePortal();
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
{/* agentMode false: portal must never be touched */}
|
||||
<TerminalView cwd="/c" open={open} portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
|
||||
xtermState.keyHandler!("l");
|
||||
|
||||
await waitFor(() => expect(write).toHaveBeenCalled());
|
||||
expect(portal.onHumanData).not.toHaveBeenCalled();
|
||||
expect(portal.bindHandle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -3,11 +3,14 @@
|
||||
* {@link TerminalGateway} port (or a custom opener), and fits it to its container:
|
||||
*
|
||||
* - PTY output (gateway `onData`) → `term.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).
|
||||
* - xterm `onData` (keystrokes) → `handle.write(bytes)` in **both** modes: the
|
||||
* agent cell is a **native terminal** (ARCHITECTURE §20), so human keystrokes
|
||||
* (Enter included) reach the PTY unconditionally, exactly like a raw shell.
|
||||
* In agent mode the keystrokes are additionally reported to the write-portal
|
||||
* (a {@link WritePortal} supplied via `portal`) which (a) keeps a *human line*
|
||||
* counter (+1 per printable, reset on Enter/Ctrl-C) and (b) can momentarily
|
||||
* **suspend** the keystroke relay while it injects a delegation. The PTY
|
||||
* **output** path stays live and unchanged in both modes.
|
||||
* - container resize (fit addon) → `handle.resize(rows, cols)`.
|
||||
*
|
||||
* Pure presentation: it only knows the port, never `invoke()`/`Channel`
|
||||
@ -40,6 +43,7 @@ import type {
|
||||
OpenTerminalOptions,
|
||||
ReattachResult,
|
||||
TerminalHandle,
|
||||
WritePortal,
|
||||
} from "@/ports";
|
||||
|
||||
interface TerminalViewProps {
|
||||
@ -76,14 +80,21 @@ interface TerminalViewProps {
|
||||
*/
|
||||
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`.
|
||||
* Agent mode (ARCHITECTURE §20). When `true` the cell hosts an agent and the
|
||||
* terminal is **native**: keystrokes (`term.onData`) reach the PTY exactly
|
||||
* like a plain shell, AND are reported to the {@link WritePortal} (`portal`)
|
||||
* for line counting / suspension. When `false`/absent the cell is a plain
|
||||
* shell with no portal. Defaults to `false`.
|
||||
*/
|
||||
agentMode?: boolean;
|
||||
/**
|
||||
* The write-portal for this agent cell (ARCHITECTURE §20). Only meaningful in
|
||||
* agent mode: it receives keystroke reports, gates the relay via
|
||||
* {@link WritePortal.isSuspended}, and is given the live handle so it can
|
||||
* inject delegations through the same single PTY writer. Absent for plain
|
||||
* cells.
|
||||
*/
|
||||
portal?: WritePortal;
|
||||
}
|
||||
|
||||
export function TerminalView({
|
||||
@ -93,6 +104,7 @@ export function TerminalView({
|
||||
sessionId,
|
||||
onSessionId,
|
||||
agentMode = false,
|
||||
portal,
|
||||
}: TerminalViewProps) {
|
||||
const { terminal } = useGateways();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -116,6 +128,8 @@ export function TerminalView({
|
||||
terminalRef.current = terminal;
|
||||
const agentModeRef = useRef(agentMode);
|
||||
agentModeRef.current = agentMode;
|
||||
const portalRef = useRef(portal);
|
||||
portalRef.current = portal;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@ -151,14 +165,22 @@ export function TerminalView({
|
||||
let handle: TerminalHandle | null = null;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// 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.
|
||||
// Keystroke → PTY path. The agent cell is a **native terminal**
|
||||
// (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell.
|
||||
// In agent mode we additionally (1) report the keystroke to the write-portal
|
||||
// for line counting, and (2) honour the portal's `isSuspended()` flag, which
|
||||
// is raised while the portal injects a delegation so the human keystroke does
|
||||
// not race the injected text. Keystrokes arriving before the PTY opened are
|
||||
// buffered. The output path below is unchanged in both modes.
|
||||
let pending = "";
|
||||
const onKey = term.onData((data) => {
|
||||
if (agentModeRef.current) return;
|
||||
const portal = portalRef.current;
|
||||
if (agentModeRef.current && portal) {
|
||||
// Always report for counting (even while suspended — the portal decides
|
||||
// what counts), then drop the relay while the portal is injecting.
|
||||
portal.onHumanData(data);
|
||||
if (portal.isSuspended()) return;
|
||||
}
|
||||
if (handle) void handle.write(encoder.encode(data));
|
||||
else pending += data;
|
||||
});
|
||||
@ -176,6 +198,9 @@ export function TerminalView({
|
||||
return;
|
||||
}
|
||||
handle = h;
|
||||
// Hand the live handle to the write-portal so it can inject delegations
|
||||
// through the SAME single PTY writer (no second physical writer).
|
||||
if (agentModeRef.current) portalRef.current?.bindHandle(h);
|
||||
if (pending) {
|
||||
void h.write(encoder.encode(pending));
|
||||
pending = "";
|
||||
@ -268,6 +293,7 @@ export function TerminalView({
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
ro.disconnect();
|
||||
onKey.dispose();
|
||||
portalRef.current?.unbindHandle();
|
||||
// DETACH, never close: tearing the view down (navigation / layout change)
|
||||
// must leave the backend PTY running so the AI isn't cut off. Killing the
|
||||
// PTY is an explicit user action handled elsewhere.
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
/** Terminals feature (L3): xterm.js wrapper bound to the `TerminalGateway`. */
|
||||
/** Terminals (L3): xterm.js wrapper bound to the `TerminalGateway`. */
|
||||
|
||||
export { TerminalView } from "./TerminalView";
|
||||
export { ResumeConversationPopup } from "./ResumeConversationPopup";
|
||||
export { MediatedInput } from "./MediatedInput";
|
||||
export type { MediatedInputProps } from "./MediatedInput";
|
||||
export { useAgentBusy } from "./useAgentBusy";
|
||||
export type { AgentBusyMap } from "./useAgentBusy";
|
||||
export { useWritePortal } from "./useWritePortal";
|
||||
export type { UseWritePortalResult } from "./useWritePortal";
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,58 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
270
frontend/src/features/terminals/useWritePortal.test.tsx
Normal file
270
frontend/src/features/terminals/useWritePortal.test.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* L4 — the write-portal hook {@link useWritePortal} (ARCHITECTURE §20).
|
||||
*
|
||||
* Driven with fake timers + a fake handle recording writes, and the real
|
||||
* {@link DIProvider} wiring the in-memory {@link MockSystemGateway} (to emit
|
||||
* `delegationReady`) and {@link MockInputGateway} (to record the ack).
|
||||
*
|
||||
* Cases (cadrage §20.6):
|
||||
* - injects NOTHING while the human line is non-empty (counter > 0);
|
||||
* - at an empty line → writes `text` WITHOUT `\n`, then `\r` after the delay;
|
||||
* - K=2 race → exactly two `\x7f` before the text;
|
||||
* - 2 s floor → overlay/suspension held until 2000 ms after step (b);
|
||||
* - the relay is suspended during the overlay;
|
||||
* - `delegationDelivered` is acked exactly once after (d);
|
||||
* - default submitSequence/delay applied when the profile omits them.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
import type { Gateways, TerminalHandle } from "@/ports";
|
||||
import {
|
||||
MockInputGateway,
|
||||
MockSystemGateway,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { useWritePortal } from "./useWritePortal";
|
||||
|
||||
function makeHandle(): { handle: TerminalHandle; writes: string[] } {
|
||||
const writes: string[] = [];
|
||||
const handle: TerminalHandle = {
|
||||
sessionId: "s1",
|
||||
write: vi.fn(async (b: Uint8Array) => {
|
||||
writes.push(new TextDecoder().decode(b));
|
||||
}),
|
||||
resize: vi.fn().mockResolvedValue(undefined),
|
||||
detach: vi.fn(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return { handle, writes };
|
||||
}
|
||||
|
||||
function setup(agentId: string | null = "ag1") {
|
||||
const system = new MockSystemGateway();
|
||||
const input = new MockInputGateway();
|
||||
const gateways = { system, input } as unknown as Gateways;
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<DIProvider gateways={gateways}>{children}</DIProvider>
|
||||
);
|
||||
const view = renderHook(() => useWritePortal("p1", agentId), { wrapper });
|
||||
return { system, input, view };
|
||||
}
|
||||
|
||||
const PROFILELESS = (ticket: string, text: string) => ({
|
||||
type: "delegationReady" as const,
|
||||
agentId: "ag1",
|
||||
ticket,
|
||||
text,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("useWritePortal (§20)", () => {
|
||||
it("injects NOTHING while the human line is non-empty, releases at empty line", async () => {
|
||||
const { system, view } = setup();
|
||||
const { handle, writes } = makeHandle();
|
||||
|
||||
// Bind a live handle and type a printable char (line non-empty).
|
||||
act(() => view.result.current.portal.bindHandle(handle));
|
||||
act(() => view.result.current.portal.onHumanData("a")); // K = 1
|
||||
|
||||
// A delegation arrives while the line is non-empty.
|
||||
await act(async () => {
|
||||
system.emit(PROFILELESS("t1", "hello"));
|
||||
});
|
||||
// Nothing written: the delegation waits.
|
||||
expect(writes.length).toBe(0);
|
||||
|
||||
// The human presses Enter → line empty → injection fires.
|
||||
await act(async () => {
|
||||
view.result.current.portal.onHumanData("\r");
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
// text written without trailing newline, then the submit sequence.
|
||||
expect(writes).toContain("hello");
|
||||
expect(writes[writes.length - 1]).toBe("\r");
|
||||
expect(writes.join("")).not.toContain("\n");
|
||||
});
|
||||
|
||||
it("at an empty line writes text then \\r after the delay, acking once", async () => {
|
||||
const { system, input, view } = setup();
|
||||
const { handle, writes } = makeHandle();
|
||||
act(() => view.result.current.portal.bindHandle(handle));
|
||||
|
||||
await act(async () => {
|
||||
system.emit(PROFILELESS("t1", "do it"));
|
||||
// Flush the microtask that defers step (c)/(d) but NOT the submit delay.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
// Line is empty (counter 0) ⇒ handshake started; overlay raised.
|
||||
expect(view.result.current.overlay).toBe(true);
|
||||
|
||||
// Before the delay elapses, only the text has been written (no submit yet).
|
||||
expect(writes).toEqual(["do it"]);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(writes).toEqual(["do it", "\r"]);
|
||||
// Acked exactly once with the right shape.
|
||||
expect(input.delivered).toEqual([
|
||||
{ projectId: "p1", agentId: "ag1", ticket: "t1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("K=2 race → exactly two \\x7f backspaces before the text (never Ctrl-U)", async () => {
|
||||
const { system, view } = setup();
|
||||
const { handle, writes } = makeHandle();
|
||||
act(() => view.result.current.portal.bindHandle(handle));
|
||||
|
||||
// The delegation arrives at an EMPTY line, so the handshake starts and
|
||||
// raises suspension. The §20.6 race: in the micro-window between "empty
|
||||
// observed" (b) and step (c) re-checking the counter, the human types K=2
|
||||
// printables. TerminalView still reports them while suspended, so the
|
||||
// counter is 2 when step (c) reads it ⇒ exactly two backspaces.
|
||||
await act(async () => {
|
||||
system.emit(PROFILELESS("t1", "TASK"));
|
||||
// Race two keystrokes in before the timers (delay) flush step (c)/(d).
|
||||
view.result.current.portal.onHumanData("x");
|
||||
view.result.current.portal.onHumanData("y");
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
const idxText = writes.indexOf("TASK");
|
||||
expect(idxText).toBeGreaterThan(0);
|
||||
expect(writes[idxText - 1]).toBe("\x7f\x7f");
|
||||
expect(writes.join("")).not.toContain("\x15"); // never Ctrl-U
|
||||
});
|
||||
|
||||
it("holds the overlay until the 2 s floor after step (b)", async () => {
|
||||
const { system, view } = setup();
|
||||
const { handle } = makeHandle();
|
||||
act(() => view.result.current.portal.bindHandle(handle));
|
||||
|
||||
await act(async () => {
|
||||
system.emit(PROFILELESS("t1", "hi"));
|
||||
});
|
||||
expect(view.result.current.overlay).toBe(true);
|
||||
|
||||
// Finish the write phase (default 60 ms delay) — still well under 2 s.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
// The relay is still suspended and the overlay still up (2 s floor).
|
||||
expect(view.result.current.portal.isSuspended()).toBe(true);
|
||||
expect(view.result.current.overlay).toBe(true);
|
||||
|
||||
// Cross the 2 s floor.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(view.result.current.overlay).toBe(false);
|
||||
expect(view.result.current.portal.isSuspended()).toBe(false);
|
||||
});
|
||||
|
||||
it("suspends the keystroke relay during the overlay", async () => {
|
||||
const { system, view } = setup();
|
||||
const { handle } = makeHandle();
|
||||
act(() => view.result.current.portal.bindHandle(handle));
|
||||
|
||||
expect(view.result.current.portal.isSuspended()).toBe(false);
|
||||
await act(async () => {
|
||||
system.emit(PROFILELESS("t1", "hi"));
|
||||
});
|
||||
// During injection the relay is suspended.
|
||||
expect(view.result.current.portal.isSuspended()).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
expect(view.result.current.portal.isSuspended()).toBe(false);
|
||||
});
|
||||
|
||||
it("applies the profile submitSequence/submitDelayMs when provided", async () => {
|
||||
const { system, view } = setup();
|
||||
const { handle, writes } = makeHandle();
|
||||
act(() => view.result.current.portal.bindHandle(handle));
|
||||
|
||||
await act(async () => {
|
||||
system.emit({
|
||||
type: "delegationReady",
|
||||
agentId: "ag1",
|
||||
ticket: "t1",
|
||||
text: "msg",
|
||||
submitSequence: "\n",
|
||||
submitDelayMs: 200,
|
||||
});
|
||||
});
|
||||
|
||||
// After 100 ms (< 200) the submit sequence has not been written yet.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
expect(writes).toEqual(["msg"]);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
expect(writes).toEqual(["msg", "\n"]);
|
||||
});
|
||||
|
||||
it("ignores delegationReady for a different agent", async () => {
|
||||
const { system, view } = setup();
|
||||
const { handle, writes } = makeHandle();
|
||||
act(() => view.result.current.portal.bindHandle(handle));
|
||||
|
||||
await act(async () => {
|
||||
system.emit({
|
||||
type: "delegationReady",
|
||||
agentId: "OTHER",
|
||||
ticket: "t1",
|
||||
text: "nope",
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
expect(writes.length).toBe(0);
|
||||
expect(view.result.current.overlay).toBe(false);
|
||||
});
|
||||
|
||||
it("waits for a live handle before injecting", async () => {
|
||||
const { system, input, view } = setup();
|
||||
const { handle, writes } = makeHandle();
|
||||
|
||||
// No handle yet: an arriving delegation must NOT write or ack.
|
||||
await act(async () => {
|
||||
system.emit(PROFILELESS("t1", "later"));
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
expect(writes.length).toBe(0);
|
||||
expect(input.delivered.length).toBe(0);
|
||||
|
||||
// Handle becomes live (PTY opened) → injection proceeds.
|
||||
await act(async () => {
|
||||
view.result.current.portal.bindHandle(handle);
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
expect(writes).toEqual(["later", "\r"]);
|
||||
expect(input.delivered.length).toBe(1);
|
||||
});
|
||||
|
||||
it("is inert for a plain (agent-less) cell", async () => {
|
||||
const { system, view } = setup(null);
|
||||
const { handle, writes } = makeHandle();
|
||||
act(() => view.result.current.portal.bindHandle(handle));
|
||||
|
||||
await act(async () => {
|
||||
system.emit(PROFILELESS("t1", "x"));
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
expect(writes.length).toBe(0);
|
||||
expect(view.result.current.overlay).toBe(false);
|
||||
});
|
||||
});
|
||||
244
frontend/src/features/terminals/useWritePortal.ts
Normal file
244
frontend/src/features/terminals/useWritePortal.ts
Normal file
@ -0,0 +1,244 @@
|
||||
/**
|
||||
* useWritePortal — the single write-portal of an agent cell (ARCHITECTURE §20).
|
||||
*
|
||||
* The agent cell hosts the CLI as a **native terminal**: every human keystroke
|
||||
* (Enter included) reaches the PTY through {@link TerminalView}. IdeA never owns
|
||||
* a chat box; it only *observes* a "human line in progress" counter and, when a
|
||||
* delegation is ready, injects its text at a **clean boundary** (human line
|
||||
* empty) through the SAME handle the human keystrokes use — so there is exactly
|
||||
* one physical PTY writer (the front), no race between two writers.
|
||||
*
|
||||
* Responsibilities (the four the cadrage assigns to the portal):
|
||||
* 1. The **human-line counter**: `+1` per printable keystroke, `reset` on Enter
|
||||
* (`\r`/`\n`) and Ctrl-C (`\x03`). Only keystrokes are counted; control
|
||||
* sequences (arrows/escape, CSI…) are ignored so they never falsely mark the
|
||||
* line "non-empty" and block injection forever.
|
||||
* 2. The **local FIFO** of `delegationReady` events received for this agent
|
||||
* (via `system.onDomainEvent`, filtered like {@link useAgentBusy}).
|
||||
* 3. The **handshake (b→e)**: only when a delegation is at the head of the FIFO
|
||||
* AND the human line is empty:
|
||||
* (b) suspend the keystroke relay + raise the overlay;
|
||||
* (c) re-check the counter K; if K>0 write exactly K backspaces `\x7f`
|
||||
* (never Ctrl-U) to erase what the human typed in the micro-window;
|
||||
* (d) `write(text)` WITHOUT `\n`, then after `submitDelayMs ?? 60` ms
|
||||
* `write(submitSequence ?? "\r")`; then ack via `delegationDelivered`
|
||||
* exactly once;
|
||||
* (e) drop the suspension + lower the overlay, with a **2 s floor** from
|
||||
* (b) (anti-flash): if (e) would happen < 2000 ms after (b), the
|
||||
* overlay/suspension are held until 2000 ms.
|
||||
* A delegation that arrives while the line is non-empty simply **waits** in
|
||||
* the FIFO (no write, no refusal, no loss) and is released at the next empty
|
||||
* line.
|
||||
* 4. The boolean **overlay** state, rendered by the hosting cell.
|
||||
*
|
||||
* Hexagonal: the hook talks to ports via DI (`system`, `input`), never to
|
||||
* `invoke()`. It returns a {@link WritePortal} object whose identity is stable
|
||||
* (memoised) so {@link TerminalView} can hold it in a ref.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { DomainEvent } from "@/domain";
|
||||
import type { TerminalHandle, WritePortal } from "@/ports";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** Default submit sequence when the profile omits one (paste-detection esquive). */
|
||||
const DEFAULT_SUBMIT_SEQUENCE = "\r";
|
||||
/** Default delay (ms) between text and submit-sequence writes. */
|
||||
const DEFAULT_SUBMIT_DELAY_MS = 60;
|
||||
/** Anti-flash overlay floor (ms) from step (b). */
|
||||
const OVERLAY_FLOOR_MS = 2000;
|
||||
|
||||
/** A pending delegation kept in the local FIFO. */
|
||||
interface PendingDelegation {
|
||||
ticket: string;
|
||||
text: string;
|
||||
submitSequence?: string;
|
||||
submitDelayMs?: number;
|
||||
}
|
||||
|
||||
/** What the hook returns: the portal object (for TerminalView) + overlay state. */
|
||||
export interface UseWritePortalResult {
|
||||
/** The portal object handed to {@link TerminalView} via its `portal` prop. */
|
||||
portal: WritePortal;
|
||||
/** Whether the "an agent is speaking…" overlay must be shown. */
|
||||
overlay: boolean;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* True for a single printable keystroke chunk. We count printable input only;
|
||||
* control bytes (ESC/CSI: arrows, function keys, Ctrl-*) must not bump the
|
||||
* counter, otherwise an arrow keypress would mark the line "non-empty" forever.
|
||||
*
|
||||
* A keystroke chunk from xterm's `onData` is normally a single grapheme, but a
|
||||
* paste can deliver many. We treat the chunk as printable iff it contains **no**
|
||||
* control character (code point < 0x20 or 0x7f). Enter (`\r`/`\n`) and Ctrl-C
|
||||
* (`\x03`) are handled separately as resets before this check.
|
||||
*/
|
||||
function isPrintable(data: string): boolean {
|
||||
if (data.length === 0) return false;
|
||||
for (const ch of data) {
|
||||
const code = ch.codePointAt(0)!;
|
||||
if (code < 0x20 || code === 0x7f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useWritePortal(
|
||||
projectId: string,
|
||||
agentId: string | null,
|
||||
): UseWritePortalResult {
|
||||
const { system, input } = useGateways();
|
||||
const [overlay, setOverlay] = useState(false);
|
||||
|
||||
// ── Mutable state held in refs (the handshake runs outside React render) ───
|
||||
const counterRef = useRef(0); // human line counter K
|
||||
const queueRef = useRef<PendingDelegation[]>([]); // local FIFO
|
||||
const handleRef = useRef<TerminalHandle | null>(null);
|
||||
const suspendedRef = useRef(false); // relay suspended while injecting
|
||||
const injectingRef = useRef(false); // a handshake is in flight
|
||||
|
||||
// Stable refs to the gateways used inside the (non-reactive) handshake.
|
||||
const inputRef = useRef(input);
|
||||
inputRef.current = input;
|
||||
const agentIdRef = useRef(agentId);
|
||||
agentIdRef.current = agentId;
|
||||
|
||||
// The input port needs the project id for the delivery ack.
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
// ── (3)+(4): the handshake, attempted whenever a boundary may have opened ──
|
||||
const tryInject = useRef<() => void>(() => {});
|
||||
tryInject.current = () => {
|
||||
if (injectingRef.current) return; // one handshake at a time
|
||||
const head = queueRef.current[0];
|
||||
if (!head) return;
|
||||
if (counterRef.current > 0) return; // line not empty ⇒ wait
|
||||
const handle = handleRef.current;
|
||||
if (!handle) return; // no live PTY yet ⇒ wait
|
||||
const agent = agentIdRef.current;
|
||||
const project = projectIdRef.current;
|
||||
if (!agent) return;
|
||||
|
||||
injectingRef.current = true;
|
||||
const startedAt = Date.now();
|
||||
|
||||
// (b) suspend the relay + raise the overlay.
|
||||
suspendedRef.current = true;
|
||||
setOverlay(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
// Yield once so any keystroke that landed in the micro-window between
|
||||
// "empty observed" (a) and the suspension taking hold (b) is counted
|
||||
// before we re-check K at step (c). Without this the race window would
|
||||
// be zero and a late keystroke would survive the injection un-erased.
|
||||
await Promise.resolve();
|
||||
// (c) re-check K; erase exactly K human keystrokes with `\x7f`
|
||||
// (backspace) — never Ctrl-U (which could clear more than the human typed).
|
||||
const k = counterRef.current;
|
||||
if (k > 0) {
|
||||
await handle.write(encoder.encode("\x7f".repeat(k)));
|
||||
counterRef.current = 0;
|
||||
}
|
||||
|
||||
// (d) write the text WITHOUT a trailing newline, then the submit
|
||||
// sequence after the profile's delay (esquive de la paste-detection).
|
||||
await handle.write(encoder.encode(head.text));
|
||||
const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS;
|
||||
await sleep(delay);
|
||||
const submit = head.submitSequence ?? DEFAULT_SUBMIT_SEQUENCE;
|
||||
await handle.write(encoder.encode(submit));
|
||||
|
||||
// Dequeue + ack exactly once (best-effort; never throws the handshake).
|
||||
queueRef.current.shift();
|
||||
try {
|
||||
await inputRef.current.delegationDelivered(project, agent, head.ticket);
|
||||
} catch {
|
||||
/* ack is observability-only; never block the portal */
|
||||
}
|
||||
} finally {
|
||||
// (e) lower the overlay + resume the relay, with the 2 s anti-flash floor.
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const remaining = OVERLAY_FLOOR_MS - elapsed;
|
||||
if (remaining > 0) await sleep(remaining);
|
||||
suspendedRef.current = false;
|
||||
setOverlay(false);
|
||||
injectingRef.current = false;
|
||||
// A boundary may now be open for the next queued delegation.
|
||||
if (queueRef.current.length > 0) tryInject.current();
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// ── (2): subscribe to delegationReady for THIS agent and enqueue ───────────
|
||||
useEffect(() => {
|
||||
if (!system || !agentId) return;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
void system
|
||||
.onDomainEvent((event: DomainEvent) => {
|
||||
if (event.type !== "delegationReady") return;
|
||||
if (event.agentId !== agentId) return;
|
||||
queueRef.current.push({
|
||||
ticket: event.ticket,
|
||||
text: event.text,
|
||||
submitSequence: event.submitSequence,
|
||||
submitDelayMs: event.submitDelayMs,
|
||||
});
|
||||
// A delegation may already be at a clean boundary (line empty) — try now.
|
||||
tryInject.current();
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
else unsubscribe = un;
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [system, agentId]);
|
||||
|
||||
// ── (1)+(3): the portal object handed to TerminalView (stable identity) ────
|
||||
const portal = useMemo<WritePortal>(
|
||||
() => ({
|
||||
onHumanData(data: string) {
|
||||
// Reset on Enter / Ctrl-C, increment on a printable keystroke, ignore
|
||||
// control sequences. After a reset the line is empty ⇒ a queued
|
||||
// delegation may now be injectable.
|
||||
if (data === "\r" || data === "\n" || data === "\x03") {
|
||||
counterRef.current = 0;
|
||||
tryInject.current();
|
||||
return;
|
||||
}
|
||||
if (isPrintable(data)) {
|
||||
counterRef.current += 1;
|
||||
}
|
||||
},
|
||||
isSuspended() {
|
||||
return suspendedRef.current;
|
||||
},
|
||||
bindHandle(handle: TerminalHandle) {
|
||||
handleRef.current = handle;
|
||||
// The PTY just became available — a queued delegation may be injectable.
|
||||
tryInject.current();
|
||||
},
|
||||
unbindHandle() {
|
||||
handleRef.current = null;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return { portal, overlay };
|
||||
}
|
||||
|
||||
/** Promise-based delay used by the handshake (driven by fake timers in tests). */
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@ -242,6 +242,33 @@ export interface TerminalHandle {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The write-portal contract an agent-cell terminal talks to (ARCHITECTURE §20).
|
||||
* The portal owns the *human line* counter, the suspension flag (raised while it
|
||||
* injects a delegation) and — once the view has a live PTY — the handle it
|
||||
* writes through. The terminal view is the only effective PTY writer
|
||||
* (single-writer invariant): it relays human keystrokes and the portal injects
|
||||
* via the same handle, never a second physical writer.
|
||||
*/
|
||||
export interface WritePortal {
|
||||
/**
|
||||
* Reports a raw human keystroke chunk (`term.onData`) so the portal can keep
|
||||
* its line counter. Called for **every** keystroke in agent mode, including
|
||||
* while suspended (the portal decides what to count).
|
||||
*/
|
||||
onHumanData(data: string): void;
|
||||
/**
|
||||
* Whether the keystroke relay to the PTY is currently suspended (the portal
|
||||
* is injecting a delegation). When `true`, the view drops keystrokes so they
|
||||
* do not race the injected text.
|
||||
*/
|
||||
isSuspended(): boolean;
|
||||
/** Binds the live PTY handle so the portal can inject through it. */
|
||||
bindHandle(handle: TerminalHandle): void;
|
||||
/** Drops the handle reference (view torn down). */
|
||||
unbindHandle(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminals (L3): open a PTY with a per-session output stream, then write/
|
||||
* resize/close it through the returned {@link TerminalHandle}.
|
||||
@ -487,22 +514,32 @@ export interface MemoryGateway {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Agent input control (ARCHITECTURE §20). The mediated-input strip is gone: the
|
||||
* agent cell is a **native terminal** and human keystrokes (Enter included) go
|
||||
* straight to the PTY. This port only carries the two out-of-band controls:
|
||||
*
|
||||
* 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.
|
||||
* - **Interrompre** → `interrupt` (preempt the current turn — a control byte,
|
||||
* not an enqueue).
|
||||
* - **Ack** → `delegationDelivered`: the cell's write-portal confirms it has
|
||||
* effectively written a delegation's text into the native PTY (closes the
|
||||
* "the turn was delivered" loop for observability; the `ask` wake-up still
|
||||
* rides on `idea_reply`).
|
||||
*
|
||||
* The component talks to this gateway via DI, never to `invoke()` directly.
|
||||
*/
|
||||
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>;
|
||||
/**
|
||||
* Ack: the cell's write-portal has written the delegation `ticket` into the
|
||||
* agent's native PTY (ARCHITECTURE §20.3). Best-effort; never changes the
|
||||
* ticket correlation.
|
||||
*/
|
||||
delegationDelivered(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
ticket: string,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user