From fdcf16c38762b278581b397f5c833bae7576f214 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 13 Jun 2026 21:42:53 +0200 Subject: [PATCH] chore(wip): checkpoint P8/C avant chantier Codex inter-agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .ideai/agents.json | 14 + .ideai/agents/newtest.md | 0 .ideai/agents/testconversation.md | 0 .../handoff.md | 7 + .../log.jsonl | 1 + .../handoff.md | 9 + .../log.jsonl | 3 + .ideai/layouts.json | 44 +- .ideai/memory/MEMORY.md | 1 + ...mcp-bridge-and-delegation-runtime-notes.md | 24 + ARCHITECTURE.md | 133 ++++ crates/app-tauri/src/commands.rs | 116 +-- crates/app-tauri/src/dto.rs | 43 +- crates/app-tauri/src/events.rs | 33 + crates/app-tauri/src/lib.rs | 11 +- crates/app-tauri/src/mcp_bridge.rs | 235 ++++-- crates/app-tauri/src/mcp_endpoint.rs | 14 +- crates/app-tauri/src/state.rs | 732 ++++++++++++++++-- crates/app-tauri/tests/chat_bridge.rs | 17 +- crates/app-tauri/tests/dto.rs | 3 +- crates/app-tauri/tests/dto_chat.rs | 14 +- .../app-tauri/tests/list_live_agents_r0b.rs | 10 +- crates/app-tauri/tests/orchestrator_wiring.rs | 10 +- crates/application/src/agent/catalogue.rs | 7 +- crates/application/src/agent/lifecycle.rs | 92 ++- crates/application/src/agent/mod.rs | 14 +- crates/application/src/layout/mod.rs | 2 +- crates/application/src/layout/usecases.rs | 7 +- crates/application/src/lib.rs | 26 +- .../src/orchestrator/context_guard.rs | 64 +- .../application/src/orchestrator/service.rs | 129 +-- crates/application/src/terminal/registry.rs | 7 +- crates/application/tests/agent_lifecycle.rs | 288 ++++--- .../application/tests/change_agent_profile.rs | 37 +- .../application/tests/conversation_record.rs | 41 +- .../tests/list_resumable_agents.rs | 33 +- .../application/tests/orchestrator_service.rs | 294 +++++-- crates/application/tests/profile_usecases.rs | 16 +- crates/application/tests/send_blocking_d1.rs | 13 +- .../application/tests/structured_launch_d3.rs | 156 +++- .../tests/structured_registry_d1.rs | 13 +- crates/domain/src/conversation.rs | 11 +- crates/domain/src/conversation_log.rs | 32 +- crates/domain/src/events.rs | 23 + crates/domain/src/input.rs | 58 +- crates/domain/src/mailbox.rs | 25 +- crates/domain/src/profile.rs | 125 ++- crates/domain/tests/agent_profile_a0.rs | 10 +- crates/domain/tests/layout.rs | 7 +- crates/domain/tests/structured_session_d0.rs | 9 +- .../src/conversation_log/handoff.rs | 6 +- crates/infrastructure/src/input/mod.rs | 293 +++++-- crates/infrastructure/src/lib.rs | 2 +- crates/infrastructure/src/mailbox/mod.rs | 20 +- .../src/orchestrator/mcp/server.rs | 9 +- .../src/orchestrator/mcp/tools.rs | 13 +- .../src/orchestrator/mcp/transport.rs | 4 +- crates/infrastructure/src/session/mod.rs | 44 +- .../infrastructure/tests/conversation_log.rs | 242 ++++-- crates/infrastructure/tests/mcp_server.rs | 87 ++- .../tests/orchestrator_watcher.rs | 25 +- frontend/src/adapters/input.ts | 30 +- frontend/src/adapters/mock/index.ts | 33 +- frontend/src/domain/index.ts | 16 + .../features/layout/LayoutGrid.f2.test.tsx | 101 +-- frontend/src/features/layout/LayoutGrid.tsx | 95 ++- .../features/terminals/MediatedInput.test.tsx | 108 --- .../src/features/terminals/MediatedInput.tsx | 93 --- .../terminals/TerminalView.portal.test.tsx | 174 +++++ .../src/features/terminals/TerminalView.tsx | 60 +- frontend/src/features/terminals/index.ts | 8 +- .../features/terminals/useAgentBusy.test.tsx | 80 -- .../src/features/terminals/useAgentBusy.ts | 58 -- .../terminals/useWritePortal.test.tsx | 270 +++++++ .../src/features/terminals/useWritePortal.ts | 244 ++++++ frontend/src/ports/index.ts | 59 +- 76 files changed, 3783 insertions(+), 1404 deletions(-) create mode 100644 .ideai/agents/newtest.md create mode 100644 .ideai/agents/testconversation.md create mode 100644 .ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md create mode 100644 .ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl create mode 100644 .ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/handoff.md create mode 100644 .ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/log.jsonl create mode 100644 .ideai/memory/mcp-bridge-and-delegation-runtime-notes.md delete mode 100644 frontend/src/features/terminals/MediatedInput.test.tsx delete mode 100644 frontend/src/features/terminals/MediatedInput.tsx create mode 100644 frontend/src/features/terminals/TerminalView.portal.test.tsx delete mode 100644 frontend/src/features/terminals/useAgentBusy.test.tsx delete mode 100644 frontend/src/features/terminals/useAgentBusy.ts create mode 100644 frontend/src/features/terminals/useWritePortal.test.tsx create mode 100644 frontend/src/features/terminals/useWritePortal.ts diff --git a/.ideai/agents.json b/.ideai/agents.json index 418a75a..c1ad72a 100644 --- a/.ideai/agents.json +++ b/.ideai/agents.json @@ -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 } ] } diff --git a/.ideai/agents/newtest.md b/.ideai/agents/newtest.md new file mode 100644 index 0000000..e69de29 diff --git a/.ideai/agents/testconversation.md b/.ideai/agents/testconversation.md new file mode 100644 index 0000000..e69de29 diff --git a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md new file mode 100644 index 0000000..b4a045a --- /dev/null +++ b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md @@ -0,0 +1,7 @@ +--- +upTo: 44bc8d1d-93e9-46b3-94a8-8d11ab72ae82 +objective: Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Cont +--- +**Objectif :** Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Cont + +- **Prompt:** Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Contexte précis ci-dessous, suis-le à la lettre. ## Où Fichier : crates/app-tauri/src/state.rs, dans le module de test existant `#[cfg(test)] mod mcp_serve_peer_tests` (commence vers la ligne 1893). Ce module pilote déjà la fn privée `serve_peer` sur un `tokio::io::duplex` (pas de socket, pas de process). Réutilise au maximum ses helpers existants : `build_service`, `spawn_peer`, `handshake_line`, `tools_call_line`, `read_one_response`, `project()`, `project_id_arg`, `McpServer::new`. ## Le trou à combler Le module couvre handshake+tools/list, propagation du requester, mismatch projet, pairs concurrents — MAIS PAS le round-trip `idea_ask_agent` → `idea_reply` à travers `serve_peer`. C'est la glue critique : l'identité de l'agent répondeur vient de la ligne de handshake (`requester`), et c'est elle qui permet à `idea_reply` de corréler la réponse au ticket en attente. Référence du comportement attendu : `crates/infrastructure/tests/mcp_server.rs::ask_agent_returns_target_reply_inline` (qui, lui, court-circuite le transport via handle_raw/for_requester ; ton test doit passer par serve_peer + handshake réel). ## Problème à régler d'abord Le `build_service` actuel du module construit l'`OrchestratorService` via `OrchestratorService::new(...)` SANS câbler le médiateur d'entrée ni le mailbox ni le registre de conversations. Or `ask_agent` exige `with_input_mediator(...)` + (idéalement) `with_conversations(...)`, sinon il renvoie « la messagerie inter-agents n'est pas disponible ». => Ajoute dans le module un `build_service_with_mailbox(contexts) -> (Arc, Arc, Arc)` en PORTANT les fakes déjà éprouvés depuis `crates/application/tests/orchestrator_service.rs` (sections après la ligne ~806) : `TestMailbox` (impl `domain::mailbox::AgentMailbox`), `TestMediator` (impl `domain::input::InputMediator`, écrit le tour dans le PTY lié + délègue l'enqueue au TestMailbox), `TestConversations` (impl `domain::conversation::ConversationRegistry`). Câble : `.with_input_mediator(mediator, mailbox).with_conversations(conversations)`. Garde le profil Claude complet déjà présent dans `build_service` (adaptateur structuré + capacité MCP) pour passer la garde F2. Tu peux factoriser le profil pour éviter la duplication. Ajoute aussi un helper `seed_live_pty(sessions, agent_id, session_id)` (copie de celui d'orchestrator_service.rs) pour que la cible soit déjà vivante en PTY et qu'`ask_agent` la réutilise sans lancer de process. ## Le test à écrire (un seul, simple) `async fn ask_reply_round_trips_over_serve_peer_via_handshake_requester()` : 1. `let proj = project();` ; `let contexts = FakeContexts::new();` ; `let target_id = contexts.seed_agent("architect");` 2. `let (service, mailbox, sessions) = build_service_with_mailbox(contexts);` 3. `seed_live_pty(&sessions, target_id, );` (cible vivante → pas de spawn) 4. `let server = Arc::new(McpServer::new(service, proj.clone()));` 5. Peer A (le demandeur = humain) : `spawn_peer(Arc::clone(&server), project_id_arg(&proj))`. Écris `handshake_line(&project_id_arg(&proj), "")` (requester vide ⇒ ask d'origine humaine, le plus simple : évite la garde de cycle et le besoin que le demandeur soit un agent enregistré), puis `tools_call_line(7, "idea_ask_agent", json!({"target":"architect","task":"What is the answer?"}))`. NE bloque pas le test : lis la réponse de A dans une task spawnée OU lis-la après avoir débloqué via B (voir étape 7). 6. Attends (borné par TIMEOUT, via boucle `mailbox.pending(&target_id) == 1` + `tokio::task::yield_now().await`) que A ait enqueué son ticket et soit en attente. 7. Peer B (la cible qui répond) : `spawn_peer(Arc::clone(&server), project_id_arg(&proj))`. Écris `handshake_line(&project_id_arg(&proj), &target_id.to_string())` (CRUCIAL : le requester du handshake = l'id de la cible, c'est ce qui fait que `idea_reply` corrèle au mailbox de la cible), puis `tools_call_line(8, "idea_reply", json!({"result":"the answer is 42"}))`. Lis la réponse de B : `isError == false`. 8. Lis la réponse de A : `isError == false` et le texte inline == "the answer is 42" (le texte est dans result["content"][0]["text"], cf. `result_text` dans mcp_server.rs — réplique ce petit helper si besoin). GARDE-FOU obligatoire : borne chaque attente/lecture par `tokio::time::timeout(TIMEOUT, …)` (TIMEOUT existe déjà dans le module) pour qu'une régression échoue vite au lieu de hang. ## Contraintes - Respecte l'archi hexagonale : les fakes implémentent les ports du domaine, aucune dépendance nouvelle. - Code lisible, commenté dans le style du module (doc-comments expliquant POURQUOI le handshake requester est le point testé). - Lance le test et rends-moi la SORTIE RÉELLE de `cargo test -p app-tauri-lib mcp_serve_peer_tests` (ou le nom de crate exact). Si ça ne compile pas / échoue, débogue jusqu'au vert et rends le diagnostic. Rends ton résultat via idea_reply : (a) le diff/chemin du test ajouté, (b) la sortie cargo test réelle (vert ou rouge avec détail), (c) toute difficulté rencontrée. \ No newline at end of file diff --git a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl new file mode 100644 index 0000000..a6935f7 --- /dev/null +++ b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl @@ -0,0 +1 @@ +{"id":"44bc8d1d-93e9-46b3-94a8-8d11ab72ae82","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781369145058,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Contexte précis ci-dessous, suis-le à la lettre.\n\n## Où\nFichier : crates/app-tauri/src/state.rs, dans le module de test existant `#[cfg(test)] mod mcp_serve_peer_tests` (commence vers la ligne 1893). Ce module pilote déjà la fn privée `serve_peer` sur un `tokio::io::duplex` (pas de socket, pas de process). Réutilise au maximum ses helpers existants : `build_service`, `spawn_peer`, `handshake_line`, `tools_call_line`, `read_one_response`, `project()`, `project_id_arg`, `McpServer::new`.\n\n## Le trou à combler\nLe module couvre handshake+tools/list, propagation du requester, mismatch projet, pairs concurrents — MAIS PAS le round-trip `idea_ask_agent` → `idea_reply` à travers `serve_peer`. C'est la glue critique : l'identité de l'agent répondeur vient de la ligne de handshake (`requester`), et c'est elle qui permet à `idea_reply` de corréler la réponse au ticket en attente. Référence du comportement attendu : `crates/infrastructure/tests/mcp_server.rs::ask_agent_returns_target_reply_inline` (qui, lui, court-circuite le transport via handle_raw/for_requester ; ton test doit passer par serve_peer + handshake réel).\n\n## Problème à régler d'abord\nLe `build_service` actuel du module construit l'`OrchestratorService` via `OrchestratorService::new(...)` SANS câbler le médiateur d'entrée ni le mailbox ni le registre de conversations. Or `ask_agent` exige `with_input_mediator(...)` + (idéalement) `with_conversations(...)`, sinon il renvoie « la messagerie inter-agents n'est pas disponible ». \n=> Ajoute dans le module un `build_service_with_mailbox(contexts) -> (Arc, Arc, Arc)` en PORTANT les fakes déjà éprouvés depuis `crates/application/tests/orchestrator_service.rs` (sections après la ligne ~806) : `TestMailbox` (impl `domain::mailbox::AgentMailbox`), `TestMediator` (impl `domain::input::InputMediator`, écrit le tour dans le PTY lié + délègue l'enqueue au TestMailbox), `TestConversations` (impl `domain::conversation::ConversationRegistry`). Câble : `.with_input_mediator(mediator, mailbox).with_conversations(conversations)`. Garde le profil Claude complet déjà présent dans `build_service` (adaptateur structuré + capacité MCP) pour passer la garde F2. Tu peux factoriser le profil pour éviter la duplication. Ajoute aussi un helper `seed_live_pty(sessions, agent_id, session_id)` (copie de celui d'orchestrator_service.rs) pour que la cible soit déjà vivante en PTY et qu'`ask_agent` la réutilise sans lancer de process.\n\n## Le test à écrire (un seul, simple)\n`async fn ask_reply_round_trips_over_serve_peer_via_handshake_requester()` :\n1. `let proj = project();` ; `let contexts = FakeContexts::new();` ; `let target_id = contexts.seed_agent(\"architect\");`\n2. `let (service, mailbox, sessions) = build_service_with_mailbox(contexts);`\n3. `seed_live_pty(&sessions, target_id, );` (cible vivante → pas de spawn)\n4. `let server = Arc::new(McpServer::new(service, proj.clone()));`\n5. Peer A (le demandeur = humain) : `spawn_peer(Arc::clone(&server), project_id_arg(&proj))`. Écris `handshake_line(&project_id_arg(&proj), \"\")` (requester vide ⇒ ask d'origine humaine, le plus simple : évite la garde de cycle et le besoin que le demandeur soit un agent enregistré), puis `tools_call_line(7, \"idea_ask_agent\", json!({\"target\":\"architect\",\"task\":\"What is the answer?\"}))`. NE bloque pas le test : lis la réponse de A dans une task spawnée OU lis-la après avoir débloqué via B (voir étape 7).\n6. Attends (borné par TIMEOUT, via boucle `mailbox.pending(&target_id) == 1` + `tokio::task::yield_now().await`) que A ait enqueué son ticket et soit en attente.\n7. Peer B (la cible qui répond) : `spawn_peer(Arc::clone(&server), project_id_arg(&proj))`. Écris `handshake_line(&project_id_arg(&proj), &target_id.to_string())` (CRUCIAL : le requester du handshake = l'id de la cible, c'est ce qui fait que `idea_reply` corrèle au mailbox de la cible), puis `tools_call_line(8, \"idea_reply\", json!({\"result\":\"the answer is 42\"}))`. Lis la réponse de B : `isError == false`.\n8. Lis la réponse de A : `isError == false` et le texte inline == \"the answer is 42\" (le texte est dans result[\"content\"][0][\"text\"], cf. `result_text` dans mcp_server.rs — réplique ce petit helper si besoin).\nGARDE-FOU obligatoire : borne chaque attente/lecture par `tokio::time::timeout(TIMEOUT, …)` (TIMEOUT existe déjà dans le module) pour qu'une régression échoue vite au lieu de hang.\n\n## Contraintes\n- Respecte l'archi hexagonale : les fakes implémentent les ports du domaine, aucune dépendance nouvelle.\n- Code lisible, commenté dans le style du module (doc-comments expliquant POURQUOI le handshake requester est le point testé).\n- Lance le test et rends-moi la SORTIE RÉELLE de `cargo test -p app-tauri-lib mcp_serve_peer_tests` (ou le nom de crate exact). Si ça ne compile pas / échoue, débogue jusqu'au vert et rends le diagnostic.\n\nRends ton résultat via idea_reply : (a) le diff/chemin du test ajouté, (b) la sortie cargo test réelle (vert ou rouge avec détail), (c) toute difficulté rencontrée."} diff --git a/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/handoff.md b/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/handoff.md new file mode 100644 index 0000000..3e0e10b --- /dev/null +++ b/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/handoff.md @@ -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é. ✅ \ No newline at end of file diff --git a/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/log.jsonl b/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/log.jsonl new file mode 100644 index 0000000..2f854b6 --- /dev/null +++ b/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/log.jsonl @@ -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é. ✅"} diff --git a/.ideai/layouts.json b/.ideai/layouts.json index 549d22d..58b25ce 100644 --- a/.ideai/layouts.json +++ b/.ideai/layouts.json @@ -1,24 +1,24 @@ { "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 } @@ -27,12 +27,36 @@ }, { "node": { - "type": "leaf", + "type": "split", "node": { - "id": "92826533-1a7c-4d9e-b6b4-f1e904ddc81a", - "session": "5d20f53a-ac76-4677-9a6f-064d3319cc73", - "agent": "edce8090-4c57-47c5-a319-c08fd172438b", - "agentWasRunning": true + "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": "b9251e74-3bd5-43ee-90e5-e6bb87faab38", + "session": "69a3bf52-05ef-45c0-badf-26b0d8224f0e", + "agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "agentWasRunning": true + } + }, + "weight": 1.0 + } + ] } }, "weight": 1.0 diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 73331b8..5492db9 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -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). diff --git a/.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md b/.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md new file mode 100644 index 0000000..d8811e4 --- /dev/null +++ b/.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md @@ -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** (` mcp-server …` declare dans chaque `.ideai/run//.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 · ticket ]` 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]]. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 90f2c2f..5ffdf76 100644 --- a/ARCHITECTURE.md +++ b/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, // 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, // 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, + submit_delay_ms: Option, +}, +``` +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; + /** Ack : la cellule a écrit la délégation `ticket` dans le PTY natif. */ + delegationDelivered(projectId: string, agentId: string, ticket: string): Promise; +} +``` +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.* diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index d3c9fcf..df31957 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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). /// diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index de74a2b..7ac7c70 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -1197,20 +1197,6 @@ impl From for TerminalSessionDto { } } -/// Request DTO for `submit_agent_input` (cadrage C4 §4.2): the human Envoyer path. -/// The frontend's [`InputGateway`](../../../frontend/src/ports) sends -/// `{ request: { projectId, agentId, text } }`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SubmitAgentInputRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent to send the human input to. - pub agent_id: String, - /// The text the operator typed (enqueued as a `from_human` ticket). - pub text: String, -} - /// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The /// frontend sends `{ request: { projectId, agentId } }`. #[derive(Debug, Clone, Deserialize)] @@ -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 { }) } +/// 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 { + 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) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index c8d2c28..b937b6f 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -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, + /// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms). + #[serde(skip_serializing_if = "Option::is_none")] + submit_delay_ms: Option, + }, /// 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, diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 43c35cb..7baedb0 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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 = args - .map(|a| a.to_string_lossy().into_owned()) - .collect(); + let rest: Vec = 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, diff --git a/crates/app-tauri/src/mcp_bridge.rs b/crates/app-tauri/src/mcp_bridge.rs index 639830d..ccc3742 100644 --- a/crates/app-tauri/src/mcp_bridge.rs +++ b/crates/app-tauri/src/mcp_bridge.rs @@ -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 { /// /// 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( 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( + mut reader: BufReader, + mut writer: W, + src: &str, + dst: &str, +) -> Result +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(()); + // Source closed: drain and hand the writer back for half-close. + writer + .flush() + .await + .map_err(|e| format!("{dst} flush failed: {e}"))?; + return Ok(writer); } - lp_write - .write_all(cli_line.as_bytes()) + writer + .write_all(line.as_bytes()) .await - .map_err(|e| format!("loopback write failed: {e}"))?; - lp_write + .map_err(|e| format!("{dst} write failed: {e}"))?; + 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()); - } - cli_out - .write_all(lp_line.as_bytes()) - .await - .map_err(|e| format!("stdout write failed: {e}"))?; - cli_out - .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, - ) - .await - .unwrap(); + 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 = 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,12 +577,9 @@ 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), - ) - .await - .expect("connect_loopback must not hang"); + 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,13 +634,10 @@ 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), - ) - .await - .expect("no hang") - .expect("connect to live endpoint"); + let conn = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint)) + .await + .expect("no hang") + .expect("connect to live endpoint"); let (lp_read, lp_write) = tokio::io::split(conn); tokio::time::timeout( @@ -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}"); diff --git a/crates/app-tauri/src/mcp_endpoint.rs b/crates/app-tauri/src/mcp_endpoint.rs index 25c9ab8..b4dc728 100644 --- a/crates/app-tauri/src/mcp_endpoint.rs +++ b/crates/app-tauri/src/mcp_endpoint.rs @@ -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 { - std::env::var("APPIMAGE") - .ok() - .or_else(|| std::env::current_exe().ok().map(|p| p.to_string_lossy().into_owned())) + std::env::var("APPIMAGE").ok().or_else(|| { + std::env::current_exe() + .ok() + .map(|p| p.to_string_lossy().into_owned()) + }) } /// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à @@ -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()), diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index fb58f1f..1e0f64e 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -5,31 +5,29 @@ //! `Arc` into the use cases (ARCHITECTURE §1.1, §10). The use cases //! are then exposed through `tauri::State` 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 (`/.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) + .with_handoff_provider( + Arc::new(AppHandoffProvider) as Arc + ) // Resumable moteur par provider (lot P8b) : après un lancement structuré // exposant un id de session moteur, rangé sous la clé de paire dans // `/.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, - ), + .with_provider_session_provider(Arc::new(AppProviderSessionProvider) + as Arc), ); // 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; + let mailbox = Arc::clone(&inmemory_mailbox) as Arc; let input_mediator = Arc::new( MediatedInbox::with_pty( Arc::clone(&inmemory_mailbox), @@ -835,8 +839,8 @@ impl AppState { ) as Arc; // Registre des conversations par paire (cadrage C3) : un fil par paire, session // vivante keyée par conversation (lève l'ambiguïté session/agent). - let conversation_registry = - Arc::new(InMemoryConversationRegistry::new()) as Arc; + let conversation_registry = Arc::new(InMemoryConversationRegistry::new()) + as Arc; 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 { + let mut doc = match serde_json::from_str::(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 { + let mut doc = match serde_json::from_str::(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, +) -> Vec { + let mut seen = HashSet::::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 { } } } - let name = endpoint - .as_cli_arg() - .to_fs_name::() - .ok()?; + let name = endpoint.as_cli_arg().to_fs_name::().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, )) as Arc; - let conversations = - Arc::new(InMemoryConversationRegistry::new()) as Arc; + let conversations = Arc::new(InMemoryConversationRegistry::new()) + as Arc; 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) } @@ -2790,7 +3367,7 @@ mod mcp_e2e_loopback_tests { { let mut line = String::new(); match tokio::time::timeout(TIMEOUT, reader.read_line(&mut line)).await { - Ok(Ok(0)) => None, // EOF before a full line + Ok(Ok(0)) => None, // EOF before a full line Ok(Ok(_)) => serde_json::from_str(line.trim_end()).ok(), Ok(Err(_)) => None, Err(_) => None, // GARDE-FOU: timed out waiting for a reply @@ -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 { ); } } - diff --git a/crates/app-tauri/tests/chat_bridge.rs b/crates/app-tauri/tests/chat_bridge.rs index ecdc4df..9d4fb5f 100644 --- a/crates/app-tauri/tests/chat_bridge.rs +++ b/crates/app-tauri/tests/chat_bridge.rs @@ -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" diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs index c1cb705..d925037 100644 --- a/crates/app-tauri/tests/dto.rs +++ b/crates/app-tauri/tests/dto.rs @@ -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(); diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index 6650238..4297333 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -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(); diff --git a/crates/app-tauri/tests/list_live_agents_r0b.rs b/crates/app-tauri/tests/list_live_agents_r0b.rs index aa95222..57dc070 100644 --- a/crates/app-tauri/tests/list_live_agents_r0b.rs +++ b/crates/app-tauri/tests/list_live_agents_r0b.rs @@ -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()); diff --git a/crates/app-tauri/tests/orchestrator_wiring.rs b/crates/app-tauri/tests/orchestrator_wiring.rs index bbc871a..4bcf510 100644 --- a/crates/app-tauri/tests/orchestrator_wiring.rs +++ b/crates/app-tauri/tests/orchestrator_wiring.rs @@ -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); } diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs index 84b5e53..fa28e41 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -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); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index c81a64b..6b0a84b 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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,14 +1302,13 @@ 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(|| { - ConversationId::for_pair( - ConversationParty::User, - ConversationParty::agent(agent.id), - ) - .to_string() - }); + let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| { + ConversationId::for_pair( + ConversationParty::User, + ConversationParty::agent(agent.id), + ) + .to_string() + }); return self .launch_structured( factory.as_ref(), @@ -1679,10 +1680,18 @@ impl LaunchAgent { /// /// Dispatch by [`McpConfigStrategy`]: /// - `ConfigFile { target }` → write `/` (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. + let declaration = mcp_server_declaration(mcp.transport, runtime); + 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; } - // An exists() probe failure is swallowed too (best-effort). - Err(_) => {} + // 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; + } + 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" diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index cda3258..1ddb711 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -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, diff --git a/crates/application/src/layout/mod.rs b/crates/application/src/layout/mod.rs index 58b0bc9..7a1b5eb 100644 --- a/crates/application/src/layout/mod.rs +++ b/crates/application/src/layout/mod.rs @@ -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, diff --git a/crates/application/src/layout/usecases.rs b/crates/application/src/layout/usecases.rs index 773e63e..794edef 100644 --- a/crates/application/src/layout/usecases.rs +++ b/crates/application/src/layout/usecases.rs @@ -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, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index aeaf841..c4d405d 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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::{ diff --git a/crates/application/src/orchestrator/context_guard.rs b/crates/application/src/orchestrator/context_guard.rs index 577f3d1..1a6adc6 100644 --- a/crates/application/src/orchestrator/context_guard.rs +++ b/crates/application/src/orchestrator/context_guard.rs @@ -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, fs: Arc, ) -> 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 { - 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, clock: Arc, ) -> 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 { - 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 { - 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, MemoryError> { Ok(Vec::new()) } - async fn get( - &self, - _root: &ProjectPath, - slug: &MemorySlug, - ) -> Result { + async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result { 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), diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 82d55c2..3e23ce5 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -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,19 +1068,18 @@ impl OrchestratorService { }) .await?; - 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" - )) - })?; + let session_id = self.sessions.session_for_agent(&agent_id).ok_or_else(|| { + AppError::Process(format!( + "agent {target} n'a pas de session terminal vivante après lancement" + )) + })?; // Lier la session fraîchement lancée à CE fil (registre terminal + registre de // conversations) ⇒ un prochain ask sur le même fil la réutilise. self.bind_conversation_session(conversation_id, session_id); self.sessions.handle(&session_id).ok_or_else(|| { - AppError::Process(format!("handle PTY de l'agent {target} introuvable après lancement")) + 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 { - let agent = self + ) -> (Option, 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`, diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 7df2ae0..b86e632 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -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 { - 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() diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index f7463ee..9d1b89e 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -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,23 +1591,23 @@ 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 // /.mcp.json with a non-empty, coherent MCP server declaration. - let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile_with_mcp( - pid(9), - McpConfigStrategy::config_file(".mcp.json").unwrap(), - ), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); launch.execute(launch_input(agent.id)).await.unwrap(); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); let mcp = writes_ending_with(&fs, "/.mcp.json"); assert_eq!(mcp.len(), 1, "exactly one MCP config file written"); - assert_eq!(mcp[0].0, format!("{run_dir}/.mcp.json"), "written at run dir"); + 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,25 +1617,20 @@ 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 /.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 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(), + }), ); - let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile, - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); // Pre-existing MCP config file on disk. let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); fs.mark_existing(&format!("{run_dir}/.mcp.json")); @@ -1649,17 +1646,13 @@ 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 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(), + }), ); - let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile, - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); // Force the MCP config write to fail. fs.fail_write_suffix("/.mcp.json"); @@ -1676,13 +1669,12 @@ 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( - profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); + let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); launch.execute(launch_input(agent.id)).await.unwrap(); @@ -1704,13 +1696,12 @@ 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, ). - 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(), - }), - ); + let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::env("IDEA_MCP").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); launch.execute(launch_input(agent.id)).await.unwrap(); @@ -1761,13 +1752,12 @@ 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( - profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); + 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 exe = "/abs/idea"; let endpoint = "/run/idea-mcp/abc.sock"; @@ -1820,13 +1810,12 @@ 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( - profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); + 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 exe = r"C:\Program Files\IdeA\idea.exe"; let endpoint = r"/tmp/idea mcp/a b\c.sock"; @@ -1867,13 +1856,12 @@ 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( - profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); // launch_input has mcp_runtime: None. launch.execute(launch_input(agent.id)).await.unwrap(); @@ -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( - profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); +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 = 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, 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(()) } @@ -2338,8 +2437,8 @@ async fn launch_with_store_without_handoff_omits_resume_section() { #[tokio::test] async fn reopened_cell_loads_handoff_saved_under_resolve_key_end_to_end() { let agent_party = ConversationParty::agent(aid(1)); // l'agent du harnais. - // (1) Clé de SAUVEGARDE = resolve_conversation(None, agent) avec registre câblé - // == for_pair(User, agent) (égalité scellée Bloc 1, côté infrastructure). + // (1) Clé de SAUVEGARDE = resolve_conversation(None, agent) avec registre câblé + // == for_pair(User, agent) (égalité scellée Bloc 1, côté infrastructure). let save_key = ConversationId::for_pair(ConversationParty::User, agent_party); // (2) Clé que P8a persiste sur la cellule neuve, donc portée à la réouverture. let persisted_on_leaf = ConversationId::for_pair(ConversationParty::User, agent_party); @@ -2383,14 +2482,15 @@ async fn reopened_cell_loads_handoff_saved_under_resolve_key_end_to_end() { #[tokio::test] async fn reopened_cell_does_not_load_handoff_saved_under_a_different_pair_key() { let agent_party = ConversationParty::agent(aid(1)); // agent du harnais. - // La cellule porte SA clé de paire (celle que P8a aurait persistée)… + // 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; diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index 83e25f0..1877ec9 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -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> { + fn handoff_store_for(&self, _root: &ProjectPath) -> Option> { 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, diff --git a/crates/application/tests/conversation_record.rs b/crates/application/tests/conversation_record.rs index c0642e8..eac29c8 100644 --- a/crates/application/tests/conversation_record.rs +++ b/crates/application/tests/conversation_record.rs @@ -61,7 +61,12 @@ impl InMemoryConversationLog { } fn thread(&self, conv: ConversationId) -> Vec { - 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()); diff --git a/crates/application/tests/list_resumable_agents.rs b/crates/application/tests/list_resumable_agents.rs index 685b703..74ec01b 100644 --- a/crates/application/tests/list_resumable_agents.rs +++ b/crates/application/tests/list_resumable_agents.rs @@ -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, diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 907c27b..c823d9e 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -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); 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 { @@ -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) { @@ -1131,8 +1144,7 @@ async fn await_until 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 { @@ -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); + service = service.with_mcp_runtime_provider(p as Arc); } 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, @@ -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"))) - .await + 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,21 +2028,30 @@ 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"))) - .await + svc.dispatch( + &project(), + cmd(&ask_from_agent_json(aid(1), "beta", "to B")), + ) + .await }); await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; // Now B→A would close the cycle ⇒ typed INVALID, no enqueue on A, returns fast. let err = timeout( TEST_GUARD, - fx.service - .dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "back to A"))), + 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,8 +2072,11 @@ 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"))) - .await + svc.dispatch( + &project(), + cmd(&ask_from_agent_json(aid(1), "beta", "to B")), + ) + .await }); await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; fx.service @@ -2012,8 +2088,11 @@ 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"))) - .await + svc2.dispatch( + &project(), + cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")), + ) + .await }); await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; fx.service @@ -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,8 +2323,11 @@ 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"))) - .await + 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, 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, )); let provider = Arc::new(SharedRecordProvider(record)) as Arc; - 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" + ); } diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 2b65776..94c123e 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -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" ); } diff --git a/crates/application/tests/send_blocking_d1.rs b/crates/application/tests/send_blocking_d1.rs index e46cd36..496cfbb 100644 --- a/crates/application/tests/send_blocking_d1.rs +++ b/crates/application/tests/send_blocking_d1.rs @@ -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()))); } diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 549955f..a6b2e33 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -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 { @@ -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; + let provider = + Arc::new(FakeProviderSessionProvider(store)) as Arc; 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()); diff --git a/crates/application/tests/structured_registry_d1.rs b/crates/application/tests/structured_registry_d1.rs index 68226d2..0f74913 100644 --- a/crates/application/tests/structured_registry_d1.rs +++ b/crates/application/tests/structured_registry_d1.rs @@ -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)),] ); } diff --git a/crates/domain/src/conversation.rs b/crates/domain/src/conversation.rs index 47efb5e..e91b84f 100644 --- a/crates/domain/src/conversation.rs +++ b/crates/domain/src/conversation.rs @@ -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] diff --git a/crates/domain/src/conversation_log.rs b/crates/domain/src/conversation_log.rs index 410ac2c..188dfb6 100644 --- a/crates/domain/src/conversation_log.rs +++ b/crates/domain/src/conversation_log.rs @@ -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, - up_to: TurnId, - objective: Option, - ) -> Self { + pub fn new(summary_md: impl Into, up_to: TurnId, objective: Option) -> 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"]); diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 4c97b93..1f9b053 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -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, + /// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms). + submit_delay_ms: Option, + }, /// Raw PTY output (usually routed to a dedicated channel, not this bus). PtyOutput { /// The session. diff --git a/crates/domain/src/input.rs b/crates/domain/src/input.rs index 856f32d..21b8897 100644 --- a/crates/domain/src/input.rs +++ b/crates/domain/src/input.rs @@ -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, + /// The profile's `submit_delay_ms`. `None` ⇒ front default (~60 ms). + pub delay_ms: Option, +} + +impl SubmitConfig { + /// Builds a submit config from the two optional profile fields. + #[must_use] + pub const fn new(sequence: Option, delay_ms: Option) -> 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, + _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 diff --git a/crates/domain/src/mailbox.rs b/crates/domain/src/mailbox.rs index 5e924b3..4eeb70a 100644 --- a/crates/domain/src/mailbox.rs +++ b/crates/domain/src/mailbox.rs @@ -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> + Send>>, - ) -> Self { + pub fn new(inner: Pin> + 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); } } diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 45505fe..e886930 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -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, + /// 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, + /// 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, } /// 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) -> 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,10 +652,9 @@ 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"), - ) - .expect("serialise"); + 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)); + } } diff --git a/crates/domain/tests/agent_profile_a0.rs b/crates/domain/tests/agent_profile_a0.rs index 83e50de..4593a08 100644 --- a/crates/domain/tests/agent_profile_a0.rs +++ b/crates/domain/tests/agent_profile_a0.rs @@ -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()); } diff --git a/crates/domain/tests/layout.rs b/crates/domain/tests/layout.rs index 875c24a..18d4d85 100644 --- a/crates/domain/tests/layout.rs +++ b/crates/domain/tests/layout.rs @@ -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, diff --git a/crates/domain/tests/structured_session_d0.rs b/crates/domain/tests/structured_session_d0.rs index ab9a95f..571af6c 100644 --- a/crates/domain/tests/structured_session_d0.rs +++ b/crates/domain/tests/structured_session_d0.rs @@ -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()) + ); } // --------------------------------------------------------------------------- diff --git a/crates/infrastructure/src/conversation_log/handoff.rs b/crates/infrastructure/src/conversation_log/handoff.rs index 44d9a98..7a5c2ac 100644 --- a/crates/infrastructure/src/conversation_log/handoff.rs +++ b/crates/infrastructure/src/conversation_log/handoff.rs @@ -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); diff --git a/crates/infrastructure/src/input/mod.rs b/crates/infrastructure/src/input/mod.rs index 7e26fdf..205eb0c 100644 --- a/crates/infrastructure/src/input/mod.rs +++ b/crates/infrastructure/src/input/mod.rs @@ -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, /// Shared busy/idle authority (also handed to prompt-ready watcher threads, C5). @@ -136,6 +139,10 @@ pub struct MediatedInbox { pty: Option>, /// Per-agent live input handle (one stream per agent), fed by `bind_handle`. handles: Mutex>, + /// 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>, /// 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> { @@ -197,6 +209,12 @@ impl MediatedInbox { .unwrap_or_else(std::sync::PoisonError::into_inner) } + fn submit(&self) -> std::sync::MutexGuard<'_, HashMap> { + 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 { @@ -250,10 +268,7 @@ impl MediatedInbox { let mut window: Vec = Vec::with_capacity(keep + 1); for chunk in stream { window.extend_from_slice(&chunk); - if window - .windows(needle.len()) - .any(|w| w == needle.as_slice()) - { + 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 · ticket ]` 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, + 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, Option)> { + 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); + 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, + ) + .with_events(Arc::clone(&bus) as Arc); + 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); + 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, + ); + 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); @@ -490,7 +676,7 @@ mod tests { let a = agent(1); inbox.enqueue(a, ticket(10, "first")); inbox.enqueue(a, ticket(11, "second")); // accepted, queues behind - // Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox. + // Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox. assert_eq!( inbox.busy_state(a).ticket(), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))) @@ -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"); } diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index b458629..c7671e8 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -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}; diff --git a/crates/infrastructure/src/mailbox/mod.rs b/crates/infrastructure/src/mailbox/mod.rs index 2b4a9f4..dba9095 100644 --- a/crates/infrastructure/src/mailbox/mod.rs +++ b/crates/infrastructure/src/mailbox/mod.rs @@ -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))) + ); } } diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index a837681..a4d9fe5 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -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 diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 99b18ed..1bb6df2 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -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 { - args.get(key).and_then(Value::as_str).map(str::to_owned) - }; + let s = + |key: &str| -> Option { 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 { 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( diff --git a/crates/infrastructure/src/orchestrator/mcp/transport.rs b/crates/infrastructure/src/orchestrator/mcp/transport.rs index 4626f38..2c25040 100644 --- a/crates/infrastructure/src/orchestrator/mcp/transport.rs +++ b/crates/infrastructure/src/orchestrator/mcp/transport.rs @@ -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>, - ) -> (Self, mpsc::UnboundedReceiver>) { + pub fn scripted(messages: Vec>) -> (Self, mpsc::UnboundedReceiver>) { let (tx, rx) = mpsc::unbounded_channel(); ( Self { diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index de4084d..d064b40 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -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,10 +679,9 @@ mod tests { label: "reasoning".into() }] ); - let c = codex::parse_event( - r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#, - ) - .unwrap(); + let c = + codex::parse_event(r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#) + .unwrap(); assert_eq!( c.events, vec![ReplyEvent::ToolActivity { @@ -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":{}}"#) - .unwrap() - .events - .is_empty()); + assert!( + codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#) + .unwrap() + .events + .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); } diff --git a/crates/infrastructure/tests/conversation_log.rs b/crates/infrastructure/tests/conversation_log.rs index 37ef810..a90f1ca 100644 --- a/crates/infrastructure/tests/conversation_log.rs +++ b/crates/infrastructure/tests/conversation_log.rs @@ -71,7 +71,8 @@ impl TempDir { } /// `/.ideai/conversations//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::(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. 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" + ); } diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 7430439..4badeda 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -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 { /// 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); diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 75b4e8a..81cce99 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -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"); diff --git a/frontend/src/adapters/input.ts b/frontend/src/adapters/input.ts index be1d132..180c7b8 100644 --- a/frontend/src/adapters/input.ts +++ b/frontend/src/adapters/input.ts @@ -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 { - await invoke("submit_agent_input", { - request: { projectId, agentId, text }, - }); - } - async interrupt(projectId: string, agentId: string): Promise { await invoke("interrupt_agent", { request: { projectId, agentId }, }); } + + async delegationDelivered( + projectId: string, + agentId: string, + ticket: string, + ): Promise { + await invoke("delegation_delivered", { + request: { projectId, agentId, ticket }, + }); + } } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 3ba28c9..6cb6c0c 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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 { - this.submits.push({ projectId, agentId, text }); - } + /** All recorded delegation-delivered acks, in order. */ + readonly delivered: MockInputCall[] = []; async interrupt(projectId: string, agentId: string): Promise { this.interrupts.push({ projectId, agentId }); } + + async delegationDelivered( + projectId: string, + agentId: string, + ticket: string, + ): Promise { + this.delivered.push({ projectId, agentId, ticket }); + } } /** Builds the full set of mock gateways. */ diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index d50f464..5a4ab73 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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 } diff --git a/frontend/src/features/layout/LayoutGrid.f2.test.tsx b/frontend/src/features/layout/LayoutGrid.f2.test.tsx index 5579a4c..30ca15f 100644 --- a/frontend/src/features/layout/LayoutGrid.f2.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.f2.test.tsx @@ -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 { return writeSpy; } +async function pinAgent(gateways: Gateways, agentId: string): Promise { + 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(); }); }); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index bd0df85..8c66fb5 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -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([]); @@ -552,44 +553,66 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm )} {/* 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. */}
-
- void vm.setSession(id, sid)} - agentMode={agentId != null} - /> -
- {agentId != null && ( - + 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 && ( +
+ + Un agent est en train de parler… + +
)}
{busyNotice && ( diff --git a/frontend/src/features/terminals/MediatedInput.test.tsx b/frontend/src/features/terminals/MediatedInput.test.tsx deleted file mode 100644 index 670735d..0000000 --- a/frontend/src/features/terminals/MediatedInput.test.tsx +++ /dev/null @@ -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> = {}, -) { - const gateways = { - input, - system: new MockSystemGateway(), - } as unknown as Gateways; - return render( - - - , - ); -} - -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" }, - ]); - }); - }); -}); diff --git a/frontend/src/features/terminals/MediatedInput.tsx b/frontend/src/features/terminals/MediatedInput.tsx deleted file mode 100644 index 569641d..0000000 --- a/frontend/src/features/terminals/MediatedInput.tsx +++ /dev/null @@ -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 ( -
- setText(e.target.value)} - /> - - -
- ); -} diff --git a/frontend/src/features/terminals/TerminalView.portal.test.tsx b/frontend/src/features/terminals/TerminalView.portal.test.tsx new file mode 100644 index 0000000..6b6f6fa --- /dev/null +++ b/frontend/src/features/terminals/TerminalView.portal.test.tsx @@ -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 { + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + {/* agentMode false: portal must never be touched */} + + , + ); + + 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(); + }); +}); diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index dd6188b..a9fb4d2 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -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(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. diff --git a/frontend/src/features/terminals/index.ts b/frontend/src/features/terminals/index.ts index a385ef3..ddb8486 100644 --- a/frontend/src/features/terminals/index.ts +++ b/frontend/src/features/terminals/index.ts @@ -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"; diff --git a/frontend/src/features/terminals/useAgentBusy.test.tsx b/frontend/src/features/terminals/useAgentBusy.test.tsx deleted file mode 100644 index 9d8965b..0000000 --- a/frontend/src/features/terminals/useAgentBusy.test.tsx +++ /dev/null @@ -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 ( - - ); -} - -function renderHarness(system: MockSystemGateway, agentId = "a1") { - const gateways = { - input: new MockInputGateway(), - system, - } as unknown as Gateways; - return render( - - - , - ); -} - -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(); - }); - }); -}); diff --git a/frontend/src/features/terminals/useAgentBusy.ts b/frontend/src/features/terminals/useAgentBusy.ts deleted file mode 100644 index 14da283..0000000 --- a/frontend/src/features/terminals/useAgentBusy.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Light per-agent busy store (lot F1, cadrage §4.2/§4.3). - * - * Keeps `agentBusy: Record` 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>; - -/** - * 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>({}); - - 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; -} diff --git a/frontend/src/features/terminals/useWritePortal.test.tsx b/frontend/src/features/terminals/useWritePortal.test.tsx new file mode 100644 index 0000000..7d4eecf --- /dev/null +++ b/frontend/src/features/terminals/useWritePortal.test.tsx @@ -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 }) => ( + {children} + ); + 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); + }); +}); diff --git a/frontend/src/features/terminals/useWritePortal.ts b/frontend/src/features/terminals/useWritePortal.ts new file mode 100644 index 0000000..8873e3d --- /dev/null +++ b/frontend/src/features/terminals/useWritePortal.ts @@ -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([]); // local FIFO + const handleRef = useRef(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( + () => ({ + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index b03c2ae..fa8999b 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -242,6 +242,33 @@ export interface TerminalHandle { close(): Promise; } +/** + * 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; /** Interrompre = preempt: signals the current turn to stop (not an enqueue). */ interrupt(projectId: string, agentId: string): Promise; + /** + * 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; } /**