Compare commits
10 Commits
e583b2b49f
...
b05d04ab7a
| Author | SHA1 | Date | |
|---|---|---|---|
| b05d04ab7a | |||
| 27597eb64e | |||
| 46492506e1 | |||
| aa2f67ae89 | |||
| 0f8ba38d51 | |||
| fdcf16c387 | |||
| 4509f0db9d | |||
| d87b8f6ed2 | |||
| 9b053216e3 | |||
| 09cc8f0902 |
@ -35,6 +35,20 @@
|
||||
"mdPath": "agents/qa.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
|
||||
"name": "TestConversation",
|
||||
"mdPath": "agents/testconversation.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "484eff91-60a1-459f-9ebe-c9552cc70447",
|
||||
"name": "NewTest",
|
||||
"mdPath": "agents/newtest.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
0
.ideai/agents/newtest.md
Normal file
0
.ideai/agents/newtest.md
Normal file
0
.ideai/agents/testconversation.md
Normal file
0
.ideai/agents/testconversation.md
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,9 @@
|
||||
---
|
||||
upTo: 88f405da-f9c9-44bb-96c9-7e1b0e5af557
|
||||
objective: Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
---
|
||||
**Objectif :** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
|
||||
- **Prompt:** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
- **Prompt:** Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA.
|
||||
- **Response:** (1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅
|
||||
@ -0,0 +1,3 @@
|
||||
{"id":"20dd8bdc-f858-46a6-9c8a-6afde591a6bd","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781367769591,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?"}
|
||||
{"id":"0b8fcc51-c692-4928-9d90-3823857dad5c","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368607174,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA."}
|
||||
{"id":"88f405da-f9c9-44bb-96c9-7e1b0e5af557","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368615582,"source":{"kind":"agent","agentId":"c932c770-cf36-4fb2-a966-71bb1644e4b4"},"role":"response","text":"(1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅"}
|
||||
@ -1,37 +1,56 @@
|
||||
{
|
||||
"version": 1,
|
||||
"activeId": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5",
|
||||
"activeId": "1af250f0-65ef-4b78-8905-b1746673aee0",
|
||||
"layouts": [
|
||||
{
|
||||
"id": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5",
|
||||
"id": "1af250f0-65ef-4b78-8905-b1746673aee0",
|
||||
"name": "Default",
|
||||
"kind": "terminal",
|
||||
"tree": {
|
||||
"root": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "1eb96c70-e954-42d0-907b-f8528d0442bf",
|
||||
"id": "56ffe1e4-636c-458d-9ab2-7e278fd45897",
|
||||
"direction": "row",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "db40e3de-4980-4bed-b6fa-1c136f49d30e",
|
||||
"session": "d7d4c099-eda3-4b33-82e6-e6f8d57b8b97",
|
||||
"id": "c3319a9a-1345-4fa2-b64e-5f3fe00d13d8",
|
||||
"session": "4965c71a-f69f-4c06-90de-ecb81acff710",
|
||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "8cf1c06e-2654-45a3-bf17-a9c2507da935",
|
||||
"direction": "column",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "69dc2e23-86f5-4770-84c4-b1b4b2c25299",
|
||||
"session": "11fbf6b4-eb62-4420-95e9-feb2ff667c43",
|
||||
"agent": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "92826533-1a7c-4d9e-b6b4-f1e904ddc81a",
|
||||
"session": "5d20f53a-ac76-4677-9a6f-064d3319cc73",
|
||||
"agent": "edce8090-4c57-47c5-a319-c08fd172438b",
|
||||
"id": "b9251e74-3bd5-43ee-90e5-e6bb87faab38",
|
||||
"session": "69a3bf52-05ef-45c0-badf-26b0d8224f0e",
|
||||
"agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
@ -39,6 +58,11 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,3 +3,4 @@
|
||||
- [agent-context-memory-and-profile-handoff](agent-context-memory-and-profile-handoff.md) — Decisions sur l'injection de contexte, la memoire durable, l'etat live et le handoff de profil entre agents IA.
|
||||
- [idea-product-directives-main-handoff](idea-product-directives-main-handoff.md) — Directives produit consolidees pour guider Main sur la robustesse, la persistance, le handoff cross-profile et la sobriete UX.
|
||||
- [remaining-work-idea-agent-control-ide](remaining-work-idea-agent-control-ide.md) — Etat des lieux des acquis et des chantiers restants pour aligner IdeA avec la cible d'IDE de controle d'agents IA.
|
||||
- [mcp-bridge-and-delegation-runtime-notes](mcp-bridge-and-delegation-runtime-notes.md) — Pieges runtime du pont MCP/delegation et regle de rebuild de l'AppImage (binaire qui tourne = AppImage, pas les sources).
|
||||
|
||||
51
.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md
Normal file
51
.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md
Normal file
@ -0,0 +1,51 @@
|
||||
---
|
||||
name: mcp-bridge-and-delegation-runtime-notes
|
||||
description: Pieges runtime du pont MCP et de la delegation inter-agents IdeA, et la regle de rebuild de l'AppImage.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Pont MCP & delegation : pieges runtime
|
||||
|
||||
Deux bugs trouves le 2026-06-13 en testant la conversation inter-agents (`idea_ask_agent` vers TestConversation), tous deux corriges. Notes utiles pour ne pas reperdre du temps :
|
||||
|
||||
## Le binaire qui tourne = AppImage installee, pas les sources
|
||||
L'IdeA en cours d'utilisation est `/home/anthony/Documents/IdeA_0.1.0_amd64.AppImage`. **Ce meme binaire sert a la fois de serveur orchestrateur (il tient `OrchestratorService`/`InputMediator` et compose les evenements) ET de binaire-pont** (`<exe> mcp-server …` declare dans chaque `.ideai/run/<id>/.mcp.json`). Donc **tout correctif cote serveur ou cote pont n'est actif dans l'app que apres rebuild + reinstall de l'AppImage et relance d'IdeA**. Un binaire `target/debug` fraichement compile ne valide que le pont (qui parle au serveur via le socket) ; il ne valide pas la composition cote serveur.
|
||||
**Comment appliquer :** apres une correction backend, rebuild AppImage (`npm --prefix frontend run build` puis, depuis `crates/app-tauri/`, `../../frontend/node_modules/.bin/tauri build --bundles appimage`), remplacer l'AppImage, relancer IdeA, puis retester. **Exclure NSIS** (`--bundles appimage`) sur Linux. **Piege FUSE (2026-06-14)** : l'etape finale `linuxdeploy` echoue avec `failed to run linuxdeploy` (linuxdeploy est une AppImage qui se monte via FUSE). Workaround obligatoire : prefixer `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1`. Le compile Rust reussit avant ce point ; seul le bundling casse, et l'`AppDir` est genere mais pas le `.AppImage`. L'artefact final = `target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage`.
|
||||
|
||||
## Env AppImage pollue le shell
|
||||
La session shell herite des variables de l'AppImage montee (`APPDIR`, `LD_LIBRARY_PATH`, `PYTHONHOME` -> `/tmp/.mount_IdeA_*`). Consequences : `python3` casse (`No module named 'encodings'`) et lancer un binaire app-tauri fraichement compile tente de booter WebKit et crash. **Workaround :** lancer avec un env propre (`env -i PATH=/usr/bin:/bin HOME=$HOME XDG_RUNTIME_DIR=/run/user/1000 ...`), utiliser `jq` plutot que python.
|
||||
|
||||
## Bug 1 — pont MCP en lockstep (corrige)
|
||||
`mcp_bridge.rs::relay` lisait 1 ligne client -> attendait 1 reponse loopback, en boucle. MCP n'est pas 1:1 : `notifications/initialized` n'a pas de reponse => deadlock juste apres `initialize`, et `tools/list` n'etait jamais relaye => les outils `idea_*` ne se chargeaient jamais (`claude mcp list` affichait quand meme "Connected" car `initialize` repond). Corrige en relay **full-duplex** (deux pompes concurrentes) + drain borne (`DRAIN_GRACE`) a la fermeture stdin. Symptome cote utilisateur : 3 jours de "outils MCP pas charges".
|
||||
|
||||
## Bug 2 — prefixe de delegation perdu (corrige)
|
||||
Le signal `[IdeA · tâche de <demandeur> · ticket <id>]` qui dit a l'agent cible "reponds via `idea_reply`, jamais en texte" n'etait plus compose nulle part : supprime du backend (`service.rs` C3 §5.1) lors du passage de l'ecriture PTY au frontend, mais le frontend (`useWritePortal.ts`) ecrit `head.text` verbatim et ne l'ajoutait pas. La cible recevait la tache brute, repondait en texte => `idea_ask_agent` timeout. Corrige en composant le prefixe dans `infrastructure/src/input/mod.rs` (`delegation_preamble`) a l'emission de `DomainEvent::DelegationReady` ; la tache brute reste dans le `Ticket`/historique. Rappel : la correlation `idea_reply` marche par ticket OU par tete de FIFO (fallback), donc le ticket echo est recommande mais pas strictement requis.
|
||||
|
||||
## Bug 3 — cold-launch race : 1er tour perdu (corrige 2026-06-14)
|
||||
Deleguer a un agent **froid** (pas encore lance) via `idea_ask_agent` echouait silencieusement : terminal cible vide, ask bloque jusqu'au timeout 300s ; un agent **chaud** (deja a son prompt, lance manuellement) marchait. Cause : avec `with_structured` non cable sur l'orchestrateur (regression assumee `aa2f67a`), tous les `ask` passent par le chemin PTY `ensure_live_pty` (`application/src/orchestrator/service.rs`). Un agent jamais vu etait initialise `Idle` (`BusyTracker::start_turn` -> `or_insert(Idle)`, `infrastructure/src/input/mod.rs`), donc le 1er `enqueue` publiait `DelegationReady` **immediatement** et ecrivait la tache dans le PTY avant que le CLI ait affiche son prompt -> tache perdue. Le prompt-ready watcher (lot C5) ne gardait que les tours suivants. Fix : `ensure_live_pty` renvoie un flag `cold_launch` ; si cold ET `prompt_ready_pattern` non vide, l'orchestrateur appelle `mark_starting(agent)` -> la `DelegationReady` du 1er tour est **differee** puis publiee par le watcher a l'apparition du prompt. Sans pattern ou agent chaud -> livraison immediate (zero regression). Touche `domain/src/input.rs` (port `mark_starting`), `infrastructure/src/input/mod.rs`, `service.rs`.
|
||||
|
||||
## Bug 4 — le fix cold-launch ne s'armait jamais en prod (corrige 2026-06-15)
|
||||
Le « fix Bug 3 corrige 2026-06-14 » etait trop optimiste : il ne s'arme que si `gate_cold_start = cold_launch && prompt_ready_pattern non vide` (`service.rs`). Or le profil **Claude Code** (`664cc20c`, partage par TOUS les agents) dans `~/.local/share/app.idea.ide/profiles.json` n'a **aucun** `prompt_ready_pattern`. Donc `mark_starting` n'etait jamais appele -> `enqueue` publiait `DelegationReady` immediatement -> tache ecrite dans le PTY avant le prompt de `claude` -> 1er tour perdu -> `idea_ask_agent` vers un agent **froid** bloque jusqu'au timeout (un agent **chaud** marche). Le Bug 3 n'etait valide que par des tests unitaires qui injectent un pattern a la main : le gap d'integration (profil sans pattern) n'avait pas ete vu.
|
||||
**Fix (option B, signal MCP, sans sniff de prompt TUI) :** on gate le cold-launch des qu'un MCP est configure sur le profil (`gate_cold_start = cold_launch && (pattern non vide || profil.mcp.is_some())`), et on **libere** le tour differe quand le pont MCP de l'agent froid se connecte (= son CLI est up + outils charges) : nouveau port `InputMediator::release_cold_start(agent)` (drain du `deferred`, **sans** `mark_idle` car c'est un signal de DEMARRAGE, idempotent et OR-safe avec `prompt_ready`), `McpServer` fire un `ready_sink: Fn(&str)` sur `initialize` avec le `requester` du handshake, et la composition root (`state.rs::ensure_mcp_server`) parse le requester en `AgentId` -> `OrchestratorService::release_agent_cold_start`. Touche `domain/src/input.rs`, `infrastructure/src/input/mod.rs`, `infrastructure/src/orchestrator/mcp/server.rs`, `application/src/orchestrator/service.rs`, `app-tauri/src/state.rs`. Tests unitaires verts ; **validation live = rebuild AppImage + relance IdeA** (cf. section binaire qui tourne). Filet OR : si un jour un profil porte un `prompt_ready_pattern`, les deux signaux coexistent.
|
||||
**Methodo :** ne PAS deleguer la reparation du systeme inter-agents via le systeme inter-agents (casse) — utiliser les subagents natifs (outil Agent).
|
||||
|
||||
## Bug 5 — la boucle `serve` du serveur MCP est en lockstep, un `ask` sans reponse wedge TOUTE la connexion (corrige 2026-06-15)
|
||||
Symptome : 1er `idea_ask_agent` vers un agent qui repond -> OK ; puis `ask` vers un agent qui ne rappelle JAMAIS `idea_reply` (ex. QA lance mais qui ne repond pas) -> ensuite **tout** appel suivant du MEME demandeur (meme `idea_list_agents`, sans rendezvous) se bloque. Cote utilisateur : "DevBackend ne marche plus" alors qu'il repondait 11s avant — en realite c'est la connexion du DEMANDEUR (Main) qui est morte, pas la cible. Annuler l'appel cote client ne deparke rien.
|
||||
Cause : `infrastructure/src/orchestrator/mcp/server.rs::serve` lisait 1 requete puis **awaitait `handle_raw` en entier** avant de relire. Pour `idea_ask_agent`, `handle_raw -> dispatch -> service.dispatch` attend le `idea_reply` de la cible : pendant cette attente la boucle ne relit plus rien -> pipeline de la connexion fige. C'est l'analogue COTE SERVEUR du Bug 1 (lockstep) qui n'avait ete corrige que cote pont (`mcp_bridge.rs`). NB : la couche application bornait deja le rendezvous (`service.rs::ask_agent` via `tokio::time::timeout`), donc l'attente n'etait pas infinie — le vrai coupable etait bien la serialisation de `serve`, pas l'absence de timeout.
|
||||
Fix : `serve` reecrite full-duplex non bloquante — `tokio::select!` entre `transport.recv()` et un canal `mpsc::unbounded::<Option<Vec<u8>>>` ; chaque message entrant traite dans une tache `tokio::spawn` qui possede un clone cheap `self.for_requester(self.requester.clone())` ('static) + un clone du `tx` ; reponses (`Some`) ou notifications (`None`) renvoyees par le canal et ecrites par la meme boucle ; arret gracieux via compteur `in_flight` (on draine les taches en vol apres EOF). Les reponses MCP portent l'`id` -> ordre indifferent. Le trait `Transport` (`&mut self` recv/send) et les signatures `serve`/`serve_as` restent intacts. + filet de securite : timeout serveur **isole au seul `idea_ask_agent`** (`ASK_RENDEZVOUS_TIMEOUT` 24h, finie ; setter `with_ask_rendezvous_timeout` `#[doc(hidden)]` pour les tests). Tests : `infrastructure/tests/mcp_server.rs` -> `pending_ask_does_not_wedge_the_connection_concurrent_call_still_answered` (anti-wedge, echoue sur l'ancien code) + `ask_agent_rendezvous_times_out_with_a_jsonrpc_error`. Verts : `cargo test -p infrastructure` (20 mcp_server), `cargo test -p app-tauri --lib mcp_e2e_loopback_tests` (6). **Validation live = rebuild AppImage + relance IdeA** (la connexion wedgee ne se deparke pas de l'interieur : il FAUT relancer IdeA). AppImage du 2026-06-15 10:58 contient le fix ; backup `~/Documents/IdeA_0.1.0_amd64.AppImage.old-prewedgefix`.
|
||||
Reste a investiguer (separe, non bloquant) : pourquoi QA lance ne repond pas du tout a une delegation (son `claude` n'appelle pas `idea_reply`) — impossible a creuser tant que la connexion du demandeur est wedgee ; le fix Bug 5 permet desormais de le diagnostiquer sans tout figer.
|
||||
|
||||
## Bug 6 — la tache n'est JAMAIS ecrite dans le PTY d'un agent delegue en arriere-plan (corrige 2026-06-15)
|
||||
C'EST la cause racine du « reste a investiguer » du Bug 5. Symptome : un agent lance a la main (cellule visible, ex. DevBackend) repond ; un agent **froid auto-lance par `idea_ask_agent`** (ex. QA) ne repond JAMAIS → `ask` bloque jusqu'au timeout (24h, `ASK_AGENT_TIMEOUT`). Reproduction sure et non bloquante : `idea_stop_agent` puis `idea_launch_agent(task=…, visibility=background)`, et observer le dossier de session `claude` de la cible (`~/.claude/projects/<run-dir>/*.jsonl`) : **aucune nouvelle session** en 28 s = la tache n'a jamais ete soumise (le pont MCP de la cible, lui, se connecte bien).
|
||||
Cause : depuis ARCHITECTURE §20, l'**ecriture physique du PTV est faite par le write-portal FRONTEND** (`useWritePortal`), qui n'est monte **que pour une cellule de layout (leaf) visible**. Or `ensure_live_pty` cold-lance la cible en **background avec `node_id: None`** (`service.rs`) → aucune cellule montee → `useWritePortal` n'existe pas → l'event `DelegationReady` n'a **aucun consommateur** → tache perdue. Les fix Bug 3/4 (differer/liberer la `DelegationReady`) ne pouvaient donc jamais marcher pour un agent background : ils publient un event que personne n'ecoute cote UI.
|
||||
Fix (option « writer PTY backend ») : le mediateur ecrit lui-meme la tache dans le PTY **quand aucune cellule frontend n'est attachee**. Registre `front_owned` dans `MediatedInbox`, alimente par le front via `bindHandle`/`unbindHandle` → commande Tauri `set_front_attached` → `OrchestratorService::set_agent_front_attached` → `InputMediator::set_front_attached`. Point de livraison unique = `BusyTracker::publish_deferred` (chemin chaud immediat ET drains froids `prompt_ready`/`release_cold_start`), qui passe par un **HeadlessSink** optionnel cable par `MediatedInbox::with_pty`/`with_events` : agent dans `front_owned` ⇒ `Some(d)` (publie l'event, le front ecrit, inchange) ; sinon ⇒ ecrit texte puis (apres `submit_delay_ms`, defaut 60ms, anti-paste-detection) la `submit_sequence` dans le handle PTY bound, sur un `std::thread` detache (le watcher prompt-ready tourne sur un std::thread, PAS tokio — ne pas utiliser `tokio::spawn`). Touche `domain/src/input.rs` (port `set_front_attached`), `infrastructure/src/input/mod.rs` (HeadlessSink + front_owned + handles en `Arc<Mutex>`), `application/src/orchestrator/service.rs`, `app-tauri/src/{dto,commands,lib}.rs`, `frontend/src/{ports,adapters/input,adapters/mock,features/terminals/useWritePortal}`. Tests verts : `cargo test -p infrastructure` (input 34, dont `headless_agent_without_front_cell_is_written_by_the_backend` + `front_attached_agent_is_delivered_via_event_not_backend_write`), app-tauri lib 42, useWritePortal 9, tsc. **Validation live = rebuild AppImage + relance IdeA** (build 2026-06-15 11:42 ; backup `~/Documents/IdeA_0.1.0_amd64.AppImage.old-prefrontwriterfix`).
|
||||
NB diagnostic : `~/.claude/projects/<encoded-run-dir>/*.jsonl` = transcript de session `claude` de l'agent ; pas de nouveau fichier apres une delegation = tache jamais soumise. `ss -xp | grep idea-mcp` = ponts MCP connectes cote serveur.
|
||||
|
||||
## Bug 7 — une delegation interrompue/annulee laisse la cible `Busy` a vie (corrige 2026-06-15, sources ; pas encore en AppImage)
|
||||
Symptome : un agent qui repondait (ex. DevFrontend a « 123x4=492 », QA pendant LP3) ne repond plus du tout aux delegations suivantes ; `idea_ask_agent` bloque jusqu'au timeout. DevBackend, lui, continue de marcher. Diagnostic ecarte 2 fausses pistes : (a) PAS le socket MCP — les 6 ponts sont ESTAB (`ss -xp | grep idea-mcp`), pont vivant ; (b) PAS « lance a la main vs par IdeA » — DevBackend est aussi auto-lance et marche. Le vrai discriminant : **une delegation vers cet agent a-t-elle ete interrompue/annulee cote demandeur ?** DevFrontend coince par l'interruption d'une tache LP3 ; QA coince par un ping diag rejete ; DevBackend jamais annule => OK. Confirmation cote transcript : la tache figure dans le `log.jsonl` de la conversation (donc enqueue cote serveur a eu lieu) mais PAS dans le transcript `claude` de la cible (`~/.claude/projects/<run-dir>/*.jsonl`) => jamais ecrite dans son PTY.
|
||||
Cause (lue dans `application/src/orchestrator/service.rs`, chemins `ask` PTY ~l.847-911 ET `ask_structured` ~l.927-1011) : l'agent passe `Idle→Busy` des `input.enqueue` (~l.978). Il ne redevient `Idle` que sur la branche **succes** (`input.mark_idle`, ~l.1001). Les branches **erreur/annulation/timeout** (~l.901-910 et ~l.1004-1009) appellent `mailbox.cancel_head` mais **jamais `mark_idle`**. Pire : quand le demandeur interrompt l'appel, le futur `ask_agent` est **dropped** => AUCUNE branche du `select!` ne s'execute => l'agent reste `Busy` pour toujours. Une cible `Busy` met les delegations suivantes en file derriere un tour fantome, jamais livrees au PTY. L'etat `Busy` vit en memoire dans le process serveur et **ne se deparke pas de l'interieur** : deblocage immediat = **relancer IdeA** (comme le wedge du Bug 5).
|
||||
Fix (corrige cote sources, `service.rs`) : garde RAII `BusyTurnGuard` (Arc clones de `InputMediator` + mailbox, `agent_id`, `ticket_id`, flag `armed`) cree juste apres l'enqueue dans les DEUX chemins (`ask` PTY et `ask_structured`) ; son `Drop` (si arme) appelle `cancel_head(agent,ticket)` puis `mark_idle(agent)` ; `disarm()` sur la branche succes (le `mark_idle` propre existant reste, pas de double cancel). Les `cancel_head` redondants des branches erreur/`_cancelled`/`_elapsed` ont ete retires au profit du garde (`cancel_head` est positionnel/idempotent). Indispensable que ce soit un garde et pas un `mark_idle` dans les branches : le cas reel est un futur DROPPED, aucune branche du `select!` ne s'execute. Tests verts : `cargo test --workspace` 80 suites 0 echec, dont `dropped_ask_future_frees_busy_target`, `second_delegation_delivered_after_dropped_ask`, `cancelled_ask_marks_target_idle` (tests/orchestrator_service.rs) + 2 tests du garde dans le `mod tests` de service.rs.
|
||||
VERDICT `sweep_stalled` (infra/input/mod.rs) : **purement advisory** — bascule `Alive→Stalled` et emet `AgentLivenessChanged` mais **n'appelle JAMAIS `mark_idle`** (par conception : « la FIFO et le tour continuent »). Ce n'est donc PAS un filet pour ce bug ; le garde RAII est la seule correction. Non recable (changement de semantique hors perimetre).
|
||||
**Pas encore actif live : rebuild AppImage requis** (`npm --prefix frontend run build` puis depuis `crates/app-tauri/` `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage`, remplacer l'AppImage, relancer IdeA). Le relancement d'IdeA debloque aussi DevFrontend/QA actuellement coinces `Busy` (etat en memoire). Methodo (rappel Bug 4) : NE PAS reparer le systeme inter-agent via `idea_ask_agent` (casse) — utiliser les subagents natifs (outil Agent).
|
||||
|
||||
See also [[remaining-work-idea-agent-control-ide]], [[agent-context-memory-and-profile-handoff]].
|
||||
133
ARCHITECTURE.md
133
ARCHITECTURE.md
@ -2027,4 +2027,137 @@ Avant correction, `launch_structured` persistait l'**id moteur (2)** sur `LeafCe
|
||||
|
||||
---
|
||||
|
||||
## 20. Terminal natif + portail d'écriture unique (cadrage — remplace la barre `MediatedInput`)
|
||||
|
||||
> **Cadrage architecture** d'une feature **déjà décidée** (cf. décision produit « terminal natif »). Produit la frontière, les contrats (ports/events/DTO/profil), le découpage en lots testables et les risques. **Aucun code applicatif ici.**
|
||||
|
||||
### 20.1 Problème & décision produit (rappel, ne pas rediscuter)
|
||||
- **Bug.** En mode agent, l'humain tape dans une barre IdeA séparée (`MediatedInput.tsx`) ; la livraison d'une délégation écrit dans le PTY un texte terminé par `\n` (`MediatedInbox::enqueue`, `infrastructure/src/input/mod.rs:307-311`). Résultat : le texte se dépose dans le prompt de la TUI mais **n'est jamais soumis** (`\n` ≠ Entrée en raw-mode ; la détection de paste absorbe le `\n`). De plus barre IdeA + prompt natif = « double chat ».
|
||||
- **Décision.** La cellule agent héberge la CLI comme **vrai terminal** : **toutes** les frappes (Entrée comprise) vont à la CLI ; on garde le chrome natif. On **supprime** `MediatedInput`. **Pas d'interception d'Entrée** (ambiguë en TUI). IdeA n'**observe** qu'un compteur « ligne humaine en cours » (+1 sur imprimable, reset sur Entrée/Ctrl-C). **Sérialisation par un portail d'écriture unique** vers le PTY : deux écrivains (frappes humaines natives + médiateur IdeA pour les délégations). Une délégation n'est livrée qu'à une **frontière propre = prompt-ready ET ligne humaine vide** ; sinon elle **patiente** (jamais de refus). Invariant inchangé : **1 agent = 1 employé = 1 session CLI** (la « conversation par paire » reste un cloisonnement **logique**, pas des sessions séparées).
|
||||
|
||||
### 20.2 Décision d'architecture — où vit le portail, qui écrit
|
||||
|
||||
**Le portail d'écriture unique vit côté FRONTEND** (le détenteur du terminal). Le backend **décide quand** une délégation est prête et **publie l'intention** ; le frontend, seul détenteur de xterm + des frappes + du compteur de ligne + de l'overlay, **exécute le handshake** (b→e) et **écrit le texte + `\r`** via la **même** `TerminalHandle.write` que les frappes humaines. Ainsi il n'existe **qu'un seul écrivain effectif du PTY** (le front) : pas de course entre deux écrivains physiques, le portail est un mutex **logique** dans la cellule. Le backend conserve son rôle d'**autorité de file/busy** (mailbox FIFO, prompt-ready watcher, `AgentBusyChanged`) mais **n'écrit plus le tour dans le PTY** — c'est le point dur tranché.
|
||||
|
||||
Justification hexagonale : la frontière reste nette. L'**application** (`OrchestratorService`) reste l'autorité métier de l'orchestration (file, corrélation par ticket, cycle, timeout) ; elle parle **ports** (`InputMediator`, `EventBus`) sans connaître le terminal. La **livraison physique** (octets vers le PTY) est un **détail d'I/O** qui appartient à l'adapter sortant — ici l'adapter **frontend** (la cellule), exactement comme les frappes humaines y vivent déjà. On **retire** au `MediatedInbox` la responsabilité d'écrire le PTY (violation SRP : il était à la fois moteur de file ET écrivain d'I/O brut) ; il redevient pur moteur de file/busy/corrélation. Le « double signal OR » prompt-ready/`idea_reply` et le `wait_for`/timeout restent **inchangés**.
|
||||
|
||||
```
|
||||
ask_agent / idea_ask_agent (MCP) idea_reply (MCP)
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────── OrchestratorService (application) ─────────────┐
|
||||
│ enqueue(ticket) → mailbox FIFO (corrélation) resolve_ticket → réveille ask │
|
||||
│ busy: Idle→Busy → AgentBusyChanged mark_idle (OR signal) │
|
||||
│ PLUS: publie DelegationReady{agent, ticket, text} ◄── NOUVEAU (ne PTY-écrit │
|
||||
└───────────────────────────────┬─────────────────────────────── plus le tour)┘
|
||||
│ EventBus → relais Tauri (event)
|
||||
▼
|
||||
┌──────────────────────── Cellule agent (frontend, détient le terminal) ───────┐
|
||||
│ TerminalView (agent natif): term.onData → handle.write [frappes humaines] │
|
||||
│ writePortal (mutex logique de la cellule): │
|
||||
│ • frappes humaines: write direct + maj compteur ligne (imprimable / reset) │
|
||||
│ • DelegationReady reçue → si prompt-ready & ligne vide → HANDSHAKE b→e: │
|
||||
│ (b) couper le relais frappes + overlay grisé « un agent parle » │
|
||||
│ (c) revérif compteur K ; si K>0 → \x7f ×K (backspaces) │
|
||||
│ (d) write(texte) puis write(submitSequence) après submitDelayMs │
|
||||
│ (e) réactiver + retirer overlay, PLANCHER 2 s depuis (b) │
|
||||
│ • sinon (busy / ligne non vide) → la délégation PATIENTE (file native CLI │
|
||||
│ empile ; la prochaine frontière propre la libère) │
|
||||
│ ack: input.deliveredDelegation(ticket) ── confirme la livraison au backend │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Pourquoi pas le backend ?** Le backend ne connaît ni l'état « ligne humaine en cours » (détenu par xterm côté front) ni l'overlay. Lui faire écrire le PTY impose un round-trip fragile (front→back « ligne vide ? », back→front « j'écris ») et **réintroduit deux écrivains physiques** du même PTY (le back via `PtyPort.write` + le front via `handle.write`) → exactement la classe de course que `terminal-input-accents-ordering` a déjà coûtée. Centraliser l'écriture côté front supprime la race par construction.
|
||||
|
||||
### 20.3 Contrats
|
||||
|
||||
**Profil (`crates/domain/src/profile.rs`) — fix Bug 1, déclaratif & model-agnostic.** Deux champs additifs sur `AgentProfile`, à côté de `prompt_ready_pattern`, sérialisés camelCase, `skip_serializing_if` ⇒ zéro régression :
|
||||
```rust
|
||||
/// Séquence de soumission écrite APRÈS le texte d'une délégation pour la
|
||||
/// faire valider par la CLI (esquive la détection de paste : texte sans `\n`,
|
||||
/// puis cette séquence seule après un court délai). Défaut `"\r"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_sequence: Option<String>, // None ⇒ défaut "\r" appliqué au point d'usage
|
||||
/// Délai (ms) entre l'écriture du texte et celle de `submit_sequence`.
|
||||
/// Défaut ~50–80 ms. Évite que la TUI absorbe la soumission comme un paste.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_delay_ms: Option<u32>, // None ⇒ défaut (p.ex. 60) au point d'usage
|
||||
```
|
||||
Withers additifs `with_submit_sequence`/`with_submit_delay_ms` (comme `with_prompt_ready_pattern`). Le **défaut** (`"\r"`, ~60 ms) est appliqué côté front (DTO `Option` → valeur effective), jamais codé en dur dans le domaine.
|
||||
|
||||
**Port domaine `InputMediator` (`crates/domain/src/input.rs`) — recentrage.** On **retire la sémantique d'écriture PTY** de `enqueue`/`bind_handle*` (la doc de `enqueue` ne promet plus la livraison physique) ; le médiateur reste autorité **file + busy + corrélation + prompt-watcher**. Pas de nouvelle méthode obligatoire : la livraison passe désormais par un **event** (ci-dessous). `bind_handle_with_prompt` **reste** (le prompt-ready watcher observe toujours le flux de sortie côté infra). `delivers_turn` devient inutile (toujours « le front délivre ») → marqué déprécié/`false`.
|
||||
|
||||
**Adapter infra `MediatedInbox` (`crates/infrastructure/src/input/mod.rs`).** `enqueue` **ne fait plus** le `pty.write(line)` (suppression du bloc 305-313, donc du `\n` band-aid). À la place, sur la transition qui **démarre le tour** (Idle→Busy) il publie un **nouvel event** `DelegationReady` portant le **texte de la tâche** + ticket + agent (le `BusyTracker`/`enqueue` a déjà l'`EventBus`). Le `preempt` (ESC) **reste** (interruption = octet de contrôle, légitime côté back ; il ne concourt pas avec une écriture de ligne). Le prompt-ready watcher **reste** identique.
|
||||
|
||||
**Nouvel event domaine `DomainEvent` (`crates/domain/src/events.rs`).**
|
||||
```rust
|
||||
/// Une délégation est prête à être injectée dans le terminal natif de l'agent
|
||||
/// (le front exécute le handshake b→e et écrit texte + submit_sequence). Le
|
||||
/// backend reste l'autorité de file/busy ; il NE PTY-écrit PLUS le tour.
|
||||
DelegationReady {
|
||||
agent_id: AgentId,
|
||||
ticket: TicketId,
|
||||
text: String,
|
||||
/// Profil de la cible : la cellule applique submit_sequence/submit_delay_ms.
|
||||
submit_sequence: Option<String>,
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
```
|
||||
Relayé au front comme les autres `DomainEvent` (mapping DTO camelCase déjà en place). Discret, basse fréquence (1/délégation).
|
||||
|
||||
**Commande Tauri (ack de livraison) — `crates/app-tauri/src/commands.rs` + `dto.rs`.** Le front confirme qu'il a **effectivement écrit** la délégation (clôt la boucle « le tour est parti »), pour distinguer « en file car ligne occupée » d'« écrit » côté observabilité/persistance :
|
||||
```rust
|
||||
// DTO
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveredDelegationRequestDto { pub project_id: String, pub agent_id: String, pub ticket: String }
|
||||
// commande
|
||||
#[tauri::command] pub async fn delegation_delivered(request: DeliveredDelegationRequestDto, state: …) -> Result<(), ErrorDto>
|
||||
```
|
||||
Mappé vers une méthode applicative best-effort (`OrchestratorService::note_delegation_delivered`) — **ne change pas** la corrélation (le réveil de l'`ask` reste `idea_reply`→`resolve_ticket`) ; sert l'observabilité/log. Les commandes existantes `submit_agent_input`/`reattach_agent_chat` deviennent **mortes** pour les agents (retirées en L5) ; `interrupt_agent` **reste** (Échap → `preempt`), mais l'UI l'invoque désormais depuis le terminal (raccourci), plus depuis la barre.
|
||||
|
||||
**Ports/adapter frontend (`frontend/src/ports/index.ts`, `adapters/input.ts`).** `InputGateway` perd `submit` (plus de barre) et **gagne** :
|
||||
```ts
|
||||
export interface InputGateway {
|
||||
/** Interrompre = preempt (Échap/stop). Reste. */
|
||||
interrupt(projectId: string, agentId: string): Promise<void>;
|
||||
/** Ack : la cellule a écrit la délégation `ticket` dans le PTY natif. */
|
||||
delegationDelivered(projectId: string, agentId: string, ticket: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
Un nouveau port d'**abonnement** aux `DelegationReady` n'est **pas** nécessaire : `SystemGateway.onDomainEvent` les relaie déjà (filtrer `event.type === "delegationReady"`), à l'image de `useAgentBusy`. Côté domaine front, ajouter la variante `DelegationReady` au type `DomainEvent`.
|
||||
|
||||
**Frontend — le portail.** Un hook `useWritePortal(handle, agentId)` détient : (1) le **compteur de ligne** (incrément/`reset` branchés sur `term.onData` dans `TerminalView`), (2) la **file locale** des `DelegationReady` reçues, (3) l'exécution du **handshake** (b→e) avec **plancher 2 s** et **revérif K + backspaces**, (4) l'**overlay** (état booléen rendu par la cellule). `TerminalView` (agent) écrit les frappes **et** notifie le portail (imprimable/Entrée/Ctrl-C) ; quand le portail injecte, il **coupe** le relais des frappes (flag `suspended`) et écrit via `handle.write`.
|
||||
|
||||
### 20.4 Comment le backend connaît « ligne humaine vide » — il ne la connaît PAS
|
||||
La décision frontière l'évite : **la frontière propre est jugée côté front** (seul détenteur du compteur). Le backend publie `DelegationReady` **dès** que le tour démarre ; c'est le **front** qui retient l'injection jusqu'à `prompt-ready ET ligne vide`. Le « prompt-ready » est connu des **deux** : le watcher backend le détecte sur le flux de sortie pour le **busy** ; le front peut soit ré-utiliser un signal (un futur `AgentPromptReady` event, optionnel) soit, plus simplement, considérer la **ligne vide** comme condition front suffisante et s'appuyer sur le fait que la CLI **empile** nativement les soumissions (robustesse : même injectée « tôt », la CLI met en file). **Choix retenu (sobre)** : le front conditionne sur **ligne humaine vide** uniquement ; la nativité de la CLI gère le reste ; aucun round-trip. Si l'expérience montre des injections trop précoces, on ajoute l'event `AgentPromptReady` (additif, sans changer le portail). Aucune des deux variantes ne crée deux écrivains.
|
||||
|
||||
### 20.5 Découpage en lots (dev/test séquencés)
|
||||
|
||||
| Lot | Couche | Contenu | Fichiers | Testable (vert) |
|
||||
|---|---|---|---|---|
|
||||
| **L1** | domaine+infra | Profil `submit_sequence`/`submit_delay_ms` (+ withers) ; `MediatedInbox::enqueue` **cesse** d'écrire le PTY (suppr. bloc `\n`) et **publie** `DelegationReady` ; `DomainEvent::DelegationReady` | `domain/src/profile.rs`, `domain/src/events.rs`, `infrastructure/src/input/mod.rs` | round-trip serde (clés omises si `None`, legacy→`None`) ; `enqueue` ne fait **aucun** `pty.write` ; publie 1 `DelegationReady{text,ticket}` sur Idle→Busy, **0** sur 2ᵉ enqueue busy ; `preempt` inchangé ; prompt-watcher tests intacts |
|
||||
| **L2** | app+app-tauri | `OrchestratorService` : `ask_agent`/`submit_human_input` n'attendent plus du médiateur l'écriture (déjà le cas) ; ajout `note_delegation_delivered` ; commande `delegation_delivered` + DTO ; relais `DelegationReady` au front | `application/src/orchestrator/service.rs`, `app-tauri/src/commands.rs`, `dto.rs`, `lib.rs` (register) | `ask_agent` toujours réveillé par `idea_reply` (timeout/cycle inchangés) ; `delegation_delivered` best-effort (no-op si non câblé) ne casse pas la corrélation ; event mappé camelCase `delegationReady` |
|
||||
| **L3** | frontend | `TerminalView` agent **natif** : frappes → PTY **inconditionnellement** (retrait du drop `agentMode`) ; compteur de ligne (imprimable +1 / Entrée|Ctrl-C reset) exposé au portail ; `InputGateway` (retrait `submit`, ajout `delegationDelivered`) + adapter ; type front `DomainEvent.DelegationReady` | `frontend/src/features/terminals/TerminalView.tsx`, `ports/index.ts`, `adapters/input.ts`, `domain/*`, `app/di.tsx`, mocks | Vitest : en agent, une frappe écrit le PTY ; compteur +1 sur `a`, reset sur `\r` et `\x03` ; multiligne natif n'altère pas le compteur de façon erronée ; adapter appelle `delegation_delivered` avec `{projectId,agentId,ticket}` |
|
||||
| **L4** | frontend | `useWritePortal` + overlay : réception `DelegationReady` → file ; injection **ssi ligne vide** ; handshake (b) couper relais+overlay, (c) revérif K→`\x7f`×K, (d) `write(text)` puis `write(submitSequence??"\r")` après `submitDelayMs??60`, (e) réactiver+overlay off **plancher 2 s** ; ack `delegationDelivered` | `frontend/src/features/terminals/useWritePortal.ts` (nouveau), `LayoutGrid.tsx` (overlay + montage), `features/terminals/index.ts` | Vitest (faux timers + faux handle) : injecte **rien** tant que ligne non vide ; à ligne vide → écrit `text` **sans** `\n` puis `\r` après le délai ; course « K=2 lettres dans le micro-intervalle » → 2 `\x7f` avant le texte ; **plancher 2 s** : overlay maintenu si (e) < 2 s après (b) ; relais frappes coupé pendant l'overlay ; `delegationDelivered` appelé une fois après (d) |
|
||||
| **L5** | frontend+app-tauri | **Retrait** de `MediatedInput` (suppr. composant + montage `LayoutGrid`), de `useAgentBusy` si plus utilisé (ou conservé pour un badge), des commandes mortes `submit_agent_input`/`reattach_agent_chat`/DTO afférents ; `interrupt` rebranché sur un raccourci terminal | `LayoutGrid.tsx`, `MediatedInput.tsx` (suppr.), `useAgentBusy.ts`, `app-tauri/src/commands.rs`/`dto.rs`/`lib.rs`, tests | suite front verte sans `MediatedInput` ; aucune cellule **plain** modifiée (régression nulle) ; `cargo build` sans les commandes retirées ; `interrupt_agent` toujours appelable |
|
||||
|
||||
**Ordre** : L1→L2 (back prêt) ∥ L3 (front natif) → L4 (portail, dépend L1/L3) → L5 (nettoyage). L1 est le pivot (supprime le `\n`, source du bug, et bascule la livraison sur event).
|
||||
|
||||
### 20.6 Plan de tests — cas limites explicites
|
||||
- **Course 1–2 lettres** (étape c) : entre (a) « ligne vide constatée » et (b) « relais coupé », l'humain tape K∈{1,2} ⇒ le portail écrit **exactement** K `\x7f` puis le texte ; **jamais** Ctrl-U. *(Vitest, faux handle enregistrant les writes.)*
|
||||
- **Plancher 2 s** (étape e) : (d) se termine à t<2 s ⇒ overlay + relais coupés **maintenus** jusqu'à t=2 s ; (e) à t≥2 s ⇒ pas d'attente résiduelle. *(faux timers.)*
|
||||
- **Multiligne natif** : l'humain compose une commande multiligne (la CLI gère ses propres `\n` internes) ⇒ le compteur **n'injecte pas** au milieu (ligne « non vide » tant que la frappe est en cours) ; on ne réécrit jamais le contenu humain.
|
||||
- **Ligne non vide à la frontière** : `DelegationReady` reçue alors que compteur>0 ⇒ **mise en file**, **aucune** écriture ; libérée à la prochaine ligne vide. *(pas de refus, pas de perte.)*
|
||||
- **Préemption pendant overlay** : Échap (`interrupt`) pendant l'overlay ⇒ `preempt` (ESC) côté back inchangé ; le portail lève l'overlay au plancher ; la délégation en cours d'écriture n'est pas dupliquée (ack idempotent).
|
||||
- **Back (Rust)** : `enqueue` ne PTY-écrit plus ; 1 `DelegationReady` sur démarrage de tour, 0 en re-enqueue busy ; `preempt`/prompt-watcher/`mark_idle` inchangés ; `ask_agent` réveillé par `idea_reply`, timeout/cycle intacts ; profil serde zéro régression.
|
||||
|
||||
### 20.7 Risques résiduels & vigilance
|
||||
- **Frontière « tôt »** : conditionner sur « ligne vide » seule peut injecter avant le tout premier prompt-ready. Mitigation : la CLI empile nativement ; si insuffisant, event additif `AgentPromptReady` (déjà détecté côté back) sans toucher le portail.
|
||||
- **`submit_sequence` par CLI** : `"\r"` + délai esquive la paste-detection de Claude Code ; d'autres TUI pourraient exiger une autre séquence/délai → c'est précisément pourquoi c'est **déclaratif** (profil), pas codé en dur.
|
||||
- **Compteur de ligne vs séquences ANSI** : ne compter que les **imprimables** issus de `term.onData` (frappes), pas la sortie ; ignorer les séquences de contrôle (flèches/échap) pour éviter un faux « ligne non vide » qui bloquerait toute injection.
|
||||
- **Reattach** : à la réouverture d'une cellule, le portail repart d'un **compteur=0** et d'une file vide ; une `DelegationReady` perdue pendant la navigation est rejouée par le `ask` en attente (le back retient le ticket jusqu'au reply/timeout) → pas de perte de corrélation.
|
||||
- **Plain shell strictement inchangé** : tout le mécanisme est gardé par `agentMode`/présence d'agent ; aucune cellule plain ne voit overlay, portail, ni compteur.
|
||||
|
||||
---
|
||||
|
||||
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*
|
||||
|
||||
@ -175,11 +175,17 @@ impl ChatBridge {
|
||||
/// Maps a domain [`ReplyEvent`] to its wire [`ReplyChunk`]. Pure translation, no
|
||||
/// I/O — the single point where the typed turn event becomes a serialisable chunk
|
||||
/// (kept here so the pump and any test share one mapping).
|
||||
///
|
||||
/// Returns `None` for [`ReplyEvent::Heartbeat`]: a heartbeat is a non-terminal
|
||||
/// liveness proof (readiness/heartbeat lot 1) with **no chat content**, so it maps
|
||||
/// to no wire chunk — the pump simply skips it. Every content-bearing event still
|
||||
/// maps to exactly one chunk.
|
||||
#[must_use]
|
||||
pub fn chunk_from_event(event: ReplyEvent) -> ReplyChunk {
|
||||
pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
|
||||
match event {
|
||||
ReplyEvent::TextDelta { text } => ReplyChunk::TextDelta { text },
|
||||
ReplyEvent::ToolActivity { label } => ReplyChunk::ToolActivity { label },
|
||||
ReplyEvent::Final { content } => ReplyChunk::Final { content },
|
||||
ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text }),
|
||||
ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }),
|
||||
ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }),
|
||||
ReplyEvent::Heartbeat => None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,48 +9,49 @@ 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,
|
||||
SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput,
|
||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateSkillInput,
|
||||
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,
|
||||
ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput,
|
||||
SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
||||
UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput,
|
||||
UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||
};
|
||||
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,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, 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,
|
||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||
ProjectPermissionsDto, ReadAgentContextResponseDto, ReattachChatDto, ReattachResultDto,
|
||||
RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||
SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, SkillListDto,
|
||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateSkillRequestDto,
|
||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
WriteTerminalRequestDto,
|
||||
};
|
||||
use crate::pty::{PtyBridge, PtyChunk};
|
||||
use crate::state::AppState;
|
||||
@ -122,6 +123,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))
|
||||
}
|
||||
|
||||
@ -210,6 +212,87 @@ pub async fn update_project_context(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `get_project_permissions` — read `.ideai/permissions.json`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on invalid project id or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn get_project_permissions(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProjectPermissionsDto, ErrorDto> {
|
||||
let project = resolve_project(&project_id, &state).await?;
|
||||
state
|
||||
.get_project_permissions
|
||||
.execute(application::GetProjectPermissionsInput { project })
|
||||
.await
|
||||
.map(|out| ProjectPermissionsDto(out.permissions))
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `update_project_permissions` — replace project default permissions.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on invalid project id or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn update_project_permissions(
|
||||
request: UpdateProjectPermissionsRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProjectPermissionsDto, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
state
|
||||
.update_project_permissions
|
||||
.execute(UpdateProjectPermissionsInput {
|
||||
project,
|
||||
permissions: request.permissions,
|
||||
})
|
||||
.await
|
||||
.map(|out| ProjectPermissionsDto(out.permissions))
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `update_agent_permissions` — replace or remove one agent override.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on invalid ids or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn update_agent_permissions(
|
||||
request: UpdateAgentPermissionsRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProjectPermissionsDto, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.update_agent_permissions
|
||||
.execute(UpdateAgentPermissionsInput {
|
||||
project,
|
||||
agent_id,
|
||||
permissions: request.permissions,
|
||||
})
|
||||
.await
|
||||
.map(|out| ProjectPermissionsDto(out.permissions))
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `resolve_agent_permissions` — resolve project defaults plus agent override.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on invalid ids or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn resolve_agent_permissions(
|
||||
request: ResolveAgentPermissionsRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<EffectivePermissionsDto>, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.resolve_agent_permissions
|
||||
.execute(ResolveAgentPermissionsInput { project, agent_id })
|
||||
.await
|
||||
.map(|out| out.effective.map(EffectivePermissionsDto))
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminals (L3)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1049,6 +1132,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 {
|
||||
@ -1193,7 +1281,11 @@ pub async fn agent_send(
|
||||
let bridge = std::sync::Arc::clone(&state.chat_bridge);
|
||||
std::thread::spawn(move || {
|
||||
for event in stream {
|
||||
let chunk = crate::chat::chunk_from_event(event);
|
||||
// Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire
|
||||
// chunk; skip them while still draining so the turn runs to its `Final`.
|
||||
let Some(chunk) = crate::chat::chunk_from_event(event) else {
|
||||
continue;
|
||||
};
|
||||
// `send_output` always records into the conversation scrollback; the
|
||||
// boolean only reflects live delivery. If the view navigated away
|
||||
// (no channel at this generation) we keep draining so the turn still
|
||||
@ -1206,31 +1298,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 +1322,55 @@ 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(())
|
||||
}
|
||||
|
||||
/// `set_front_attached` — the write-portal reports whether a **frontend terminal cell**
|
||||
/// is mounted for an agent (mount ⇒ `true`, unmount ⇒ `false`).
|
||||
///
|
||||
/// Routes to [`OrchestratorService::set_agent_front_attached`]. This is what lets the
|
||||
/// mediator deliver a turn to a **headless** (background-delegated, cell-less) agent by
|
||||
/// writing its PTY itself: with no mounted cell nobody consumes `DelegationReady`, so
|
||||
/// the task would otherwise be lost (a delegated agent that never receives — and never
|
||||
/// answers — its task). An agent **with** a cell keeps the frontend write-portal path.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID`) for a malformed agent id.
|
||||
#[tauri::command]
|
||||
pub async fn set_front_attached(
|
||||
request: FrontAttachedRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.orchestrator_service
|
||||
.set_agent_front_attached(agent_id, request.attached);
|
||||
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).
|
||||
///
|
||||
|
||||
@ -1066,7 +1066,7 @@ use application::{
|
||||
ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput,
|
||||
ListAgentsOutput, ReadAgentContextOutput,
|
||||
};
|
||||
use domain::{Agent, TerminalSession};
|
||||
use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession};
|
||||
|
||||
/// An agent crossing the wire. [`Agent`] already serialises camelCase
|
||||
/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`),
|
||||
@ -1135,6 +1135,52 @@ pub struct UpdateAgentContextRequestDto {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permissions (LP1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full project permission document crossing the wire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ProjectPermissionsDto(pub ProjectPermissions);
|
||||
|
||||
/// Effective permissions crossing the wire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct EffectivePermissionsDto(pub EffectivePermissions);
|
||||
|
||||
/// Request DTO for updating project default permissions.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateProjectPermissionsRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// New project defaults. `null` removes defaults.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Request DTO for updating one agent permission override.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateAgentPermissionsRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Target agent id.
|
||||
pub agent_id: String,
|
||||
/// New override. `null` removes the override.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Request DTO for resolving one agent's effective permissions.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolveAgentPermissionsRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Target agent id.
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `update_project_context`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -1197,20 +1243,6 @@ impl From<LaunchAgentOutput> for TerminalSessionDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `submit_agent_input` (cadrage C4 §4.2): the human Envoyer path.
|
||||
/// The frontend's [`InputGateway`](../../../frontend/src/ports) sends
|
||||
/// `{ request: { projectId, agentId, text } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubmitAgentInputRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Id of the agent to send the human input to.
|
||||
pub agent_id: String,
|
||||
/// The text the operator typed (enqueued as a `from_human` ticket).
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The
|
||||
/// frontend sends `{ request: { projectId, agentId } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@ -1222,6 +1254,37 @@ 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 `set_front_attached`: the write-portal of an agent cell reports
|
||||
/// whether a **frontend terminal cell is mounted** for `agentId` (`true` on mount,
|
||||
/// `false` on unmount). The mediator uses it to choose, at delivery time, between
|
||||
/// publishing `DelegationReady` (a cell will write it) and writing the turn into the
|
||||
/// PTY itself (headless/background-delegated agent with no cell). The frontend sends
|
||||
/// `{ request: { agentId, attached } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrontAttachedRequestDto {
|
||||
/// Id of the agent whose terminal cell mounted/unmounted.
|
||||
pub agent_id: String,
|
||||
/// `true` when the cell's write-portal is now active, `false` on teardown.
|
||||
pub attached: bool,
|
||||
}
|
||||
|
||||
/// 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 +1498,19 @@ pub fn parse_agent_id(raw: &str) -> Result<AgentId, ErrorDto> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
|
||||
pub fn parse_ticket_id(raw: &str) -> Result<domain::TicketId, ErrorDto> {
|
||||
uuid::Uuid::parse_str(raw)
|
||||
.map(domain::TicketId::from_uuid)
|
||||
.map_err(|_| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: format!("invalid ticket id: {raw}"),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resumable agents (§15.2 — Chantier B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -13,6 +13,7 @@ use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::input::AgentLiveness;
|
||||
use infrastructure::TokioBroadcastEventBus;
|
||||
|
||||
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||
@ -56,6 +57,26 @@ pub enum DomainEventDto {
|
||||
/// `true` when a turn is in flight, `false` when idle.
|
||||
busy: bool,
|
||||
},
|
||||
/// A delegation is ready to be injected into the agent's **native terminal**
|
||||
/// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence`
|
||||
/// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty,
|
||||
/// then acks via the `delegation_delivered` command. The backend no longer PTY-writes
|
||||
/// the turn.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DelegationReady {
|
||||
/// Target agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`.
|
||||
ticket: String,
|
||||
/// Task text to inject (written without a trailing newline by the portal).
|
||||
text: String,
|
||||
/// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
submit_sequence: Option<String>,
|
||||
/// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
/// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentReplied {
|
||||
@ -64,6 +85,17 @@ pub enum DomainEventDto {
|
||||
/// Reply length in bytes (metric, not the payload).
|
||||
reply_len: usize,
|
||||
},
|
||||
/// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The
|
||||
/// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived
|
||||
/// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late
|
||||
/// battement or when the turn ends.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentLivenessChanged {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// New liveness, as a lowercase string (`"alive"` / `"stalled"`).
|
||||
liveness: AgentLivenessDto,
|
||||
},
|
||||
/// An agent's runtime profile was changed (hot-swap of the AI engine).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentProfileChanged {
|
||||
@ -195,6 +227,26 @@ pub enum OrchestrationSourceDto {
|
||||
Mcp,
|
||||
}
|
||||
|
||||
/// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2),
|
||||
/// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentLivenessDto {
|
||||
/// The agent shows proof of liveness (or is idle).
|
||||
Alive,
|
||||
/// No proof of liveness past the profile's `stallAfterMs` threshold.
|
||||
Stalled,
|
||||
}
|
||||
|
||||
impl From<AgentLiveness> for AgentLivenessDto {
|
||||
fn from(liveness: AgentLiveness) -> Self {
|
||||
match liveness {
|
||||
AgentLiveness::Alive => Self::Alive,
|
||||
AgentLiveness::Stalled => Self::Stalled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OrchestrationSource> for OrchestrationSourceDto {
|
||||
fn from(source: OrchestrationSource) -> Self {
|
||||
match source {
|
||||
@ -225,6 +277,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,
|
||||
@ -232,6 +297,12 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
reply_len: *reply_len,
|
||||
},
|
||||
DomainEvent::AgentLivenessChanged { agent_id, liveness } => {
|
||||
Self::AgentLivenessChanged {
|
||||
agent_id: agent_id.to_string(),
|
||||
liveness: (*liveness).into(),
|
||||
}
|
||||
}
|
||||
DomainEvent::AgentProfileChanged {
|
||||
agent_id,
|
||||
profile_id,
|
||||
@ -343,3 +414,40 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::ids::AgentId;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
|
||||
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
|
||||
/// le front badge). Garantit le câblage présentation de la détection de stall.
|
||||
#[test]
|
||||
fn liveness_changed_stalled_relays_to_dto_and_wire() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
|
||||
agent_id: agent(7),
|
||||
liveness: AgentLiveness::Stalled,
|
||||
});
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "agentLivenessChanged");
|
||||
assert_eq!(json["agentId"], agent(7).to_string());
|
||||
assert_eq!(json["liveness"], "stalled");
|
||||
}
|
||||
|
||||
/// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`.
|
||||
#[test]
|
||||
fn liveness_changed_alive_relays_to_dto_and_wire() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
|
||||
agent_id: agent(3),
|
||||
liveness: AgentLiveness::Alive,
|
||||
});
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "agentLivenessChanged");
|
||||
assert_eq!(json["liveness"], "alive");
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,15 +44,10 @@ pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
|
||||
#[must_use]
|
||||
pub fn dispatch() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
if args
|
||||
.next()
|
||||
.is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND)
|
||||
{
|
||||
if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) {
|
||||
// Headless bridge: bypass Tauri entirely. Forward the remaining args
|
||||
// (`--endpoint`, `--project`, `--requester`) to the bridge parser.
|
||||
let rest: Vec<String> = args
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
return mcp_bridge::run_mcp_bridge(rest);
|
||||
}
|
||||
|
||||
@ -132,6 +127,10 @@ pub fn run() {
|
||||
commands::list_projects,
|
||||
commands::read_project_context,
|
||||
commands::update_project_context,
|
||||
commands::get_project_permissions,
|
||||
commands::update_project_permissions,
|
||||
commands::update_agent_permissions,
|
||||
commands::resolve_agent_permissions,
|
||||
commands::open_terminal,
|
||||
commands::write_terminal,
|
||||
commands::resize_terminal,
|
||||
@ -166,8 +165,9 @@ pub fn run() {
|
||||
commands::launch_agent,
|
||||
commands::change_agent_profile,
|
||||
commands::agent_send,
|
||||
commands::submit_agent_input,
|
||||
commands::interrupt_agent,
|
||||
commands::delegation_delivered,
|
||||
commands::set_front_attached,
|
||||
commands::reattach_agent_chat,
|
||||
commands::close_agent_session,
|
||||
commands::list_resumable_agents,
|
||||
|
||||
@ -49,14 +49,18 @@ use std::time::Duration;
|
||||
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
|
||||
use interprocess::local_socket::traits::tokio::Stream as _;
|
||||
use interprocess::local_socket::{GenericFilePath, ToFsName as _};
|
||||
use tokio::io::{
|
||||
AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader,
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
/// How long the bridge waits to connect to the project loopback before giving up.
|
||||
/// Bounded so an absent/unreachable endpoint fails fast instead of hanging.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// After the CLI closes stdin, how long to keep draining the loopback so an
|
||||
/// in-flight response still reaches the CLI. Bounded so the bridge never blocks
|
||||
/// waiting on the server: when stdin closes the CLI is gone, and the OS closes the
|
||||
/// loopback fd at process exit anyway.
|
||||
const DRAIN_GRACE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Parsed `mcp-server` invocation arguments.
|
||||
///
|
||||
/// `--endpoint` is **required** (without it the bridge has nowhere to relay).
|
||||
@ -222,18 +226,26 @@ async fn connect_loopback(endpoint: &str) -> Result<LocalSocketStream, String> {
|
||||
///
|
||||
/// Sequence:
|
||||
/// 1. Write the **handshake line** (`project`+`requester`) to the loopback.
|
||||
/// 2. Loop: read one **line** from the CLI (`cli_in`) → write it to the loopback;
|
||||
/// read one **line** from the loopback → write it to the CLI (`cli_out`).
|
||||
/// 3. **CLI stdin EOF** ⇒ stop, return `Ok(())` (clean code 0). Dropping the
|
||||
/// loopback writer closes that direction of the connection.
|
||||
/// 2. Run **two independent directional pumps concurrently** (full duplex):
|
||||
/// - `CLI → loopback`: every line from `cli_in` is forwarded to the loopback;
|
||||
/// - `loopback → CLI`: every line from the loopback is forwarded to `cli_out`.
|
||||
/// 3. **CLI stdin EOF** ⇒ half-close the loopback write side (so the server sees
|
||||
/// EOF), **drain** any responses still in flight, then return `Ok(())`.
|
||||
///
|
||||
/// The loop is request/response paced (one CLI line in, one loopback line back),
|
||||
/// matching the JSON Lines `StdioTransport` the server speaks. A loopback that
|
||||
/// closes mid-exchange surfaces as an error (non-zero exit).
|
||||
/// ## Why full duplex (and not lockstep)
|
||||
///
|
||||
/// JSON-RPC over MCP is **not** one-response-per-request. **Notifications** (e.g.
|
||||
/// `notifications/initialized` sent right after `initialize`) carry no `id` and get
|
||||
/// **no response**, and the server may push messages unsolicited. A lockstep pump
|
||||
/// that reads one CLI line then *blocks* for exactly one loopback line **deadlocks**
|
||||
/// on the first notification: it waits forever for a reply that never comes, and
|
||||
/// never reads the client's next request (e.g. `tools/list`) — so the CLI never
|
||||
/// receives its tool list. Two decoupled pumps let notifications and asynchronous
|
||||
/// server messages flow freely in both directions.
|
||||
async fn relay<CIn, COut, LIn, LOut>(
|
||||
args: &BridgeArgs,
|
||||
cli_in: CIn,
|
||||
mut cli_out: COut,
|
||||
cli_out: COut,
|
||||
lp_read: LIn,
|
||||
mut lp_write: LOut,
|
||||
) -> Result<(), String>
|
||||
@ -253,48 +265,66 @@ where
|
||||
.await
|
||||
.map_err(|e| format!("handshake flush failed: {e}"))?;
|
||||
|
||||
let mut cli_reader = BufReader::new(cli_in);
|
||||
let mut lp_reader = BufReader::new(lp_read);
|
||||
let mut cli_line = String::new();
|
||||
let mut lp_line = String::new();
|
||||
// 2. Two decoupled pumps. Each owns its streams so neither blocks the other.
|
||||
let cli_to_lp = pump_lines(BufReader::new(cli_in), lp_write, "stdin", "loopback");
|
||||
let lp_to_cli = pump_lines(BufReader::new(lp_read), cli_out, "loopback", "stdout");
|
||||
tokio::pin!(cli_to_lp);
|
||||
tokio::pin!(lp_to_cli);
|
||||
|
||||
tokio::select! {
|
||||
// CLI closed stdin (or errored): half-close the loopback writer to nudge
|
||||
// the server, then drain briefly so an in-flight response still reaches the
|
||||
// CLI — but never block on the server (bounded by DRAIN_GRACE).
|
||||
r = &mut cli_to_lp => {
|
||||
let mut lp_write = r?;
|
||||
let _ = lp_write.shutdown().await;
|
||||
let _ = tokio::time::timeout(DRAIN_GRACE, &mut lp_to_cli).await;
|
||||
Ok(())
|
||||
}
|
||||
// Loopback closed first (server hangup): nothing left to relay. The CLI's
|
||||
// stdin EOF is no longer needed to exit.
|
||||
r = &mut lp_to_cli => r.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards every newline-delimited line from `reader` to `writer` until `reader`
|
||||
/// hits EOF, flushing after each line so a peer blocked on a read sees it promptly.
|
||||
///
|
||||
/// On clean EOF it returns the (now-drained) `writer` so the caller can half-close
|
||||
/// it. `src`/`dst` name the two ends for error messages only.
|
||||
async fn pump_lines<R, W>(
|
||||
mut reader: BufReader<R>,
|
||||
mut writer: W,
|
||||
src: &str,
|
||||
dst: &str,
|
||||
) -> Result<W, String>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
// 2a. CLI → loopback (one line). EOF ⇒ clean shutdown.
|
||||
cli_line.clear();
|
||||
let n = cli_reader
|
||||
.read_line(&mut cli_line)
|
||||
line.clear();
|
||||
let n = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| format!("stdin read failed: {e}"))?;
|
||||
.map_err(|e| format!("{src} read failed: {e}"))?;
|
||||
if n == 0 {
|
||||
// CLI closed stdin: nothing more to forward. Clean exit.
|
||||
return Ok(());
|
||||
}
|
||||
lp_write
|
||||
.write_all(cli_line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("loopback write failed: {e}"))?;
|
||||
lp_write
|
||||
// Source closed: drain and hand the writer back for half-close.
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("loopback flush failed: {e}"))?;
|
||||
|
||||
// 2b. loopback → CLI (one line). EOF here is a server hangup mid-exchange.
|
||||
lp_line.clear();
|
||||
let m = lp_reader
|
||||
.read_line(&mut lp_line)
|
||||
.await
|
||||
.map_err(|e| format!("loopback read failed: {e}"))?;
|
||||
if m == 0 {
|
||||
return Err("loopback closed before answering".to_string());
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
return Ok(writer);
|
||||
}
|
||||
cli_out
|
||||
.write_all(lp_line.as_bytes())
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("stdout write failed: {e}"))?;
|
||||
cli_out
|
||||
.map_err(|e| format!("{dst} write failed: {e}"))?;
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("stdout flush failed: {e}"))?;
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
@ -313,7 +343,12 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_all_three_flags() {
|
||||
let a = BridgeArgs::parse([
|
||||
"--endpoint", "/tmp/x.sock", "--project", "p1", "--requester", "agent-7",
|
||||
"--endpoint",
|
||||
"/tmp/x.sock",
|
||||
"--project",
|
||||
"p1",
|
||||
"--requester",
|
||||
"agent-7",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(a.endpoint, "/tmp/x.sock");
|
||||
@ -355,8 +390,7 @@ mod tests {
|
||||
};
|
||||
let line = a.handshake_line();
|
||||
assert_eq!(*line.last().unwrap(), b'\n');
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(&line[..line.len() - 1]).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
|
||||
assert_eq!(v["project"], "proj");
|
||||
assert_eq!(v["requester"], "rq");
|
||||
}
|
||||
@ -393,24 +427,19 @@ mod tests {
|
||||
reader.read_line(&mut request).await.unwrap();
|
||||
|
||||
let mut w = srv_write;
|
||||
w.write_all(b"{\"result\":\"ok\",\"id\":1}\n").await.unwrap();
|
||||
w.write_all(b"{\"result\":\"ok\",\"id\":1}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
(handshake, request)
|
||||
});
|
||||
|
||||
relay(
|
||||
&args,
|
||||
&cli_in[..],
|
||||
&mut cli_out,
|
||||
lp_read,
|
||||
lp_write,
|
||||
)
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (handshake, request) = server.await.unwrap();
|
||||
let hs: serde_json::Value =
|
||||
serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-1");
|
||||
assert_eq!(hs["requester"], "agent-9");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"tools/call\",\"id\":1}");
|
||||
@ -420,6 +449,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (the bug that hid the tool list for days): a **notification**
|
||||
/// (no `id`, no response) sent between two requests must NOT stall the relay.
|
||||
/// A lockstep pump deadlocks here — after forwarding the notification it blocks
|
||||
/// waiting for a reply that never comes, and never reads `tools/list`. The
|
||||
/// full-duplex relay forwards all three lines and delivers the one response.
|
||||
#[tokio::test]
|
||||
async fn relay_does_not_block_on_notification_without_response() {
|
||||
// initialize result, then an unanswered notification, then tools/list.
|
||||
let cli_in = b"{\"method\":\"initialize\",\"id\":1}\n\
|
||||
{\"method\":\"notifications/initialized\"}\n\
|
||||
{\"method\":\"tools/list\",\"id\":2}\n"
|
||||
.to_vec();
|
||||
let mut cli_out: Vec<u8> = Vec::new();
|
||||
|
||||
let (lp_read, srv_write) = tokio::io::duplex(4096); // server → bridge
|
||||
let (srv_read, lp_write) = tokio::io::duplex(4096); // bridge → server
|
||||
|
||||
let args = BridgeArgs {
|
||||
endpoint: "unused".into(),
|
||||
project: "p".into(),
|
||||
requester: "agent".into(),
|
||||
};
|
||||
|
||||
// Server: handshake, then read all three forwarded lines, answering only
|
||||
// the two that carry an `id`. The notification gets no reply — exactly the
|
||||
// case that used to wedge the relay.
|
||||
let server = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(srv_read);
|
||||
let mut w = srv_write;
|
||||
for _ in 0..4 {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).await.unwrap() == 0 {
|
||||
break;
|
||||
}
|
||||
let v: serde_json::Value = match serde_json::from_str(line.trim_end()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // handshake line
|
||||
};
|
||||
if let Some(id) = v.get("id") {
|
||||
w.write_all(format!("{{\"result\":\"ok\",\"id\":{id}}}\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write),
|
||||
)
|
||||
.await
|
||||
.expect("relay must not deadlock on a notification")
|
||||
.expect("relay ok");
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
let out = String::from_utf8(cli_out).unwrap();
|
||||
// Both request responses arrived; the notification produced none.
|
||||
assert!(out.contains("\"id\":1"), "missing initialize response: {out}");
|
||||
assert!(out.contains("\"id\":2"), "missing tools/list response: {out}");
|
||||
}
|
||||
|
||||
/// stdin EOF with no request ⇒ relay returns `Ok` (code 0), having still sent
|
||||
/// the handshake.
|
||||
#[tokio::test]
|
||||
@ -485,10 +577,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn connect_to_absent_endpoint_errors_fast() {
|
||||
let endpoint = temp_endpoint("absent");
|
||||
let res = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connect_loopback(&endpoint),
|
||||
)
|
||||
let res = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
|
||||
.await
|
||||
.expect("connect_loopback must not hang");
|
||||
assert!(res.is_err(), "absent endpoint must yield an error");
|
||||
@ -545,10 +634,7 @@ mod tests {
|
||||
|
||||
// Drive the whole bridge_over_loopback path (real connect + split + relay),
|
||||
// but feed our own stdio streams via `relay` to avoid touching process std.
|
||||
let conn = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connect_loopback(&endpoint),
|
||||
)
|
||||
let conn = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
|
||||
.await
|
||||
.expect("no hang")
|
||||
.expect("connect to live endpoint");
|
||||
@ -565,8 +651,7 @@ mod tests {
|
||||
server.await.unwrap();
|
||||
|
||||
let (handshake, request) = captured.lock().await.clone();
|
||||
let hs: serde_json::Value =
|
||||
serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-e2e");
|
||||
assert_eq!(hs["requester"], "agent-e2e");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"ping\",\"id\":42}");
|
||||
|
||||
@ -136,9 +136,11 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
|
||||
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
|
||||
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
|
||||
pub(crate) fn idea_exe_path() -> Option<String> {
|
||||
std::env::var("APPIMAGE")
|
||||
std::env::var("APPIMAGE").ok().or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.or_else(|| std::env::current_exe().ok().map(|p| p.to_string_lossy().into_owned()))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à
|
||||
@ -209,7 +211,9 @@ mod tests {
|
||||
fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() {
|
||||
let p = pid("11111111-1111-1111-1111-111111111111");
|
||||
let ep = mcp_endpoint(&p);
|
||||
let path = ep.socket_path().expect("unix endpoint exposes a socket path");
|
||||
let path = ep
|
||||
.socket_path()
|
||||
.expect("unix endpoint exposes a socket path");
|
||||
assert!(path.extension().is_some_and(|e| e == "sock"));
|
||||
assert_eq!(path.parent().unwrap(), unix_runtime_dir());
|
||||
}
|
||||
@ -252,9 +256,9 @@ mod tests {
|
||||
/// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
|
||||
#[test]
|
||||
fn app_provider_runtime_for_matches_sources_of_truth() {
|
||||
use domain::{AgentId, ProjectId, Project};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{AgentId, Project, ProjectId};
|
||||
|
||||
let project = Project::new(
|
||||
ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -57,7 +57,7 @@ fn final_chunk(s: &str) -> ReplyChunk {
|
||||
fn chunk_from_event_maps_text_delta() {
|
||||
assert_eq!(
|
||||
chunk_from_event(ReplyEvent::TextDelta { text: "hi".into() }),
|
||||
ReplyChunk::TextDelta { text: "hi".into() }
|
||||
Some(ReplyChunk::TextDelta { text: "hi".into() })
|
||||
);
|
||||
}
|
||||
|
||||
@ -67,9 +67,9 @@ fn chunk_from_event_maps_tool_activity() {
|
||||
chunk_from_event(ReplyEvent::ToolActivity {
|
||||
label: "reads file".into()
|
||||
}),
|
||||
ReplyChunk::ToolActivity {
|
||||
Some(ReplyChunk::ToolActivity {
|
||||
label: "reads file".into()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@ -79,12 +79,19 @@ fn chunk_from_event_maps_final() {
|
||||
chunk_from_event(ReplyEvent::Final {
|
||||
content: "done".into()
|
||||
}),
|
||||
ReplyChunk::Final {
|
||||
Some(ReplyChunk::Final {
|
||||
content: "done".into()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_from_event_drops_heartbeat() {
|
||||
// A heartbeat is a non-terminal liveness proof with no chat content (lot 1) ⇒ no
|
||||
// wire chunk; the pump skips it while still draining to the Final.
|
||||
assert_eq!(chunk_from_event(ReplyEvent::Heartbeat), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agent_send pump behaviour: deltas* then exactly one Final (zone 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -94,7 +101,10 @@ fn chunk_from_event_maps_final() {
|
||||
/// end. This is the body of the spawned pump thread, run inline.
|
||||
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
|
||||
for event in events {
|
||||
let _ = bridge.send_output(session, chunk_from_event(event));
|
||||
// Mirror the real pump: heartbeats map to no chunk and are skipped.
|
||||
if let Some(chunk) = chunk_from_event(event) {
|
||||
let _ = bridge.send_output(session, chunk);
|
||||
}
|
||||
}
|
||||
bridge.detach_if(session, gen);
|
||||
}
|
||||
@ -126,7 +136,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 +240,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 +253,9 @@ fn scrollback_accumulates_every_routed_chunk_in_order() {
|
||||
bridge.scrollback(&session),
|
||||
vec![
|
||||
delta("x"),
|
||||
ReplyChunk::ToolActivity { label: "runs".into() },
|
||||
ReplyChunk::ToolActivity {
|
||||
label: "runs".into()
|
||||
},
|
||||
final_chunk("x"),
|
||||
],
|
||||
"scrollback retains the full conversation, in order"
|
||||
|
||||
@ -148,7 +148,8 @@ fn orchestration_source_dto_serialises_lowercase() {
|
||||
use domain::events::OrchestrationSource;
|
||||
|
||||
// File → "file"; Mcp → "mcp" (camelCase rename on a unit enum = lowercase).
|
||||
let file = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap();
|
||||
let file =
|
||||
serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap();
|
||||
assert_eq!(file, json!("file"));
|
||||
|
||||
let mcp = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::Mcp)).unwrap();
|
||||
|
||||
@ -8,9 +8,9 @@
|
||||
|
||||
use app_tauri_lib::dto::{CellKind, ReattachChatDto, ReplyChunk, TerminalSessionDto};
|
||||
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
|
||||
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::SessionId;
|
||||
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -49,8 +49,12 @@ fn reply_chunk_final_serialises_exact_camel_case() {
|
||||
fn reply_chunk_round_trips_through_json_for_every_variant() {
|
||||
for chunk in [
|
||||
ReplyChunk::TextDelta { text: "x".into() },
|
||||
ReplyChunk::ToolActivity { label: "runs".into() },
|
||||
ReplyChunk::Final { content: "y".into() },
|
||||
ReplyChunk::ToolActivity {
|
||||
label: "runs".into(),
|
||||
},
|
||||
ReplyChunk::Final {
|
||||
content: "y".into(),
|
||||
},
|
||||
] {
|
||||
let v = serde_json::to_value(&chunk).unwrap();
|
||||
let back: ReplyChunk = serde_json::from_value(v).unwrap();
|
||||
@ -84,7 +88,9 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
|
||||
session_id: "sess-1".into(),
|
||||
scrollback: vec![
|
||||
ReplyChunk::TextDelta { text: "Hi".into() },
|
||||
ReplyChunk::Final { content: "Hi".into() },
|
||||
ReplyChunk::Final {
|
||||
content: "Hi".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
@ -16,9 +16,7 @@ use async_trait::async_trait;
|
||||
use app_tauri_lib::dto::LiveAgentListDto;
|
||||
use application::{LiveSessions, StructuredSessions, TerminalSessions};
|
||||
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream};
|
||||
use domain::{
|
||||
AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession,
|
||||
};
|
||||
use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- petits constructeurs déterministes ------------------------------------
|
||||
@ -160,7 +158,11 @@ fn same_agent_in_both_registries_is_deduplicated() {
|
||||
structured.insert(fake(sid(2)), a, nid(200));
|
||||
|
||||
let dto = dto_for(&pty, &structured);
|
||||
assert_eq!(dto.0.len(), 1, "un même agent ne doit apparaître qu'une fois");
|
||||
assert_eq!(
|
||||
dto.0.len(),
|
||||
1,
|
||||
"un même agent ne doit apparaître qu'une fois"
|
||||
);
|
||||
assert_eq!(dto.0[0].agent_id, a.to_string());
|
||||
// On garde la première occurrence (PTY, listé en premier par l'agrégateur).
|
||||
assert_eq!(dto.0[0].node_id, nid(100).to_string());
|
||||
|
||||
@ -164,10 +164,7 @@ async fn stop_watch_unregisters_the_mcp_server() {
|
||||
assert!(has_mcp(&state, &project.id));
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
assert!(
|
||||
!has_mcp(&state, &project.id),
|
||||
"MCP server removed on close"
|
||||
);
|
||||
assert!(!has_mcp(&state, &project.id), "MCP server removed on close");
|
||||
assert_eq!(mcp_count(&state), 0);
|
||||
|
||||
// Stopping an unknown project is a no-op (does not panic) for the MCP twin too.
|
||||
@ -274,7 +271,10 @@ async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
|
||||
// socket) — it returns early. One endpoint, still bound, no panic.
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert_eq!(mcp_count(&state), 1, "one endpoint per project");
|
||||
assert!(socket_exists(&project), "endpoint still bound after re-open");
|
||||
assert!(
|
||||
socket_exists(&project),
|
||||
"endpoint still bound after re-open"
|
||||
);
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
}
|
||||
|
||||
@ -18,8 +18,10 @@
|
||||
//! making the reference profiles addressable across runs without a registry.
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::permission::ProjectorKey;
|
||||
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.
|
||||
@ -60,6 +62,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
)
|
||||
.expect("claude reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_projector(ProjectorKey::Claude)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json")
|
||||
.expect(".mcp.json is a valid relative MCP config target"),
|
||||
@ -78,9 +81,13 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
)
|
||||
.expect("codex reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_projector(ProjectorKey::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json")
|
||||
.expect(".mcp.json is a valid relative MCP config target"),
|
||||
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
|
||||
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour
|
||||
// isoler l'agent du `~/.codex` global (miroir du `.mcp.json` de Claude).
|
||||
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||
.expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"),
|
||||
McpTransport::Stdio,
|
||||
)),
|
||||
AgentProfile::new(
|
||||
@ -156,16 +163,36 @@ mod mcp_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_mcp_use_config_file_mcp_json() {
|
||||
for slug in ["claude", "codex"] {
|
||||
let mcp = profile(slug).mcp.expect("mcp present");
|
||||
fn claude_mcp_uses_config_file_mcp_json() {
|
||||
let mcp = profile("claude").mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() },
|
||||
"profile `{slug}` should declare `.mcp.json`"
|
||||
McpConfigStrategy::ConfigFile {
|
||||
target: ".mcp.json".to_owned()
|
||||
},
|
||||
"Claude should declare `.mcp.json`"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_mcp_uses_toml_config_home_codex() {
|
||||
// Codex lit `$CODEX_HOME/config.toml`, pas `.mcp.json` : le seed doit déclarer
|
||||
// la stratégie TOML isolée par `CODEX_HOME` (pendant Codex de Claude).
|
||||
let mcp = profile("codex").mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::TomlConfigHome {
|
||||
target: ".codex/config.toml".to_owned(),
|
||||
home_env: "CODEX_HOME".to_owned(),
|
||||
},
|
||||
"Codex should declare `.codex/config.toml` + CODEX_HOME"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
assert!(
|
||||
profile("codex").materializes_idea_bridge(),
|
||||
"the Codex seed must materialise the idea bridge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -177,4 +204,30 @@ mod mcp_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_seed_their_projector_key() {
|
||||
assert_eq!(
|
||||
profile("claude").projector,
|
||||
Some(ProjectorKey::Claude),
|
||||
"the Claude seed must pose the Claude projector"
|
||||
);
|
||||
assert_eq!(
|
||||
profile("codex").projector,
|
||||
Some(ProjectorKey::Codex),
|
||||
"the Codex seed must pose the Codex projector"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_and_aider_have_no_projector() {
|
||||
for slug in ["gemini", "aider"] {
|
||||
assert!(
|
||||
profile(slug).projector.is_none(),
|
||||
"non-structured profile `{slug}` must NOT carry a projector (native prompting)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -16,21 +16,20 @@ mod usecases;
|
||||
pub(crate) use lifecycle::unique_md_path;
|
||||
pub(crate) use lifecycle::ReattachDecision;
|
||||
|
||||
pub use structured::send_blocking;
|
||||
pub use structured::{drain_with_readiness, 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,
|
||||
LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
||||
UpdateAgentContext,
|
||||
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
};
|
||||
pub use usecases::{
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
||||
|
||||
@ -14,7 +14,10 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::input::InputMediator;
|
||||
use domain::ids::AgentId;
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
||||
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
|
||||
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
|
||||
@ -39,14 +42,83 @@ pub async fn send_blocking(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await
|
||||
}
|
||||
|
||||
/// Comme [`send_blocking`], mais **branche la readiness** : à chaque événement du
|
||||
/// tour, [`ReadinessPolicy::classify`] est consulté et, dès qu'il renvoie
|
||||
/// [`ReadinessSignal::TurnEnded`] (le `Final`), le médiateur d'entrée est notifié
|
||||
/// (`mark_idle(agent)`) pour que la FIFO de l'agent avance — **sans** dépendre d'un
|
||||
/// `idea_reply` explicite ni du sniff de prompt PTY (chantier readiness/heartbeat,
|
||||
/// lot 1, fix de la cause racine du blocage `Busy`).
|
||||
///
|
||||
/// DRY : **un seul** chemin de lecture du flux (la boucle de [`drain_bounded`]) ;
|
||||
/// cette fonction n'est que `send_blocking` muni d'un *sink* de readiness. Le `Final`
|
||||
/// réveille donc à la fois le `pending` (via la valeur de retour) **et** la FIFO (via
|
||||
/// `mark_idle`). `idea_reply` reste un signal alternatif (premier arrivé gagne) côté
|
||||
/// orchestrateur.
|
||||
///
|
||||
/// # Errors
|
||||
/// Identiques à [`send_blocking`] (échec `send`/décodage, flux clos sans `Final`,
|
||||
/// timeout).
|
||||
pub async fn drain_with_readiness(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
mediator: &dyn InputMediator,
|
||||
agent: AgentId,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
// `on_signal` ne reçoit QUE les événements terminaux (le `Final` ⇒ `TurnEnded`) :
|
||||
// la readiness ne classe pas les non-terminaux. Pour le **battement** de vivacité
|
||||
// (lot 2) on a besoin de notifier le médiateur à CHAQUE événement non terminal
|
||||
// (delta / activité / heartbeat) ⇒ on passe un sink d'événement bruts `on_event`.
|
||||
drain_bounded_events(
|
||||
session,
|
||||
prompt,
|
||||
timeout,
|
||||
|event| {
|
||||
// Tout événement **non terminal** prouve la vivacité ⇒ un battement.
|
||||
if !matches!(event, ReplyEvent::Final { .. }) {
|
||||
mediator.mark_alive(agent);
|
||||
}
|
||||
},
|
||||
|signal| {
|
||||
if signal == ReadinessSignal::TurnEnded {
|
||||
mediator.mark_idle(agent);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**, en appliquant
|
||||
/// la borne temporelle `timeout`, en notifiant `on_event` à **chaque** événement brut
|
||||
/// (pour le battement de vivacité, lot 2) et `on_signal` à chaque [`ReadinessSignal`]
|
||||
/// dérivé par [`ReadinessPolicy`] (le `Final` ⇒ `TurnEnded`).
|
||||
///
|
||||
/// **Chemin de lecture unique** (DRY) : `send_blocking` et `drain_with_readiness`
|
||||
/// passent tous deux par ici, en différant seulement par leurs *sinks*. La session
|
||||
/// **reste vivante** sur timeout (on ne `shutdown` rien ici, §17.1).
|
||||
async fn drain_bounded_events(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
on_event: impl FnMut(&ReplyEvent),
|
||||
on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<String, AgentSessionError> {
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(dur, drain_to_final(session, prompt)).await {
|
||||
Some(dur) => match tokio::time::timeout(
|
||||
dur,
|
||||
drain_to_final(session, prompt, on_event, on_signal),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
|
||||
Err(_elapsed) => Err(AgentSessionError::Timeout),
|
||||
},
|
||||
None => drain_to_final(session, prompt).await,
|
||||
None => drain_to_final(session, prompt, on_event, on_signal).await,
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,18 +128,124 @@ pub async fn send_blocking(
|
||||
/// après le `Final` il ne produit plus rien. On le parcourt donc simplement
|
||||
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
|
||||
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
|
||||
///
|
||||
/// Chaque événement est classé par [`ReadinessPolicy`] et le signal éventuel est
|
||||
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`]). Deltas,
|
||||
/// activités et heartbeats sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
|
||||
async fn drain_to_final(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
mut on_event: impl FnMut(&ReplyEvent),
|
||||
mut on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<String, AgentSessionError> {
|
||||
let stream = session.send(prompt).await?;
|
||||
for event in stream {
|
||||
// Battement de vivacité (lot 2) : notifié pour CHAQUE événement brut, avant le
|
||||
// classement readiness. Le sink décide (les non-terminaux prouvent la vivacité).
|
||||
on_event(&event);
|
||||
if let Some(signal) = ReadinessPolicy::classify(&event) {
|
||||
on_signal(signal);
|
||||
}
|
||||
if let ReplyEvent::Final { content } = event {
|
||||
return Ok(content);
|
||||
}
|
||||
// TextDelta / ToolActivity : ignorés par le rendez-vous synchrone.
|
||||
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
|
||||
}
|
||||
Err(AgentSessionError::Io(
|
||||
"le flux de réponse s'est terminé sans événement Final".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use domain::ids::SessionId;
|
||||
use domain::input::{AgentBusyState, SubmitConfig};
|
||||
use domain::mailbox::{PendingReply, Ticket};
|
||||
use domain::ports::PtyHandle;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Session factice : `send` rejoue une liste fixe d'événements (terminée par un
|
||||
/// `Final`).
|
||||
struct FakeSession {
|
||||
events: Vec<ReplyEvent>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
fn id(&self) -> SessionId {
|
||||
SessionId::from_uuid(uuid::Uuid::from_u128(1))
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(
|
||||
&self,
|
||||
_prompt: &str,
|
||||
) -> Result<domain::ports::ReplyStream, AgentSessionError> {
|
||||
Ok(Box::new(self.events.clone().into_iter()))
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Médiateur factice qui enregistre l'ordre des `mark_alive` / `mark_idle`.
|
||||
#[derive(Default)]
|
||||
struct RecordingMediator {
|
||||
calls: Mutex<Vec<&'static str>>,
|
||||
}
|
||||
impl InputMediator for RecordingMediator {
|
||||
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
||||
unreachable!("non utilisé par drain_with_readiness")
|
||||
}
|
||||
fn preempt(&self, _agent: AgentId) {}
|
||||
fn mark_idle(&self, _agent: AgentId) {
|
||||
self.calls.lock().unwrap().push("idle");
|
||||
}
|
||||
fn mark_alive(&self, _agent: AgentId) {
|
||||
self.calls.lock().unwrap().push("alive");
|
||||
}
|
||||
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
|
||||
AgentBusyState::Idle
|
||||
}
|
||||
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
|
||||
fn bind_handle_with_prompt(
|
||||
&self,
|
||||
_agent: AgentId,
|
||||
_handle: PtyHandle,
|
||||
_pattern: Option<String>,
|
||||
_submit: SubmitConfig,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drain_marks_alive_on_each_non_terminal_then_idle_on_final() {
|
||||
let session = FakeSession {
|
||||
events: vec![
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::ToolActivity { label: "lit".into() },
|
||||
ReplyEvent::Heartbeat,
|
||||
ReplyEvent::Final {
|
||||
content: "fini".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness(&session, "go", None, &mediator, agent(1))
|
||||
.await
|
||||
.expect("drain ok");
|
||||
assert_eq!(out, "fini");
|
||||
// Trois battements (delta, activité, heartbeat) PUIS l'idle sur le Final.
|
||||
assert_eq!(
|
||||
*mediator.calls.lock().unwrap(),
|
||||
vec!["alive", "alive", "alive", "idle"],
|
||||
"un battement par événement non terminal, idle au Final (pas de battement sur le Final)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,8 +35,8 @@ pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOut
|
||||
pub use snapshot::{
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
||||
};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub(crate) use store::{persist_doc, resolve_doc};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub use usecases::{
|
||||
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
|
||||
MutateLayoutInput, MutateLayoutOutput,
|
||||
|
||||
@ -109,12 +109,7 @@ impl LayoutOperation {
|
||||
direction,
|
||||
new_leaf,
|
||||
container,
|
||||
} => tree.split(
|
||||
*target,
|
||||
*direction,
|
||||
LeafCell::new(*new_leaf),
|
||||
*container,
|
||||
),
|
||||
} => tree.split(*target, *direction, LeafCell::new(*new_leaf), *container),
|
||||
Self::Merge {
|
||||
container,
|
||||
keep_index,
|
||||
|
||||
@ -20,6 +20,7 @@ pub mod health;
|
||||
pub mod layout;
|
||||
pub mod memory;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod project;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
@ -28,19 +29,20 @@ 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,
|
||||
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,
|
||||
drain_with_readiness, 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, PermissionProjectorRegistry,
|
||||
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::{
|
||||
@ -77,6 +79,12 @@ pub use memory::{
|
||||
pub use orchestrator::{
|
||||
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
|
||||
};
|
||||
pub use permission::{
|
||||
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
|
||||
ResolveAgentPermissions, ResolveAgentPermissionsInput, ResolveAgentPermissionsOutput,
|
||||
UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions,
|
||||
UpdateProjectPermissionsInput,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||
|
||||
@ -29,9 +29,7 @@ use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{
|
||||
AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath,
|
||||
};
|
||||
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, Project};
|
||||
|
||||
use crate::error::AppError;
|
||||
@ -90,7 +88,11 @@ impl ReadContext {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
) -> Self {
|
||||
Self { guard, contexts, fs }
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the requested context, returning its Markdown body.
|
||||
@ -98,7 +100,11 @@ impl ReadContext {
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
|
||||
let ReadContextInput { project, target, requester } = input;
|
||||
let ReadContextInput {
|
||||
project,
|
||||
target,
|
||||
requester,
|
||||
} = input;
|
||||
match target {
|
||||
None => {
|
||||
// Global project context: shared read-lease, then read the root file.
|
||||
@ -109,8 +115,8 @@ impl ReadContext {
|
||||
.map_err(map_guard_err)?;
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let bytes = self.fs.read(&path).await?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let text =
|
||||
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
Ok(MarkdownDoc::new(text))
|
||||
}
|
||||
Some(name) => {
|
||||
@ -173,7 +179,12 @@ impl ProposeContext {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self { guard, contexts, fs, clock }
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the proposal: direct write under a write-lease, or a materialised
|
||||
@ -182,7 +193,12 @@ impl ProposeContext {
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ProposeContextInput) -> Result<ProposeOutcome, AppError> {
|
||||
let ProposeContextInput { project, target, content, requester } = input;
|
||||
let ProposeContextInput {
|
||||
project,
|
||||
target,
|
||||
content,
|
||||
requester,
|
||||
} = input;
|
||||
match target {
|
||||
Some(name) => {
|
||||
// Per-agent context: direct write under an exclusive write-lease.
|
||||
@ -273,7 +289,11 @@ impl ReadMemory {
|
||||
/// [`AppError`] when the note does not exist or the store fails. An invalid slug
|
||||
/// is [`AppError::Invalid`].
|
||||
pub async fn execute(&self, input: ReadMemoryInput) -> Result<String, AppError> {
|
||||
let ReadMemoryInput { project, slug, requester } = input;
|
||||
let ReadMemoryInput {
|
||||
project,
|
||||
slug,
|
||||
requester,
|
||||
} = input;
|
||||
match slug {
|
||||
Some(raw) => {
|
||||
let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
@ -329,7 +349,12 @@ impl WriteMemory {
|
||||
/// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store
|
||||
/// failure.
|
||||
pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> {
|
||||
let WriteMemoryInput { project, slug, content, requester } = input;
|
||||
let WriteMemoryInput {
|
||||
project,
|
||||
slug,
|
||||
content,
|
||||
requester,
|
||||
} = input;
|
||||
let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let _lease = self
|
||||
.guard
|
||||
@ -527,11 +552,7 @@ mod tests {
|
||||
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
slug: &MemorySlug,
|
||||
) -> Result<Memory, MemoryError> {
|
||||
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
||||
let body = self
|
||||
.notes
|
||||
.lock()
|
||||
@ -556,11 +577,7 @@ mod tests {
|
||||
.insert(memory.slug().to_string(), memory.body.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_slug: &MemorySlug,
|
||||
) -> Result<(), MemoryError> {
|
||||
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn read_index(
|
||||
@ -805,7 +822,10 @@ mod tests {
|
||||
guard.acquire_write(agent_party(2), slug.clone()),
|
||||
)
|
||||
.await;
|
||||
assert!(blocked.is_err(), "a second writer must block while w1 holds");
|
||||
assert!(
|
||||
blocked.is_err(),
|
||||
"a second writer must block while w1 holds"
|
||||
);
|
||||
drop(w1);
|
||||
let w2 = tokio::time::timeout(
|
||||
Duration::from_millis(200),
|
||||
|
||||
@ -19,21 +19,22 @@ 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;
|
||||
|
||||
use crate::agent::{
|
||||
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
|
||||
ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
|
||||
drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
||||
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
|
||||
UpdateAgentContextInput,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::orchestrator::{
|
||||
@ -41,7 +42,7 @@ use crate::orchestrator::{
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
use crate::skill::{CreateSkill, CreateSkillInput};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
||||
|
||||
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
|
||||
/// resizes the PTY to the real cell size on attach; these are sane starting rows
|
||||
@ -57,7 +58,13 @@ const DEFAULT_COLS: u16 = 80;
|
||||
/// **without killing the session**, so the requester can retry. Internal and
|
||||
/// intentionally not yet config-exposed (it may become a per-project setting
|
||||
/// without changing the contract).
|
||||
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
///
|
||||
/// TEMPORAIRE (demande utilisateur 2026-06-13) : la borne de 300 s coupait des tours
|
||||
/// délégués légitimement très longs (gros refactors + tests). On la relève donc à 24 h
|
||||
/// (≈ « pas de limite ») le temps de concevoir un vrai signal de vivacité (savoir si
|
||||
/// l'agent travaille encore) qui remplacera ce timeout brut. NE PAS considérer comme
|
||||
/// définitif : à reconvertir en borne courte + heartbeat once that liveness signal exists.
|
||||
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0,
|
||||
/// cadrage v5 §4).
|
||||
@ -72,7 +79,82 @@ const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// [`domain::ports::AgentSessionError::Timeout`]) ; le tour en cours n'est pas
|
||||
/// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours
|
||||
/// pleins d'attente.
|
||||
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600);
|
||||
///
|
||||
/// TEMPORAIRE (cf. [`ASK_AGENT_TIMEOUT`]) : relevé à 48 h (≈ deux tours « sans limite »)
|
||||
/// en cohérence avec la levée temporaire de la borne de tour, le temps d'un vrai
|
||||
/// signal de vivacité.
|
||||
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(48 * 60 * 60);
|
||||
|
||||
/// Borne de tour effective (lot 2, timeouts pilotés par profil) : le
|
||||
/// `turn_timeout_ms` du profil de la cible **quand il existe**, sinon le défaut
|
||||
/// historique [`ASK_AGENT_TIMEOUT`] (zéro régression pour un profil sans `liveness` /
|
||||
/// sans `turn_timeout_ms`). Pure et testable sans wiring de service.
|
||||
#[must_use]
|
||||
fn resolve_turn_timeout(turn_timeout_ms: Option<u32>) -> Duration {
|
||||
turn_timeout_ms.map_or(ASK_AGENT_TIMEOUT, |ms| Duration::from_millis(u64::from(ms)))
|
||||
}
|
||||
|
||||
/// Garde RAII de **fin de tour** : ramène la cible `Idle` quoi qu'il arrive (succès,
|
||||
/// erreur, timeout, **et surtout future abandonné/dropped**) tant qu'elle n'a pas été
|
||||
/// désarmée.
|
||||
///
|
||||
/// Cause racine du blocage `Busy` à vie : l'agent passe `Idle→Busy` dès l'`enqueue`
|
||||
/// (qui démarre le tour) mais ne revenait `Idle` que sur la **branche succès** du
|
||||
/// `select!`/`match`. Si le futur `ask_agent` est **dropped** (le demandeur a interrompu
|
||||
/// son appel), AUCUNE branche ne s'exécute ⇒ l'agent reste `Busy`, et toutes les
|
||||
/// délégations suivantes s'empilent derrière ce tour fantôme sans jamais être livrées.
|
||||
///
|
||||
/// Le garde tient les `Arc` nécessaires pour, à son `Drop`, faire à la fois
|
||||
/// `cancel_head(agent, ticket)` (retire le ticket fantôme de la FIFO — idempotent et
|
||||
/// positionnel : no-op si le head a déjà changé) **et** `mark_idle(agent)` (libère la
|
||||
/// FIFO — idempotent). Sur **succès**, on appelle [`BusyTurnGuard::disarm`] AVANT de
|
||||
/// retourner : le `mark_idle` « propre » déjà présent reste en place et on évite tout
|
||||
/// double `cancel_head` d'un ticket déjà résolu.
|
||||
struct BusyTurnGuard {
|
||||
input: Arc<dyn InputMediator>,
|
||||
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
agent: AgentId,
|
||||
ticket: TicketId,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl BusyTurnGuard {
|
||||
/// Arme le garde juste après l'`enqueue` (qui a démarré le tour ⇒ cible `Busy`).
|
||||
fn new(
|
||||
input: Arc<dyn InputMediator>,
|
||||
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
agent: AgentId,
|
||||
ticket: TicketId,
|
||||
) -> Self {
|
||||
Self {
|
||||
input,
|
||||
mailbox,
|
||||
agent,
|
||||
ticket,
|
||||
armed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Désarme le garde (branche succès) : le `Drop` devient un no-op. À appeler AVANT
|
||||
/// de retourner le contenu, pour préserver le `mark_idle` propre déjà fait et ne pas
|
||||
/// re-`cancel_head` un ticket déjà résolu.
|
||||
fn disarm(mut self) {
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BusyTurnGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
// Ordre : retirer le ticket fantôme de la FIFO PUIS libérer le busy state.
|
||||
// `cancel_head` est positionnel/idempotent (no-op si le head n'est plus ce
|
||||
// ticket) et `mark_idle` est idempotent (no-op si déjà Idle) ⇒ sûr quel que
|
||||
// soit le chemin (erreur, timeout, drop).
|
||||
self.mailbox.cancel_head(self.agent, self.ticket);
|
||||
self.input.mark_idle(self.agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP
|
||||
/// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans
|
||||
@ -182,6 +264,15 @@ pub struct OrchestratorService {
|
||||
/// injectée avec [`Self::record_turn`]. La couche `application` reste **pure** : pas
|
||||
/// de `SystemTime::now()` brut ici, le temps vient du port injecté au composition root.
|
||||
clock: Option<Arc<dyn Clock>>,
|
||||
/// Registre des **sessions IA structurées** vivantes (§17.5), pour router un `ask`
|
||||
/// vers une cible à `structured_adapter` directement sur sa session
|
||||
/// ([`drain_with_readiness`](crate::agent::drain_with_readiness)) — le `Final`
|
||||
/// déterministe réveille le `pending` **et** marque l'agent `Idle` (chantier
|
||||
/// readiness/heartbeat, lot 1, fix du blocage `Busy`). Injecté via
|
||||
/// [`Self::with_structured`] ; `None` ⇒ on conserve **uniquement** le chemin
|
||||
/// PTY/mailbox legacy (zéro régression pour les call sites/tests qui ne le branchent
|
||||
/// pas).
|
||||
structured: Option<Arc<StructuredSessions>>,
|
||||
}
|
||||
|
||||
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||
@ -244,9 +335,19 @@ impl OrchestratorService {
|
||||
context_guard: None,
|
||||
record_turn: None,
|
||||
clock: None,
|
||||
structured: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Branche le registre des [`StructuredSessions`] (§17.5) pour router un `ask`
|
||||
/// vers une cible **structurée** sur sa propre session. Builder additif (signature
|
||||
/// de [`Self::new`] inchangée) ; `None` ⇒ chemin PTY/mailbox legacy uniquement.
|
||||
#[must_use]
|
||||
pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
|
||||
self.structured = Some(structured);
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche les use cases C7 (`context.*`/`memory.*`) sous
|
||||
/// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`]
|
||||
/// inchangée).
|
||||
@ -416,7 +517,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 +557,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 +582,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
|
||||
@ -748,15 +851,62 @@ impl OrchestratorService {
|
||||
// Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`).
|
||||
let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id));
|
||||
|
||||
// ── Chemin **structuré** (readiness/heartbeat lot 1) ──────────────────────
|
||||
// Une cible à `structured_adapter` n'a **pas** de PTY : `ensure_live_pty`
|
||||
// échouerait, et le tour ne pourrait se débloquer que par un `idea_reply`
|
||||
// explicite (cause racine du blocage `Busy`). Quand le registre structuré est
|
||||
// câblé et que la cible a une session vivante, on draine son tour via
|
||||
// `drain_with_readiness` : le `Final` déterministe réveille le `pending` (valeur
|
||||
// de retour) **et** marque l'agent `Idle` (`mark_idle`). On enregistre tout de
|
||||
// même un ticket dans la FIFO pour la comptabilité busy et pour préserver
|
||||
// `idea_reply` comme signal **alternatif** (premier arrivé gagne).
|
||||
if let Some(structured) = self.structured.as_ref() {
|
||||
// Lot 1b — auto-lancement d'une cible **froide** structurée : si le registre
|
||||
// est câblé, qu'aucune session ne vit encore pour la cible, mais que son
|
||||
// profil porte un `structured_adapter`, on **démarre** sa session via le
|
||||
// launcher (qui route §17.4 vers `launch_structured` et l'insère dans CE
|
||||
// même registre, avec la conf MCP matérialisée) plutôt que de tomber dans
|
||||
// `ensure_live_pty` — chemin PTY qui échouerait pour une cible sans PTY.
|
||||
// Une cible SANS `structured_adapter` (agent PTY/TUI legacy) conserve le
|
||||
// chemin `ensure_live_pty` ci-dessous (zéro régression).
|
||||
if let Some(session) = self
|
||||
.ensure_structured_session(project, agent_id, &agent.profile_id, structured)
|
||||
.await?
|
||||
{
|
||||
return self
|
||||
.ask_structured(
|
||||
project,
|
||||
agent_id,
|
||||
&target,
|
||||
conversation_id,
|
||||
requester,
|
||||
task,
|
||||
session.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Garantir la cible vivante en PTY pour CE fil ; lier sa session à la
|
||||
// conversation, et brancher son handle d'entrée sur le médiateur (livraison).
|
||||
let handle = self
|
||||
let (handle, cold_launch) = self
|
||||
.ensure_live_pty(project, agent_id, conversation_id, &target)
|
||||
.await?;
|
||||
// Arm prompt-ready detection (C5) with the target profile's literal marker, so a
|
||||
// return-to-prompt frees the turn (the other OR signal being `idea_reply`).
|
||||
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
|
||||
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern);
|
||||
let (prompt_pattern, submit, has_mcp) =
|
||||
self.prompt_and_submit_for_agent(project, agent_id).await;
|
||||
// Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le
|
||||
// 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher
|
||||
// (pattern non vide), soit la connexion du pont MCP de l'agent
|
||||
// (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun
|
||||
// des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini).
|
||||
let gate_cold_start =
|
||||
cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp);
|
||||
if gate_cold_start {
|
||||
input.mark_starting(agent_id);
|
||||
}
|
||||
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
|
||||
@ -784,14 +934,28 @@ impl OrchestratorService {
|
||||
}
|
||||
None => Ticket::from_human(ticket_id, conversation_id, requester_label, task),
|
||||
};
|
||||
// Timeout de tour piloté par profil (lot 2) : `turn_timeout_ms` de la cible si
|
||||
// défini, sinon le défaut [`ASK_AGENT_TIMEOUT`]. Arme aussi le seuil de stall sur
|
||||
// le médiateur AVANT l'enqueue (consommé au start_turn).
|
||||
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
|
||||
let pending = input.enqueue(agent_id, ticket);
|
||||
// Garde RAII de fin de tour, armé JUSTE après l'enqueue (la cible est maintenant
|
||||
// `Busy`). Quel que soit le chemin de sortie — erreur, timeout, ou **futur
|
||||
// abandonné (drop)** — son `Drop` ramène la cible `Idle` et retire le ticket
|
||||
// fantôme de la FIFO. C'est le fix de la cause racine (cf. [`BusyTurnGuard`]).
|
||||
let busy_guard = BusyTurnGuard::new(
|
||||
Arc::clone(input),
|
||||
Arc::clone(mailbox),
|
||||
agent_id,
|
||||
ticket_id,
|
||||
);
|
||||
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
|
||||
// turn into the bound handle). The service no longer writes the PTY directly —
|
||||
// no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1).
|
||||
|
||||
// 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ retirer le ticket
|
||||
// (cible laissée vivante) et renvoyer une erreur typée.
|
||||
match tokio::time::timeout(ASK_AGENT_TIMEOUT, pending).await {
|
||||
// 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ le garde retire le ticket
|
||||
// (cible laissée vivante) et la ramène `Idle` au Drop ; renvoie une erreur typée.
|
||||
match tokio::time::timeout(turn_timeout, pending).await {
|
||||
Ok(Ok(result)) => {
|
||||
// Checkpoint Response (P6b, best-effort) : persister la réponse AVANT de
|
||||
// déplacer `result` dans `reply_outcome`. Source = la **cible** (c'est
|
||||
@ -804,19 +968,143 @@ impl OrchestratorService {
|
||||
result.clone(),
|
||||
)
|
||||
.await;
|
||||
// Succès : désarmer le garde AVANT de retourner. Le `mark_idle` propre
|
||||
// sur cette branche est porté par le médiateur (prompt-ready / idea_reply
|
||||
// qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket
|
||||
// déjà résolu, ni libérer un busy state qui ne nous appartient plus.
|
||||
busy_guard.disarm();
|
||||
Ok(self.reply_outcome(agent_id, &target, result))
|
||||
}
|
||||
Ok(Err(_cancelled)) => {
|
||||
mailbox.cancel_head(agent_id, ticket_id);
|
||||
Err(AppError::Process(format!(
|
||||
// Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au
|
||||
// Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent).
|
||||
Ok(Err(_cancelled)) => Err(AppError::Process(format!(
|
||||
"agent {target} : canal de réponse fermé avant un résultat"
|
||||
)))
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
mailbox.cancel_head(agent_id, ticket_id);
|
||||
Err(AppError::from(domain::ports::AgentSessionError::Timeout))
|
||||
))),
|
||||
Err(_elapsed) => Err(AppError::from(domain::ports::AgentSessionError::Timeout)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Chemin `ask` **structuré** (readiness/heartbeat lot 1) : la cible a un
|
||||
/// `structured_adapter` et une session vivante ([`StructuredSessions`]). On pilote
|
||||
/// son tour **directement** sur le port [`domain::ports::AgentSession`] et on le
|
||||
/// draine via [`drain_with_readiness`] : le [`domain::ports::ReplyEvent::Final`]
|
||||
/// déterministe rend le contenu (réveille le `pending`) **et** marque l'agent `Idle`
|
||||
/// (`mark_idle`), sans dépendre d'un `idea_reply` explicite ni d'un PTY (que la cible
|
||||
/// n'a pas). C'est le fix de la cause racine du blocage `Busy`.
|
||||
///
|
||||
/// On enregistre tout de même un ticket dans la FIFO (`enqueue`) pour la comptabilité
|
||||
/// busy et pour **préserver `idea_reply` comme signal alternatif** : on attend la
|
||||
/// **première** des deux issues (réponse de la session OU résolution du `pending`).
|
||||
/// Le checkpoint Prompt/Response best-effort est conservé à l'identique du chemin PTY.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn ask_structured(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
target: &str,
|
||||
conversation_id: domain::conversation::ConversationId,
|
||||
requester: Option<AgentId>,
|
||||
task: String,
|
||||
session: &dyn domain::ports::AgentSession,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let (input, mailbox) = match (&self.input, &self.mailbox) {
|
||||
(Some(i), Some(m)) => (i, m),
|
||||
_ => {
|
||||
return Err(AppError::Invalid(
|
||||
"la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \
|
||||
médiateur d'entrée non câblé"
|
||||
.to_owned(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Checkpoint Prompt (best-effort), AVANT de déplacer `task` dans le ticket.
|
||||
let prompt_source = match requester {
|
||||
Some(from) => InputSource::agent(from),
|
||||
None => InputSource::Human,
|
||||
};
|
||||
self.record_turn_best_effort(
|
||||
&project.root,
|
||||
conversation_id,
|
||||
prompt_source,
|
||||
TurnRole::Prompt,
|
||||
task.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Ticket dans la FIFO : comptabilité busy + `idea_reply` comme signal alternatif.
|
||||
let requester_label = self.requester_label(project, requester).await;
|
||||
let ticket_id = TicketId::new_random();
|
||||
let ticket = match requester {
|
||||
Some(from) => Ticket::from_agent(
|
||||
ticket_id,
|
||||
from,
|
||||
conversation_id,
|
||||
requester_label,
|
||||
task.clone(),
|
||||
),
|
||||
None => Ticket::from_human(ticket_id, conversation_id, requester_label, task.clone()),
|
||||
};
|
||||
// Timeout de tour piloté par profil (lot 2) + armement du seuil de stall, AVANT
|
||||
// l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité).
|
||||
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
|
||||
let pending = input.enqueue(agent_id, ticket);
|
||||
// Garde RAII de fin de tour, armé JUSTE après l'enqueue (cible `Busy`). Couvre
|
||||
// toutes les sorties — `return Err` des bras du select, OU **futur abandonné
|
||||
// (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est
|
||||
// le fix de la cause racine du blocage `Busy` à vie.
|
||||
let busy_guard = BusyTurnGuard::new(
|
||||
Arc::clone(input),
|
||||
Arc::clone(mailbox),
|
||||
agent_id,
|
||||
ticket_id,
|
||||
);
|
||||
|
||||
// Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`), borné par le
|
||||
// **même** garde-fou que le chemin PTY (profil ou défaut). On attend la
|
||||
// **première** issue : le tour structuré OU un `idea_reply` explicite.
|
||||
let drain =
|
||||
drain_with_readiness(session, &task, Some(turn_timeout), input.as_ref(), agent_id);
|
||||
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
// Issue déterministe : la session a rendu son `Final`.
|
||||
drained = drain => match drained {
|
||||
Ok(content) => content,
|
||||
// Erreur de drain : le garde fait `cancel_head` + `mark_idle` au Drop.
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
},
|
||||
// Issue alternative : un `idea_reply` explicite a résolu le ticket d'abord.
|
||||
replied = pending => match replied {
|
||||
Ok(content) => {
|
||||
// La session draine encore en arrière-plan ; on s'assure que la FIFO
|
||||
// avance même si le `Final` n'est pas encore observé.
|
||||
input.mark_idle(agent_id);
|
||||
content
|
||||
}
|
||||
// Canal fermé : le garde fait `cancel_head` + `mark_idle` au Drop.
|
||||
Err(_cancelled) => {
|
||||
return Err(AppError::Process(format!(
|
||||
"agent {target} : canal de réponse fermé avant un résultat"
|
||||
)));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Succès : désarmer le garde (le `mark_idle` propre est déjà porté par le `Final`
|
||||
// de la session ou par le bras `replied`) — pas de double `cancel_head`.
|
||||
busy_guard.disarm();
|
||||
|
||||
// Checkpoint Response (best-effort), AVANT de déplacer `result`.
|
||||
self.record_turn_best_effort(
|
||||
&project.root,
|
||||
conversation_id,
|
||||
InputSource::agent(agent_id),
|
||||
TurnRole::Response,
|
||||
result.clone(),
|
||||
)
|
||||
.await;
|
||||
Ok(self.reply_outcome(agent_id, target, result))
|
||||
}
|
||||
|
||||
/// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path.
|
||||
@ -863,11 +1151,22 @@ impl OrchestratorService {
|
||||
|
||||
// Ensure the target is live for this thread and bind its input handle on the
|
||||
// mediator (delivery path). Same call the ask path uses.
|
||||
let handle = self
|
||||
let (handle, cold_launch) = 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, has_mcp) =
|
||||
self.prompt_and_submit_for_agent(project, agent_id).await;
|
||||
// Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le
|
||||
// 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher
|
||||
// (pattern non vide), soit la connexion du pont MCP de l'agent
|
||||
// (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun
|
||||
// des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini).
|
||||
let gate_cold_start =
|
||||
cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp);
|
||||
if gate_cold_start {
|
||||
input.mark_starting(agent_id);
|
||||
}
|
||||
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 +1221,54 @@ 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})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Libère le premier tour différé d'un agent **lancé à froid** quand son pont MCP se
|
||||
/// connecte (readiness de démarrage). Pont entre l'McpServer (adapter entrant) et le
|
||||
/// médiateur d'entrée. No-op si aucun médiateur n'est câblé ou si rien n'est différé.
|
||||
pub fn release_agent_cold_start(&self, agent: domain::AgentId) {
|
||||
if let Some(input) = &self.input {
|
||||
input.release_cold_start(agent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Déclare si une **cellule terminal frontend** est montée pour `agent` (write-portal
|
||||
/// actif). Pont entre le write-portal (adapter UI) et le médiateur : un agent avec
|
||||
/// cellule reçoit ses tours via l'événement `DelegationReady` (le front écrit) ; un
|
||||
/// agent **headless** (délégué en arrière-plan, sans cellule) voit le médiateur écrire
|
||||
/// lui-même la tâche dans son PTY — sinon le tour est perdu. No-op sans médiateur.
|
||||
pub fn set_agent_front_attached(&self, agent: domain::AgentId, attached: bool) {
|
||||
if let Some(input) = &self.input {
|
||||
input.set_front_attached(agent, attached);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +1291,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).
|
||||
///
|
||||
@ -997,13 +1343,19 @@ impl OrchestratorService {
|
||||
/// a launch the handle is resolved from the registry; a missing handle is a
|
||||
/// [`AppError::Process`] (the launch did not register a PTY session, e.g. a profile
|
||||
/// IdeA cannot drive as a terminal).
|
||||
///
|
||||
/// Le booléen renvoyé indique un **lancement à froid** (`true` quand l'agent n'avait
|
||||
/// pas de session vivante et vient d'être démarré) : l'appelant l'utilise pour
|
||||
/// *gater* le premier tour sur le prompt-ready (cf. [`InputMediator::mark_starting`]),
|
||||
/// car un agent froid n'est pas encore à son prompt. `false` ⇒ session réutilisée
|
||||
/// (agent déjà chaud) ⇒ livraison immédiate, comportement inchangé.
|
||||
async fn ensure_live_pty(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
conversation_id: domain::conversation::ConversationId,
|
||||
target: &str,
|
||||
) -> Result<PtyHandle, AppError> {
|
||||
) -> Result<(PtyHandle, bool), AppError> {
|
||||
// «1 session vivante / conversation» (cadrage C3 §5.2) : on cherche d'abord la
|
||||
// session du **fil**, puis on retombe sur la session de l'agent (compat : un
|
||||
// agent mono-fil dont la session n'a pas encore été liée à sa conversation).
|
||||
@ -1013,9 +1365,10 @@ impl OrchestratorService {
|
||||
.or_else(|| self.sessions.session_for_agent(&agent_id));
|
||||
if let Some(session_id) = existing {
|
||||
if let Some(handle) = self.sessions.handle(&session_id) {
|
||||
// (Re)lier le fil à cette session vivante (idempotent).
|
||||
// (Re)lier le fil à cette session vivante (idempotent). Réutilisation
|
||||
// d'une session déjà chaude ⇒ pas de gate de démarrage à froid.
|
||||
self.bind_conversation_session(conversation_id, session_id);
|
||||
return Ok(handle);
|
||||
return Ok((handle, false));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1039,10 +1392,7 @@ impl OrchestratorService {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = self
|
||||
.sessions
|
||||
.session_for_agent(&agent_id)
|
||||
.ok_or_else(|| {
|
||||
let session_id = self.sessions.session_for_agent(&agent_id).ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"agent {target} n'a pas de session terminal vivante après lancement"
|
||||
))
|
||||
@ -1050,8 +1400,83 @@ impl OrchestratorService {
|
||||
// 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"))
|
||||
let handle = self.sessions.handle(&session_id).ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"handle PTY de l'agent {target} introuvable après lancement"
|
||||
))
|
||||
})?;
|
||||
// Lancement à froid : `true` ⇒ l'appelant gatera le premier tour sur le prompt.
|
||||
Ok((handle, true))
|
||||
}
|
||||
|
||||
/// Lot 1b — garantit une **session structurée vivante** pour la cible d'un `ask`.
|
||||
///
|
||||
/// Retourne :
|
||||
/// - `Ok(Some(session))` si la cible a déjà une session vivante, **ou** si son
|
||||
/// profil porte un `structured_adapter` et qu'on vient de la (re)lancer ;
|
||||
/// - `Ok(None)` si la cible n'est **pas** structurée (profil sans `structured_adapter`,
|
||||
/// ou profil introuvable) ⇒ l'appelant retombe sur le chemin PTY `ensure_live_pty`.
|
||||
///
|
||||
/// L'auto-lancement réutilise le **même** [`LaunchAgent`] que le chemin PTV
|
||||
/// ([`Self::ensure_live_pty`]) avec le **même** `mcp_runtime` matérialisé : pour un
|
||||
/// profil structuré, le launcher route §17.4 vers `launch_structured`, démarre la
|
||||
/// session via la fabrique et l'insère dans le registre [`StructuredSessions`]
|
||||
/// **partagé** (le même `Arc` que `self.structured`, câblé au composition root).
|
||||
/// La conf MCP est donc matérialisée comme pour une cellule chat lancée à la main,
|
||||
/// si bien que la cible voit les outils `idea_*` pour répondre.
|
||||
async fn ensure_structured_session(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
profile_id: &ProfileId,
|
||||
structured: &Arc<StructuredSessions>,
|
||||
) -> Result<Option<Arc<dyn domain::ports::AgentSession>>, AppError> {
|
||||
// Cible déjà chaude : route directe (aucun lancement).
|
||||
if let Some(session) = structured.session_for_agent(&agent_id) {
|
||||
return Ok(Some(session));
|
||||
}
|
||||
|
||||
// Cible froide : ne (re)lancer que si le profil sait être piloté en mode
|
||||
// structuré. Sinon (agent PTY/TUI legacy, ou profil introuvable) ⇒ chemin PTY.
|
||||
let is_structured = self
|
||||
.profiles
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|p| &p.id == profile_id)
|
||||
.is_some_and(|p| p.is_selectable());
|
||||
if !is_structured {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Démarrer la session via le launcher (route §17.4 → `launch_structured`,
|
||||
// insère dans CE registre). Mêmes faits MCP que le chemin PTY pour que le pont
|
||||
// `idea_*` de la cible se branche. `conversation_id: None` ⇒ le launcher dérive
|
||||
// l'id de paire (User↔agent) ou réutilise celui de la cellule (P8a), comme pour
|
||||
// un lancement direct utilisateur.
|
||||
self.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
project: project.clone(),
|
||||
agent_id,
|
||||
rows: DEFAULT_ROWS,
|
||||
cols: DEFAULT_COLS,
|
||||
node_id: None,
|
||||
conversation_id: None,
|
||||
mcp_runtime: self
|
||||
.mcp_runtime_provider
|
||||
.as_ref()
|
||||
.and_then(|p| p.runtime_for(project, agent_id)),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Le launcher a inséré la session dans le registre partagé : la relire.
|
||||
structured
|
||||
.session_for_agent(&agent_id)
|
||||
.map(Some)
|
||||
.ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"agent {agent_id} : aucune session structurée vivante après lancement"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@ -1287,8 +1712,6 @@ impl OrchestratorService {
|
||||
profile_id: &ProfileId,
|
||||
target: &str,
|
||||
) -> Result<(), AppError> {
|
||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||
|
||||
let Some(profile) = self
|
||||
.profiles
|
||||
.list()
|
||||
@ -1299,49 +1722,110 @@ impl OrchestratorService {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let honours_mcp_json = matches!(
|
||||
profile.mcp.as_ref().map(|c| &c.config),
|
||||
Some(McpConfigStrategy::ConfigFile { target }) if target == ".mcp.json"
|
||||
);
|
||||
let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude);
|
||||
|
||||
if honours_mcp_json && is_claude {
|
||||
// Source de vérité UNIQUE (domaine) : un profil supporte le pont `idea_*` ssi
|
||||
// IdeA matérialise réellement sa config MCP pour la CLI qu'il pilote — Claude
|
||||
// via `.mcp.json`, Codex via `config.toml`/`CODEX_HOME`. Tout autre couple
|
||||
// (y compris MCP absent) ⇒ repli fichier, pont non branché.
|
||||
if profile.materializes_idea_bridge() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(AppError::Invalid(format!(
|
||||
"la cible '{target}' (profil '{}', adaptateur {:?}) ne supporte pas encore le \
|
||||
pont idea_* : la délégation inter-agents passe par un serveur MCP déclaré en \
|
||||
.mcp.json, que seul un profil Claude consomme aujourd'hui. Cible un agent au \
|
||||
profil Claude.",
|
||||
pont idea_* : la délégation inter-agents passe par un serveur MCP qu'IdeA \
|
||||
matérialise pour la CLI cible, et ce profil ne déclare pas un couple \
|
||||
(adaptateur × stratégie MCP) pris en charge. Cible un agent dont le profil \
|
||||
expose le pont MCP (Claude ou Codex).",
|
||||
profile.name, profile.structured_adapter
|
||||
)))
|
||||
}
|
||||
|
||||
/// Resolves the **prompt-ready pattern** (cadrage §6, lot C5) of the agent's
|
||||
/// profile, used to arm the [`InputMediator`]'s prompt detection at `bind_handle`
|
||||
/// time. Returns `None` when the agent, its profile, or the pattern is absent —
|
||||
/// the safe fallback: no pattern ⇒ Idle only via explicit signal/timeout.
|
||||
async fn prompt_pattern_for_agent(
|
||||
/// Resolves the target agent profile's **prompt-ready pattern** (§6, lot C5) **and**
|
||||
/// its **submit config** (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) in
|
||||
/// a single profile lookup. Both are carried into `bind_handle_with_prompt`: the
|
||||
/// pattern arms prompt detection, the submit config is echoed on the next
|
||||
/// `DelegationReady` so the frontend write-portal knows how to submit. Returns
|
||||
/// `(None, default)` when the agent, its profile, or the field is absent — the safe
|
||||
/// fallback (no pattern ⇒ Idle only via explicit signal/timeout; no submit ⇒ the
|
||||
/// front applies its own `"\r"`/~60 ms default).
|
||||
async fn prompt_and_submit_for_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Option<String> {
|
||||
let agent = self
|
||||
) -> (Option<String>, SubmitConfig, bool) {
|
||||
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(), false);
|
||||
};
|
||||
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(), false);
|
||||
};
|
||||
let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms);
|
||||
// 3e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion
|
||||
// (initialize) servira de signal de readiness de démarrage pour libérer un 1er
|
||||
// tour différé — d'où le gate cold-launch même sans `prompt_ready_pattern`.
|
||||
(profile.prompt_ready_pattern, submit, profile.mcp.is_some())
|
||||
}
|
||||
|
||||
/// Résout la [`domain::profile::LivenessStrategy`] du profil de la cible (lot 2) :
|
||||
/// le seuil de stagnation (`stall_after_ms`) et le timeout de tour
|
||||
/// (`turn_timeout_ms`). `None`/absent ⇒ `(None, None)` : pas de détection de stall et
|
||||
/// repli sur les bornes par défaut codées en dur (zéro régression pour un profil sans
|
||||
/// bloc `liveness`).
|
||||
async fn liveness_for_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> (Option<u32>, Option<u32>) {
|
||||
let Some(agent) = self
|
||||
.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
|
||||
else {
|
||||
return (None, None);
|
||||
};
|
||||
let Some(profile) = self
|
||||
.profiles
|
||||
.list()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
|
||||
else {
|
||||
return (None, None);
|
||||
};
|
||||
match profile.liveness {
|
||||
Some(l) => (l.stall_after_ms, l.turn_timeout_ms),
|
||||
None => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borne de tour effective pour un `ask` : le `turn_timeout_ms` du profil de la cible
|
||||
/// **quand il existe**, sinon le défaut historique [`ASK_AGENT_TIMEOUT`] (lot 2,
|
||||
/// timeouts pilotés par profil). Arme aussi le seuil de stagnation sur le médiateur
|
||||
/// avant l'enqueue (battement/`sweep_stalled`).
|
||||
async fn turn_timeout_for(&self, project: &Project, agent_id: AgentId) -> Duration {
|
||||
let (stall_after_ms, turn_timeout_ms) = self.liveness_for_agent(project, agent_id).await;
|
||||
if let Some(input) = &self.input {
|
||||
input.set_stall_threshold(agent_id, stall_after_ms);
|
||||
}
|
||||
resolve_turn_timeout(turn_timeout_ms)
|
||||
}
|
||||
|
||||
/// Resolves a human-friendly profile reference (slug like `claude-code`,
|
||||
@ -1420,6 +1904,18 @@ mod tests {
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::ProfileId;
|
||||
|
||||
// (c) — timeouts pilotés par profil (lot 2) : `turn_timeout_ms` prime sur le défaut.
|
||||
#[test]
|
||||
fn profile_turn_timeout_overrides_default() {
|
||||
// Seuil de profil défini ⇒ il prime sur ASK_AGENT_TIMEOUT.
|
||||
assert_eq!(
|
||||
resolve_turn_timeout(Some(120_000)),
|
||||
Duration::from_millis(120_000)
|
||||
);
|
||||
// Absent (profil sans liveness / sans turn_timeout_ms) ⇒ repli sur le défaut.
|
||||
assert_eq!(resolve_turn_timeout(None), ASK_AGENT_TIMEOUT);
|
||||
}
|
||||
|
||||
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||
@ -1452,4 +1948,109 @@ mod tests {
|
||||
assert!(by_name);
|
||||
assert_eq!(p.id.to_string(), p.id.to_string());
|
||||
}
|
||||
|
||||
// --- BusyTurnGuard (RAII de fin de tour) -------------------------------
|
||||
//
|
||||
// Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur
|
||||
// qui enregistre les `mark_idle` et un mailbox qui enregistre les `cancel_head`.
|
||||
|
||||
use std::sync::Mutex as TestMutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyMediator {
|
||||
idled: TestMutex<Vec<AgentId>>,
|
||||
}
|
||||
impl domain::input::InputMediator for SpyMediator {
|
||||
fn enqueue(
|
||||
&self,
|
||||
_agent: AgentId,
|
||||
_ticket: domain::mailbox::Ticket,
|
||||
) -> domain::mailbox::PendingReply {
|
||||
domain::mailbox::PendingReply::new(Box::pin(async {
|
||||
Err(domain::mailbox::MailboxError::Cancelled)
|
||||
}))
|
||||
}
|
||||
fn preempt(&self, _agent: AgentId) {}
|
||||
fn mark_idle(&self, agent: AgentId) {
|
||||
self.idled.lock().unwrap().push(agent);
|
||||
}
|
||||
fn busy_state(&self, _agent: AgentId) -> domain::input::AgentBusyState {
|
||||
domain::input::AgentBusyState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyMailbox {
|
||||
cancelled: TestMutex<Vec<(AgentId, TicketId)>>,
|
||||
}
|
||||
impl domain::mailbox::AgentMailbox for SpyMailbox {
|
||||
fn enqueue(
|
||||
&self,
|
||||
_agent: AgentId,
|
||||
_ticket: domain::mailbox::Ticket,
|
||||
) -> domain::mailbox::PendingReply {
|
||||
domain::mailbox::PendingReply::new(Box::pin(async {
|
||||
Err(domain::mailbox::MailboxError::Cancelled)
|
||||
}))
|
||||
}
|
||||
fn resolve(
|
||||
&self,
|
||||
_agent: AgentId,
|
||||
_result: String,
|
||||
) -> Result<(), domain::mailbox::MailboxError> {
|
||||
Ok(())
|
||||
}
|
||||
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) {
|
||||
self.cancelled.lock().unwrap().push((agent, ticket_id));
|
||||
}
|
||||
}
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
fn tid(n: u128) -> TicketId {
|
||||
TicketId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Drop d'un garde **armé** ⇒ `cancel_head` + `mark_idle` sur la cible (c'est le
|
||||
/// comportement qui débloque un agent resté `Busy` sur un futur abandonné).
|
||||
#[test]
|
||||
fn armed_guard_drop_cancels_head_and_marks_idle() {
|
||||
let med = Arc::new(SpyMediator::default());
|
||||
let mb = Arc::new(SpyMailbox::default());
|
||||
{
|
||||
let _g = BusyTurnGuard::new(
|
||||
Arc::clone(&med) as Arc<dyn domain::input::InputMediator>,
|
||||
Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
aid(1),
|
||||
tid(7),
|
||||
);
|
||||
} // Drop ici.
|
||||
assert_eq!(*med.idled.lock().unwrap(), vec![aid(1)], "mark_idle au Drop");
|
||||
assert_eq!(
|
||||
*mb.cancelled.lock().unwrap(),
|
||||
vec![(aid(1), tid(7))],
|
||||
"cancel_head au Drop"
|
||||
);
|
||||
}
|
||||
|
||||
/// Garde **désarmé** (branche succès) ⇒ son Drop est un no-op : pas de `mark_idle`
|
||||
/// parasite, surtout pas de `cancel_head` d'un ticket déjà résolu.
|
||||
#[test]
|
||||
fn disarmed_guard_drop_is_a_noop() {
|
||||
let med = Arc::new(SpyMediator::default());
|
||||
let mb = Arc::new(SpyMailbox::default());
|
||||
let g = BusyTurnGuard::new(
|
||||
Arc::clone(&med) as Arc<dyn domain::input::InputMediator>,
|
||||
Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
aid(1),
|
||||
tid(7),
|
||||
);
|
||||
g.disarm();
|
||||
assert!(med.idled.lock().unwrap().is_empty(), "pas de mark_idle après disarm");
|
||||
assert!(
|
||||
mb.cancelled.lock().unwrap().is_empty(),
|
||||
"pas de cancel_head après disarm"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
150
crates/application/src/permission.rs
Normal file
150
crates/application/src/permission.rs
Normal file
@ -0,0 +1,150 @@
|
||||
//! Permission use cases.
|
||||
//!
|
||||
//! This module stays at the application boundary: it loads the project
|
||||
//! permission document through [`PermissionStore`], applies simple mutations, and
|
||||
//! delegates all merge semantics to the pure domain model.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::PermissionStore;
|
||||
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Reads the full project permission document.
|
||||
pub struct GetProjectPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl GetProjectPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the read.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: GetProjectPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let permissions = self.store.load_permissions(&input.project).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`GetProjectPermissions`].
|
||||
pub struct GetProjectPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
}
|
||||
|
||||
/// Output for [`GetProjectPermissions`].
|
||||
pub struct GetProjectPermissionsOutput {
|
||||
/// Persisted permission document.
|
||||
pub permissions: ProjectPermissions,
|
||||
}
|
||||
|
||||
/// Replaces the project default policy.
|
||||
pub struct UpdateProjectPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl UpdateProjectPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the mutation.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateProjectPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let mut doc = self.store.load_permissions(&input.project).await?;
|
||||
doc.set_project_defaults(input.permissions);
|
||||
self.store.save_permissions(&input.project, &doc).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions: doc })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateProjectPermissions`].
|
||||
pub struct UpdateProjectPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// New project default policy. `None` removes project defaults.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Replaces one agent override.
|
||||
pub struct UpdateAgentPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl UpdateAgentPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the mutation.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateAgentPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let mut doc = self.store.load_permissions(&input.project).await?;
|
||||
doc.set_agent_permissions(input.agent_id, input.permissions);
|
||||
self.store.save_permissions(&input.project, &doc).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions: doc })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateAgentPermissions`].
|
||||
pub struct UpdateAgentPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// Target agent.
|
||||
pub agent_id: AgentId,
|
||||
/// New agent policy. `None` removes the override.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Resolves effective permissions for one agent.
|
||||
pub struct ResolveAgentPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl ResolveAgentPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the resolution.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ResolveAgentPermissionsInput,
|
||||
) -> Result<ResolveAgentPermissionsOutput, AppError> {
|
||||
let doc = self.store.load_permissions(&input.project).await?;
|
||||
Ok(ResolveAgentPermissionsOutput {
|
||||
effective: doc.resolve_for(input.agent_id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ResolveAgentPermissions`].
|
||||
pub struct ResolveAgentPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// Target agent.
|
||||
pub agent_id: AgentId,
|
||||
}
|
||||
|
||||
/// Output for [`ResolveAgentPermissions`].
|
||||
pub struct ResolveAgentPermissionsOutput {
|
||||
/// Resolved policy, or `None` when neither project nor agent policy exists.
|
||||
pub effective: Option<EffectivePermissions>,
|
||||
}
|
||||
@ -90,7 +90,12 @@ impl TerminalSessions {
|
||||
/// per-agent lookup for the orchestrator's ask path.
|
||||
#[must_use]
|
||||
pub fn session_for(&self, conversation: ConversationId) -> Option<SessionId> {
|
||||
let sid = self.conversations.lock().ok()?.get(&conversation).copied()?;
|
||||
let sid = self
|
||||
.conversations
|
||||
.lock()
|
||||
.ok()?
|
||||
.get(&conversation)
|
||||
.copied()?;
|
||||
// Only return it if the session is still live in `entries`.
|
||||
self.entries
|
||||
.lock()
|
||||
|
||||
@ -79,6 +79,7 @@ impl OpenTerminal {
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
sandbox: None,
|
||||
};
|
||||
|
||||
// The PTY layer owns the session identity; we adopt the returned handle's
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -61,7 +61,12 @@ impl InMemoryConversationLog {
|
||||
}
|
||||
|
||||
fn thread(&self, conv: ConversationId) -> Vec<ConversationTurn> {
|
||||
self.threads.lock().unwrap().get(&conv).cloned().unwrap_or_default()
|
||||
self.threads
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&conv)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,11 +161,7 @@ impl HandoffStore for InMemoryHandoffStore {
|
||||
Ok(self.store.lock().unwrap().get(&conversation).cloned())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
if let Some(err) = self.fail_save.lock().unwrap().clone() {
|
||||
return Err(err);
|
||||
}
|
||||
@ -219,7 +220,9 @@ async fn single_record_appends_turn_and_advances_handoff() {
|
||||
let conv = conv_id(1);
|
||||
let t1 = turn(conv, turn_id(10), "hello world");
|
||||
|
||||
uc.record(conv, t1.clone()).await.expect("record should succeed");
|
||||
uc.record(conv, t1.clone())
|
||||
.await
|
||||
.expect("record should succeed");
|
||||
|
||||
// Log contains the turn.
|
||||
assert_eq!(log.thread(conv), vec![t1.clone()]);
|
||||
@ -227,7 +230,11 @@ async fn single_record_appends_turn_and_advances_handoff() {
|
||||
// Handoff advanced: up_to == turn id, summary contains the turn text.
|
||||
let h = handoffs.loaded(conv).expect("handoff should be saved");
|
||||
assert_eq!(h.up_to, t1.id);
|
||||
assert!(h.summary_md.contains("hello world"), "summary={:?}", h.summary_md);
|
||||
assert!(
|
||||
h.summary_md.contains("hello world"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
|
||||
// First fold: prev = None, exactly one new turn.
|
||||
assert_eq!(summarizer.calls(), vec![(false, 1)]);
|
||||
@ -254,8 +261,16 @@ async fn two_records_fold_incrementally_without_full_reread() {
|
||||
// Handoff up_to == t2.id; summary contains both texts.
|
||||
let h = handoffs.loaded(conv).expect("handoff saved");
|
||||
assert_eq!(h.up_to, t2.id);
|
||||
assert!(h.summary_md.contains("first-text"), "summary={:?}", h.summary_md);
|
||||
assert!(h.summary_md.contains("second-text"), "summary={:?}", h.summary_md);
|
||||
assert!(
|
||||
h.summary_md.contains("first-text"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
assert!(
|
||||
h.summary_md.contains("second-text"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
|
||||
// Proof of incrementality: prev None then Some, and len == 1 on BOTH calls
|
||||
// (never 2 → the whole log is never re-read into the fold).
|
||||
@ -311,9 +326,9 @@ async fn load_error_propagates_as_store() {
|
||||
#[tokio::test]
|
||||
async fn save_error_propagates_as_store() {
|
||||
let log = Arc::new(InMemoryConversationLog::default());
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::failing_save(StoreError::Serialization(
|
||||
"save boom".to_owned(),
|
||||
)));
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::failing_save(
|
||||
StoreError::Serialization("save boom".to_owned()),
|
||||
));
|
||||
let summarizer = Arc::new(RecordingSummarizer::default());
|
||||
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
|
||||
|
||||
|
||||
338
crates/application/tests/drain_with_readiness_lot1.rs
Normal file
338
crates/application/tests/drain_with_readiness_lot1.rs
Normal file
@ -0,0 +1,338 @@
|
||||
//! Lot 1 (readiness/heartbeat model-agnostique) — tests unitaires QA **indépendants**
|
||||
//! du helper applicatif `drain_with_readiness`, **100 % fakes**.
|
||||
//!
|
||||
//! Couvre les points 5 et 6 du périmètre QA :
|
||||
//!
|
||||
//! - **Point 5** : un flux se terminant par `Final` appelle `mark_idle(agent)`
|
||||
//! **exactement une fois** ; les `Heartbeat`/deltas/activités intermédiaires
|
||||
//! n'appellent **jamais** `mark_idle`.
|
||||
//! - **Point 6 (le bug d'origine)** : un agent cible qui **ne fait que renvoyer un
|
||||
//! `Final`** (sans jamais appeler `idea_reply`) débloque bien la file via
|
||||
//! `mark_idle` — c'est exactement la cause racine du blocage `Busy`.
|
||||
//!
|
||||
//! On teste au niveau `drain_with_readiness` (le rendez-vous synchrone qu'`ask_agent`
|
||||
//! utilise sur la session structurée d'une cible) avec :
|
||||
//! - un fake `AgentSession` scriptable (calqué sur `send_blocking_d1.rs`) ;
|
||||
//! - un fake `InputMediator` qui **compte** les `mark_idle` par agent et journalise
|
||||
//! l'ordre des appels, sans seconde file ni I/O.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::drain_with_readiness;
|
||||
use domain::ids::AgentId;
|
||||
use domain::input::{AgentBusyState, InputMediator};
|
||||
use domain::mailbox::{PendingReply, Ticket};
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||
use domain::SessionId;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn sid(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake AgentSession scriptable (mono-usage), repris de send_blocking_d1.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum Script {
|
||||
Stream(Vec<ReplyEvent>),
|
||||
Err(AgentSessionError),
|
||||
}
|
||||
|
||||
struct ScriptedSession {
|
||||
id: SessionId,
|
||||
script: Mutex<Option<Script>>,
|
||||
shutdowns: AtomicUsize,
|
||||
}
|
||||
|
||||
impl ScriptedSession {
|
||||
fn new(script: Script) -> Self {
|
||||
Self {
|
||||
id: sid(1),
|
||||
script: Mutex::new(Some(script)),
|
||||
shutdowns: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
fn shutdown_count(&self) -> usize {
|
||||
self.shutdowns.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for ScriptedSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
let script = self
|
||||
.script
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.expect("send scripté une seule fois");
|
||||
match script {
|
||||
Script::Stream(events) => Ok(Box::new(events.into_iter())),
|
||||
Script::Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
self.shutdowns.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake InputMediator : compte les mark_idle par agent + journalise les appels.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct RecordingMediator {
|
||||
/// (agent, "mark_idle") dans l'ordre des appels.
|
||||
calls: Mutex<Vec<(AgentId, &'static str)>>,
|
||||
}
|
||||
|
||||
impl RecordingMediator {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
fn mark_idle_count(&self, agent: AgentId) -> usize {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(a, kind)| *a == agent && *kind == "mark_idle")
|
||||
.count()
|
||||
}
|
||||
fn total_calls(&self) -> usize {
|
||||
self.calls.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl InputMediator for RecordingMediator {
|
||||
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
||||
// Jamais utilisé par drain_with_readiness ; un future qui ne résout pas.
|
||||
PendingReply::new(Box::pin(std::future::pending()))
|
||||
}
|
||||
fn preempt(&self, agent: AgentId) {
|
||||
self.calls.lock().unwrap().push((agent, "preempt"));
|
||||
}
|
||||
fn mark_idle(&self, agent: AgentId) {
|
||||
self.calls.lock().unwrap().push((agent, "mark_idle"));
|
||||
}
|
||||
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
|
||||
AgentBusyState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers d'événements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn delta(t: &str) -> ReplyEvent {
|
||||
ReplyEvent::TextDelta { text: t.to_owned() }
|
||||
}
|
||||
fn tool(l: &str) -> ReplyEvent {
|
||||
ReplyEvent::ToolActivity {
|
||||
label: l.to_owned(),
|
||||
}
|
||||
}
|
||||
fn heartbeat() -> ReplyEvent {
|
||||
ReplyEvent::Heartbeat
|
||||
}
|
||||
fn final_(c: &str) -> ReplyEvent {
|
||||
ReplyEvent::Final {
|
||||
content: c.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Point 6 (LE point qui compte) : un Final SEUL débloque la file via mark_idle,
|
||||
// sans aucun idea_reply.
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_only_stream_unblocks_queue_via_mark_idle() {
|
||||
// L'agent cible ne fait QUE renvoyer un Final (jamais d'idea_reply).
|
||||
let agent = aid(42);
|
||||
let session = ScriptedSession::new(Script::Stream(vec![final_("done")]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "tâche", None, &mediator, agent).await;
|
||||
|
||||
assert_eq!(out, Ok("done".to_owned()), "le Final rend bien son contenu");
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(agent),
|
||||
1,
|
||||
"le Final déterministe DOIT marquer l'agent Idle (fix de la cause racine du blocage Busy)"
|
||||
);
|
||||
// Aucun autre appel parasite, et la session reste vivante (pas de shutdown).
|
||||
assert_eq!(mediator.total_calls(), 1);
|
||||
assert_eq!(session.shutdown_count(), 0);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Point 5 : exactement UN mark_idle ; les events intermédiaires n'en émettent pas.
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn intermediate_events_do_not_mark_idle_only_final_does() {
|
||||
let agent = aid(7);
|
||||
// Heartbeats + deltas + activité PUIS le Final.
|
||||
let session = ScriptedSession::new(Script::Stream(vec![
|
||||
heartbeat(),
|
||||
delta("hel"),
|
||||
tool("lit un fichier"),
|
||||
heartbeat(),
|
||||
delta("lo"),
|
||||
final_("hello"),
|
||||
]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
|
||||
assert_eq!(out, Ok("hello".to_owned()));
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(agent),
|
||||
1,
|
||||
"mark_idle exactement UNE fois (au Final), jamais sur heartbeat/delta/activité"
|
||||
);
|
||||
assert_eq!(
|
||||
mediator.total_calls(),
|
||||
1,
|
||||
"aucun appel mediator hormis l'unique mark_idle"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heartbeats_alone_never_mark_idle_before_final() {
|
||||
// Que des heartbeats avant le Final : aucun ne doit marquer Idle.
|
||||
let agent = aid(9);
|
||||
let session = ScriptedSession::new(Script::Stream(vec![
|
||||
heartbeat(),
|
||||
heartbeat(),
|
||||
heartbeat(),
|
||||
final_("fini"),
|
||||
]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
assert_eq!(out, Ok("fini".to_owned()));
|
||||
assert_eq!(mediator.mark_idle_count(agent), 1);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Bords : pas de Final ⇒ pas de mark_idle ; erreur de send propagée ; mauvais agent.
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_without_final_does_not_mark_idle_and_is_io_error() {
|
||||
let agent = aid(3);
|
||||
let session = ScriptedSession::new(Script::Stream(vec![heartbeat(), delta("a"), tool("b")]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
assert!(
|
||||
matches!(out, Err(AgentSessionError::Io(_))),
|
||||
"flux épuisé sans Final ⇒ Io, obtenu {out:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(agent),
|
||||
0,
|
||||
"sans Final, on ne marque JAMAIS Idle (jamais de faux Idle)"
|
||||
);
|
||||
assert_eq!(mediator.total_calls(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_error_is_propagated_and_no_mark_idle() {
|
||||
let agent = aid(5);
|
||||
let session =
|
||||
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
assert_eq!(mediator.mark_idle_count(agent), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_idle_targets_the_drained_agent_only() {
|
||||
// Le mark_idle doit porter sur l'agent passé à drain_with_readiness, pas un autre.
|
||||
let drained = aid(100);
|
||||
let other = aid(200);
|
||||
let session = ScriptedSession::new(Script::Stream(vec![final_("ok")]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let _ = drain_with_readiness(&session, "x", None, &mediator, drained).await;
|
||||
assert_eq!(mediator.mark_idle_count(drained), 1);
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(other),
|
||||
0,
|
||||
"mark_idle ne doit cibler que l'agent drainé"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Timeout (garde-fou du tour) : Timeout renvoyé, pas de mark_idle, session vivante.
|
||||
// (Le helper borne via tokio::time::timeout ; on force l'expiration avec un Final
|
||||
// reporté — réutilise un fake retardé minimal local.)
|
||||
// ===========================================================================
|
||||
|
||||
struct DelayedSession {
|
||||
delay: Duration,
|
||||
shutdowns: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for DelayedSession {
|
||||
fn id(&self) -> SessionId {
|
||||
sid(2)
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
tokio::time::sleep(self.delay).await;
|
||||
Ok(Box::new(vec![final_("too late")].into_iter()))
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
self.shutdowns.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_returns_timeout_no_mark_idle_session_alive() {
|
||||
let agent = aid(11);
|
||||
let session = DelayedSession {
|
||||
delay: Duration::from_secs(1),
|
||||
shutdowns: AtomicUsize::new(0),
|
||||
};
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out =
|
||||
drain_with_readiness(&session, "x", Some(Duration::from_millis(20)), &mediator, agent).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Timeout));
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(agent),
|
||||
0,
|
||||
"un timeout ne marque pas Idle (le tour n'a pas rendu son Final)"
|
||||
);
|
||||
assert_eq!(
|
||||
session.shutdowns.load(Ordering::SeqCst),
|
||||
0,
|
||||
"timeout ne tue PAS la session (§17.1)"
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -270,6 +270,7 @@ impl AgentRuntime for FakeRuntime {
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: Some(ContextInjectionPlan::Stdin),
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -343,10 +344,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 +381,6 @@ impl EventBus for SpyBus {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl SeqIds {
|
||||
fn new() -> Self {
|
||||
@ -655,13 +655,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 +790,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 +831,11 @@ impl TestMailbox {
|
||||
}
|
||||
}
|
||||
fn pending(&self, agent: &AgentId) -> usize {
|
||||
self.queues.lock().unwrap().get(agent).map_or(0, VecDeque::len)
|
||||
self.queues
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(agent)
|
||||
.map_or(0, VecDeque::len)
|
||||
}
|
||||
/// Ids of the tickets queued for `agent`, in FIFO order (test inspection).
|
||||
fn ticket_ids(&self, agent: &AgentId) -> Vec<TicketId> {
|
||||
@ -953,7 +962,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 +1016,9 @@ impl ConversationRegistry for TestConversations {
|
||||
}
|
||||
fn bind_session(&self, id: ConversationId, session: SessionRef) {
|
||||
if let Some(c) = self.by_id.lock().unwrap().get_mut(&id) {
|
||||
c.session = ConversationSession::Live { handle_ref: session };
|
||||
c.session = ConversationSession::Live {
|
||||
handle_ref: session,
|
||||
};
|
||||
}
|
||||
}
|
||||
fn suspend(&self, id: ConversationId, resumable_id: Option<String>) {
|
||||
@ -1131,8 +1145,7 @@ async fn await_until<F: Fn() -> bool>(cond: F) {
|
||||
.expect("condition never reached within guard (possible hang/deadlock)");
|
||||
}
|
||||
|
||||
const ASK_JSON: &str =
|
||||
r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
|
||||
const ASK_JSON: &str = r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
|
||||
|
||||
/// Builds an `agent.reply` command for `from` with `result` (the handshake-injected
|
||||
/// path, exactly what the MCP server produces from a peer's `idea_reply`).
|
||||
@ -1171,12 +1184,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
|
||||
@ -1230,6 +1256,124 @@ async fn idea_reply_marks_emitter_idle() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Régression (garde RAII de fin de tour) — LE test décisif : un `ask_agent` dont le
|
||||
/// **futur est abandonné (drop)** alors qu'il attendait la réponse doit laisser la cible
|
||||
/// `Idle`, **pas** `Busy` à vie. Avant le fix, aucune branche du `match`/`select!` ne
|
||||
/// s'exécutait sur un drop ⇒ l'agent restait `Busy` et toute délégation suivante était
|
||||
/// mise en file derrière ce tour fantôme sans jamais être livrée.
|
||||
#[tokio::test]
|
||||
async fn dropped_ask_future_frees_busy_target() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
// Le tour a démarré : la cible est Busy, un ticket est en file.
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
assert!(
|
||||
fx.mediator.busy_state(aid(1)).is_busy(),
|
||||
"cible Busy pendant le tour délégué"
|
||||
);
|
||||
|
||||
// Le demandeur interrompt son appel ⇒ le futur `ask_agent` est dropped.
|
||||
ask.abort();
|
||||
let _ = ask.await; // récolte la JoinError(Cancelled), ignorée.
|
||||
|
||||
// Le garde RAII a ramené la cible Idle ET retiré le ticket fantôme de la FIFO.
|
||||
await_until(|| !fx.mediator.busy_state(aid(1)).is_busy()).await;
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
AgentBusyState::Idle,
|
||||
"futur dropped ⇒ la cible est ramenée Idle par le garde (fix cause racine)"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.mailbox.pending(&aid(1)),
|
||||
0,
|
||||
"ticket fantôme retiré de la FIFO au Drop du garde"
|
||||
);
|
||||
}
|
||||
|
||||
/// Régression (garde RAII) — une **2e délégation** vers une cible dont un 1er `ask` a
|
||||
/// été abandonné est bien livrée : l'agent n'est pas coincé `Busy`, son tour suivant
|
||||
/// démarre et peut être résolu par `idea_reply`.
|
||||
#[tokio::test]
|
||||
async fn second_delegation_delivered_after_dropped_ask() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
// 1er ask : démarre puis est abandonné (drop).
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask1 = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
ask1.abort();
|
||||
let _ = ask1.await;
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 0).await;
|
||||
|
||||
// 2e ask vers la MÊME cible : doit démarrer un nouveau tour (pas coincé derrière un
|
||||
// tour fantôme) et se laisser résoudre.
|
||||
let svc2 = Arc::clone(&fx.service);
|
||||
let ask2 = tokio::spawn(async move { svc2.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
assert!(
|
||||
fx.mediator.busy_state(aid(1)).is_busy(),
|
||||
"le 2e tour démarre bien (cible Busy) — preuve qu'elle n'était pas coincée"
|
||||
);
|
||||
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(1), "réponse au 2e tour"))
|
||||
.await
|
||||
.expect("reply ok");
|
||||
let out = timeout(TEST_GUARD, ask2)
|
||||
.await
|
||||
.expect("le 2e ask se termine")
|
||||
.expect("join ok")
|
||||
.expect("ask ok");
|
||||
assert_eq!(out.reply.as_deref(), Some("réponse au 2e tour"));
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
AgentBusyState::Idle,
|
||||
"cible Idle après résolution du 2e tour"
|
||||
);
|
||||
}
|
||||
|
||||
/// Régression (garde RAII) — la **branche erreur/timeout** (canal fermé) ramène aussi la
|
||||
/// cible `Idle`. Avant le fix, ces branches faisaient `cancel_head` mais jamais
|
||||
/// `mark_idle` ⇒ l'agent restait `Busy`. On déclenche la fermeture du canal en retirant
|
||||
/// le ticket de tête (même nettoyage que le timeout de tour).
|
||||
#[tokio::test]
|
||||
async fn cancelled_ask_marks_target_idle() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
assert!(fx.mediator.busy_state(aid(1)).is_busy());
|
||||
|
||||
// Retire le ticket de tête (drop du sender) ⇒ l'ask voit un canal fermé et part en
|
||||
// erreur typée (PROCESS), le MÊME nettoyage que le timeout de tour.
|
||||
let t = fx.mailbox.ticket_ids(&aid(1))[0];
|
||||
fx.mailbox.cancel_head(aid(1), t);
|
||||
let err = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask retourne vite sur canal fermé")
|
||||
.expect("join ok")
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err.code(), "PROCESS" | "INVALID"),
|
||||
"ask sur canal fermé ⇒ erreur typée : {err:?}"
|
||||
);
|
||||
// Le garde a ramené la cible Idle (la FIFO peut avancer).
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
AgentBusyState::Idle,
|
||||
"branche erreur ⇒ cible Idle (garde RAII)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A dead target is launched in the background (PTY) before the task is written.
|
||||
#[tokio::test]
|
||||
async fn ask_dead_target_launches_pty_then_writes_and_replies() {
|
||||
@ -1242,7 +1386,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 +1437,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 +1509,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 +1572,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 +1657,10 @@ impl FileSystem for CapturingFs {
|
||||
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((path.as_str().to_owned(), String::from_utf8_lossy(data).into_owned()));
|
||||
self.0.lock().unwrap().push((
|
||||
path.as_str().to_owned(),
|
||||
String::from_utf8_lossy(data).into_owned(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
||||
@ -1640,8 +1799,7 @@ fn ask_fixture_ex(
|
||||
.with_events(Arc::new(bus.clone()));
|
||||
|
||||
if let Some(p) = provider {
|
||||
service =
|
||||
service.with_mcp_runtime_provider(p as Arc<dyn application::McpRuntimeProvider>);
|
||||
service = service.with_mcp_runtime_provider(p as Arc<dyn application::McpRuntimeProvider>);
|
||||
}
|
||||
|
||||
AskFixtureEx {
|
||||
@ -1677,7 +1835,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 +1942,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 +2025,10 @@ fn ask_fixture_c3(contexts: FakeContexts) -> AskFixtureC3 {
|
||||
None,
|
||||
));
|
||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||
let close = Arc::new(CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)));
|
||||
let close = Arc::new(CloseTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
));
|
||||
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
|
||||
let create_skill = Arc::new(CreateSkill::new(
|
||||
Arc::new(RecordingSkills::default()) as Arc<dyn SkillStore>,
|
||||
@ -1898,8 +2070,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 +2096,19 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "do B")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "do B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
|
||||
// The registry now holds the A↔B thread (agent 1 ↔ agent 2), not User↔B.
|
||||
let a_to_b = fx
|
||||
.conversations
|
||||
.resolve(ConversationParty::agent(aid(1)), ConversationParty::agent(aid(2)));
|
||||
let a_to_b = fx.conversations.resolve(
|
||||
ConversationParty::agent(aid(1)),
|
||||
ConversationParty::agent(aid(2)),
|
||||
);
|
||||
let user_b = fx
|
||||
.conversations
|
||||
.resolve(ConversationParty::User, ConversationParty::agent(aid(2)));
|
||||
@ -1941,7 +2121,10 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
|
||||
"ask A→B resolved the A↔B pair"
|
||||
);
|
||||
// The live B session is bound to the A↔B thread (session keyed by conversation).
|
||||
assert!(a_to_b.session.is_live(), "A↔B thread bound to a live session");
|
||||
assert!(
|
||||
a_to_b.session.is_live(),
|
||||
"A↔B thread bound to a live session"
|
||||
);
|
||||
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(2), "B done"))
|
||||
@ -1964,7 +2147,10 @@ async fn cycle_a_to_b_to_a_is_refused_before_deadlock() {
|
||||
// A→B in flight (held — B never replies during the test): edge A→B is posted.
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let _ask_ab = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
@ -1972,13 +2158,19 @@ async fn cycle_a_to_b_to_a_is_refused_before_deadlock() {
|
||||
// Now B→A would close the cycle ⇒ typed INVALID, no enqueue on A, returns fast.
|
||||
let err = timeout(
|
||||
TEST_GUARD,
|
||||
fx.service
|
||||
.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "back to A"))),
|
||||
fx.service.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(2), "alpha", "back to A")),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("cycle ask must return fast, never deadlock")
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "re-entrant cycle is typed INVALID: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"re-entrant cycle is typed INVALID: {err:?}"
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("cycle") || err.to_string().contains("ré-entrante"),
|
||||
"explicit cycle message: {err}"
|
||||
@ -1999,7 +2191,10 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
|
||||
// A→B completes.
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask_ab = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
@ -2012,7 +2207,10 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
|
||||
// Edge A→B is now gone ⇒ B→A is allowed (would only cycle if A→B still pending).
|
||||
let svc2 = Arc::clone(&fx.service);
|
||||
let ask_ba = tokio::spawn(async move {
|
||||
svc2.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")))
|
||||
svc2.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
@ -2021,7 +2219,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 +2266,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 +2335,11 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() {
|
||||
"closed-channel ask is a typed error: {err:?}"
|
||||
);
|
||||
// Queue freed, and the target B is still live (never killed by the failed ask).
|
||||
assert_eq!(fx.mailbox.pending(&aid(2)), 0, "queue freed after retirement");
|
||||
assert_eq!(
|
||||
fx.mailbox.pending(&aid(2)),
|
||||
0,
|
||||
"queue freed after retirement"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.sessions.session_for_agent(&aid(2)),
|
||||
Some(sid(802)),
|
||||
@ -2232,7 +2442,10 @@ async fn submit_and_ask_share_one_fifo_per_agent() {
|
||||
// A delegation A→B blocks in B's FIFO (it awaits a reply).
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(10), "architect", "deleg")))
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(10), "architect", "deleg")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
@ -2256,7 +2469,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 +2489,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 +2514,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 +2536,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 +2580,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 +2614,7 @@ impl HandoffStore for NoopHandoffStore {
|
||||
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().get(&conversation).cloned())
|
||||
}
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().insert(conversation, handoff);
|
||||
Ok(())
|
||||
}
|
||||
@ -2419,7 +2630,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 +2778,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 +2793,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 +2835,11 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
|
||||
Arc::new(ConcatSummarizer) as Arc<dyn HandoffSummarizer>,
|
||||
));
|
||||
let provider = Arc::new(SharedRecordProvider(record)) as Arc<dyn RecordTurnProvider>;
|
||||
let fx =
|
||||
ask_fixture_with_record(FakeContexts::with_agent(&architect, "# persona"), provider, 0);
|
||||
let fx = ask_fixture_with_record(
|
||||
FakeContexts::with_agent(&architect, "# persona"),
|
||||
provider,
|
||||
0,
|
||||
);
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
// Requester is agent A (id 7). The orchestrator threads `requester` from the
|
||||
@ -2625,7 +2860,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 +2893,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 +2910,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 +2939,262 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lot 1b — auto-lancement d'une cible **froide** structurée sur le chemin `ask`
|
||||
// ===========================================================================
|
||||
//
|
||||
// Avant le Lot 1b, `ask_agent` ne routait vers `ask_structured` que si la cible avait
|
||||
// **déjà** une session structurée vivante ; une cible structurée froide tombait dans
|
||||
// `ensure_live_pty` (chemin PTY) — or une cible structurée n'a pas de PTY, donc le tour
|
||||
// restait bloqué `Busy` (débloquable seulement par un `idea_reply` explicite). Le Lot 1b
|
||||
// démarre la session structurée via le **même** launcher que le chemin PTY (qui route
|
||||
// §17.4 vers `launch_structured` et l'insère dans le registre **partagé**), puis route
|
||||
// vers `ask_structured` : le `Final` déterministe de la session débloque le tour et
|
||||
// marque l'agent `Idle` (`mark_idle`), **sans** `idea_reply` ni PTY.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use application::StructuredSessions;
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, AgentSessionFactory, ReplyEvent, ReplyStream,
|
||||
};
|
||||
|
||||
/// Session structurée fake : `send()` rend un unique [`ReplyEvent::Final`] qui **écho**
|
||||
/// le prompt reçu — c'est ce `Final` que `drain_with_readiness` transforme en réponse
|
||||
/// du tour **et** en `mark_idle`. Aucun `idea_reply` n'est nécessaire.
|
||||
struct EchoSession {
|
||||
id: SessionId,
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentSession for EchoSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
let stream: ReplyStream = Box::new(
|
||||
vec![ReplyEvent::Final {
|
||||
content: format!("structured: {prompt}"),
|
||||
}]
|
||||
.into_iter(),
|
||||
);
|
||||
Ok(stream)
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fabrique fake : `supports` = profil structuré, et `start` **compte** les démarrages
|
||||
/// (preuve qu'une session froide a bien été auto-lancée) en rendant une [`EchoSession`].
|
||||
#[derive(Clone, Default)]
|
||||
struct CountingFactory {
|
||||
starts: Arc<AtomicUsize>,
|
||||
next_id: Arc<Mutex<u128>>,
|
||||
}
|
||||
impl CountingFactory {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
starts: Arc::new(AtomicUsize::new(0)),
|
||||
next_id: Arc::new(Mutex::new(9000)),
|
||||
}
|
||||
}
|
||||
fn start_count(&self) -> usize {
|
||||
self.starts.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentSessionFactory for CountingFactory {
|
||||
fn supports(&self, profile: &AgentProfile) -> bool {
|
||||
profile.structured_adapter.is_some()
|
||||
}
|
||||
async fn start(
|
||||
&self,
|
||||
_profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
_session: &SessionPlan,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||
let id = {
|
||||
let mut n = self.next_id.lock().unwrap();
|
||||
let id = SessionId::from_uuid(Uuid::from_u128(*n));
|
||||
*n += 1;
|
||||
id
|
||||
};
|
||||
Ok(Arc::new(EchoSession { id }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tout le câblage `ask` **structuré** : launcher + service voient le **même** registre
|
||||
/// `StructuredSessions`, et le launcher porte la fabrique (routage §17.4).
|
||||
struct StructuredAskFixture {
|
||||
service: Arc<OrchestratorService>,
|
||||
structured: Arc<StructuredSessions>,
|
||||
factory: CountingFactory,
|
||||
pty: FakePty,
|
||||
}
|
||||
|
||||
fn structured_ask_fixture(contexts: FakeContexts) -> StructuredAskFixture {
|
||||
let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()]));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let pty = FakePty::new(sid(777));
|
||||
let bus = SpyBus::default();
|
||||
let mailbox = Arc::new(TestMailbox::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let factory = CountingFactory::new();
|
||||
|
||||
let create = Arc::new(CreateAgentFromScratch::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(bus.clone()),
|
||||
));
|
||||
// Launcher câblé **structuré** : pour un profil à `structured_adapter`, `execute`
|
||||
// route §17.4 → `launch_structured`, démarre la session via la fabrique et l'insère
|
||||
// dans CE registre partagé (le même `Arc` que le service ci-dessous).
|
||||
let launch = Arc::new(
|
||||
LaunchAgent::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
Arc::new(FakeRuntime),
|
||||
Arc::new(FakeFs),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(FakeSkills),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall),
|
||||
None,
|
||||
)
|
||||
.with_structured(
|
||||
Arc::new(factory.clone()) as Arc<dyn AgentSessionFactory>,
|
||||
Arc::clone(&structured),
|
||||
),
|
||||
);
|
||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||
let close = Arc::new(CloseTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
));
|
||||
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
|
||||
let create_skill = Arc::new(CreateSkill::new(
|
||||
Arc::new(RecordingSkills::default()) as Arc<dyn SkillStore>,
|
||||
Arc::new(SeqIds::new()),
|
||||
));
|
||||
let mediator = Arc::new(TestMediator::new(
|
||||
Arc::clone(&mailbox),
|
||||
Arc::new(pty.clone()) as Arc<dyn PtyPort>,
|
||||
));
|
||||
let conversations = Arc::new(TestConversations::new()) as Arc<dyn ConversationRegistry>;
|
||||
|
||||
let service = Arc::new(
|
||||
OrchestratorService::new(
|
||||
create,
|
||||
launch,
|
||||
list,
|
||||
close,
|
||||
update,
|
||||
create_skill,
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
Arc::clone(&sessions),
|
||||
)
|
||||
.with_input_mediator(
|
||||
Arc::clone(&mediator) as Arc<dyn InputMediator>,
|
||||
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
|
||||
)
|
||||
.with_conversations(conversations)
|
||||
.with_events(Arc::new(bus.clone()))
|
||||
// Même registre que le launcher : `ask_agent` y relit la session fraîchement
|
||||
// insérée par `launch_structured`.
|
||||
.with_structured(Arc::clone(&structured)),
|
||||
);
|
||||
|
||||
StructuredAskFixture {
|
||||
service,
|
||||
structured,
|
||||
factory,
|
||||
pty,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cible **froide** structurée (adaptateur Claude, aucune session vivante) : `ask_agent`
|
||||
/// auto-lance sa session structurée via le launcher (factory.start appelé **une** fois),
|
||||
/// puis la draine — le `Final` débloque le round-trip **sans** `idea_reply` ni PTY.
|
||||
#[tokio::test]
|
||||
async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
// Pré-condition : aucune session structurée vivante pour la cible (elle est froide).
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_none(),
|
||||
"cible structurée froide : aucune session avant l'ask"
|
||||
);
|
||||
|
||||
let out = fx
|
||||
.service
|
||||
.dispatch(&project(), cmd(ASK_JSON))
|
||||
.await
|
||||
.expect("ask ok");
|
||||
|
||||
// Le `Final` de la session a rendu le contenu — sans aucun `idea_reply` dispatché.
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("structured: Analyse §17"),
|
||||
"le Final de la session auto-lancée débloque le tour"
|
||||
);
|
||||
// Exactement **un** démarrage de session structurée (la cible froide a été lancée).
|
||||
assert_eq!(
|
||||
fx.factory.start_count(),
|
||||
1,
|
||||
"une session structurée a été auto-lancée pour la cible froide"
|
||||
);
|
||||
// La session est désormais vivante dans le registre partagé (insérée par le launcher).
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_some(),
|
||||
"la session auto-lancée est enregistrée dans StructuredSessions"
|
||||
);
|
||||
// Chemin structuré ⇒ **aucun** PTY spawné (la cible n'a pas de terminal).
|
||||
assert!(
|
||||
fx.pty.spawns().is_empty(),
|
||||
"cible structurée : aucun PTY spawné (chemin chat, pas terminal)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Réutilisation : une **deuxième** délégation vers la **même** cible (désormais chaude)
|
||||
/// ne relance **pas** de session — elle réutilise celle insérée au premier `ask`. Prouve
|
||||
/// que le `session_for_agent` court-circuite l'auto-lancement (idempotence du chemin).
|
||||
#[tokio::test]
|
||||
async fn ask_warm_structured_target_reuses_session_without_relaunch() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
// 1er ask : auto-lance (start_count == 1).
|
||||
let _ = fx
|
||||
.service
|
||||
.dispatch(&project(), cmd(ASK_JSON))
|
||||
.await
|
||||
.expect("first ask ok");
|
||||
assert_eq!(fx.factory.start_count(), 1, "1er ask auto-lance la session");
|
||||
|
||||
// 2e ask : cible chaude ⇒ aucune relance.
|
||||
let out = fx
|
||||
.service
|
||||
.dispatch(&project(), cmd(ASK_JSON))
|
||||
.await
|
||||
.expect("second ask ok");
|
||||
assert_eq!(out.reply.as_deref(), Some("structured: Analyse §17"));
|
||||
assert_eq!(
|
||||
fx.factory.start_count(),
|
||||
1,
|
||||
"cible chaude : la session est réutilisée, aucune relance"
|
||||
);
|
||||
}
|
||||
|
||||
128
crates/application/tests/permission_usecases.rs
Normal file
128
crates/application/tests/permission_usecases.rs
Normal file
@ -0,0 +1,128 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
ResolveAgentPermissions, ResolveAgentPermissionsInput, UpdateAgentPermissions,
|
||||
UpdateAgentPermissionsInput, UpdateProjectPermissions, UpdateProjectPermissionsInput,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ids::{AgentId, ProjectId};
|
||||
use domain::ports::{PermissionStore, StoreError};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{PermissionSet, Posture, ProjectPermissions};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakePermissionStore {
|
||||
doc: Mutex<ProjectPermissions>,
|
||||
saves: Mutex<usize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PermissionStore for FakePermissionStore {
|
||||
async fn load_permissions(&self, _project: &Project) -> Result<ProjectPermissions, StoreError> {
|
||||
Ok(self.doc.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save_permissions(
|
||||
&self,
|
||||
_project: &Project,
|
||||
permissions: &ProjectPermissions,
|
||||
) -> Result<(), StoreError> {
|
||||
*self.doc.lock().unwrap() = permissions.clone();
|
||||
*self.saves.lock().unwrap() += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::new_random(),
|
||||
"permissions",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_permissions_replaces_defaults_and_persists() {
|
||||
let store = Arc::new(FakePermissionStore::default());
|
||||
let use_case = UpdateProjectPermissions::new(store.clone());
|
||||
|
||||
let out = use_case
|
||||
.execute(UpdateProjectPermissionsInput {
|
||||
project: project(),
|
||||
permissions: Some(PermissionSet::new(vec![], Posture::Ask)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
out.permissions.project_defaults.unwrap().fallback(),
|
||||
Posture::Ask
|
||||
);
|
||||
assert_eq!(*store.saves.lock().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_permissions_adds_and_removes_sparse_override() {
|
||||
let store = Arc::new(FakePermissionStore::default());
|
||||
let use_case = UpdateAgentPermissions::new(store.clone());
|
||||
let agent = AgentId::new_random();
|
||||
|
||||
use_case
|
||||
.execute(UpdateAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
permissions: Some(PermissionSet::new(vec![], Posture::Deny)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.doc
|
||||
.lock()
|
||||
.unwrap()
|
||||
.agent_permissions(agent)
|
||||
.unwrap()
|
||||
.fallback(),
|
||||
Posture::Deny
|
||||
);
|
||||
|
||||
let out = use_case
|
||||
.execute(UpdateAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
permissions: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(out.permissions.agent_permissions(agent).is_none());
|
||||
assert_eq!(*store.saves.lock().unwrap(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_agent_permissions_returns_effective_policy() {
|
||||
let agent = AgentId::new_random();
|
||||
let store = Arc::new(FakePermissionStore {
|
||||
doc: Mutex::new(ProjectPermissions::new(
|
||||
Some(PermissionSet::new(vec![], Posture::Ask)),
|
||||
vec![],
|
||||
)),
|
||||
saves: Mutex::new(0),
|
||||
});
|
||||
let use_case = ResolveAgentPermissions::new(store);
|
||||
|
||||
let out = use_case
|
||||
.execute(ResolveAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.effective.unwrap().fallback(), Posture::Ask);
|
||||
}
|
||||
@ -218,16 +218,26 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
|
||||
assert!(out.is_first_run);
|
||||
// §17.3/D7: the wizard is seeded only with the *selectable* (structured-
|
||||
// drivable) profiles — Claude + Codex — not the full 4-profile catalogue.
|
||||
assert_eq!(out.reference_profiles.len(), 2, "selectable catalogue seeded");
|
||||
assert_eq!(
|
||||
out.reference_profiles.len(),
|
||||
2,
|
||||
"selectable catalogue seeded"
|
||||
);
|
||||
let commands: Vec<&str> = out
|
||||
.reference_profiles
|
||||
.iter()
|
||||
.map(|p| p.command.as_str())
|
||||
.collect();
|
||||
assert_eq!(commands, vec!["claude", "codex"], "only Claude/Codex offered");
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec!["claude", "codex"],
|
||||
"only Claude/Codex offered"
|
||||
);
|
||||
// Every seeded profile is selectable (the gate the menu relies on).
|
||||
assert!(
|
||||
out.reference_profiles.iter().all(AgentProfile::is_selectable),
|
||||
out.reference_profiles
|
||||
.iter()
|
||||
.all(AgentProfile::is_selectable),
|
||||
"seeded profiles must all be selectable"
|
||||
);
|
||||
}
|
||||
|
||||
@ -99,10 +99,14 @@ fn delta(t: &str) -> ReplyEvent {
|
||||
ReplyEvent::TextDelta { text: t.to_owned() }
|
||||
}
|
||||
fn tool(l: &str) -> ReplyEvent {
|
||||
ReplyEvent::ToolActivity { label: l.to_owned() }
|
||||
ReplyEvent::ToolActivity {
|
||||
label: l.to_owned(),
|
||||
}
|
||||
}
|
||||
fn final_(c: &str) -> ReplyEvent {
|
||||
ReplyEvent::Final { content: c.to_owned() }
|
||||
ReplyEvent::Final {
|
||||
content: c.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -174,8 +178,9 @@ async fn empty_stream_is_io_error() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_decode_error_is_propagated() {
|
||||
let session =
|
||||
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
let session = ScriptedSession::new(Script::Err(AgentSessionError::Decode(
|
||||
"bad json".to_owned(),
|
||||
)));
|
||||
let out = send_blocking(&session, "x", None).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
}
|
||||
|
||||
@ -303,6 +303,7 @@ impl AgentRuntime for FakeRuntime {
|
||||
context_plan: Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -538,6 +539,96 @@ impl AgentSessionFactory for FakeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeProviderSessionStore + FakeProviderSessionProvider (lot P8b)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::ProviderSessionProvider;
|
||||
use domain::{ConversationId, ProviderSessionStore};
|
||||
|
||||
/// In-memory [`ProviderSessionStore`] for the P8b launch tests: a
|
||||
/// `(conversation, provider_id) → resumable_id` map, observable after the launch.
|
||||
/// `set` can be made to fail (best-effort scenario) so we can prove a write error
|
||||
/// never degrades the launch.
|
||||
#[derive(Clone, Default)]
|
||||
struct FakeProviderSessionStore {
|
||||
entries: Arc<Mutex<HashMap<(ConversationId, String), String>>>,
|
||||
fail_set: bool,
|
||||
}
|
||||
|
||||
impl FakeProviderSessionStore {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
/// A store whose `set` always errors (best-effort proof).
|
||||
fn failing() -> Self {
|
||||
Self {
|
||||
entries: Arc::new(Mutex::new(HashMap::new())),
|
||||
fail_set: true,
|
||||
}
|
||||
}
|
||||
/// Pre-seeds a `(conversation, provider) → resumable_id` mapping synchronously,
|
||||
/// 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());
|
||||
}
|
||||
/// Observed resumable id for a `(conversation, provider)` couple, if any.
|
||||
fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option<String> {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(conversation, provider.to_owned()))
|
||||
.cloned()
|
||||
}
|
||||
fn len(&self) -> usize {
|
||||
self.entries.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderSessionStore for FakeProviderSessionStore {
|
||||
async fn get(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
provider_id: &str,
|
||||
) -> Result<Option<String>, StoreError> {
|
||||
Ok(self.get_sync(conversation, provider_id))
|
||||
}
|
||||
async fn set(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
provider_id: &str,
|
||||
resumable_id: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
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(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ProviderSessionProvider`] that hands back the same store for any root (the
|
||||
/// launch tests use a single project root).
|
||||
#[derive(Clone)]
|
||||
struct FakeProviderSessionProvider(Arc<FakeProviderSessionStore>);
|
||||
|
||||
impl ProviderSessionProvider for FakeProviderSessionProvider {
|
||||
fn provider_session_store_for(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
) -> Option<Arc<dyn ProviderSessionStore>> {
|
||||
Some(Arc::clone(&self.0) as Arc<dyn ProviderSessionStore>)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -772,14 +863,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));
|
||||
}
|
||||
|
||||
@ -821,7 +919,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),
|
||||
@ -883,7 +985,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");
|
||||
@ -1035,7 +1141,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!(
|
||||
@ -1047,12 +1157,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)));
|
||||
}
|
||||
@ -1079,12 +1202,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)));
|
||||
@ -1170,3 +1300,445 @@ async fn structured_profile_fresh_cell_resolves_to_none() {
|
||||
"fresh structured cell without a session block plans None"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// LOT P8b — écriture de `providers.json` au lancement structuré
|
||||
//
|
||||
// Prouve `persist_provider_session` (best-effort) : après un lancement structuré
|
||||
// exposant un id de session moteur, IdeA range `(pair, provider_key) →
|
||||
// engine_session_id` via le `ProviderSessionStore` câblé. Toutes les conditions de
|
||||
// skip (provider absent, id moteur absent) et la robustesse (set en échec) sont
|
||||
// couvertes — le lancement réussit toujours.
|
||||
// ===========================================================================
|
||||
|
||||
/// Profil **structuré Codex** (porte `StructuredAdapter::Codex`) pour prouver la clé
|
||||
/// provider `"codex"`.
|
||||
fn structured_codex_profile(id: ProfileId) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
id,
|
||||
"Codex Structuré",
|
||||
"codex",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("AGENTS.md").unwrap(),
|
||||
Some("codex --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
}
|
||||
|
||||
/// Builds a `LaunchAgent.with_structured(...).with_provider_session_provider(...)`
|
||||
/// over the given profile/factory, returning the launcher, the agent and the
|
||||
/// observable provider store. When `provider` is `None`, no provider is wired
|
||||
/// (legacy path).
|
||||
fn launch_fixture_p8b(
|
||||
profile: AgentProfile,
|
||||
factory: FakeFactory,
|
||||
store: Option<Arc<FakeProviderSessionStore>>,
|
||||
) -> (Arc<LaunchAgent>, Agent) {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let runtime = FakeRuntime::new();
|
||||
let fs = FakeFs::default();
|
||||
let pty = FakePty::new(sid(777));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let bus = SpyBus::default();
|
||||
let mut launch = LaunchAgent::new(
|
||||
Arc::new(contexts),
|
||||
Arc::new(profiles),
|
||||
Arc::new(runtime),
|
||||
Arc::new(fs),
|
||||
Arc::new(pty),
|
||||
Arc::new(FakeSkills),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall),
|
||||
None,
|
||||
)
|
||||
.with_structured(Arc::new(factory), Arc::clone(&structured));
|
||||
if let Some(store) = store {
|
||||
let provider =
|
||||
Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
|
||||
launch = launch.with_provider_session_provider(provider);
|
||||
}
|
||||
(Arc::new(launch), agent)
|
||||
}
|
||||
|
||||
/// **Cas nominal Claude** : provider câblé, profil structuré Claude, moteur exposant
|
||||
/// `conversation_id() == Some("engine-xyz")`, cellule portant une `conversation_id`
|
||||
/// UUID valide ⇒ le store contient `(pair, "claude") == "engine-xyz"`, où la clé de
|
||||
/// conversation est exactement le `pair_conversation_id` (= l'uuid d'entrée).
|
||||
#[tokio::test]
|
||||
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)),
|
||||
);
|
||||
|
||||
// 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));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured launch");
|
||||
|
||||
assert_eq!(
|
||||
store.get_sync(pair, "claude").as_deref(),
|
||||
Some("engine-xyz"),
|
||||
"engine resumable stored under (pair, claude)"
|
||||
);
|
||||
assert_eq!(store.len(), 1, "exactly one entry written");
|
||||
// La clé de conversation est bien le pair_conversation_id (l'uuid d'entrée), pas
|
||||
// l'id moteur : un lookup sous l'id moteur (non-UUID) ne renverrait rien.
|
||||
assert!(
|
||||
store.get_sync(pair, "codex").is_none(),
|
||||
"nothing written under a different provider key"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Cas nominal Codex** : même scénario, profil structuré Codex ⇒ clé provider
|
||||
/// `"codex"`.
|
||||
#[tokio::test]
|
||||
async fn p8b_nominal_codex_writes_engine_id_under_codex_key() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, Some("engine-codex"));
|
||||
let (launch, agent) = launch_fixture_p8b(
|
||||
structured_codex_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(456));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured launch");
|
||||
|
||||
assert_eq!(
|
||||
store.get_sync(pair, "codex").as_deref(),
|
||||
Some("engine-codex"),
|
||||
"engine resumable stored under (pair, codex)"
|
||||
);
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
/// **provider_key correct** : test direct du contrat de persistance.
|
||||
#[test]
|
||||
fn p8b_provider_key_mapping_is_stable() {
|
||||
assert_eq!(StructuredAdapter::Claude.provider_key(), "claude");
|
||||
assert_eq!(StructuredAdapter::Codex.provider_key(), "codex");
|
||||
}
|
||||
|
||||
/// **No-op sans provider** : sans `with_provider_session_provider`, le lancement
|
||||
/// réussit et rien n'est écrit (le store, non câblé, reste vide — on en garde une
|
||||
/// copie pour le prouver).
|
||||
#[tokio::test]
|
||||
async fn p8b_no_provider_wired_writes_nothing_launch_ok() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, Some("engine-xyz"));
|
||||
// store NON câblé (None) ⇒ aucune écriture possible.
|
||||
let (launch, agent) = launch_fixture_p8b(structured_profile(pid(9)), factory, None);
|
||||
|
||||
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 provider");
|
||||
|
||||
assert_eq!(store.len(), 0, "no provider wired ⇒ nothing written");
|
||||
}
|
||||
|
||||
/// **No-op sans id moteur** : la session fake n'expose aucun id
|
||||
/// (`conversation_id() == None`) ⇒ aucune écriture, le lancement réussit.
|
||||
#[tokio::test]
|
||||
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 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");
|
||||
|
||||
assert_eq!(
|
||||
store.len(),
|
||||
0,
|
||||
"no engine session id ⇒ persist skipped, launch unaffected"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Best-effort** : un store dont `set` renvoie `Err` ⇒ `execute` réussit quand
|
||||
/// même (la sortie de lancement n'est pas dégradée : descripteur structuré présent,
|
||||
/// engine_session_id exposé).
|
||||
#[tokio::test]
|
||||
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 pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
// Le lancement réussit malgré l'échec d'écriture du resumable.
|
||||
let out = launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("launch must succeed despite store set failure");
|
||||
|
||||
// Sortie non dégradée : descripteur structuré + id moteur toujours exposés.
|
||||
let desc = out.structured.expect("structured descriptor still present");
|
||||
assert_eq!(desc.session_id, sid(500));
|
||||
assert_eq!(out.engine_session_id.as_deref(), Some("engine-xyz"));
|
||||
// Le store n'a (logiquement) rien persisté.
|
||||
assert_eq!(store.len(), 0, "failing set wrote nothing");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// LOT P8c — routage du `--resume` moteur via providers.json
|
||||
//
|
||||
// Prouve `resolve_session_plan` (privée) **par son contrat observable** : le
|
||||
// `SessionPlan` que `LaunchAgent::execute` transmet à `factory.start` (capturé par
|
||||
// la `FakeFactory`). Pour un profil **structuré** avec un store provider câblé, le
|
||||
// resume moteur est lu dans `providers.json` keyé par (id de paire, provider_key) —
|
||||
// **jamais** l'id de paire lui-même n'est passé en `--resume`.
|
||||
//
|
||||
// Le fallback (provider non câblé) et le chemin non structuré sont déjà attestés par
|
||||
// `structured_profile_with_cell_conversation_resolves_to_resume`,
|
||||
// `structured_profile_fresh_cell_resolves_to_none` (cas 3 structuré non câblé) et les
|
||||
// suites existantes ; on ajoute ici le **cœur P8c** : la lecture du store.
|
||||
// ===========================================================================
|
||||
|
||||
/// Variante de [`launch_fixture_p8b`] qui **rend aussi la factory** (clone partageant
|
||||
/// `starts` via `Arc`), pour observer le `SessionPlan` transmis à `start`.
|
||||
fn launch_fixture_p8c(
|
||||
profile: AgentProfile,
|
||||
factory: FakeFactory,
|
||||
store: Option<Arc<FakeProviderSessionStore>>,
|
||||
) -> (Arc<LaunchAgent>, Agent, FakeFactory) {
|
||||
let observed = factory.clone();
|
||||
let (launch, agent) = launch_fixture_p8b(profile, factory, store);
|
||||
(launch, agent, observed)
|
||||
}
|
||||
|
||||
/// **Cas 1 — Claude câblé, store contient l'engine id** : la cellule porte l'id de
|
||||
/// paire (UUID) ; le store mappe `(pair, "claude") → "engine-x"`. Le lancement doit
|
||||
/// transmettre `SessionPlan::Resume{ conversation_id: "engine-x" }` à la factory —
|
||||
/// **et surtout PAS** l'id de paire.
|
||||
#[tokio::test]
|
||||
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)),
|
||||
);
|
||||
|
||||
// Pré-remplit le store : (pair, "claude") → "engine-x".
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
store.seed_sync(pair, "claude", "engine-x");
|
||||
|
||||
// La cellule porte l'id de **paire** (UUID), jamais l'id moteur.
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
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();
|
||||
assert_eq!(starts.len(), 1, "factory.start called once");
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::Resume {
|
||||
conversation_id: "engine-x".to_owned()
|
||||
},
|
||||
"resume must carry the engine resumable id from providers.json"
|
||||
);
|
||||
// GARDE-FOU EXPLICITE : ce n'est PAS l'id de paire qui part en --resume.
|
||||
match &starts[0].1 {
|
||||
SessionPlan::Resume { conversation_id } => {
|
||||
assert_ne!(
|
||||
conversation_id,
|
||||
&pair.to_string(),
|
||||
"the pair id must NEVER be passed as the engine --resume"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Resume, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// **Cas 1bis — Codex câblé, store contient l'engine id sous la clé `"codex"`** :
|
||||
/// même contrat, clé provider `"codex"`.
|
||||
#[tokio::test]
|
||||
async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_codex_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(456));
|
||||
store.seed_sync(pair, "codex", "engine-codex-x");
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("structured codex launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::Resume {
|
||||
conversation_id: "engine-codex-x".to_owned()
|
||||
},
|
||||
"codex resume reads its own provider key"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Cas 1ter — la clé provider discrimine** : le store ne contient l'engine id que
|
||||
/// sous `"codex"`, mais le profil est **Claude** ⇒ le lookup sous `"claude"` ne trouve
|
||||
/// rien ⇒ `SessionPlan::None` (premier lancement propre, jamais l'id de paire).
|
||||
#[tokio::test]
|
||||
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 pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
// Engine id rangé sous une AUTRE clé provider.
|
||||
store.seed_sync(pair, "codex", "engine-codex");
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::None,
|
||||
"a claude profile must not pick up a codex-keyed resumable"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Cas 2 — store câblé mais vide** (`get` → `None`) : la cellule porte un id de
|
||||
/// paire valide mais aucun resumable n'a été rangé ⇒ `SessionPlan::None` (premier
|
||||
/// lancement propre). On vérifie aussi que l'id de paire n'est jamais transmis.
|
||||
#[tokio::test]
|
||||
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 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("structured launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::None,
|
||||
"empty store ⇒ clean first launch, never the pair id"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Cas 2bis — store câblé, cellule neuve (pas d'id de paire)** : aucun id à
|
||||
/// chercher ⇒ `SessionPlan::None`.
|
||||
#[tokio::test]
|
||||
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)),
|
||||
);
|
||||
|
||||
// Cellule neuve : conversation_id = None.
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("fresh structured launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::None,
|
||||
"no cell id ⇒ nothing to look up ⇒ None"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Robustesse — id de paire non-UUID + store câblé** : un `conversation_id` qui ne
|
||||
/// parse pas en UUID (donnée legacy/corrompue) ⇒ pas de lookup possible ⇒
|
||||
/// `SessionPlan::None`, jamais l'id de paire passé tel quel en `--resume`.
|
||||
#[tokio::test]
|
||||
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 mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some("not-a-uuid".to_owned());
|
||||
|
||||
launch.execute(input).await.expect("structured launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::None,
|
||||
"a non-UUID pair id must degrade to None, never resume the raw string"
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,12 +21,8 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, PtyHandle, ReplyStream,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession,
|
||||
};
|
||||
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream};
|
||||
use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- petits constructeurs déterministes ------------------------------------
|
||||
@ -121,10 +117,7 @@ fn structured_live_agents_lists_triples() {
|
||||
|
||||
assert_eq!(
|
||||
live,
|
||||
vec![
|
||||
(aid(10), nid(100), sid(1)),
|
||||
(aid(20), nid(200), sid(2)),
|
||||
]
|
||||
vec![(aid(10), nid(100), sid(1)), (aid(20), nid(200), sid(2)),]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -366,11 +366,7 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_two_users() {
|
||||
assert_eq!(
|
||||
Conversation::try_new(
|
||||
conv_id(1),
|
||||
ConversationParty::User,
|
||||
ConversationParty::User
|
||||
),
|
||||
Conversation::try_new(conv_id(1), ConversationParty::User, ConversationParty::User),
|
||||
Err(ConversationError::TwoUsers)
|
||||
);
|
||||
}
|
||||
@ -428,10 +424,7 @@ mod tests {
|
||||
// A→B exists; B→C is fine (no path C→…→B).
|
||||
let mut g = WaitForGraph::new();
|
||||
g.add_edge(agent(1), agent(2)); // A waits B
|
||||
assert!(
|
||||
!g.would_cycle(agent(2), agent(3)),
|
||||
"A→B→C is acyclic"
|
||||
);
|
||||
assert!(!g.would_cycle(agent(2), agent(3)), "A→B→C is acyclic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -31,9 +31,7 @@ use crate::ports::StoreError;
|
||||
/// Newtype autour d'[`uuid::Uuid`], calqué sur [`crate::conversation::ConversationId`]
|
||||
/// / [`crate::mailbox::TicketId`]. Sa monotonie (un id frappé à l'`append`) sert de
|
||||
/// **curseur** à la relecture incrémentale ([`ConversationLog::read`] avec `since`).
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
|
||||
)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct TurnId(pub uuid::Uuid);
|
||||
|
||||
@ -199,11 +197,7 @@ pub struct Handoff {
|
||||
impl Handoff {
|
||||
/// Construit un handoff à partir de ses composants.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
summary_md: impl Into<String>,
|
||||
up_to: TurnId,
|
||||
objective: Option<String>,
|
||||
) -> Self {
|
||||
pub fn new(summary_md: impl Into<String>, up_to: TurnId, objective: Option<String>) -> Self {
|
||||
Self {
|
||||
summary_md: summary_md.into(),
|
||||
up_to,
|
||||
@ -235,11 +229,7 @@ pub trait HandoffStore: Send + Sync {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] en cas d'échec d'écriture ou de sérialisation.
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError>;
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Le résumeur incrémental de handoff (port driving, lot P4).
|
||||
@ -435,7 +425,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn turn_role_serializes_camel_case() {
|
||||
assert_eq!(serde_json::to_string(&TurnRole::Prompt).unwrap(), "\"prompt\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnRole::Prompt).unwrap(),
|
||||
"\"prompt\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnRole::Response).unwrap(),
|
||||
"\"response\""
|
||||
@ -581,7 +574,11 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
let last2 = log.last(c, 2).await.unwrap();
|
||||
assert_eq!(texts(&last2), vec!["c", "d"], "the 2 last, in insertion order");
|
||||
assert_eq!(
|
||||
texts(&last2),
|
||||
vec!["c", "d"],
|
||||
"the 2 last, in insertion order"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -599,7 +596,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c1, None).await.unwrap()),
|
||||
vec!["c1-a", "c1-b"]
|
||||
);
|
||||
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
|
||||
// A cursor from one thread never leaks tours from another.
|
||||
assert_eq!(texts(&log.last(c2, 10).await.unwrap()), vec!["c2-a"]);
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
//! presentation layer (ARCHITECTURE §3.2).
|
||||
|
||||
use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
|
||||
use crate::mailbox::TicketId;
|
||||
use crate::memory::MemorySlug;
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
@ -70,6 +71,19 @@ pub enum DomainEvent {
|
||||
/// `true` when a turn is in flight, `false` when the agent is idle.
|
||||
busy: bool,
|
||||
},
|
||||
/// An agent's **liveness** (alive/stalled) changed (lot 2, chantier
|
||||
/// readiness/heartbeat). Emitted **once per transition** by the stall detector:
|
||||
/// `Alive→Stalled` when no proof of liveness arrived for longer than the profile's
|
||||
/// `stall_after_ms`, and `Stalled→Alive` when a late battement (delta / tool
|
||||
/// activity / heartbeat) revives it or the agent returns to `Idle`. Discrete,
|
||||
/// low-frequency beacon (no spam) relayed to the front so the mediated-input view
|
||||
/// can badge a frozen agent. Purely advisory — the FIFO and the turn keep running.
|
||||
AgentLivenessChanged {
|
||||
/// The agent whose liveness changed.
|
||||
agent_id: AgentId,
|
||||
/// The new liveness state.
|
||||
liveness: crate::input::AgentLiveness,
|
||||
},
|
||||
/// An agent's runtime profile was changed (hot-swap of the AI engine).
|
||||
AgentProfileChanged {
|
||||
/// The agent.
|
||||
@ -173,6 +187,28 @@ pub enum DomainEvent {
|
||||
/// Whether the in-process ONNX capability (`localOnnx`) is compiled in.
|
||||
vector_onnx_enabled: bool,
|
||||
},
|
||||
/// A delegation is ready to be injected into the agent's **native terminal**
|
||||
/// (ARCHITECTURE §20.2/§20.3). Published when a turn *starts* (Idle→Busy); the
|
||||
/// backend stays the authority of the FIFO/busy state and **no longer writes the
|
||||
/// turn into the PTY** — the frontend cell runs the write-portal handshake and
|
||||
/// writes `text` + `submit_sequence` through the single PTY writer (the front).
|
||||
/// Discrete, low-frequency (one per delegation). Relayed to the front as
|
||||
/// `delegationReady` like every other [`DomainEvent`].
|
||||
DelegationReady {
|
||||
/// The target agent whose terminal will receive the delegation.
|
||||
agent_id: AgentId,
|
||||
/// The mailbox ticket correlating this turn (acked back via
|
||||
/// `delegation_delivered`); the requester's `ask` is woken by `idea_reply`.
|
||||
ticket: TicketId,
|
||||
/// The task text to inject (written without a trailing `\n` by the portal).
|
||||
text: String,
|
||||
/// Target profile's submit sequence (the cell applies it after the text).
|
||||
/// `None` ⇒ the front applies its default (`"\r"`); never hard-coded in the
|
||||
/// domain.
|
||||
submit_sequence: Option<String>,
|
||||
/// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms).
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
|
||||
@ -80,6 +80,29 @@ pub enum AgentBusyState {
|
||||
},
|
||||
}
|
||||
|
||||
/// Whether an agent currently shows **signs of life** during a turn (lot 2,
|
||||
/// chantier readiness/heartbeat — détection « Stalled »).
|
||||
///
|
||||
/// Orthogonal to [`AgentBusyState`]: an agent can be `Busy` **and** `Alive` (it is
|
||||
/// working, producing deltas/heartbeats) or `Busy` **and** `Stalled` (no proof of
|
||||
/// liveness for longer than its profile's `stall_after_ms`). Only meaningful while
|
||||
/// `Busy`; an `Idle` agent is considered `Alive` (its turn ended cleanly).
|
||||
///
|
||||
/// Published to the front (`AgentLivenessChanged`) **once per transition** so the UI
|
||||
/// can badge a frozen agent without event spam. Derived from the per-agent
|
||||
/// `last_seen_ms` heartbeat, never from parsing the model output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "liveness")]
|
||||
pub enum AgentLiveness {
|
||||
/// The agent is producing proof of liveness (deltas / tool activity /
|
||||
/// heartbeats) within its `stall_after_ms` window — or is idle.
|
||||
Alive,
|
||||
/// No proof of liveness for longer than the profile's `stall_after_ms`: the
|
||||
/// agent is presumed frozen. Advisory only — the FIFO and the turn keep running
|
||||
/// (a late heartbeat flips it back to [`Self::Alive`]).
|
||||
Stalled,
|
||||
}
|
||||
|
||||
impl AgentBusyState {
|
||||
/// Whether a turn is currently in flight.
|
||||
#[must_use]
|
||||
@ -97,6 +120,29 @@ impl AgentBusyState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-agent submit configuration (ARCHITECTURE §20.3), resolved from the target
|
||||
/// agent's profile and carried to the [`InputMediator`] at bind time so it can be
|
||||
/// echoed on a [`crate::events::DomainEvent::DelegationReady`].
|
||||
///
|
||||
/// Both fields stay `Option`: the **default** (`"\r"`, ~60 ms) is applied at the
|
||||
/// point of use (the frontend write-portal), never hard-coded in the domain — the
|
||||
/// domain only transports the declared values.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SubmitConfig {
|
||||
/// The profile's `submit_sequence` (e.g. `"\r"`). `None` ⇒ front default.
|
||||
pub sequence: Option<String>,
|
||||
/// The profile's `submit_delay_ms`. `None` ⇒ front default (~60 ms).
|
||||
pub delay_ms: Option<u32>,
|
||||
}
|
||||
|
||||
impl SubmitConfig {
|
||||
/// Builds a submit config from the two optional profile fields.
|
||||
#[must_use]
|
||||
pub const fn new(sequence: Option<String>, delay_ms: Option<u32>) -> Self {
|
||||
Self { sequence, delay_ms }
|
||||
}
|
||||
}
|
||||
|
||||
/// The single convergence point of **all** of an agent's input (one FIFO/agent),
|
||||
/// with `enqueue` (Envoyer) and `preempt` (Interrompre) kept **distinct**, plus the
|
||||
/// busy state.
|
||||
@ -108,12 +154,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,26 +182,74 @@ pub trait InputMediator: Send + Sync {
|
||||
/// only returns `Idle` on the explicit signal or the per-turn timeout (fallback
|
||||
/// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`).
|
||||
///
|
||||
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern (a
|
||||
/// mediator that does not observe the output stream). The infra adapter overrides
|
||||
/// it to arm the watcher.
|
||||
/// It also records the target's [`SubmitConfig`] (ARCHITECTURE §20.3) so the
|
||||
/// adapter can echo `submit_sequence`/`submit_delay_ms` on the
|
||||
/// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts.
|
||||
/// The bind is the natural carrier: both the prompt pattern and the submit config
|
||||
/// are per-agent profile data the orchestrator resolves at the same time, so they
|
||||
/// travel together (no extra ticket field, no second resolve).
|
||||
///
|
||||
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and
|
||||
/// submit config (a mediator that does not observe the output stream). The infra
|
||||
/// adapter overrides it to arm the watcher and stash the submit config.
|
||||
fn bind_handle_with_prompt(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
handle: PtyHandle,
|
||||
_prompt_ready_pattern: Option<String>,
|
||||
_submit: SubmitConfig,
|
||||
) {
|
||||
self.bind_handle(agent, handle);
|
||||
}
|
||||
|
||||
/// Whether this mediator owns the turn-delivery write for `agent` (i.e. it has a
|
||||
/// bound handle **and** a PTY port). When `false`, the orchestrator must deliver
|
||||
/// the turn itself. Default: `false`.
|
||||
/// **Deprecated since ARCHITECTURE §20**: the mediator never writes the turn into
|
||||
/// the PTY anymore — the **frontend** always delivers (via the write-portal). Kept
|
||||
/// for source compatibility; it now always returns `false`. Callers should stop
|
||||
/// branching on it (the orchestrator no longer falls back to its own PTY write).
|
||||
#[must_use]
|
||||
fn delivers_turn(&self, _agent: AgentId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Déclare qu'`agent` vient d'être **lancé à froid** : la livraison de son tout
|
||||
/// premier tour doit être *gatée* sur le prompt-ready (la `DelegationReady` est
|
||||
/// différée jusqu'à l'apparition du prompt du CLI), pour éviter d'écrire la tâche
|
||||
/// avant que le CLI ait fini de booter (premier tour perdu sinon).
|
||||
///
|
||||
/// À n'appeler **que** lorsqu'un prompt-ready watcher sera effectivement armé (le
|
||||
/// profil porte un `prompt_ready_pattern` non vide). Sans watcher pour le libérer,
|
||||
/// gater le premier tour le bloquerait indéfiniment ; dans ce cas l'orchestrateur ne
|
||||
/// doit **pas** appeler `mark_starting` et l'`enqueue` livre la tâche immédiatement
|
||||
/// (chemin chaud, fallback sûr, zéro régression).
|
||||
///
|
||||
/// Default: no-op (a mediator that does not gate cold starts).
|
||||
fn mark_starting(&self, _agent: AgentId) {}
|
||||
|
||||
/// Signal de **readiness de démarrage** : le pont MCP de `agent` vient de se
|
||||
/// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour
|
||||
/// a été différé par [`InputMediator::mark_starting`] (démarrage à froid), c'est le
|
||||
/// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à
|
||||
/// `prompt_ready`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de
|
||||
/// fin de tour. Idempotent : un second appel (ou après que `prompt_ready` ait déjà
|
||||
/// drainé) ne trouve rien et est un no-op. En OR avec le prompt-ready : le premier
|
||||
/// arrivé draine, l'autre est no-op.
|
||||
///
|
||||
/// Default: no-op (médiateur qui ne gate pas les démarrages à froid).
|
||||
fn release_cold_start(&self, _agent: AgentId) {}
|
||||
|
||||
/// Déclare si une **cellule terminal du frontend** est montée pour `agent`
|
||||
/// (`true` au montage du write-portal, `false` au démontage). C'est le frontend
|
||||
/// qui possède l'écriture physique du PTY *quand une cellule existe* (cas d'un
|
||||
/// agent épinglé dans le layout) ; un agent **délégué en arrière-plan n'a aucune
|
||||
/// cellule**, donc personne ne consomme `DelegationReady` côté UI. Le médiateur
|
||||
/// utilise cet état pour décider, à la livraison d'un tour, entre **publier
|
||||
/// l'événement** (cellule présente ⇒ le front écrit) et **écrire lui-même** la
|
||||
/// tâche dans le PTY (agent headless ⇒ sinon le tour est perdu).
|
||||
///
|
||||
/// Default: no-op (médiateur sans notion de cellule front — tout passe par
|
||||
/// l'événement, comportement historique).
|
||||
fn set_front_attached(&self, _agent: AgentId, _attached: bool) {}
|
||||
|
||||
/// Interrompre = preempt: signals the running turn to stop (Échap/stop). This
|
||||
/// is **not** an enqueue and correlates **no** ticket.
|
||||
fn preempt(&self, agent: AgentId);
|
||||
@ -161,6 +257,29 @@ pub trait InputMediator: Send + Sync {
|
||||
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
|
||||
fn mark_idle(&self, agent: AgentId);
|
||||
|
||||
/// Records a **proof of liveness** (« battement ») for `agent` — called on every
|
||||
/// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`])
|
||||
/// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall
|
||||
/// detector ([`crate::events::DomainEvent::AgentLivenessChanged`], lot 2) knows the
|
||||
/// agent is still working; a battement arriving after a `Stalled` transition flips it
|
||||
/// back to [`AgentLiveness::Alive`].
|
||||
///
|
||||
/// Default: no-op (a mediator that does not track liveness). The infra adapter
|
||||
/// `MediatedInbox` overrides it to refresh `last_seen` and publish an
|
||||
/// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement.
|
||||
fn mark_alive(&self, _agent: AgentId) {}
|
||||
|
||||
/// Declares the agent's **stall threshold** (its profile's
|
||||
/// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows
|
||||
/// how long without a battement means "frozen" (lot 2). The orchestrator resolves it
|
||||
/// from the target's profile and calls this **before** the enqueue that starts a
|
||||
/// turn; the adapter stashes it and arms a fresh liveness window when the turn
|
||||
/// begins. `None` ⇒ no stall detection for this agent (legacy / no `liveness` block —
|
||||
/// zero regression).
|
||||
///
|
||||
/// Default: no-op (a mediator that does not track liveness).
|
||||
fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option<u32>) {}
|
||||
|
||||
/// The current [`AgentBusyState`] of `agent`.
|
||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
|
||||
}
|
||||
|
||||
@ -44,9 +44,12 @@ pub mod mailbox;
|
||||
pub mod markdown;
|
||||
pub mod memory;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod ports;
|
||||
pub mod profile;
|
||||
pub mod project;
|
||||
pub mod readiness;
|
||||
pub mod sandbox;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
pub mod template;
|
||||
@ -74,7 +77,8 @@ pub use skill::{Skill, SkillRef, SkillScope};
|
||||
pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
pub use profile::{
|
||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy,
|
||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
|
||||
McpServerWiring, SessionStrategy,
|
||||
};
|
||||
|
||||
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
|
||||
@ -84,7 +88,9 @@ pub use conversation::{
|
||||
ConversationSession, SessionRef, WaitForGraph,
|
||||
};
|
||||
|
||||
pub use input::{AgentBusyState, InputMediator, InputSource};
|
||||
pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
|
||||
|
||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
pub use conversation_log::{
|
||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
@ -113,6 +119,18 @@ pub use layout::{
|
||||
|
||||
pub use events::{DomainEvent, OrchestrationSource};
|
||||
|
||||
pub use permission::{
|
||||
render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability,
|
||||
CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError,
|
||||
PermissionProjection, PermissionProjector, PermissionRule, PermissionSet, Posture,
|
||||
ProjectedFile, ProjectionContext, ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION,
|
||||
};
|
||||
|
||||
pub use sandbox::{
|
||||
compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError,
|
||||
SandboxKind, SandboxPlan, SandboxStatus,
|
||||
};
|
||||
|
||||
pub use orchestrator::{
|
||||
OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility,
|
||||
};
|
||||
@ -122,7 +140,8 @@ pub use ports::{
|
||||
EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore,
|
||||
EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem,
|
||||
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
|
||||
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PreparedContext,
|
||||
ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
|
||||
RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore,
|
||||
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore,
|
||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError,
|
||||
TemplateStore,
|
||||
};
|
||||
|
||||
@ -40,16 +40,7 @@ use crate::input::InputSource;
|
||||
/// correlation is positional (head of the FIFO), the id only lets the caller name
|
||||
/// the exact ticket it wants retired on timeout ([`AgentMailbox::cancel_head`]).
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct TicketId(pub uuid::Uuid);
|
||||
@ -189,9 +180,7 @@ pub struct PendingReply {
|
||||
impl PendingReply {
|
||||
/// Wraps a reply future built by the adapter (e.g. over a one-shot receiver).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
|
||||
) -> Self {
|
||||
pub fn new(inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
@ -286,10 +275,7 @@ mod tests {
|
||||
assert_eq!(t.task, "do X");
|
||||
// Back-compat default: human source, nil conversation.
|
||||
assert_eq!(t.source, InputSource::Human);
|
||||
assert_eq!(
|
||||
t.conversation,
|
||||
ConversationId::from_uuid(uuid::Uuid::nil())
|
||||
);
|
||||
assert_eq!(t.conversation, ConversationId::from_uuid(uuid::Uuid::nil()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -322,9 +308,6 @@ mod tests {
|
||||
#[test]
|
||||
fn mailbox_errors_are_distinct_and_typed() {
|
||||
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
assert_ne!(
|
||||
MailboxError::NoPendingRequest(a),
|
||||
MailboxError::Cancelled
|
||||
);
|
||||
assert_ne!(MailboxError::NoPendingRequest(a), MailboxError::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
1482
crates/domain/src/permission.rs
Normal file
1482
crates/domain/src/permission.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -32,6 +32,7 @@ use crate::events::DomainEvent;
|
||||
use crate::ids::{AgentId, SessionId};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use crate::permission::ProjectPermissions;
|
||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
||||
use crate::project::{Project, ProjectPath};
|
||||
use crate::remote::RemoteKind;
|
||||
@ -97,6 +98,10 @@ pub struct SpawnSpec {
|
||||
pub env: Vec<(String, String)>,
|
||||
/// How the context is injected, if any.
|
||||
pub context_plan: Option<ContextInjectionPlan>,
|
||||
/// OS-sandbox plan to enforce on the spawned process (lot LP4). `None` ⇒ no
|
||||
/// enforcement (the agent runs natively); the field is wired but unused until
|
||||
/// the Landlock adapter (LP4-1) and launch path (LP4-2) consume it.
|
||||
pub sandbox: Option<crate::sandbox::SandboxPlan>,
|
||||
}
|
||||
|
||||
/// The agent context prepared for injection (content + the on-disk path it maps
|
||||
@ -209,6 +214,17 @@ pub enum ReplyEvent {
|
||||
/// Libellé humain-lisible de l'activité.
|
||||
label: String,
|
||||
},
|
||||
/// **Preuve de vivacité non terminale** (model-agnostique) : l'agent est
|
||||
/// toujours en train de travailler. Émis par l'adapter quand le moteur signale
|
||||
/// un battement de cœur natif **sans contenu utile** (handshake/init, fenêtre de
|
||||
/// limite de débit, début/fin de tour côté moteur…). Sert à distinguer « agent
|
||||
/// vivant mais lent » d'« agent bloqué » (readiness/heartbeat, lot 1).
|
||||
///
|
||||
/// **Jamais terminal** : un `Heartbeat` ne clôt **pas** le flux — il s'intercale
|
||||
/// comme un delta (ignoré par les consommateurs synchrones) et le flux continue
|
||||
/// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais
|
||||
/// d'obligation d'en émettre.
|
||||
Heartbeat,
|
||||
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
|
||||
/// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
|
||||
/// Après `Final`, le flux se termine (plus aucun événement).
|
||||
@ -220,8 +236,10 @@ pub enum ReplyEvent {
|
||||
|
||||
/// Flux borné d'événements de réponse d'UN tour (ARCHITECTURE §17.1). Se termine
|
||||
/// après le [`ReplyEvent::Final`] (ou sur erreur). Calqué sur [`OutputStream`],
|
||||
/// mais **typé** : deltas de texte → activités d'outil → un `Final` déterministe,
|
||||
/// plutôt que des octets bruts.
|
||||
/// mais **typé** : deltas de texte → activités d'outil → battements de cœur*
|
||||
/// ([`ReplyEvent::Heartbeat`], non terminaux, en nombre quelconque et entrelacés) →
|
||||
/// **un** `Final` terminal déterministe, plutôt que des octets bruts. Seul le `Final`
|
||||
/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux.
|
||||
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -604,6 +622,21 @@ pub trait FileSystem: Send + Sync {
|
||||
/// [`FsError`] on failure.
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError>;
|
||||
|
||||
/// Removes a single file. A **missing** file is treated as success (idempotent
|
||||
/// delete), so this is safe to call best-effort.
|
||||
///
|
||||
/// Defaults to a **no-op `Ok(())`** so existing adapters and in-memory test
|
||||
/// doubles keep compiling unchanged; the real adapter overrides it with an
|
||||
/// actual delete. Callers that rely on the file actually being gone must use an
|
||||
/// adapter that overrides this (the local FS does).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`FsError`] on a failure other than not-found.
|
||||
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a directory and all missing parents.
|
||||
///
|
||||
/// # Errors
|
||||
@ -1050,6 +1083,27 @@ pub trait AgentContextStore: Send + Sync {
|
||||
) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Reads/writes a project's `.ideai/permissions.json`.
|
||||
#[async_trait]
|
||||
pub trait PermissionStore: Send + Sync {
|
||||
/// Loads the project's permission document. Missing file returns the default
|
||||
/// empty document.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on I/O or deserialisation failure.
|
||||
async fn load_permissions(&self, project: &Project) -> Result<ProjectPermissions, StoreError>;
|
||||
|
||||
/// Saves the project's permission document.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on I/O or serialisation failure.
|
||||
async fn save_permissions(
|
||||
&self,
|
||||
project: &Project,
|
||||
permissions: &ProjectPermissions,
|
||||
) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Git operations for a project. Named `GitPort` to avoid clashing with the
|
||||
/// [`crate::git::GitRepository`] *entity* (state image).
|
||||
#[async_trait]
|
||||
|
||||
@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::ProfileId;
|
||||
use crate::permission::ProjectorKey;
|
||||
|
||||
/// Strategy for injecting an agent's `.md` context into the launched CLI.
|
||||
///
|
||||
@ -118,6 +119,61 @@ impl SessionStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Réglages de **vivacité** (readiness/heartbeat) d'un profil IA — place ménagée
|
||||
/// pour le lot 2 (chantier readiness/heartbeat). Donnée **déclarative** (pas de code
|
||||
/// par CLI — Open/Closed), calquée sur [`SessionStrategy`] / [`McpCapability`].
|
||||
///
|
||||
/// Deux seuils optionnels :
|
||||
/// - `stall_after_ms` : délai sans **aucune** preuve de vivacité (delta / activité /
|
||||
/// `ReplyEvent::Heartbeat`) au bout duquel l'agent est présumé **bloqué**
|
||||
/// (`ReadinessSignal::Stalled`) — détection au lot 2 ;
|
||||
/// - `turn_timeout_ms` : durée maximale d'**un tour** avant le garde-fou
|
||||
/// (`ReadinessSignal::TimedOut`) — remplacement des timeouts en dur au lot 2.
|
||||
///
|
||||
/// **Lot 1 : champs présents mais non consommés** — on ne fait que ménager la place
|
||||
/// (le modèle de sérialisation et l'API sont figés ici pour éviter une migration au
|
||||
/// lot 2). `None` (défaut, et valeur des profils existants) ⇒ comportement actuel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LivenessStrategy {
|
||||
/// Délai (ms) sans preuve de vivacité avant de présumer l'agent bloqué.
|
||||
/// `None` ⇒ pas de détection de stagnation (lot 2).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stall_after_ms: Option<u32>,
|
||||
/// Durée maximale (ms) d'un tour avant le garde-fou de timeout. `None` ⇒ pas de
|
||||
/// garde-fou par profil (lot 2).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub turn_timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
impl LivenessStrategy {
|
||||
/// Construit une stratégie de vivacité validée (parse-don't-validate, comme
|
||||
/// [`SessionStrategy::new`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// Renvoie [`DomainError::EmptyField`] si un seuil fourni vaut `0` (un seuil de
|
||||
/// `0 ms` n'a pas de sens : `None` est la façon d'exprimer « pas de seuil »).
|
||||
pub const fn new(
|
||||
stall_after_ms: Option<u32>,
|
||||
turn_timeout_ms: Option<u32>,
|
||||
) -> Result<Self, DomainError> {
|
||||
if let Some(0) = stall_after_ms {
|
||||
return Err(DomainError::EmptyField {
|
||||
field: "liveness.stallAfterMs",
|
||||
});
|
||||
}
|
||||
if let Some(0) = turn_timeout_ms {
|
||||
return Err(DomainError::EmptyField {
|
||||
field: "liveness.turnTimeoutMs",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
stall_after_ms,
|
||||
turn_timeout_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
|
||||
///
|
||||
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
|
||||
@ -134,6 +190,24 @@ pub enum StructuredAdapter {
|
||||
Codex,
|
||||
}
|
||||
|
||||
impl StructuredAdapter {
|
||||
/// Clé **stable par famille de moteur** (provider), indépendante de l'uuid
|
||||
/// d'instance d'un profil. Sert de clé dans `providers.json` (lot P8b) : un swap
|
||||
/// d'un profil vers un autre profil de **la même famille** réutilise la même
|
||||
/// entrée (resumable partagé), et le fichier reste lisible à l'œil.
|
||||
///
|
||||
/// Mapping : `Claude → "claude"`, `Codex → "codex"` (aligné sur la sérialisation
|
||||
/// camelCase de l'enum, mais figé ici comme **contrat de persistance** : ce
|
||||
/// mapping ne doit pas dériver si la représentation serde change).
|
||||
#[must_use]
|
||||
pub const fn provider_key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "claude",
|
||||
Self::Codex => "codex",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport du serveur MCP IdeA exposé à une CLI (détail invisible au domaine).
|
||||
///
|
||||
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert).
|
||||
@ -175,6 +249,22 @@ pub enum McpConfigStrategy {
|
||||
/// Nom de la variable d'environnement.
|
||||
var: String,
|
||||
},
|
||||
/// Écrire un fichier de conf MCP **TOML** au chemin (relatif au run dir isolé
|
||||
/// §14.1) attendu par la CLI, et pousser `home_env` (le nom d'une variable
|
||||
/// d'environnement) vers le **dossier parent** de `target` pour isoler la CLI
|
||||
/// de sa config globale. C'est le pendant Codex de [`Self::ConfigFile`] : Codex
|
||||
/// lit ses serveurs MCP dans `$CODEX_HOME/config.toml` (défaut `~/.codex`), table
|
||||
/// TOML `[mcp_servers.<nom>]`. IdeA écrit ce `config.toml` DANS le run dir de
|
||||
/// l'agent et pointe `CODEX_HOME={runDir}/.codex` pour ne JAMAIS toucher au
|
||||
/// `~/.codex` global (isolation par agent, miroir du `.mcp.json` de Claude).
|
||||
TomlConfigHome {
|
||||
/// Chemin relatif sûr du fichier `config.toml` (convention :
|
||||
/// `".codex/config.toml"`).
|
||||
target: String,
|
||||
/// Nom de la variable d'environnement pointée sur le **dossier parent** de
|
||||
/// `target` (ex. `"CODEX_HOME"`).
|
||||
home_env: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl McpConfigStrategy {
|
||||
@ -209,6 +299,24 @@ impl McpConfigStrategy {
|
||||
crate::validation::valid_env_var(&var)?;
|
||||
Ok(Self::Env { var })
|
||||
}
|
||||
|
||||
/// Constructeur validé `TomlConfigHome` (pendant Codex de [`Self::config_file`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou contient `..`
|
||||
/// (même validation que [`Self::config_file`]),
|
||||
/// - [`DomainError::InvalidEnvVar`] si `home_env` n'est pas un identifiant de
|
||||
/// variable d'environnement valide.
|
||||
pub fn toml_config_home(
|
||||
target: impl Into<String>,
|
||||
home_env: impl Into<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let target = target.into();
|
||||
let home_env = home_env.into();
|
||||
crate::validation::relative_safe(&target)?;
|
||||
crate::validation::valid_env_var(&home_env)?;
|
||||
Ok(Self::TomlConfigHome { target, home_env })
|
||||
}
|
||||
}
|
||||
|
||||
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
|
||||
@ -235,6 +343,128 @@ impl McpCapability {
|
||||
}
|
||||
}
|
||||
|
||||
/// Donnée de **wiring** du serveur MCP IdeA (`command` + `args` + `transport`),
|
||||
/// factorisée pour servir de **source unique** aux deux sérialisations qui en
|
||||
/// dérivaient séparément (et risquaient de diverger) : la déclaration `.mcp.json`
|
||||
/// de Claude (JSON) et la table `[mcp_servers.idea]` de Codex (TOML).
|
||||
///
|
||||
/// Pure (aucune I/O, aucune dépendance) : les deux encodeurs construisent une
|
||||
/// chaîne à la main, donc le domaine reste sans dépendance (`serde_json`/`toml`
|
||||
/// non requis). Le contenu (exe, endpoint, …) est calculé par l'appelant — le
|
||||
/// domaine ne fait que la **mise en forme**.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpServerWiring {
|
||||
/// Commande à lancer (le binaire IdeA en mode `mcp-server`, ou `"idea"` en
|
||||
/// déclaration minimale dégradée).
|
||||
pub command: String,
|
||||
/// Arguments passés à `command` (ex. `["mcp-server", "--endpoint", …]`).
|
||||
pub args: Vec<String>,
|
||||
/// Transport du serveur MCP (surfacé dans les deux formats).
|
||||
pub transport: McpTransport,
|
||||
}
|
||||
|
||||
impl McpServerWiring {
|
||||
/// Construit le wiring depuis ses parties.
|
||||
#[must_use]
|
||||
pub const fn new(command: String, args: Vec<String>, transport: McpTransport) -> Self {
|
||||
Self {
|
||||
command,
|
||||
args,
|
||||
transport,
|
||||
}
|
||||
}
|
||||
|
||||
/// Étiquette stable du transport, identique pour les deux formats.
|
||||
#[must_use]
|
||||
const fn transport_label(&self) -> &'static str {
|
||||
match self.transport {
|
||||
McpTransport::Stdio => "stdio",
|
||||
McpTransport::Socket => "socket",
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode un document **`.mcp.json`** complet (Claude Code et CLIs apparentées) :
|
||||
/// `{ "mcpServers": { "idea": { command, args, transport } } }`. Chaque chaîne est
|
||||
/// échappée en littéral JSON (chemins avec espaces/backslash/quotes restent
|
||||
/// valides).
|
||||
#[must_use]
|
||||
pub fn to_mcp_json(&self) -> String {
|
||||
let command = json_string(&self.command);
|
||||
let args = if self.args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let joined = self
|
||||
.args
|
||||
.iter()
|
||||
.map(|a| format!("\n {}", json_string(a)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!("{joined}\n ")
|
||||
};
|
||||
let transport = self.transport_label();
|
||||
format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"idea": {{
|
||||
"command": {command},
|
||||
"args": [{args}],
|
||||
"transport": "{transport}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex :
|
||||
/// `command`, `args` (tableau TOML), `transport`. Chaque chaîne est échappée en
|
||||
/// chaîne basique TOML (équivalent du `json_string` : espaces/backslash/quotes
|
||||
/// restent valides).
|
||||
#[must_use]
|
||||
pub fn to_config_toml(&self) -> String {
|
||||
let command = toml_string(&self.command);
|
||||
let args = self
|
||||
.args
|
||||
.iter()
|
||||
.map(|a| toml_string(a))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let transport = self.transport_label();
|
||||
format!(
|
||||
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Échappe `s` en **littéral de chaîne JSON** (guillemets inclus) pour les chemins
|
||||
/// exe/endpoint avec espaces, backslash ou quotes.
|
||||
fn json_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// Échappe `s` en **chaîne basique TOML** (guillemets inclus). Les chaînes
|
||||
/// basiques TOML utilisent les mêmes séquences d'échappement que JSON pour `"`,
|
||||
/// `\`, et les contrôles.
|
||||
fn toml_string(s: &str) -> String {
|
||||
// Le jeu d'échappement requis par une chaîne basique TOML coïncide avec celui de
|
||||
// JSON pour les caractères qui nous concernent (chemins, flags).
|
||||
json_string(s)
|
||||
}
|
||||
|
||||
/// Declarative runtime configuration for one AI CLI.
|
||||
///
|
||||
/// Invariants:
|
||||
@ -298,6 +528,13 @@ pub struct AgentProfile {
|
||||
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
|
||||
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
|
||||
///
|
||||
/// **Rang (chantier readiness/heartbeat, lot 1) : signal de repli n°3.** Depuis
|
||||
/// l'introduction de la fin-de-tour structurée ([`crate::ports::ReplyEvent::Final`]
|
||||
/// ⇒ [`crate::readiness::ReadinessSignal::TurnEnded`], signal n°1) et du signal
|
||||
/// explicite `idea_reply` (n°2), ce sniff littéral est **rétrogradé** au rang de
|
||||
/// repli : il ne sert plus que pour les agents **TUI/PTY sans adapter structuré**.
|
||||
/// Conservé tel quel pour la rétro-compat (jamais supprimé).
|
||||
///
|
||||
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
|
||||
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
|
||||
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
|
||||
@ -307,6 +544,51 @@ pub struct AgentProfile {
|
||||
/// un profil sans motif sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_ready_pattern: Option<String>,
|
||||
/// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut,
|
||||
/// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ
|
||||
/// présent mais **non consommé** (place ménagée pour les seuils de stagnation /
|
||||
/// timeout de tour du lot 2).
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
|
||||
/// un profil sans cette clé sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub liveness: Option<LivenessStrategy>,
|
||||
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
|
||||
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
|
||||
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
|
||||
/// la TUI), puis **cette séquence seule** après un court délai.
|
||||
///
|
||||
/// `None` ⇒ défaut `"\r"` appliqué **au point d'usage** (front), jamais codé
|
||||
/// en dur dans le domaine (model-agnostic, déclaratif). Une autre TUI peut
|
||||
/// exiger une séquence différente → c'est précisément pourquoi c'est donnée.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** : un profil
|
||||
/// sans cette clé sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_sequence: Option<String>,
|
||||
/// Délai (ms) entre l'écriture du texte et celle de [`Self::submit_sequence`]
|
||||
/// (§20.3, fix Bug 1). Évite que la TUI absorbe la soumission comme un paste.
|
||||
///
|
||||
/// `None` ⇒ défaut (~60 ms) appliqué **au point d'usage** (front), jamais codé
|
||||
/// en dur dans le domaine.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submit_delay_ms: Option<u32>,
|
||||
/// Clé du **projecteur de permissions** par-CLI (lot LP3) : QUEL adapter
|
||||
/// traduit les [`crate::permission::EffectivePermissions`] résolues en config
|
||||
/// de permission concrète de cette CLI au lancement. Donnée **déclarative**
|
||||
/// (Open/Closed), calquée sur [`StructuredAdapter`].
|
||||
///
|
||||
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** projection :
|
||||
/// la CLI garde son prompting natif (invariant produit de
|
||||
/// [`crate::permission::resolve`]). `Some(_)` ⇒ IdeA matérialise la config de
|
||||
/// permission de cette CLI au lancement.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
|
||||
/// sérialisation : un profil sans cette clé sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub projector: Option<ProjectorKey>,
|
||||
}
|
||||
|
||||
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
|
||||
@ -443,6 +725,10 @@ impl AgentProfile {
|
||||
structured_adapter: None,
|
||||
mcp: None,
|
||||
prompt_ready_pattern: None,
|
||||
liveness: None,
|
||||
submit_sequence: None,
|
||||
submit_delay_ms: None,
|
||||
projector: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -473,6 +759,41 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie
|
||||
/// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils sans réglage de vivacité ne l'appellent simplement pas.
|
||||
#[must_use]
|
||||
pub const fn with_liveness(mut self, liveness: LivenessStrategy) -> Self {
|
||||
self.liveness = Some(liveness);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
|
||||
#[must_use]
|
||||
pub fn with_submit_sequence(mut self, sequence: impl Into<String>) -> Self {
|
||||
self.submit_sequence = Some(sequence.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe le [`Self::submit_delay_ms`] (§20.3, fix Bug 1) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel).
|
||||
#[must_use]
|
||||
pub const fn with_submit_delay_ms(mut self, delay_ms: u32) -> Self {
|
||||
self.submit_delay_ms = Some(delay_ms);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`ProjectorKey`] de permissions (lot LP3) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils sans projection de permission ne l'appellent simplement pas.
|
||||
#[must_use]
|
||||
pub const fn with_projector(mut self, projector: ProjectorKey) -> Self {
|
||||
self.projector = Some(projector);
|
||||
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
|
||||
@ -487,6 +808,33 @@ impl AgentProfile {
|
||||
pub fn is_selectable(&self) -> bool {
|
||||
self.structured_adapter.is_some()
|
||||
}
|
||||
|
||||
/// **Source de vérité UNIQUE** de la whitelist des couples (adaptateur structuré
|
||||
/// × stratégie MCP) qu'IdeA **matérialise réellement** pour exposer les outils
|
||||
/// `idea_*` à la CLI — donc les seuls couples vers lesquels la délégation
|
||||
/// inter-agents (`idea_ask_agent`/`idea_reply`) peut router une cible.
|
||||
///
|
||||
/// Un profil déclare bien une [`McpConfigStrategy`], mais ce n'est honoré que si
|
||||
/// IdeA sait écrire la config que **cette** CLI lit nativement :
|
||||
/// - `Claude` + `ConfigFile { target == ".mcp.json" }` ⇒ Claude lit `.mcp.json`
|
||||
/// dans son cwd (run dir isolé) ;
|
||||
/// - `Codex` + `TomlConfigHome { .. }` ⇒ Codex lit `$CODEX_HOME/config.toml`,
|
||||
/// qu'IdeA isole dans le run dir via `home_env` ;
|
||||
/// - tout autre couple (y compris `mcp` absent) ⇒ `false` : repli fichier
|
||||
/// `.ideai/requests` + prose, le pont natif n'est pas branché.
|
||||
///
|
||||
/// Cette fonction centralise le critère pour que la garde applicative
|
||||
/// ([`crate`] côté application) et la matérialisation ne puissent pas diverger.
|
||||
#[must_use]
|
||||
pub fn materializes_idea_bridge(&self) -> bool {
|
||||
match (self.structured_adapter, self.mcp.as_ref().map(|c| &c.config)) {
|
||||
(Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => {
|
||||
target == ".mcp.json"
|
||||
}
|
||||
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -593,9 +941,8 @@ mod mcp_tests {
|
||||
#[test]
|
||||
fn mcp_config_strategy_uses_tagged_camel_case() {
|
||||
// The `strategy` tag and camelCase rename are part of the wire contract.
|
||||
let json = serde_json::to_string(
|
||||
&McpConfigStrategy::config_file(".mcp.json").expect("valid"),
|
||||
)
|
||||
let json =
|
||||
serde_json::to_string(&McpConfigStrategy::config_file(".mcp.json").expect("valid"))
|
||||
.expect("serialise");
|
||||
assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}");
|
||||
}
|
||||
@ -633,7 +980,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]
|
||||
@ -645,7 +997,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]
|
||||
@ -657,7 +1014,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) --------------
|
||||
@ -703,4 +1065,305 @@ 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));
|
||||
}
|
||||
|
||||
// -- Lot 1 : liveness (readiness/heartbeat) — non-régression de sérialisation --
|
||||
|
||||
#[test]
|
||||
fn profile_default_has_no_liveness() {
|
||||
// Profils existants (via `new`) : aucun réglage de vivacité.
|
||||
assert!(profile_without_mcp().liveness.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_liveness_omits_key_in_json() {
|
||||
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("liveness"),
|
||||
"a profile without liveness must NOT serialise the key (zero regression); got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_liveness_deserialises_to_none() {
|
||||
let legacy = r#"{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"name": "Dev",
|
||||
"command": "claude",
|
||||
"args": [],
|
||||
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||
"detect": null,
|
||||
"cwdTemplate": "{agentRunDir}"
|
||||
}"#;
|
||||
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
|
||||
assert!(profile.liveness.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_liveness_sets_and_round_trips_camel_case() {
|
||||
let liveness = LivenessStrategy::new(Some(30_000), Some(600_000)).expect("valid liveness");
|
||||
let profile = profile_without_mcp().with_liveness(liveness);
|
||||
assert_eq!(profile.liveness, Some(liveness));
|
||||
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(json.contains("liveness"), "key present: {json}");
|
||||
assert!(json.contains("stallAfterMs"), "camelCase field: {json}");
|
||||
assert!(json.contains("turnTimeoutMs"), "camelCase field: {json}");
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(profile, back);
|
||||
assert_eq!(back.liveness, Some(liveness));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liveness_omits_unset_thresholds_in_json() {
|
||||
// Un seul seuil fixé : l'autre est `None` ⇒ sa clé est omise.
|
||||
let liveness = LivenessStrategy::new(None, Some(600_000)).expect("valid liveness");
|
||||
let json = serde_json::to_string(&liveness).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("stallAfterMs"),
|
||||
"an unset stall threshold must be omitted; got: {json}"
|
||||
);
|
||||
assert!(json.contains("turnTimeoutMs"), "set threshold present: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liveness_new_rejects_zero_thresholds() {
|
||||
assert!(matches!(
|
||||
LivenessStrategy::new(Some(0), None).unwrap_err(),
|
||||
DomainError::EmptyField { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
LivenessStrategy::new(None, Some(0)).unwrap_err(),
|
||||
DomainError::EmptyField { .. }
|
||||
));
|
||||
// Les deux None : valide (= « aucun seuil »).
|
||||
assert!(LivenessStrategy::new(None, None).is_ok());
|
||||
}
|
||||
|
||||
// -- Codex : surface MCP `TomlConfigHome` (pont inter-agents Codex) ----------
|
||||
|
||||
#[test]
|
||||
fn toml_config_home_round_trips_with_tagged_strategy() {
|
||||
let strategy = McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||
.expect("valid toml config home");
|
||||
let json = serde_json::to_string(&strategy).expect("serialise");
|
||||
|
||||
// Wire contract: tagged enum (`strategy` tag) + camelCase variant & fields.
|
||||
assert!(
|
||||
json.contains("\"strategy\":\"tomlConfigHome\""),
|
||||
"tagged camelCase variant expected; got: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"target\":\".codex/config.toml\""),
|
||||
"target field expected; got: {json}"
|
||||
);
|
||||
// NB: `rename_all = "camelCase"` on this enum renames *variants*, not the
|
||||
// fields of a struct variant, so `home_env` stays snake_case on the wire.
|
||||
assert!(
|
||||
json.contains("\"home_env\":\"CODEX_HOME\""),
|
||||
"home_env field expected; got: {json}"
|
||||
);
|
||||
|
||||
let back: McpConfigStrategy = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(strategy, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_config_home_rejects_absolute_and_parent_target() {
|
||||
let abs = McpConfigStrategy::toml_config_home("/abs/x", "CODEX_HOME").unwrap_err();
|
||||
assert!(matches!(abs, DomainError::PathNotRelativeSafe { .. }));
|
||||
|
||||
let parent = McpConfigStrategy::toml_config_home("../escape", "CODEX_HOME").unwrap_err();
|
||||
assert!(matches!(parent, DomainError::PathNotRelativeSafe { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_config_home_rejects_invalid_home_env() {
|
||||
// Empty home_env: first char is None ⇒ invalid identifier.
|
||||
let empty = McpConfigStrategy::toml_config_home(".codex/config.toml", "").unwrap_err();
|
||||
assert!(matches!(empty, DomainError::InvalidEnvVar { .. }));
|
||||
|
||||
// Illegal character in the env var name.
|
||||
let illegal =
|
||||
McpConfigStrategy::toml_config_home(".codex/config.toml", "BAD-NAME").unwrap_err();
|
||||
assert!(matches!(illegal, DomainError::InvalidEnvVar { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materializes_idea_bridge_matrix() {
|
||||
// Claude + `.mcp.json` ConfigFile ⇒ bridge materialised.
|
||||
let claude = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
|
||||
McpTransport::Stdio,
|
||||
));
|
||||
assert!(claude.materializes_idea_bridge());
|
||||
|
||||
// Codex + TomlConfigHome ⇒ bridge materialised (the key point: Codex used to
|
||||
// be refused before this surface existed).
|
||||
let codex = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||
.expect("valid toml config home"),
|
||||
McpTransport::Stdio,
|
||||
));
|
||||
assert!(codex.materializes_idea_bridge());
|
||||
|
||||
// Codex WITHOUT any MCP capability ⇒ no bridge.
|
||||
let codex_no_mcp =
|
||||
profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex);
|
||||
assert!(!codex_no_mcp.materializes_idea_bridge());
|
||||
|
||||
// Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge.
|
||||
let codex_wrong_strategy = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
|
||||
McpTransport::Stdio,
|
||||
));
|
||||
assert!(!codex_wrong_strategy.materializes_idea_bridge());
|
||||
}
|
||||
|
||||
// -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------
|
||||
|
||||
#[test]
|
||||
fn profile_default_has_no_projector() {
|
||||
// Profils existants (via `new`) : aucune clé de projecteur ⇒ prompting natif.
|
||||
assert!(profile_without_mcp().projector.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_projector_omits_key_in_json() {
|
||||
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("projector"),
|
||||
"a profile without a projector must NOT serialise the key (zero regression); got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_projector_deserialises_to_none() {
|
||||
// JSON produit avant l'existence du champ `projector` : aucune clé `projector`.
|
||||
let legacy = r#"{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"name": "Dev",
|
||||
"command": "claude",
|
||||
"args": [],
|
||||
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||
"detect": null,
|
||||
"cwdTemplate": "{agentRunDir}"
|
||||
}"#;
|
||||
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
|
||||
assert!(profile.projector.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_projector_sets_and_round_trips_camel_case() {
|
||||
let profile = profile_without_mcp().with_projector(ProjectorKey::Claude);
|
||||
assert_eq!(profile.projector, Some(ProjectorKey::Claude));
|
||||
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(
|
||||
json.contains("\"projector\":\"claude\""),
|
||||
"projector key present in stable wire form: {json}"
|
||||
);
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(profile, back);
|
||||
assert_eq!(back.projector, Some(ProjectorKey::Claude));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_wiring_encodes_expected_toml() {
|
||||
// A command path with a space and a backslash exercises TOML escaping.
|
||||
let wiring = McpServerWiring::new(
|
||||
"/opt/My Apps\\idea".to_owned(),
|
||||
vec![
|
||||
"mcp-server".to_owned(),
|
||||
"--endpoint".to_owned(),
|
||||
"/tmp/sock 1".to_owned(),
|
||||
],
|
||||
McpTransport::Stdio,
|
||||
);
|
||||
let toml = wiring.to_config_toml();
|
||||
|
||||
// Table header present.
|
||||
assert!(
|
||||
toml.contains("[mcp_servers.idea]"),
|
||||
"table header expected; got: {toml}"
|
||||
);
|
||||
// Command path escaped as a TOML basic string (backslash doubled, space kept).
|
||||
assert!(
|
||||
toml.contains("command = \"/opt/My Apps\\\\idea\""),
|
||||
"escaped command path expected; got: {toml}"
|
||||
);
|
||||
// Args preserved in order, as a TOML array.
|
||||
assert!(
|
||||
toml.contains("args = [\"mcp-server\", \"--endpoint\", \"/tmp/sock 1\"]"),
|
||||
"args in order expected; got: {toml}"
|
||||
);
|
||||
// Transport surfaced.
|
||||
assert!(
|
||||
toml.contains("transport = \"stdio\""),
|
||||
"transport expected; got: {toml}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
111
crates/domain/src/readiness.rs
Normal file
111
crates/domain/src/readiness.rs
Normal file
@ -0,0 +1,111 @@
|
||||
//! Politique de **readiness** (« fin-de-tour ») model-agnostique (chantier
|
||||
//! readiness/heartbeat, lot 1).
|
||||
//!
|
||||
//! Objet **pur** (aucune I/O, aucune dépendance externe) qui classe un signal
|
||||
//! observable d'un tour d'agent en un [`ReadinessSignal`] normalisé. Le but : que
|
||||
//! l'application puisse décider de marquer un agent `Idle`
|
||||
//! ([`crate::input::InputMediator::mark_idle`]) sur un **signal déterministe**
|
||||
//! (`Final` du flux structuré) plutôt que de dépendre uniquement d'un `idea_reply`
|
||||
//! explicite ou d'un sniff littéral de prompt PTY.
|
||||
//!
|
||||
//! # Hiérarchie des signaux de fin-de-tour (rappel cadrage)
|
||||
//!
|
||||
//! 1. **Signal n°1 — fin de tour structurée** : [`ReplyEvent::Final`] émis par
|
||||
//! l'adapter (Claude `type:"result"`, Codex `agent_message`/`item.completed`).
|
||||
//! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`].
|
||||
//! 2. **Signal n°2 — `idea_reply` explicite** : l'agent appelle l'outil MCP
|
||||
//! [`crate::ports`]/délégation. Premier arrivé gagne avec le n°1.
|
||||
//! 3. **Signal n°3 — repli `prompt_ready_pattern`** : sniff littéral du sigil de
|
||||
//! prompt dans la sortie PTY ([`crate::profile::AgentProfile::prompt_ready_pattern`]).
|
||||
//! **Rétrogradé** au rang de repli depuis ce lot : il ne sert que pour les agents
|
||||
//! TUI/PTY sans adapter structuré (rétro-compat, jamais supprimé).
|
||||
//!
|
||||
//! Les variantes [`ReadinessSignal::Stalled`]/[`ReadinessSignal::TimedOut`] sont la
|
||||
//! place réservée au **lot 2** (détection de stagnation, remplacement des timeouts) :
|
||||
//! elles existent dans le vocabulaire mais ne sont **pas** produites par
|
||||
//! [`ReadinessPolicy::classify`] dans ce lot.
|
||||
|
||||
use crate::ports::ReplyEvent;
|
||||
|
||||
/// Signal de readiness normalisé, model-agnostique, qu'une [`ReadinessPolicy`]
|
||||
/// déduit d'un événement observable du tour.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReadinessSignal {
|
||||
/// Le tour est **déterministiquement terminé** : l'agent a rendu son `Final`.
|
||||
/// C'est le signal n°1, model-agnostique — il doit réveiller le `pending` et
|
||||
/// marquer l'agent `Idle`.
|
||||
TurnEnded,
|
||||
/// Un `idea_reply` explicite a été observé (signal n°2). N'est **pas** produit
|
||||
/// par [`ReadinessPolicy::classify`] (qui ne voit que des [`ReplyEvent`]) : il
|
||||
/// est porté par le chemin de délégation, présent ici pour compléter le
|
||||
/// vocabulaire et le rendre explicite.
|
||||
ExplicitReply,
|
||||
/// Le sigil de prompt PTY (repli n°3) est apparu. Idem : non produit par
|
||||
/// `classify`, présent pour nommer le signal de repli legacy.
|
||||
PromptReady,
|
||||
/// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place
|
||||
/// réservée au **lot 2** — non produit dans ce lot.
|
||||
Stalled,
|
||||
/// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non
|
||||
/// produit dans ce lot.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Politique **pure** de classification d'un événement de tour en
|
||||
/// [`ReadinessSignal`]. Sans état, sans I/O : un simple `match` sur le contrat de
|
||||
/// port universel [`ReplyEvent`], pour que la décision « ce tour est-il fini ? »
|
||||
/// vive dans le **domaine** et reste testable sans process ni réseau.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct ReadinessPolicy;
|
||||
|
||||
impl ReadinessPolicy {
|
||||
/// Classe un [`ReplyEvent`] en signal de readiness.
|
||||
///
|
||||
/// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul
|
||||
/// événement terminal, il signe la fin de tour déterministe.
|
||||
/// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::ToolActivity`] /
|
||||
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
|
||||
/// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour.
|
||||
#[must_use]
|
||||
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
||||
match event {
|
||||
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn final_classifies_as_turn_ended() {
|
||||
let ev = ReplyEvent::Final {
|
||||
content: "fini".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ev),
|
||||
Some(ReadinessSignal::TurnEnded)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deltas_activities_and_heartbeats_are_non_terminal() {
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ReplyEvent::TextDelta { text: "x".into() }),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ReplyEvent::ToolActivity { label: "lit".into() }),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ReplyEvent::Heartbeat),
|
||||
None,
|
||||
"un heartbeat prouve la vivacité mais ne termine JAMAIS le tour"
|
||||
);
|
||||
}
|
||||
}
|
||||
715
crates/domain/src/sandbox.rs
Normal file
715
crates/domain/src/sandbox.rs
Normal file
@ -0,0 +1,715 @@
|
||||
//! OS-sandbox **contract & planning** (lot LP4-0, domaine pur).
|
||||
//!
|
||||
//! This module owns the *declarative plan* an OS sandbox adapter applies, plus
|
||||
//! the **pure** translation from the resolved [`EffectivePermissions`] into that
|
||||
//! plan. It is the domain half of the «sandbox OS» concern that the permission
|
||||
//! model (`permission.rs`) deliberately left out of scope.
|
||||
//!
|
||||
//! Like the rest of `domain`, it is **pure** (ARCHITECTURE dependency rule): no
|
||||
//! `tokio`, no `std::fs`, no `std::process`, no `landlock`. The concrete Landlock
|
||||
//! adapter (lot LP4-1) and the launch-path wiring (lot LP4-2) live in
|
||||
//! `infrastructure`/`application`; here we only define the contract
|
||||
//! ([`SandboxEnforcer`], [`SandboxPlan`]) and compute the plan
|
||||
//! ([`compile_sandbox_plan`]).
|
||||
//!
|
||||
//! ## Reality bound: Landlock locks **files only**
|
||||
//!
|
||||
//! Landlock can only restrict **filesystem** access. The command capability
|
||||
//! ([`crate::permission::Capability::ExecuteBash`]) cannot be enforced by the OS
|
||||
//! sandbox — argv matching is not a kernel concept — so command rules stay
|
||||
//! **advisory** (honoured by the CLI's own prompting via the LP3 projection),
|
||||
//! never OS-locked. [`compile_sandbox_plan`] therefore only ever derives grants
|
||||
//! from the three **file** capabilities.
|
||||
//!
|
||||
//! ## The fail-closed, per-access-class translation invariant
|
||||
//!
|
||||
//! Landlock is **additive only**: a grant opens a whole directory subtree and a
|
||||
//! sub-path cannot be carved back out. So whenever a `Deny` would fall inside (or
|
||||
//! over) an `Allow`'s granted root without a directory boundary cleanly
|
||||
//! separating them, we **drop the allow** rather than grant a root that would
|
||||
//! leak the denied path. We always prefer losing an allow to leaking a deny — a
|
||||
//! conservative under-approximation.
|
||||
//!
|
||||
//! Crucially this is computed **per access class** (read vs write/delete), never
|
||||
//! capability-blind: a `Deny Write` fences only the write grants, it never
|
||||
//! amputates a `Read` allow. This maximises agent autonomy — over-restricting a
|
||||
//! read because some write was denied would force the agent to keep asking, the
|
||||
//! opposite of the product goal. It is the same per-`capability`+target deny-wins
|
||||
//! as [`crate::permission`], lifted to the directory granularity Landlock works
|
||||
//! at and made fail-closed within each class. Encoded as a testable invariant in
|
||||
//! [`compile_sandbox_plan`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::permission::{Capability, Effect, EffectivePermissions, Posture};
|
||||
|
||||
/// The set of filesystem accesses a [`PathGrant`] opens on its root, as an
|
||||
/// additive bit set.
|
||||
///
|
||||
/// Hand-rolled (no external `bitflags` crate — the domain forbids extra deps).
|
||||
/// The three bits are **independent**: [`PathAccess::RW`] does not imply
|
||||
/// [`PathAccess::RO`]; a grant carries exactly the accesses that were posed.
|
||||
///
|
||||
/// Capability → bit mapping used by [`compile_sandbox_plan`]:
|
||||
/// [`Capability::Read`] ⇒ [`PathAccess::RO`], [`Capability::Write`] and
|
||||
/// [`Capability::Delete`] ⇒ [`PathAccess::RW`]. [`PathAccess::EXEC`] is reserved
|
||||
/// for the adapter/future use — the current permission model carries no file
|
||||
/// execute capability, so the compiler never emits it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct PathAccess(u8);
|
||||
|
||||
impl PathAccess {
|
||||
/// Read access.
|
||||
pub const RO: Self = Self(0b001);
|
||||
/// Write access (create / modify / delete).
|
||||
pub const RW: Self = Self(0b010);
|
||||
/// Execute access (reserved; not emitted by [`compile_sandbox_plan`]).
|
||||
pub const EXEC: Self = Self(0b100);
|
||||
|
||||
/// The empty access set.
|
||||
#[must_use]
|
||||
pub const fn empty() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
/// Whether `self` contains **all** bits of `other`.
|
||||
#[must_use]
|
||||
pub const fn contains(self, other: Self) -> bool {
|
||||
self.0 & other.0 == other.0
|
||||
}
|
||||
|
||||
/// The union of `self` and `other`.
|
||||
#[must_use]
|
||||
pub const fn union(self, other: Self) -> Self {
|
||||
Self(self.0 | other.0)
|
||||
}
|
||||
|
||||
/// Adds the bits of `other` to `self` in place.
|
||||
pub fn insert(&mut self, other: Self) {
|
||||
self.0 |= other.0;
|
||||
}
|
||||
|
||||
/// Whether no bit is set.
|
||||
#[must_use]
|
||||
pub const fn is_empty(self) -> bool {
|
||||
self.0 == 0
|
||||
}
|
||||
|
||||
/// The raw bits.
|
||||
#[must_use]
|
||||
pub const fn bits(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::BitOr for PathAccess {
|
||||
type Output = Self;
|
||||
fn bitor(self, rhs: Self) -> Self {
|
||||
self.union(rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::BitOrAssign for PathAccess {
|
||||
fn bitor_assign(&mut self, rhs: Self) {
|
||||
self.insert(rhs);
|
||||
}
|
||||
}
|
||||
|
||||
/// One granted directory/file root and the accesses it opens.
|
||||
///
|
||||
/// `abs_root` is an **absolute** path (the project root joined with the glob's
|
||||
/// static prefix). The adapter applies it as a Landlock `path_beneath` rule.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PathGrant {
|
||||
/// Absolute root the grant opens (file or directory subtree).
|
||||
pub abs_root: String,
|
||||
/// Accesses opened on that root.
|
||||
pub access: PathAccess,
|
||||
}
|
||||
|
||||
/// The declarative, OS-neutral plan an OS sandbox adapter enforces.
|
||||
///
|
||||
/// It is a **value** (a plan), never an action: the adapter ([`SandboxEnforcer`])
|
||||
/// turns it into a concrete ruleset. `allowed` may legally be empty (a policy that
|
||||
/// posed a posture but no file allow) — that is **not** the same as «no plan»;
|
||||
/// `compile_sandbox_plan` returns `None` for «no plan».
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SandboxPlan {
|
||||
/// The granted roots (deduplicated, deterministic order).
|
||||
pub allowed: Vec<PathGrant>,
|
||||
/// The default posture for paths matched by no grant (mirrors
|
||||
/// [`EffectivePermissions::fallback`]).
|
||||
pub default_posture: Posture,
|
||||
}
|
||||
|
||||
/// Immutable inputs [`compile_sandbox_plan`] interpolates into the absolute roots
|
||||
/// it emits. Borrowed: a plan is computed at the launch site, never stored.
|
||||
pub struct SandboxContext<'a> {
|
||||
/// Absolute project root (glob static prefixes are joined onto this).
|
||||
pub project_root: &'a str,
|
||||
/// Absolute isolated run dir of the agent (`.ideai/run/<agent-id>/`).
|
||||
///
|
||||
/// Reserved for the adapter (the run dir must stay reachable by the agent);
|
||||
/// the pure file-rule translation does not consume it.
|
||||
pub run_dir: &'a str,
|
||||
}
|
||||
|
||||
/// Which concrete OS-sandbox mechanism an [`SandboxEnforcer`] implements.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SandboxKind {
|
||||
/// Linux Landlock LSM.
|
||||
Landlock,
|
||||
/// No OS sandbox available on this platform/build.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// The outcome of applying a [`SandboxPlan`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SandboxStatus {
|
||||
/// The plan was fully enforced by the OS.
|
||||
Enforced,
|
||||
/// No OS sandbox is available; nothing was enforced (the caller keeps the
|
||||
/// advisory LP3 projection as its only guard).
|
||||
Unsupported,
|
||||
/// The plan was only **partially** enforced; the string explains what was
|
||||
/// dropped (e.g. an older Landlock ABI lacking a needed access right).
|
||||
Degraded(String),
|
||||
}
|
||||
|
||||
/// Errors an [`SandboxEnforcer::enforce`] may return.
|
||||
///
|
||||
/// An adapter raises these only for **fail-closed** situations it must surface to
|
||||
/// the caller (kernel too old when a `Deny` posture demands enforcement, ruleset
|
||||
/// application failure). A platform that simply has no sandbox does **not** error:
|
||||
/// it returns [`SandboxStatus::Unsupported`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum SandboxError {
|
||||
/// The running kernel / Landlock ABI is too old to honour a plan that must be
|
||||
/// enforced (fail-closed: a required `Deny` cannot be guaranteed).
|
||||
#[error("kernel/landlock too old to enforce sandbox: {0}")]
|
||||
KernelTooOld(String),
|
||||
/// Building or applying the OS ruleset failed.
|
||||
#[error("failed to apply sandbox ruleset: {0}")]
|
||||
Ruleset(String),
|
||||
}
|
||||
|
||||
/// The OS-sandbox **port**: applies a [`SandboxPlan`] to the current process.
|
||||
///
|
||||
/// ## Liskov contract
|
||||
///
|
||||
/// `enforce` is a one-shot, **irreversible** tightening of the *current* process
|
||||
/// and is meant to be called **after `fork`, before `exec`, in the child** — never
|
||||
/// on the IdeA process itself. Every implementation must honour:
|
||||
///
|
||||
/// - it only ever **removes** access (a sandbox never grants more than the host);
|
||||
/// - an [`SandboxKind::Unsupported`] adapter is a valid no-op: its `enforce`
|
||||
/// returns `Ok(`[`SandboxStatus::Unsupported`]`)` and changes nothing;
|
||||
/// - `enforce` is total for any well-formed [`SandboxPlan`]; it returns
|
||||
/// [`SandboxError`] only for the fail-closed cases above.
|
||||
pub trait SandboxEnforcer: Send + Sync {
|
||||
/// Which mechanism this enforcer implements.
|
||||
fn kind(&self) -> SandboxKind;
|
||||
|
||||
/// Applies `plan` to the current process (post-fork / pre-exec, in the child).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`SandboxError`] only for fail-closed situations (cf. the type docs); an
|
||||
/// absent sandbox returns `Ok(`[`SandboxStatus::Unsupported`]`)`.
|
||||
fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError>;
|
||||
}
|
||||
|
||||
/// Compiles the resolved [`EffectivePermissions`] into a [`SandboxPlan`].
|
||||
///
|
||||
/// **Pure**: only computes a plan; writes nothing, touches no I/O.
|
||||
///
|
||||
/// ## Invariants
|
||||
///
|
||||
/// 1. **`eff == None ⇒ None`** — nothing posed ⇒ no sandbox ⇒ the agent runs
|
||||
/// natively (mirrors [`crate::permission::resolve`]'s product invariant). A
|
||||
/// `Some` result (even with an empty `allowed`) means IdeA has a policy to
|
||||
/// enforce.
|
||||
/// 2. Only the three **file** capabilities feed the grants; bash rules are
|
||||
/// skipped (Landlock cannot lock command execution — they stay advisory).
|
||||
/// 3. **Per-access-class, fail-closed roots**: grants are computed **separately
|
||||
/// for each access class** so an agent keeps the widest autonomy possible.
|
||||
/// - The **RO** class is fed by `Allow Read`; its fences are `Deny Read`.
|
||||
/// - The **RW** class is fed by `Allow Write` **and** `Allow Delete`; its
|
||||
/// fences are `Deny Write` **and** `Deny Delete`.
|
||||
///
|
||||
/// Within a class, each `Allow` glob is reduced to its *static prefix* root and
|
||||
/// **dropped** if a fence **of the same class** overlaps it (equal, ancestor, or
|
||||
/// descendant) — an additive sandbox cannot carve a sub-path deny out of a
|
||||
/// granted subtree, so we lose the allow rather than leak the deny. A fence of
|
||||
/// the **other** class has **no effect**: a `Deny Write` never amputates an
|
||||
/// `Allow Read` (that would over-restrict and force the agent to keep asking).
|
||||
/// This is exactly the per-`capability`+target deny-wins of
|
||||
/// [`crate::permission`], applied at the (coarser) directory granularity Landlock
|
||||
/// allows, fail-closed inside each class.
|
||||
///
|
||||
/// The surviving roots of both classes are then merged by `abs_root`, unioning
|
||||
/// their accesses: a root surviving only in RO ⇒ [`PathAccess::RO`]; surviving
|
||||
/// in both ⇒ `RO | RW`; surviving only in RW ⇒ [`PathAccess::RW`].
|
||||
#[must_use]
|
||||
pub fn compile_sandbox_plan(
|
||||
eff: Option<&EffectivePermissions>,
|
||||
ctx: &SandboxContext,
|
||||
) -> Option<SandboxPlan> {
|
||||
let eff = eff?;
|
||||
|
||||
// 1. Collect the static-prefix fences of every Deny file rule, **per access
|
||||
// class** (RO = Deny Read; RW = Deny Write/Delete). A fence only ever
|
||||
// blocks allows of its own class. Kept relative for the overlap tests.
|
||||
let mut ro_fences: Vec<String> = Vec::new();
|
||||
let mut rw_fences: Vec<String> = Vec::new();
|
||||
for rule in eff.rules() {
|
||||
if rule.effect() != Effect::Deny {
|
||||
continue;
|
||||
}
|
||||
let fences = match access_class(rule.capability()) {
|
||||
Some(PathAccess::RO) => &mut ro_fences,
|
||||
Some(PathAccess::RW) => &mut rw_fences,
|
||||
// Non-file (ExecuteBash) ⇒ no class ⇒ never a filesystem fence.
|
||||
_ => continue,
|
||||
};
|
||||
for glob in rule.paths().globs() {
|
||||
fences.push(static_prefix(glob.pattern()));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. For each Allow file rule, reduce every glob to its static-prefix root and
|
||||
// keep it only if no same-class fence overlaps it. Merge by relative root,
|
||||
// unioning the access bits across classes.
|
||||
let mut grants: Vec<(String, PathAccess)> = Vec::new();
|
||||
for rule in eff.rules() {
|
||||
if rule.effect() != Effect::Allow {
|
||||
continue;
|
||||
}
|
||||
let Some(access) = access_class(rule.capability()) else {
|
||||
continue;
|
||||
};
|
||||
let fences: &[String] = if access == PathAccess::RO {
|
||||
&ro_fences
|
||||
} else {
|
||||
&rw_fences
|
||||
};
|
||||
for glob in rule.paths().globs() {
|
||||
let rel = static_prefix(glob.pattern());
|
||||
if fences.iter().any(|d| paths_conflict(&rel, d)) {
|
||||
// A same-class deny falls inside/over this root ⇒ we cannot grant
|
||||
// it for this class without leaking the deny.
|
||||
continue;
|
||||
}
|
||||
match grants.iter_mut().find(|(r, _)| *r == rel) {
|
||||
Some((_, acc)) => acc.insert(access),
|
||||
None => grants.push((rel, access)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Join the surviving relative roots onto the absolute project root.
|
||||
let base = ctx.project_root.trim_end_matches('/');
|
||||
let allowed = grants
|
||||
.into_iter()
|
||||
.map(|(rel, access)| PathGrant {
|
||||
abs_root: join_root(base, &rel),
|
||||
access,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(SandboxPlan {
|
||||
allowed,
|
||||
default_posture: eff.fallback(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The access **class** a file capability belongs to: [`Capability::Read`] ⇒
|
||||
/// [`PathAccess::RO`]; [`Capability::Write`]/[`Capability::Delete`] ⇒
|
||||
/// [`PathAccess::RW`]. Non-file capabilities ([`Capability::ExecuteBash`]) have no
|
||||
/// class (`None`) — they never produce a filesystem grant or fence.
|
||||
fn access_class(cap: Capability) -> Option<PathAccess> {
|
||||
match cap {
|
||||
Capability::Read => Some(PathAccess::RO),
|
||||
Capability::Write | Capability::Delete => Some(PathAccess::RW),
|
||||
Capability::ExecuteBash => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The **static prefix** of a glob: the leading literal directory path before the
|
||||
/// first glob metacharacter (`*`, `?`, `[`), with any trailing `/` trimmed.
|
||||
///
|
||||
/// - `"**"` / `"*.rs"` ⇒ `""` (the project root itself),
|
||||
/// - `"src/**"` / `"src/*.rs"` ⇒ `"src"`,
|
||||
/// - `"a/b/**/*.rs"` ⇒ `"a/b"`,
|
||||
/// - `"src/foo.rs"` (no metachar) ⇒ `"src/foo.rs"` (the file itself).
|
||||
fn static_prefix(pattern: &str) -> String {
|
||||
let first_meta = pattern.find(['*', '?', '[']);
|
||||
let literal = match first_meta {
|
||||
// Cut back to the last '/' *before* the metacharacter: everything up to
|
||||
// and including that slash is a settled directory path.
|
||||
Some(idx) => match pattern[..idx].rfind('/') {
|
||||
Some(slash) => &pattern[..slash],
|
||||
None => "",
|
||||
},
|
||||
// No metacharacter: the whole pattern is a literal path.
|
||||
None => pattern,
|
||||
};
|
||||
literal.trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// Whether two project-relative roots overlap such that an additive sandbox could
|
||||
/// not keep them apart: they are equal, or one is a path-ancestor of the other.
|
||||
fn paths_conflict(a: &str, b: &str) -> bool {
|
||||
a == b || is_descendant(a, b) || is_descendant(b, a)
|
||||
}
|
||||
|
||||
/// Whether `child` is a **strict** path-descendant of `ancestor` (component
|
||||
/// boundary aware). The empty string denotes the project root, an ancestor of
|
||||
/// every non-empty path.
|
||||
fn is_descendant(child: &str, ancestor: &str) -> bool {
|
||||
if ancestor.is_empty() {
|
||||
return !child.is_empty();
|
||||
}
|
||||
child.len() > ancestor.len()
|
||||
&& child.starts_with(ancestor)
|
||||
&& child.as_bytes()[ancestor.len()] == b'/'
|
||||
}
|
||||
|
||||
/// Joins a relative root onto the absolute base. An empty `rel` denotes the base
|
||||
/// itself.
|
||||
fn join_root(base: &str, rel: &str) -> String {
|
||||
if rel.is_empty() {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}/{rel}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::permission::{PathScope, PermissionRule, PermissionSet, resolve};
|
||||
|
||||
// ---- helpers ---------------------------------------------------------
|
||||
|
||||
const ROOT: &str = "/home/anthony/proj";
|
||||
|
||||
fn ctx() -> SandboxContext<'static> {
|
||||
SandboxContext {
|
||||
project_root: ROOT,
|
||||
run_dir: "/home/anthony/proj/.ideai/run/agent-x",
|
||||
}
|
||||
}
|
||||
|
||||
fn scope(patterns: &[&str]) -> PathScope {
|
||||
PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap()
|
||||
}
|
||||
|
||||
fn file_rule(cap: Capability, effect: Effect, patterns: &[&str]) -> PermissionRule {
|
||||
PermissionRule::file(cap, effect, scope(patterns)).unwrap()
|
||||
}
|
||||
|
||||
/// Resolves a single-set policy into `EffectivePermissions` (project set only).
|
||||
fn eff(rules: Vec<PermissionRule>, fallback: Posture) -> EffectivePermissions {
|
||||
let set = PermissionSet::new(rules, fallback);
|
||||
resolve(Some(&set), None).unwrap()
|
||||
}
|
||||
|
||||
fn grant<'a>(plan: &'a SandboxPlan, abs_root: &str) -> Option<&'a PathGrant> {
|
||||
plan.allowed.iter().find(|g| g.abs_root == abs_root)
|
||||
}
|
||||
|
||||
// ---- invariant 1: presence of a policy != non-empty grants ----------
|
||||
|
||||
#[test]
|
||||
fn none_eff_yields_no_plan() {
|
||||
// Nothing posed ⇒ no sandbox ⇒ the agent runs natively.
|
||||
assert!(compile_sandbox_plan(None, &ctx()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_with_no_allow_still_yields_some_plan() {
|
||||
// A posed policy with an empty `allowed` is NOT the same as "no plan":
|
||||
// it must compile to `Some` with an empty grant list.
|
||||
let e = eff(vec![], Posture::Deny);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).expect("a posed policy yields a plan");
|
||||
assert!(
|
||||
plan.allowed.is_empty(),
|
||||
"no allow rule ⇒ no grant, but still a plan"
|
||||
);
|
||||
|
||||
// Same with only a Deny file rule present (still a policy, still no grant).
|
||||
let e = eff(
|
||||
vec![file_rule(Capability::Write, Effect::Deny, &["secret/**"])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).expect("deny-only is still a plan");
|
||||
assert!(plan.allowed.is_empty());
|
||||
}
|
||||
|
||||
// ---- invariant 2: capability → access mapping; bash never a grant ----
|
||||
|
||||
#[test]
|
||||
fn read_maps_to_ro() {
|
||||
let e = eff(
|
||||
vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
let g = grant(&plan, "/home/anthony/proj/src").expect("src granted");
|
||||
assert_eq!(g.access, PathAccess::RO);
|
||||
assert!(!g.access.contains(PathAccess::RW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_delete_map_to_rw() {
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Write, Effect::Allow, &["out/**"]),
|
||||
file_rule(Capability::Delete, Effect::Allow, &["tmp/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert_eq!(
|
||||
grant(&plan, "/home/anthony/proj/out").unwrap().access,
|
||||
PathAccess::RW
|
||||
);
|
||||
assert_eq!(
|
||||
grant(&plan, "/home/anthony/proj/tmp").unwrap().access,
|
||||
PathAccess::RW
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_only_policy_produces_no_path_grant() {
|
||||
// ExecuteBash is never translated to a PathGrant (Landlock = files only).
|
||||
let e = eff(
|
||||
vec![
|
||||
PermissionRule::bash(Effect::Allow, vec![]),
|
||||
PermissionRule::bash(
|
||||
Effect::Deny,
|
||||
vec![crate::permission::CommandRule::new(
|
||||
crate::permission::CommandMatcher::prefix("rm ").unwrap(),
|
||||
Effect::Deny,
|
||||
)],
|
||||
),
|
||||
],
|
||||
Posture::Allow,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(
|
||||
plan.allowed.is_empty(),
|
||||
"a purely-bash policy yields zero PathGrant"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- invariant 3: fail-closed glob → static-prefix translation -------
|
||||
|
||||
#[test]
|
||||
fn root_glob_with_same_class_deny_drops_root_grant() {
|
||||
// Allow Read `**` (root) + a single SAME-CLASS Deny Read file ⇒ the RO root
|
||||
// grant is abandoned: an additive sandbox cannot carve the denied file back
|
||||
// out of the granted subtree (fail-closed within the RO class).
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["**"]),
|
||||
file_rule(Capability::Read, Effect::Deny, &["secret.txt"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(
|
||||
plan.allowed.is_empty(),
|
||||
"a same-class deny under the root fence drops the root grant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descendant_same_class_deny_drops_overlapping_allow() {
|
||||
// Allow Read `src/**` (root `src`) + Deny Read `src/secret/**`
|
||||
// (root `src/secret`): the same-class deny is a descendant of the granted
|
||||
// root ⇒ the RO grant on `src` is dropped (fail-closed within RO).
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Read, Effect::Deny, &["src/secret/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(
|
||||
grant(&plan, "/home/anthony/proj/src").is_none(),
|
||||
"an inside same-class deny abandons the enclosing allow"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_class_deny_does_not_amputate_read_allow() {
|
||||
// The DUAL of the case above and the key autonomy guarantee: a Deny of a
|
||||
// DIFFERENT class (Write) over a descendant must NOT touch the Read allow.
|
||||
// `Allow Read src/**` + `Deny Write src/secret/**` ⇒ `src` keeps its RO.
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Write, Effect::Deny, &["src/secret/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert_eq!(
|
||||
grant(&plan, "/home/anthony/proj/src").map(|g| g.access),
|
||||
Some(PathAccess::RO),
|
||||
"a Deny Write must never amputate an Allow Read (per-class autonomy)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_same_class_deny_keeps_allow() {
|
||||
// Allow Read `src/**` + SAME-CLASS Deny Read `other/**`: disjoint roots ⇒
|
||||
// grant `src` kept. Same-class fence so the test proves disjointness, not
|
||||
// merely a class mismatch.
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Read, Effect::Deny, &["other/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert_eq!(
|
||||
grant(&plan, "/home/anthony/proj/src").unwrap().access,
|
||||
PathAccess::RO,
|
||||
"a disjoint same-class deny does not touch the allow"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestor_same_class_deny_also_drops_allow() {
|
||||
// Deny Read `src/**` (root `src`) is an ancestor of Allow Read `src/sub/**`
|
||||
// (root `src/sub`) ⇒ same-class overlap ⇒ allow dropped (fence symmetry).
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/sub/**"]),
|
||||
file_rule(Capability::Read, Effect::Deny, &["src/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(grant(&plan, "/home/anthony/proj/src/sub").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_prefix_is_not_a_descendant() {
|
||||
// Component-boundary awareness: `src2` must NOT be treated as inside `src`
|
||||
// just because the string `src` is a prefix of `src2`. Uses a SAME-CLASS
|
||||
// (Deny Read) fence so the survival proves boundary-awareness, not a class
|
||||
// mismatch.
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src2/**"]),
|
||||
file_rule(Capability::Read, Effect::Deny, &["src/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(
|
||||
grant(&plan, "/home/anthony/proj/src2").is_some(),
|
||||
"`src2` is a sibling of `src`, not a descendant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accesses_union_on_a_shared_root() {
|
||||
// Read + Write allows on the same root merge into RO|RW on one grant.
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Write, Effect::Allow, &["src/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
let g = grant(&plan, "/home/anthony/proj/src").expect("single merged grant");
|
||||
assert!(g.access.contains(PathAccess::RO));
|
||||
assert!(g.access.contains(PathAccess::RW));
|
||||
assert_eq!(
|
||||
plan.allowed
|
||||
.iter()
|
||||
.filter(|g| g.abs_root == "/home/anthony/proj/src")
|
||||
.count(),
|
||||
1,
|
||||
"the two allows merge onto one root, not two grants"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_root_drops_rw_but_keeps_ro_under_a_write_deny() {
|
||||
// Direct proof of per-access-class granularity on a single root:
|
||||
// `Allow Read src/**` + `Allow Write src/**` + `Deny Write src/secret/**`.
|
||||
// The RW class is fenced (deny descendant) ⇒ RW dropped; the RO class has no
|
||||
// fence ⇒ RO survives. The grant on `src` ends up RO-only.
|
||||
let e = eff(
|
||||
vec![
|
||||
file_rule(Capability::Read, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Write, Effect::Allow, &["src/**"]),
|
||||
file_rule(Capability::Write, Effect::Deny, &["src/secret/**"]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
let g = grant(&plan, "/home/anthony/proj/src").expect("RO survives the write deny");
|
||||
assert_eq!(
|
||||
g.access,
|
||||
PathAccess::RO,
|
||||
"RW class fenced out, RO class preserved on the same root"
|
||||
);
|
||||
assert!(!g.access.contains(PathAccess::RW), "the RW grant was dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_prefix_of_literal_file_is_the_file_itself() {
|
||||
// A metacharacter-free allow grants exactly that file path.
|
||||
let e = eff(
|
||||
vec![file_rule(Capability::Read, Effect::Allow, &["src/lib.rs"])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert!(grant(&plan, "/home/anthony/proj/src/lib.rs").is_some());
|
||||
}
|
||||
|
||||
// ---- invariant 4: residual posture reflected in the plan -------------
|
||||
|
||||
#[test]
|
||||
fn default_posture_mirrors_resolved_fallback() {
|
||||
for posture in [Posture::Allow, Posture::Ask, Posture::Deny] {
|
||||
let e = eff(
|
||||
vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])],
|
||||
posture,
|
||||
);
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap();
|
||||
assert_eq!(plan.default_posture, posture);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_slash_on_project_root_is_normalised() {
|
||||
let e = eff(
|
||||
vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let ctx = SandboxContext {
|
||||
project_root: "/home/anthony/proj/",
|
||||
run_dir: "/x",
|
||||
};
|
||||
let plan = compile_sandbox_plan(Some(&e), &ctx).unwrap();
|
||||
assert!(
|
||||
grant(&plan, "/home/anthony/proj/src").is_some(),
|
||||
"the trailing slash must not produce `//`"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -194,13 +194,19 @@ fn leaf_returns_none_for_grid_node_id() {
|
||||
// L'id de la grille n'est pas une feuille.
|
||||
assert!(grid.leaf(node(50)).is_none());
|
||||
// La feuille imbriquée est bien retrouvée.
|
||||
assert_eq!(*grid.leaf(node(3)).expect("nested leaf"), leaf_cell(3, None));
|
||||
assert_eq!(
|
||||
*grid.leaf(node(3)).expect("nested leaf"),
|
||||
leaf_cell(3, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_finds_single_root_leaf() {
|
||||
let tree = LayoutTree::single(leaf_cell(7, Some(70)));
|
||||
assert_eq!(*tree.leaf(node(7)).expect("root leaf"), leaf_cell(7, Some(70)));
|
||||
assert_eq!(
|
||||
*tree.leaf(node(7)).expect("root leaf"),
|
||||
leaf_cell(7, Some(70))
|
||||
);
|
||||
assert!(tree.leaf(node(8)).is_none());
|
||||
}
|
||||
|
||||
|
||||
@ -837,12 +837,7 @@ fn move_session_preserves_resume_fields_on_both_leaves() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Builds an agent-bearing leaf with explicit resume signals.
|
||||
fn agent_leaf_full(
|
||||
id: u128,
|
||||
agent: u128,
|
||||
conv: Option<&str>,
|
||||
running: bool,
|
||||
) -> LeafCell {
|
||||
fn agent_leaf_full(id: u128, agent: u128, conv: Option<&str>, running: bool) -> LeafCell {
|
||||
LeafCell {
|
||||
id: node(id),
|
||||
session: None,
|
||||
|
||||
@ -150,7 +150,9 @@ fn profile_with_adapter_roundtrips_and_uses_camel_case() {
|
||||
#[test]
|
||||
fn with_structured_adapter_sets_only_that_field() {
|
||||
let before = pty_profile();
|
||||
let after = before.clone().with_structured_adapter(StructuredAdapter::Codex);
|
||||
let after = before
|
||||
.clone()
|
||||
.with_structured_adapter(StructuredAdapter::Codex);
|
||||
|
||||
// Le seul champ muté :
|
||||
assert_eq!(after.structured_adapter, Some(StructuredAdapter::Codex));
|
||||
@ -275,7 +277,10 @@ fn agent_session_error_is_std_error_and_equates() {
|
||||
AgentSessionError::Start("a".into()),
|
||||
AgentSessionError::Start("b".into())
|
||||
);
|
||||
assert_ne!(AgentSessionError::Timeout, AgentSessionError::Io("t".into()));
|
||||
assert_ne!(
|
||||
AgentSessionError::Timeout,
|
||||
AgentSessionError::Io("t".into())
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -194,4 +194,94 @@ mod tests {
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
assert!(reg.get(ConversationId::new_random()).is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bloc 1 — déterminisme du registre (§19 : la clé de persistance = id de paire,
|
||||
// déterministe et **stable au redémarrage**). Scelle l'invariant que Main a
|
||||
// rattrapé : deux instances neuves du registre (= deux démarrages de l'IDE)
|
||||
// doivent dériver **le même** id pour la même paire, et cet id doit être
|
||||
// **exactement** `ConversationId::for_pair`, sinon la clé sous laquelle le
|
||||
// handoff/log est rangé dériverait au redémarrage.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resolve_is_stable_across_registry_restart() {
|
||||
// Deux instances neuves (by_pair/by_id vides) simulent deux démarrages de
|
||||
// l'IDE : la même paire User↔Agent doit produire le **même** id.
|
||||
let a = agent(7);
|
||||
let first = InMemoryConversationRegistry::new()
|
||||
.resolve(ConversationParty::User, a)
|
||||
.id;
|
||||
let second = InMemoryConversationRegistry::new()
|
||||
.resolve(ConversationParty::User, a)
|
||||
.id;
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"id de paire stable au redémarrage (registre neuf ⇒ même id)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_is_stable_across_registry_restart_agent_agent() {
|
||||
// Même garantie pour une paire Agent↔Agent (dérivation XOR commutative).
|
||||
let x = agent(11);
|
||||
let y = agent(13);
|
||||
let first = InMemoryConversationRegistry::new().resolve(x, y).id;
|
||||
let second = InMemoryConversationRegistry::new().resolve(y, x).id;
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"id de paire Agent↔Agent stable au redémarrage, insensible à l'ordre"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_id_equals_for_pair_user_agent() {
|
||||
// Alignement de clé : l'id que le registre matérialise == le repli pur
|
||||
// `for_pair` == `from_uuid(agent)` (la clé que P8a/`resolve_conversation`
|
||||
// dérivent pour la paire canonique User↔Agent).
|
||||
let agent_id = AgentId::from_uuid(uuid::Uuid::from_u128(42));
|
||||
let party = ConversationParty::agent(agent_id);
|
||||
let resolved = InMemoryConversationRegistry::new()
|
||||
.resolve(ConversationParty::User, party)
|
||||
.id;
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ConversationId::for_pair(ConversationParty::User, party),
|
||||
"resolve == for_pair (alignement de clé)"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ConversationId::from_uuid(agent_id.as_uuid()),
|
||||
"User↔Agent ⇒ id == uuid de l'agent (repli resolve_conversation)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_id_equals_for_pair_agent_agent_commutative() {
|
||||
// Commutativité et alignement sur `for_pair` pour Agent↔Agent.
|
||||
let x = agent(101);
|
||||
let y = agent(202);
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
let id_xy = reg.resolve(x, y).id;
|
||||
let id_yx = reg.resolve(y, x).id;
|
||||
assert_eq!(id_xy, id_yx, "resolve(a,b) == resolve(b,a)");
|
||||
assert_eq!(
|
||||
id_xy,
|
||||
ConversationId::for_pair(x, y),
|
||||
"resolve == for_pair (Agent↔Agent)"
|
||||
);
|
||||
assert_eq!(reg.len(), 1, "une seule conversation pour la paire {{a,b}}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_pairs_yield_distinct_ids_across_kinds() {
|
||||
// Deux paires distinctes ⇒ deux ids distincts (pas de collision de clé).
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
let user_a = reg.resolve(ConversationParty::User, agent(1)).id;
|
||||
let user_b = reg.resolve(ConversationParty::User, agent(2)).id;
|
||||
let a_b = reg.resolve(agent(1), agent(2)).id;
|
||||
assert_ne!(user_a, user_b, "User↔A ≠ User↔B");
|
||||
assert_ne!(user_a, a_b, "User↔A ≠ A↔B");
|
||||
assert_ne!(user_b, a_b, "User↔B ≠ A↔B");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -54,6 +54,15 @@ impl FileSystem for LocalFileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
match fs::remove_file(path.as_str()).await {
|
||||
Ok(()) => Ok(()),
|
||||
// Idempotent best-effort delete: an already-absent file is success.
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(map_io(path, &e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
fs::create_dir_all(path.as_str())
|
||||
.await
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -24,6 +24,7 @@ pub mod input;
|
||||
pub mod inspector;
|
||||
pub mod mailbox;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod process;
|
||||
pub mod pty;
|
||||
pub mod remote;
|
||||
@ -38,10 +39,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};
|
||||
@ -49,6 +50,7 @@ pub use orchestrator::{
|
||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||
REQUESTS_SUBDIR,
|
||||
};
|
||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||
pub use process::LocalProcessSpawner;
|
||||
pub use pty::PortablePtyAdapter;
|
||||
pub use remote::{remote_host, LocalHost};
|
||||
@ -61,8 +63,8 @@ pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
|
||||
pub use store::{
|
||||
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||
AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsMemoryStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, HashEmbedder,
|
||||
IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore,
|
||||
FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo,
|
||||
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
@ -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)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,10 +18,12 @@
|
||||
//! duplicated here.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use application::OrchestratorService;
|
||||
use domain::{DomainEvent, OrchestrationSource, Project};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::jsonrpc::{
|
||||
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
||||
@ -32,6 +34,18 @@ use super::tools::{self, ToolMapError};
|
||||
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
||||
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
||||
|
||||
/// **Server-side safety net** bounding the synchronous `idea_ask_agent` rendezvous.
|
||||
///
|
||||
/// `idea_ask_agent` is the only tool whose `tools/call` *blocks* awaiting another
|
||||
/// agent's `idea_reply` (a synchronous rendezvous, resolved deep in the application
|
||||
/// layer). The application layer already bounds the rendezvous itself, but this
|
||||
/// adapter adds its own **finite** outer bound so a wedged target can never park a
|
||||
/// `tools/call` task forever: on expiry the call returns a clean JSON-RPC error
|
||||
/// instead of hanging. Generous on purpose (delegated turns can be very long), but
|
||||
/// never infinite. Every other tool (`idea_reply`, `idea_list_agents`, …) is left
|
||||
/// untouched — they don't rendezvous.
|
||||
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
|
||||
///
|
||||
/// Cheap to clone the dependencies it holds; one instance serves one project's
|
||||
@ -53,6 +67,18 @@ pub struct McpServer {
|
||||
/// frozen `"mcp"` placeholder; empty ⇒ the legacy `"mcp"` label (back-compat for
|
||||
/// M2 callers and connections that arrive without a requester).
|
||||
requester: String,
|
||||
/// Sink optionnel de **readiness de démarrage** : appelé avec le `requester` brut
|
||||
/// (handshake) la première fois que le peer émet `initialize` (son CLI est up et
|
||||
/// parle MCP). La composition root y branche
|
||||
/// `OrchestratorService::release_agent_cold_start` pour livrer un 1er tour différé
|
||||
/// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
|
||||
/// composition root parse le `requester`. `None` ⇒ no-op.
|
||||
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||
/// Finite outer bound applied **only** to the `idea_ask_agent` rendezvous (see
|
||||
/// [`ASK_RENDEZVOUS_TIMEOUT`]). Carried per instance so tests can shrink it to a
|
||||
/// few milliseconds without polluting the public API; production code always uses
|
||||
/// the default.
|
||||
ask_rendezvous_timeout: Duration,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@ -66,6 +92,8 @@ impl McpServer {
|
||||
project,
|
||||
events: None,
|
||||
requester: String::new(),
|
||||
ready_sink: None,
|
||||
ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,6 +107,17 @@ impl McpServer {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attache un sink de **readiness de démarrage** : appelé avec l'id (handshake
|
||||
/// `requester`) de l'agent connecté la première fois qu'il émet `initialize` (son CLI
|
||||
/// est up et parle MCP). La composition root y branche
|
||||
/// `OrchestratorService::release_agent_cold_start` pour livrer un éventuel 1er tour
|
||||
/// différé (fix race cold-launch, signal MCP). Additif : sans sink, no-op.
|
||||
#[must_use]
|
||||
pub fn with_ready_sink(mut self, ready_sink: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
|
||||
self.ready_sink = Some(ready_sink);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a per-connection clone of this server tagged with the connected
|
||||
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
|
||||
///
|
||||
@ -94,9 +133,22 @@ impl McpServer {
|
||||
project: self.project.clone(),
|
||||
events: self.events.clone(),
|
||||
requester: requester.into(),
|
||||
ready_sink: self.ready_sink.clone(),
|
||||
ask_rendezvous_timeout: self.ask_rendezvous_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
/// **Test seam** (doc-hidden): shrinks the `idea_ask_agent` rendezvous bound
|
||||
/// (see [`ASK_RENDEZVOUS_TIMEOUT`]) so a wedged-target test need not wait the
|
||||
/// full production timeout. Doc-hidden so it does not widen the documented public
|
||||
/// surface; production code never calls it.
|
||||
#[doc(hidden)]
|
||||
#[must_use]
|
||||
pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.ask_rendezvous_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Serves JSON-RPC messages from `transport`, tagging every processed
|
||||
/// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4).
|
||||
///
|
||||
@ -113,21 +165,78 @@ impl McpServer {
|
||||
/// Every inbound line is handled in isolation: a malformed line or an unknown
|
||||
/// method yields a JSON-RPC error response, **never a panic** and never a
|
||||
/// dropped connection. Notifications (no `id`) are processed without a reply.
|
||||
///
|
||||
/// **Full-duplex / non-blocking (anti-wedge).** A request whose handling blocks —
|
||||
/// notably `idea_ask_agent`, which awaits another agent's `idea_reply` in a
|
||||
/// synchronous rendezvous — must **not** stall the read loop: otherwise a single
|
||||
/// in-flight ask parks the whole connection and every later call (even a
|
||||
/// rendezvous-free `idea_list_agents`) is never even read. So each inbound message
|
||||
/// is handled on its own spawned task that owns a cheap per-call clone of the
|
||||
/// server; finished responses are funnelled back through an `mpsc` channel and
|
||||
/// written by the same loop. Responses carry their JSON-RPC `id`, so out-of-order
|
||||
/// completion is fine (the full-duplex bridge correlates by id). Notifications
|
||||
/// (`handle_raw` ⇒ `None`) emit nothing.
|
||||
pub async fn serve<T: Transport>(&self, transport: &mut T) {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<Option<Vec<u8>>>();
|
||||
// Number of spawned handler tasks not yet observed on `rx`. While `> 0`, an
|
||||
// EOF on the read side must NOT exit immediately: we keep draining `rx` so
|
||||
// already-computed responses still reach the transport (graceful shutdown).
|
||||
// A response-less notification decrements without ever sending — see below.
|
||||
let mut in_flight: usize = 0;
|
||||
// Once the peer closed its read side, stop accepting new inbound; only drain.
|
||||
let mut reading = true;
|
||||
loop {
|
||||
let raw = match transport.recv().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(TransportError::Closed) => break,
|
||||
Err(TransportError::Io(_)) => break,
|
||||
};
|
||||
if let Some(response) = self.handle_raw(&raw).await {
|
||||
let Ok(bytes) = serde_json::to_vec(&response) else {
|
||||
continue;
|
||||
};
|
||||
tokio::select! {
|
||||
// Drain ready responses first so a flood of inbound never starves
|
||||
// writes; correctness does not depend on the bias.
|
||||
biased;
|
||||
|
||||
outbound = rx.recv() => {
|
||||
// `tx` is held by the loop, so `recv` only yields `None` once it
|
||||
// is dropped — which never happens before the loop returns.
|
||||
let Some(slot) = outbound else { break };
|
||||
in_flight -= 1;
|
||||
if let Some(bytes) = slot {
|
||||
if transport.send(&bytes).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Peer gone and every spawned task accounted for ⇒ done.
|
||||
if !reading && in_flight == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
incoming = transport.recv(), if reading => {
|
||||
let raw = match incoming {
|
||||
Ok(bytes) => bytes,
|
||||
// Peer closed / I/O error: stop reading but keep draining the
|
||||
// responses of tasks still in flight before returning.
|
||||
Err(TransportError::Closed) | Err(TransportError::Io(_)) => {
|
||||
reading = false;
|
||||
if in_flight == 0 {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Own a cheap clone (Arc/String/Project) so the task is `'static`
|
||||
// and never borrows the transport. Every spawned task sends
|
||||
// exactly one slot back (`Some(bytes)` for a reply, `None` for a
|
||||
// notification) so `in_flight` is always reconciled.
|
||||
in_flight += 1;
|
||||
let server = self.for_requester(self.requester.clone());
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let slot = match server.handle_raw(&raw).await {
|
||||
Some(response) => serde_json::to_vec(&response).ok(),
|
||||
None => None,
|
||||
};
|
||||
// Receiver dropped ⇒ the loop has stopped; discard.
|
||||
let _ = tx.send(slot);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,7 +285,10 @@ impl McpServer {
|
||||
params: Option<Value>,
|
||||
) -> Result<Value, JsonRpcError> {
|
||||
match method {
|
||||
"initialize" => Ok(self.initialize_result()),
|
||||
"initialize" => {
|
||||
self.notify_ready();
|
||||
Ok(self.initialize_result())
|
||||
}
|
||||
"tools/list" => Ok(self.tools_list_result()),
|
||||
"tools/call" => self.tools_call(params.unwrap_or(Value::Null)).await,
|
||||
other => Err(JsonRpcError::new(
|
||||
@ -186,6 +298,16 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notifie le sink de readiness de démarrage avec l'identité du peer connecté
|
||||
/// (handshake `requester`). No-op si pas de sink ou requester vide (peer legacy/anonyme).
|
||||
fn notify_ready(&self) {
|
||||
if let Some(sink) = &self.ready_sink {
|
||||
if !self.requester.is_empty() {
|
||||
sink(&self.requester);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `initialize` result: protocol version, server identity, and the fact
|
||||
/// that we expose tools.
|
||||
fn initialize_result(&self) -> Value {
|
||||
@ -219,9 +341,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!({}));
|
||||
|
||||
@ -230,7 +350,25 @@ impl McpServer {
|
||||
let command =
|
||||
tools::map_tool_call(&name, &arguments, &self.requester).map_err(map_err_to_jsonrpc)?;
|
||||
|
||||
let result = self.service.dispatch(&self.project, command).await;
|
||||
// `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous
|
||||
// (the target's `idea_reply`). Bound *only* that path with a finite outer
|
||||
// timeout (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`]): on expiry
|
||||
// return a clean JSON-RPC error instead of parking the task forever. Every
|
||||
// other tool dispatches unbounded — they never rendezvous.
|
||||
let dispatch = self.service.dispatch(&self.project, command);
|
||||
let result = if name == "idea_ask_agent" {
|
||||
match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await {
|
||||
Ok(result) => result,
|
||||
Err(_elapsed) => {
|
||||
return Err(JsonRpcError::new(
|
||||
error_codes::INTERNAL_ERROR,
|
||||
"idea_ask_agent : aucune réponse de la cible avant le timeout du rendezvous",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatch.await
|
||||
};
|
||||
// Surface the processed delegation on the bus, tagged as the MCP door — the
|
||||
// twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the
|
||||
// action is the tool name. No-op when no sink is attached.
|
||||
@ -238,7 +376,10 @@ impl McpServer {
|
||||
match result {
|
||||
Ok(outcome) => {
|
||||
// The text the agent sees: the reply for an `ask`, else the detail.
|
||||
let text = outcome.reply.clone().unwrap_or_else(|| outcome.detail.clone());
|
||||
let text = outcome
|
||||
.reply
|
||||
.clone()
|
||||
.unwrap_or_else(|| outcome.detail.clone());
|
||||
Ok(tool_result_text(&text, false))
|
||||
}
|
||||
// A failed IdeA command is reported as a tool execution error
|
||||
|
||||
@ -242,9 +242,8 @@ pub fn map_tool_call(
|
||||
.ok_or_else(|| ToolMapError::BadArguments(name.to_owned()))?;
|
||||
|
||||
// Small helpers to pull optional string fields out of the arguments object.
|
||||
let s = |key: &str| -> Option<String> {
|
||||
args.get(key).and_then(Value::as_str).map(str::to_owned)
|
||||
};
|
||||
let s =
|
||||
|key: &str| -> Option<String> { args.get(key).and_then(Value::as_str).map(str::to_owned) };
|
||||
|
||||
// Translate the tool into the wire-level request shape the watcher already
|
||||
// uses (v2 `type`), then let `validate` enforce the invariants once.
|
||||
@ -361,7 +360,9 @@ fn base() -> OrchestratorRequest {
|
||||
/// missing its node, with a precise field error).
|
||||
fn parse_node_id(value: Option<&Value>) -> Option<domain::NodeId> {
|
||||
let raw = value?.as_str()?;
|
||||
uuid::Uuid::parse_str(raw).ok().map(domain::NodeId::from_uuid)
|
||||
uuid::Uuid::parse_str(raw)
|
||||
.ok()
|
||||
.map(domain::NodeId::from_uuid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -413,7 +414,9 @@ mod tests {
|
||||
fn stop_update_and_skill_map_to_their_commands() {
|
||||
assert_eq!(
|
||||
map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
|
||||
OrchestratorCommand::StopAgent { name: "Dev".to_owned() }
|
||||
OrchestratorCommand::StopAgent {
|
||||
name: "Dev".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
map(
|
||||
|
||||
@ -108,9 +108,7 @@ impl MemoryTransport {
|
||||
/// signal EOF. Returns the transport and a receiver of everything the server
|
||||
/// `send`s back.
|
||||
#[must_use]
|
||||
pub fn scripted(
|
||||
messages: Vec<Vec<u8>>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Vec<u8>>) {
|
||||
pub fn scripted(messages: Vec<Vec<u8>>) -> (Self, mpsc::UnboundedReceiver<Vec<u8>>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(
|
||||
Self {
|
||||
|
||||
362
crates/infrastructure/src/permission/claude.rs
Normal file
362
crates/infrastructure/src/permission/claude.rs
Normal file
@ -0,0 +1,362 @@
|
||||
//! Claude Code permission projector (lot LP3-2).
|
||||
//!
|
||||
//! Produces the `.claude/settings.local.json` seed written into an agent run dir:
|
||||
//! full project autonomy (`bypassPermissions` + broad Read/Edit/Write/Bash) with
|
||||
//! the project root granted as an additional working directory, while keeping
|
||||
//! destructive/out-of-project commands denied. The translation is extracted
|
||||
//! verbatim from the former `claude_settings_seed` in `lifecycle.rs`.
|
||||
|
||||
use domain::permission::{
|
||||
Capability, CommandMatcher, Effect, EffectivePermissions, PermissionProjection,
|
||||
PermissionProjector, PermissionRule, Posture, ProjectedFile, ProjectionContext, ProjectorKey,
|
||||
};
|
||||
|
||||
use super::json_escape;
|
||||
|
||||
/// Run-dir-relative path of the owned Claude settings seed.
|
||||
const SETTINGS_REL_PATH: &str = ".claude/settings.local.json";
|
||||
|
||||
/// Projects [`EffectivePermissions`] into Claude Code's `settings.local.json`.
|
||||
///
|
||||
/// Pure: `project` only computes the JSON; the launch path materialises it. A
|
||||
/// `Replace`-owned file (clobbered at launch, removed on swap-away).
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct ClaudePermissionProjector;
|
||||
|
||||
impl PermissionProjector for ClaudePermissionProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Claude
|
||||
}
|
||||
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected (native prompting).
|
||||
let Some(_) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let contents = claude_settings_seed(ctx.project_root, eff);
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::Replace {
|
||||
rel_path: SETTINGS_REL_PATH.to_owned(),
|
||||
contents,
|
||||
}],
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
vec![SETTINGS_REL_PATH.to_owned()]
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the Claude Code permission seed. `project_root` is embedded verbatim
|
||||
/// (JSON-escaped) and granted as an additional working directory, since the cwd is
|
||||
/// the run dir and the agent works on the root above it.
|
||||
fn claude_settings_seed(project_root: &str, permissions: Option<&EffectivePermissions>) -> String {
|
||||
let root = json_escape(project_root);
|
||||
let default_mode = match permissions.map(EffectivePermissions::fallback) {
|
||||
Some(Posture::Deny) => "plan",
|
||||
Some(Posture::Ask) => "acceptEdits",
|
||||
Some(Posture::Allow) | None => "bypassPermissions",
|
||||
};
|
||||
let allow = claude_permission_entries(permissions, Effect::Allow);
|
||||
let deny = claude_permission_entries(permissions, Effect::Deny);
|
||||
let default_allow = [
|
||||
"Read".to_owned(),
|
||||
"Edit".to_owned(),
|
||||
"Write".to_owned(),
|
||||
"Bash".to_owned(),
|
||||
];
|
||||
let allow = json_string_array(if allow.is_empty() {
|
||||
&default_allow
|
||||
} else {
|
||||
&allow
|
||||
});
|
||||
let deny = json_string_array(&merge_default_deny(deny));
|
||||
format!(
|
||||
r#"{{
|
||||
"permissions": {{
|
||||
"defaultMode": "{default_mode}",
|
||||
"additionalDirectories": [
|
||||
"{root}"
|
||||
],
|
||||
"allow": {allow},
|
||||
"deny": {deny}
|
||||
}},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"enabledMcpjsonServers": ["idea"],
|
||||
"sandbox": {{
|
||||
"enabled": false
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_default_deny(mut deny: Vec<String>) -> Vec<String> {
|
||||
for item in [
|
||||
"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*)",
|
||||
] {
|
||||
if !deny.iter().any(|existing| existing == item) {
|
||||
deny.push(item.to_owned());
|
||||
}
|
||||
}
|
||||
deny
|
||||
}
|
||||
|
||||
fn claude_permission_entries(
|
||||
permissions: Option<&EffectivePermissions>,
|
||||
effect: Effect,
|
||||
) -> Vec<String> {
|
||||
let Some(permissions) = permissions else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for rule in permissions.rules() {
|
||||
if rule.effect() != effect {
|
||||
continue;
|
||||
}
|
||||
match rule.capability() {
|
||||
Capability::Read => push_path_entries(&mut out, "Read", rule),
|
||||
Capability::Write => {
|
||||
push_path_entries(&mut out, "Edit", rule);
|
||||
push_path_entries(&mut out, "Write", rule);
|
||||
}
|
||||
Capability::Delete => push_delete_entries(&mut out, rule),
|
||||
Capability::ExecuteBash => push_bash_entries(&mut out, rule),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn push_path_entries(out: &mut Vec<String>, capability: &str, rule: &PermissionRule) {
|
||||
if rule.paths().is_empty() {
|
||||
out.push(capability.to_owned());
|
||||
return;
|
||||
}
|
||||
for glob in rule.paths().globs() {
|
||||
out.push(format!("{capability}({})", glob.pattern()));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_delete_entries(out: &mut Vec<String>, rule: &PermissionRule) {
|
||||
if rule.paths().is_empty() {
|
||||
out.push("Bash(rm *)".to_owned());
|
||||
return;
|
||||
}
|
||||
for glob in rule.paths().globs() {
|
||||
out.push(format!("Bash(rm {})", glob.pattern()));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_bash_entries(out: &mut Vec<String>, rule: &PermissionRule) {
|
||||
if rule.commands().is_empty() {
|
||||
out.push("Bash".to_owned());
|
||||
return;
|
||||
}
|
||||
for cmd in rule.commands() {
|
||||
if cmd.effect != rule.effect() {
|
||||
continue;
|
||||
}
|
||||
out.push(format!("Bash({})", command_matcher_pattern(&cmd.matcher)));
|
||||
}
|
||||
}
|
||||
|
||||
fn command_matcher_pattern(matcher: &CommandMatcher) -> String {
|
||||
match matcher {
|
||||
CommandMatcher::Exact(value) => value.clone(),
|
||||
CommandMatcher::Prefix(value) => format!("{value}*"),
|
||||
CommandMatcher::Glob(glob) => glob.pattern().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_string_array(items: &[String]) -> String {
|
||||
if items.is_empty() {
|
||||
return "[]".to_owned();
|
||||
}
|
||||
let body = items
|
||||
.iter()
|
||||
.map(|item| format!(" \"{}\"", json_escape(item)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n");
|
||||
format!("[\n{body}\n ]")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::permission::{resolve, PathScope, PermissionSet};
|
||||
use serde_json::Value;
|
||||
|
||||
fn ctx<'a>(root: &'a str, run_dir: &'a str) -> ProjectionContext<'a> {
|
||||
ProjectionContext {
|
||||
project_root: root,
|
||||
run_dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_scope(patterns: &[&str]) -> PathScope {
|
||||
PathScope::new(patterns.iter().map(ToString::to_string)).unwrap()
|
||||
}
|
||||
|
||||
/// Builds an [`EffectivePermissions`] from a single (project) set via the
|
||||
/// domain API, exactly like the `permission` unit tests do.
|
||||
fn eff_with(rules: Vec<PermissionRule>, fallback: Posture) -> EffectivePermissions {
|
||||
resolve(Some(&PermissionSet::new(rules, fallback)), None).unwrap()
|
||||
}
|
||||
|
||||
/// Projects and returns the parsed `settings.local.json` value, asserting the
|
||||
/// projection's structural contract (1 Replace file, no args/env) along the way.
|
||||
fn project_json(eff: &EffectivePermissions, root: &str) -> Value {
|
||||
let proj = ClaudePermissionProjector.project(Some(eff), &ctx(root, "/run/agent"));
|
||||
assert!(proj.args.is_empty(), "Claude projection carries no args");
|
||||
assert!(proj.env.is_empty(), "Claude projection carries no env");
|
||||
assert_eq!(proj.files.len(), 1, "exactly one file projected");
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::Replace { rel_path, contents } => {
|
||||
assert_eq!(rel_path, SETTINGS_REL_PATH);
|
||||
serde_json::from_str(contents).expect("the produced settings is valid JSON")
|
||||
}
|
||||
ProjectedFile::MergeToml { .. } => panic!("Claude must emit a Replace file"),
|
||||
}
|
||||
}
|
||||
|
||||
fn str_array(value: &Value) -> Vec<String> {
|
||||
value
|
||||
.as_array()
|
||||
.expect("array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().expect("string").to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---- product invariant + ownership ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn project_none_is_empty() {
|
||||
let proj = ClaudePermissionProjector.project(None, &ctx("/proj", "/run"));
|
||||
assert!(proj.files.is_empty());
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_replace_paths_is_the_settings_file() {
|
||||
assert_eq!(
|
||||
ClaudePermissionProjector.owned_replace_paths(),
|
||||
vec![SETTINGS_REL_PATH.to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (1) posture → defaultMode --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn default_mode_maps_each_posture() {
|
||||
for (posture, mode) in [
|
||||
(Posture::Allow, "bypassPermissions"),
|
||||
(Posture::Ask, "acceptEdits"),
|
||||
(Posture::Deny, "plan"),
|
||||
] {
|
||||
let json = project_json(&eff_with(vec![], posture), "/proj");
|
||||
assert_eq!(
|
||||
json["permissions"]["defaultMode"], mode,
|
||||
"posture {posture:?} should map to defaultMode {mode}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (2) deny-wins: deny entry surfaces in the deny list -------------
|
||||
|
||||
#[test]
|
||||
fn specific_deny_with_broad_allow_appears_in_deny_list() {
|
||||
let rules = vec![
|
||||
PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&[".ideai/**"]))
|
||||
.unwrap(),
|
||||
PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["**"])).unwrap(),
|
||||
];
|
||||
let json = project_json(&eff_with(rules, Posture::Allow), "/proj");
|
||||
|
||||
let deny = str_array(&json["permissions"]["deny"]);
|
||||
// A Write capability fans out to both Edit(..) and Write(..) entries.
|
||||
assert!(deny.contains(&"Edit(.ideai/**)".to_owned()), "deny={deny:?}");
|
||||
assert!(deny.contains(&"Write(.ideai/**)".to_owned()), "deny={deny:?}");
|
||||
|
||||
let allow = str_array(&json["permissions"]["allow"]);
|
||||
assert!(
|
||||
allow.contains(&"Edit(**)".to_owned()) && allow.contains(&"Write(**)".to_owned()),
|
||||
"the broad allow stays in the allow list; allow={allow:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (3) additionalDirectories carries the (escaped) project root ----
|
||||
|
||||
#[test]
|
||||
fn additional_directories_contains_project_root_escaped() {
|
||||
// A Windows-ish path with a backslash AND a quote exercises JSON escaping;
|
||||
// parsing it back must yield the original raw path verbatim.
|
||||
let root = r#"C:\Users\a"b\proj"#;
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), root);
|
||||
let dirs = str_array(&json["permissions"]["additionalDirectories"]);
|
||||
assert_eq!(dirs, vec![root.to_owned()]);
|
||||
}
|
||||
|
||||
// ---- (4) hard-coded destructive guardrails --------------------------
|
||||
|
||||
#[test]
|
||||
fn default_deny_guardrails_are_present() {
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), "/proj");
|
||||
let deny = str_array(&json["permissions"]["deny"]);
|
||||
for guard in [
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)",
|
||||
] {
|
||||
assert!(deny.contains(&guard.to_owned()), "missing guardrail {guard}; deny={deny:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (5) valid JSON + expected static shape -------------------------
|
||||
|
||||
#[test]
|
||||
fn produced_settings_has_expected_static_shape() {
|
||||
// `project_json` already proved the document parses; assert the fixed keys.
|
||||
let json = project_json(&eff_with(vec![], Posture::Ask), "/proj");
|
||||
assert_eq!(json["enabledMcpjsonServers"][0], "idea");
|
||||
assert_eq!(json["skipDangerousModePermissionPrompt"], true);
|
||||
assert_eq!(json["sandbox"]["enabled"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_rules_fall_back_to_broad_default_allow() {
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), "/proj");
|
||||
let allow = str_array(&json["permissions"]["allow"]);
|
||||
assert_eq!(
|
||||
allow,
|
||||
vec![
|
||||
"Read".to_owned(),
|
||||
"Edit".to_owned(),
|
||||
"Write".to_owned(),
|
||||
"Bash".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
190
crates/infrastructure/src/permission/codex.rs
Normal file
190
crates/infrastructure/src/permission/codex.rs
Normal file
@ -0,0 +1,190 @@
|
||||
//! Codex CLI permission projector (lot LP3-2).
|
||||
//!
|
||||
//! Produces the **permission-relevant** part of Codex's `config.toml`
|
||||
//! (`sandbox_mode` / `approval_policy`) plus the matching launch args
|
||||
//! (`--sandbox` / `--ask-for-approval`). The posture→mode derivation is extracted
|
||||
//! verbatim from the former `codex_sandbox_mode` / `codex_approval_policy` /
|
||||
//! `apply_codex_cli_permission_args` in `lifecycle.rs`.
|
||||
//!
|
||||
//! Unlike Claude's seed, Codex's `config.toml` is **co-owned** (it also carries the
|
||||
//! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust
|
||||
//! concerns, not permissions). The projector therefore emits a
|
||||
//! [`ProjectedFile::MergeToml`] limited to the two permission keys it manages —
|
||||
//! everything else in the file is preserved by the fold, and the file is **never**
|
||||
//! deleted on swap (hence an empty `owned_replace_paths`).
|
||||
|
||||
use domain::permission::{
|
||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey,
|
||||
};
|
||||
|
||||
use super::toml_string;
|
||||
|
||||
/// Run-dir-relative path of Codex's `config.toml`. Codex reads its config from
|
||||
/// `$CODEX_HOME/config.toml`; IdeA isolates `CODEX_HOME` to `{runDir}/.codex`, so
|
||||
/// the file lives at `.codex/config.toml` relative to the run dir.
|
||||
const CONFIG_REL_PATH: &str = ".codex/config.toml";
|
||||
|
||||
/// The two top-level keys this projector manages in `config.toml`. Everything else
|
||||
/// (MCP table, trust entries, user keys) is preserved by the merge.
|
||||
const MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
|
||||
|
||||
/// Projects [`EffectivePermissions`] into Codex's sandbox/approval config + args.
|
||||
///
|
||||
/// Pure: `project` only computes the plan; the launch path merges the TOML fragment
|
||||
/// and appends the args.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct CodexPermissionProjector;
|
||||
|
||||
impl PermissionProjector for CodexPermissionProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Codex
|
||||
}
|
||||
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
|
||||
// native sandbox/approval defaults (no args, no managed keys written).
|
||||
let Some(permissions) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let sandbox = codex_sandbox_mode(permissions);
|
||||
let approval = codex_approval_policy(permissions);
|
||||
|
||||
// Permission-only TOML fragment (escaped exactly like the former
|
||||
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
|
||||
// permission concern and stay with the MCP wiring (LP3-3).
|
||||
let contents = format!(
|
||||
"sandbox_mode = {}\napproval_policy = {}\n",
|
||||
toml_string(sandbox),
|
||||
toml_string(approval),
|
||||
);
|
||||
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||
managed_tables: Vec::new(),
|
||||
managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
|
||||
contents,
|
||||
}],
|
||||
args: vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
// config.toml is co-owned (MergeToml), never an owned Replace file.
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_sandbox_mode(permissions: &EffectivePermissions) -> &'static str {
|
||||
match permissions.fallback() {
|
||||
Posture::Deny => "read-only",
|
||||
Posture::Ask | Posture::Allow => "workspace-write",
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_approval_policy(permissions: &EffectivePermissions) -> &'static str {
|
||||
match permissions.fallback() {
|
||||
Posture::Allow => "never",
|
||||
Posture::Ask | Posture::Deny => "on-request",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::permission::{resolve, PermissionSet};
|
||||
|
||||
fn ctx<'a>() -> ProjectionContext<'a> {
|
||||
ProjectionContext {
|
||||
project_root: "/proj",
|
||||
run_dir: "/run/agent",
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an [`EffectivePermissions`] with the given fallback posture via the
|
||||
/// domain API (only the fallback drives Codex's sandbox/approval derivation).
|
||||
fn eff(fallback: Posture) -> EffectivePermissions {
|
||||
resolve(Some(&PermissionSet::new(vec![], fallback)), None).unwrap()
|
||||
}
|
||||
|
||||
// ---- product invariant + ownership ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn project_none_is_empty() {
|
||||
let proj = CodexPermissionProjector.project(None, &ctx());
|
||||
assert!(proj.files.is_empty());
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_replace_paths_is_empty() {
|
||||
// config.toml is co-owned (MergeToml) ⇒ nothing to clean up on swap.
|
||||
assert!(CodexPermissionProjector.owned_replace_paths().is_empty());
|
||||
}
|
||||
|
||||
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
|
||||
// coherence + MergeToml shape -------------------------------
|
||||
|
||||
#[test]
|
||||
fn posture_maps_sandbox_and_approval_in_file_and_args() {
|
||||
for (posture, sandbox, approval) in [
|
||||
(Posture::Deny, "read-only", "on-request"),
|
||||
(Posture::Ask, "workspace-write", "on-request"),
|
||||
(Posture::Allow, "workspace-write", "never"),
|
||||
] {
|
||||
let proj = CodexPermissionProjector.project(Some(&eff(posture)), &ctx());
|
||||
assert!(proj.env.is_empty(), "Codex projection carries no env");
|
||||
|
||||
// -- The single MergeToml file, with the two managed permission keys.
|
||||
assert_eq!(proj.files.len(), 1, "exactly one file projected");
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::MergeToml {
|
||||
rel_path,
|
||||
managed_tables,
|
||||
managed_keys,
|
||||
contents,
|
||||
} => {
|
||||
assert_eq!(rel_path, CONFIG_REL_PATH);
|
||||
assert!(managed_tables.is_empty(), "no managed tables");
|
||||
assert_eq!(
|
||||
managed_keys,
|
||||
&vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()]
|
||||
);
|
||||
assert!(
|
||||
contents.contains(&format!("sandbox_mode = \"{sandbox}\"")),
|
||||
"posture {posture:?}: contents={contents:?}"
|
||||
);
|
||||
assert!(
|
||||
contents.contains(&format!("approval_policy = \"{approval}\"")),
|
||||
"posture {posture:?}: contents={contents:?}"
|
||||
);
|
||||
}
|
||||
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
||||
}
|
||||
|
||||
// -- (7) Args reflect the SAME values as the TOML, in CLI order.
|
||||
assert_eq!(
|
||||
proj.args,
|
||||
vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
"posture {posture:?}: args must mirror the TOML values"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
crates/infrastructure/src/permission/mod.rs
Normal file
46
crates/infrastructure/src/permission/mod.rs
Normal file
@ -0,0 +1,46 @@
|
||||
//! Per-CLI **permission projectors** (lot LP3-2).
|
||||
//!
|
||||
//! Concrete implementations of the domain port
|
||||
//! [`domain::permission::PermissionProjector`]: they translate the resolved
|
||||
//! [`domain::permission::EffectivePermissions`] into a CLI-specific
|
||||
//! [`domain::permission::PermissionProjection`] — a **plan** (files + args + env),
|
||||
//! never an action. Writing/merging the plan into the agent run dir is the launch
|
||||
//! path's job (lot LP3-3); the projectors here stay **pure** (no `FileSystem`, no
|
||||
//! I/O), exactly like the domain trait demands.
|
||||
//!
|
||||
//! This is an **extraction**: the translation rules (postures → allow/deny/ask
|
||||
//! lists for Claude, posture → sandbox/approval modes for Codex) are moved here
|
||||
//! verbatim from `application/src/agent/lifecycle.rs`, only reshaped to return a
|
||||
//! `PermissionProjection`. The rules themselves are unchanged.
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
|
||||
pub use claude::ClaudePermissionProjector;
|
||||
pub use codex::CodexPermissionProjector;
|
||||
|
||||
/// Minimal JSON string escaper for embedding a filesystem path / permission entry
|
||||
/// in a settings document (handles the characters that actually occur in paths:
|
||||
/// backslash, quote, control chars). Extracted verbatim from `lifecycle.rs`.
|
||||
pub(crate) fn json_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Escapes `s` as a TOML basic string (quotes included). The escape set required
|
||||
/// by a TOML basic string coincides with JSON's for the characters that concern
|
||||
/// us (paths, mode keywords). Extracted verbatim from `lifecycle.rs`.
|
||||
pub(crate) fn toml_string(s: &str) -> String {
|
||||
format!("\"{}\"", json_escape(s))
|
||||
}
|
||||
@ -77,6 +77,7 @@ impl CliAgentRuntime {
|
||||
cwd,
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -219,6 +220,7 @@ impl AgentRuntime for CliAgentRuntime {
|
||||
cwd: resolved_cwd,
|
||||
env: Vec::new(),
|
||||
context_plan: Some(plan),
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,10 +47,10 @@ pub struct ParsedLine {
|
||||
/// Le flux est du **JSONL** (un objet JSON par ligne). Types réels :
|
||||
///
|
||||
/// - `{"type":"system","subtype":"init","session_id":"<uuid>","cwd":…,"tools":…,…}`
|
||||
/// ⇒ capture le `session_id` (= id de conversation pour la reprise), **aucun**
|
||||
/// événement émis.
|
||||
/// ⇒ capture le `session_id` (= id de conversation pour la reprise) **et** émet un
|
||||
/// [`ReplyEvent::Heartbeat`] (preuve de vivacité non terminale : la CLI a démarré).
|
||||
/// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}`
|
||||
/// ⇒ **ignoré** (comme tout `type` inconnu).
|
||||
/// ⇒ [`ReplyEvent::Heartbeat`] (pas de contenu, mais le moteur est vivant).
|
||||
/// - `{"type":"assistant","message":{"role":"assistant","content":[
|
||||
/// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …}
|
||||
/// ], …},"session_id":"…","parent_tool_use_id":null}`
|
||||
@ -81,7 +81,12 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
.map(str::to_owned);
|
||||
|
||||
let events = match value.get("type").and_then(Value::as_str) {
|
||||
Some("system") => Vec::new(), // init/handshake : on ne capte que le session_id.
|
||||
// init/handshake : on capte le session_id ET on émet un battement de cœur
|
||||
// (preuve de vivacité non terminale : la CLI a démarré et répond).
|
||||
Some("system") => vec![ReplyEvent::Heartbeat],
|
||||
// Fenêtre de limite de débit : pas de contenu, mais le moteur est vivant ⇒
|
||||
// battement de cœur (readiness/heartbeat lot 1), plus ignoré.
|
||||
Some("rate_limit_event") => vec![ReplyEvent::Heartbeat],
|
||||
Some("assistant") => assistant_events(&value),
|
||||
Some("result") => value
|
||||
.get("result")
|
||||
@ -92,7 +97,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(), // type inconnu / non pertinent (rate_limit_event, …) : ignoré.
|
||||
_ => Vec::new(), // type inconnu / non pertinent : ignoré (robustesse).
|
||||
};
|
||||
|
||||
Ok(ParsedLine { events, session_id })
|
||||
@ -216,13 +221,22 @@ impl AgentSession for ClaudeSdkSession {
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut captured_id = None;
|
||||
for line in &raw_lines {
|
||||
'lines: for line in &raw_lines {
|
||||
let parsed = parse_event(line)?;
|
||||
if let Some(id) = parsed.session_id {
|
||||
captured_id = Some(id);
|
||||
}
|
||||
// Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements.
|
||||
events.extend(parsed.events);
|
||||
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
|
||||
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`rate_limit_event` tardif…)
|
||||
// ne le suive dans le flux.
|
||||
for event in parsed.events {
|
||||
let is_final = matches!(event, ReplyEvent::Final { .. });
|
||||
events.push(event);
|
||||
if is_final {
|
||||
break 'lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Persiste le session_id capté (pivot de reprise) avant de rendre le flux.
|
||||
if let Some(id) = captured_id {
|
||||
|
||||
@ -43,13 +43,13 @@ pub struct ParsedLine {
|
||||
///
|
||||
/// - `{"type":"thread.started","thread_id":"<id>"}` ⇒ capte le `thread_id`
|
||||
/// (= id de conversation pour la reprise), **aucun** événement émis.
|
||||
/// - `{"type":"turn.started"}` ⇒ ignoré.
|
||||
/// - `{"type":"turn.started"}` ⇒ [`ReplyEvent::Heartbeat`] (vivacité non terminale).
|
||||
/// - `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"…"}}`
|
||||
/// ⇒ si `item.type=="agent_message"` ⇒ [`ReplyEvent::Final`] (`content` = `item.text`,
|
||||
/// c'est la réponse) ; sinon (`reasoning`/`command`/autre) ⇒
|
||||
/// [`ReplyEvent::ToolActivity`] (`label` = `item.type`).
|
||||
/// - `{"type":"turn.completed","usage":{…}}` ⇒ ignoré (le `Final` vient de
|
||||
/// l'`agent_message`).
|
||||
/// - `{"type":"turn.completed","usage":{…}}` ⇒ [`ReplyEvent::Heartbeat`] (le `Final`
|
||||
/// vient de l'`agent_message`, pas de `turn.completed`).
|
||||
///
|
||||
/// Ligne vide ⇒ ignorée ; type inconnu ⇒ ignoré sans erreur ; JSON illisible ⇒
|
||||
/// [`AgentSessionError::Decode`] (jamais de JSON brut propagé).
|
||||
@ -75,6 +75,10 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
}
|
||||
// Début/fin de tour côté moteur : pas de contenu, mais preuve de vivacité ⇒
|
||||
// battement de cœur non terminal (readiness/heartbeat lot 1). Le `Final` vient
|
||||
// toujours de l'`agent_message`, jamais de `turn.completed`.
|
||||
Some("turn.started") | Some("turn.completed") => events.push(ReplyEvent::Heartbeat),
|
||||
Some("item.completed") => {
|
||||
if let Some(item) = value.get("item") {
|
||||
match item.get("type").and_then(Value::as_str) {
|
||||
@ -94,7 +98,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// turn.started / turn.completed / type inconnu : ignoré (robustesse).
|
||||
// type inconnu : ignoré (robustesse).
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@ -192,12 +196,21 @@ impl AgentSession for CodexExecSession {
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut captured_id = None;
|
||||
for line in &raw_lines {
|
||||
'lines: for line in &raw_lines {
|
||||
let parsed = parse_event(line)?;
|
||||
if let Some(id) = parsed.conversation_id {
|
||||
captured_id = Some(id);
|
||||
}
|
||||
events.extend(parsed.events);
|
||||
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
|
||||
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`turn.completed` postérieur)
|
||||
// ne le suive dans le flux.
|
||||
for event in parsed.events {
|
||||
let is_final = matches!(event, ReplyEvent::Final { .. });
|
||||
events.push(event);
|
||||
if is_final {
|
||||
break 'lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(id) = captured_id {
|
||||
*self.conversation_id.lock().expect("mutex sain") = Some(id);
|
||||
|
||||
@ -208,14 +208,17 @@ pub(crate) mod harness {
|
||||
}
|
||||
other => panic!("le dernier événement doit être Final, vu: {other:?}"),
|
||||
}
|
||||
// Les événements avant le Final ne sont que des deltas / activités.
|
||||
// Les événements avant le Final ne sont que des deltas / activités / heartbeats
|
||||
// (tous **non terminaux** ; le heartbeat est une preuve de vivacité, lot 1).
|
||||
for e in &events[..events.len() - 1] {
|
||||
assert!(
|
||||
matches!(
|
||||
e,
|
||||
ReplyEvent::TextDelta { .. } | ReplyEvent::ToolActivity { .. }
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat
|
||||
),
|
||||
"avant le Final, seuls deltas/activités sont permis, vu: {e:?}"
|
||||
"avant le Final, seuls deltas/activités/heartbeats sont permis, vu: {e:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -106,23 +106,24 @@ mod tests {
|
||||
// -- parse_event Claude (format RÉEL vérifié 2026-06-09) --------------
|
||||
|
||||
#[test]
|
||||
fn claude_parse_init_captures_session_id_without_event() {
|
||||
fn claude_parse_init_captures_session_id_and_heartbeats() {
|
||||
let parsed = claude::parse_event(
|
||||
r#"{"type":"system","subtype":"init","session_id":"conv-123","cwd":"/tmp","tools":[],"model":"claude-opus-4-8"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
||||
assert!(parsed.events.is_empty());
|
||||
// L'init capte le session_id ET émet un heartbeat (vivacité non terminale, lot 1).
|
||||
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_parse_rate_limit_event_is_ignored() {
|
||||
fn claude_parse_rate_limit_event_is_heartbeat() {
|
||||
let parsed = claude::parse_event(
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
// Type inconnu/non pertinent : ignoré (mais session_id tout de même capté).
|
||||
assert!(parsed.events.is_empty());
|
||||
// Plus ignoré : preuve de vivacité ⇒ heartbeat (mais session_id tout de même capté).
|
||||
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
||||
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
||||
}
|
||||
|
||||
@ -165,7 +166,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,13 +219,18 @@ 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"));
|
||||
// Le handshake ne capte que le thread_id, sans événement (pas un heartbeat).
|
||||
assert!(sess.events.is_empty());
|
||||
|
||||
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
|
||||
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
|
||||
assert!(started.events.is_empty());
|
||||
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
|
||||
let completed =
|
||||
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
|
||||
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
|
||||
|
||||
let msg = codex::parse_event(
|
||||
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#,
|
||||
@ -234,11 +242,6 @@ mod tests {
|
||||
content: "fini".to_owned()
|
||||
}]
|
||||
);
|
||||
|
||||
let completed =
|
||||
codex::parse_event(r#"{"type":"turn.completed","usage":{"input_tokens":10}}"#)
|
||||
.expect("ok");
|
||||
assert!(completed.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -371,7 +374,11 @@ mod tests {
|
||||
expected.insert("gemini", false);
|
||||
expected.insert("aider", false);
|
||||
|
||||
assert_eq!(profiles.len(), 4, "catalogue has the four reference profiles");
|
||||
assert_eq!(
|
||||
profiles.len(),
|
||||
4,
|
||||
"catalogue has the four reference profiles"
|
||||
);
|
||||
for profile in &profiles {
|
||||
let selectable = profile.is_selectable();
|
||||
assert_eq!(
|
||||
@ -673,9 +680,8 @@ mod tests {
|
||||
label: "reasoning".into()
|
||||
}]
|
||||
);
|
||||
let c = codex::parse_event(
|
||||
r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#,
|
||||
)
|
||||
let c =
|
||||
codex::parse_event(r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
c.events,
|
||||
@ -707,7 +713,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.
|
||||
@ -718,19 +729,13 @@ mod tests {
|
||||
assert!(p.events.is_empty());
|
||||
}
|
||||
|
||||
/// Ligne vide / type inconnu / turn.started / turn.completed ⇒ ignorés sans erreur.
|
||||
/// Ligne vide / type inconnu ⇒ ignorés sans erreur. (turn.started/completed sont
|
||||
/// désormais des heartbeats : couverts par `codex_parse_thread_started_message_and_final`.)
|
||||
#[test]
|
||||
fn codex_empty_and_unknown_ignored() {
|
||||
assert_eq!(codex::parse_event("").unwrap(), Default::default());
|
||||
assert!(codex::parse_event(r#"{"type":"heartbeat"}"#)
|
||||
.unwrap()
|
||||
.events
|
||||
.is_empty());
|
||||
assert!(codex::parse_event(r#"{"type":"turn.started"}"#)
|
||||
.unwrap()
|
||||
.events
|
||||
.is_empty());
|
||||
assert!(codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#)
|
||||
// Un `type` inconnu reste ignoré (robustesse), pas un heartbeat.
|
||||
assert!(codex::parse_event(r#"{"type":"telemetry"}"#)
|
||||
.unwrap()
|
||||
.events
|
||||
.is_empty());
|
||||
@ -738,8 +743,12 @@ mod tests {
|
||||
|
||||
// ---- Machinerie process via FakeCli ---------------------------------
|
||||
|
||||
/// Deltas PUIS Final : le flux ne contient rien après le `Final` (déjà couvert
|
||||
/// pour Claude ; ici on le prouve aussi pour Codex, substituabilité Liskov).
|
||||
/// Deltas PUIS Final : **exactement un** `Final`, et aucun autre `Final` après lui
|
||||
/// (substituabilité Liskov). Note (lot 1) : un `turn.completed` postérieur émet un
|
||||
/// `Heartbeat` non terminal — légitimement après le `Final` —, donc on ne teste plus
|
||||
/// « rien après le Final » mais « pas de second Final, et seul un heartbeat peut
|
||||
/// suivre ». Le rendez-vous synchrone (`drain_to_final`) s'arrête de toute façon au
|
||||
/// premier `Final`.
|
||||
#[tokio::test]
|
||||
async fn codex_stream_closed_after_final() {
|
||||
let fake = FakeCli::printing(&[
|
||||
@ -750,12 +759,19 @@ mod tests {
|
||||
]);
|
||||
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
|
||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||
let after = events
|
||||
let finals = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(finals, 1, "exactement un Final");
|
||||
// Après le Final, seuls des événements non terminaux (heartbeat) peuvent suivre.
|
||||
let after_final_terminals = events
|
||||
.iter()
|
||||
.skip_while(|e| !matches!(e, ReplyEvent::Final { .. }))
|
||||
.skip(1)
|
||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(after, 0);
|
||||
assert_eq!(after_final_terminals, 0, "aucun second Final après le premier");
|
||||
}
|
||||
|
||||
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
|
||||
@ -1085,6 +1101,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
// L'init `system` émet un heartbeat (vivacité non terminale, lot 1).
|
||||
ReplyEvent::Heartbeat,
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::ToolActivity { label: "T".into() },
|
||||
ReplyEvent::TextDelta { text: "b".into() },
|
||||
@ -1092,7 +1110,7 @@ mod tests {
|
||||
content: "final-ok".into()
|
||||
},
|
||||
],
|
||||
"le flot complet doit aplatir les 3 blocs PUIS un seul Final"
|
||||
"heartbeat d'init, puis les 3 blocs aplatis, PUIS un seul Final"
|
||||
);
|
||||
// Un seul Final, en dernière position (redondant mais explicite).
|
||||
assert_eq!(
|
||||
@ -1160,7 +1178,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);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
mod context;
|
||||
mod embedder;
|
||||
mod memory;
|
||||
mod permission;
|
||||
mod profile;
|
||||
mod project;
|
||||
mod skill;
|
||||
@ -24,6 +25,7 @@ pub use embedder::{
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
||||
pub use permission::FsPermissionStore;
|
||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||
pub use project::FsProjectStore;
|
||||
pub use skill::FsSkillStore;
|
||||
|
||||
65
crates/infrastructure/src/store/permission.rs
Normal file
65
crates/infrastructure/src/store/permission.rs
Normal file
@ -0,0 +1,65 @@
|
||||
//! Filesystem-backed [`PermissionStore`] for project agent permissions.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ports::{FileSystem, FsError, PermissionStore, RemotePath, StoreError};
|
||||
use domain::{Project, ProjectPermissions};
|
||||
|
||||
const PERMISSIONS_FILE: &str = "permissions.json";
|
||||
|
||||
/// JSON-file implementation for `<project>/.ideai/permissions.json`.
|
||||
#[derive(Clone)]
|
||||
pub struct FsPermissionStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl FsPermissionStore {
|
||||
/// Builds the store from an injected filesystem port.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { fs }
|
||||
}
|
||||
|
||||
fn path(project: &Project) -> RemotePath {
|
||||
let root = project.root.as_str().trim_end_matches(['/', '\\']);
|
||||
RemotePath::new(format!("{root}/.ideai/{PERMISSIONS_FILE}"))
|
||||
}
|
||||
|
||||
async fn ensure_ideai(&self, project: &Project) -> Result<(), StoreError> {
|
||||
let root = project.root.as_str().trim_end_matches(['/', '\\']);
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(format!("{root}/.ideai")))
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PermissionStore for FsPermissionStore {
|
||||
async fn load_permissions(&self, project: &Project) -> Result<ProjectPermissions, StoreError> {
|
||||
match self.fs.read(&Self::path(project)).await {
|
||||
Ok(bytes) => {
|
||||
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
Err(FsError::NotFound(_)) => Ok(ProjectPermissions::default()),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_permissions(
|
||||
&self,
|
||||
project: &Project,
|
||||
permissions: &ProjectPermissions,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ensure_ideai(project).await?;
|
||||
let mut bytes = serde_json::to_vec_pretty(permissions)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
bytes.push(b'\n');
|
||||
self.fs
|
||||
.write(&Self::path(project), &bytes)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
@ -71,7 +71,8 @@ impl TempDir {
|
||||
}
|
||||
/// `<root>/.ideai/conversations/<conversationId>/providers.json.tmp` — the atomic-write tmp (P5).
|
||||
fn providers_tmp_path(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.conversation_dir(conversation).join("providers.json.tmp")
|
||||
self.conversation_dir(conversation)
|
||||
.join("providers.json.tmp")
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
@ -119,10 +120,13 @@ async fn append_writes_one_valid_json_line_per_turn() {
|
||||
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
assert_eq!(lines.len(), 3, "one line per append, got: {raw:?}");
|
||||
for line in &lines {
|
||||
let parsed: ConversationTurn =
|
||||
serde_json::from_str(line).unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"));
|
||||
let parsed: ConversationTurn = serde_json::from_str(line)
|
||||
.unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"));
|
||||
// camelCase shape leaks through to the file (sanity on the persisted format).
|
||||
assert!(line.contains("\"atMs\""), "expected camelCase atMs in {line:?}");
|
||||
assert!(
|
||||
line.contains("\"atMs\""),
|
||||
"expected camelCase atMs in {line:?}"
|
||||
);
|
||||
let _ = parsed;
|
||||
}
|
||||
}
|
||||
@ -144,7 +148,9 @@ async fn persists_across_a_fresh_instance_restart() {
|
||||
(2, TurnRole::Response, "b"),
|
||||
(3, TurnRole::Prompt, "c"),
|
||||
] {
|
||||
log.append(c, turn(c, turn_id(id), role, txt)).await.unwrap();
|
||||
log.append(c, turn(c, turn_id(id), role, txt))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@ -184,7 +190,10 @@ async fn conversations_land_in_disjoint_files() {
|
||||
assert_ne!(p1, p2, "the two conversations must use distinct files");
|
||||
|
||||
// No cross-leak through the API, and the c2 file holds only c2's single line.
|
||||
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c1, None).await.unwrap()),
|
||||
vec!["c1-a", "c1-b"]
|
||||
);
|
||||
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
|
||||
let c2_raw = std::fs::read_to_string(&p2).unwrap();
|
||||
assert_eq!(
|
||||
@ -192,7 +201,10 @@ async fn conversations_land_in_disjoint_files() {
|
||||
1,
|
||||
"c2 file must not contain c1's turns"
|
||||
);
|
||||
assert!(!c2_raw.contains("c1-"), "no c1 content leaked into c2's file");
|
||||
assert!(
|
||||
!c2_raw.contains("c1-"),
|
||||
"no c1 content leaked into c2's file"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -228,11 +240,21 @@ async fn corrupted_line_is_skipped_without_panic() {
|
||||
// A fresh instance must skip the junk line and return only the two valid turns.
|
||||
let log = FsConversationLog::new(&tmp.project_path());
|
||||
let all = log.read(c, None).await.unwrap();
|
||||
assert_eq!(texts(&all), vec!["good-1", "good-2"], "garbage line skipped");
|
||||
assert_eq!(
|
||||
texts(&all),
|
||||
vec!["good-1", "good-2"],
|
||||
"garbage line skipped"
|
||||
);
|
||||
// `last` must be just as tolerant.
|
||||
assert_eq!(texts(&log.last(c, 5).await.unwrap()), vec!["good-1", "good-2"]);
|
||||
assert_eq!(
|
||||
texts(&log.last(c, 5).await.unwrap()),
|
||||
vec!["good-1", "good-2"]
|
||||
);
|
||||
// And the cursor still works across the corrupted tail.
|
||||
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["good-2"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c, Some(turn_id(1))).await.unwrap()),
|
||||
vec!["good-2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -260,7 +282,11 @@ async fn good_line_appended_after_corruption_is_still_read() {
|
||||
.unwrap();
|
||||
|
||||
let all = log.read(c, None).await.unwrap();
|
||||
assert_eq!(texts(&all), vec!["before", "after"], "junk in the middle is skipped, both reals survive");
|
||||
assert_eq!(
|
||||
texts(&all),
|
||||
vec!["before", "after"],
|
||||
"junk in the middle is skipped, both reals survive"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -274,7 +300,11 @@ async fn missing_conversation_reads_empty() {
|
||||
|
||||
// Nothing was ever appended: no .ideai dir at all.
|
||||
assert!(log.read(conv_id(99), None).await.unwrap().is_empty());
|
||||
assert!(log.read(conv_id(99), Some(turn_id(1))).await.unwrap().is_empty());
|
||||
assert!(log
|
||||
.read(conv_id(99), Some(turn_id(1)))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert!(log.last(conv_id(99), 5).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
@ -293,8 +323,14 @@ async fn read_cursor_is_strictly_exclusive() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["b", "c"]);
|
||||
assert_eq!(texts(&log.read(c, Some(turn_id(2))).await.unwrap()), vec!["c"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c, Some(turn_id(1))).await.unwrap()),
|
||||
vec!["b", "c"]
|
||||
);
|
||||
assert_eq!(
|
||||
texts(&log.read(c, Some(turn_id(2))).await.unwrap()),
|
||||
vec!["c"]
|
||||
);
|
||||
// Cursor on the last id => nothing after.
|
||||
assert!(log.read(c, Some(turn_id(3))).await.unwrap().is_empty());
|
||||
// Unknown cursor => empty (cohérent avec le double in-memory de P1).
|
||||
@ -317,9 +353,15 @@ async fn last_contract_zero_and_bounds() {
|
||||
// last(n < len) => the n last, in insertion order.
|
||||
assert_eq!(texts(&log.last(c, 2).await.unwrap()), vec!["c", "d"]);
|
||||
// last(n > len) => everything.
|
||||
assert_eq!(texts(&log.last(c, 10).await.unwrap()), vec!["a", "b", "c", "d"]);
|
||||
assert_eq!(
|
||||
texts(&log.last(c, 10).await.unwrap()),
|
||||
vec!["a", "b", "c", "d"]
|
||||
);
|
||||
// last(n == len) => everything.
|
||||
assert_eq!(texts(&log.last(c, 4).await.unwrap()), vec!["a", "b", "c", "d"]);
|
||||
assert_eq!(
|
||||
texts(&log.last(c, 4).await.unwrap()),
|
||||
vec!["a", "b", "c", "d"]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -352,7 +394,11 @@ async fn concurrent_appends_on_same_conversation_keep_both_lines_intact() {
|
||||
// Exactly two non-empty lines, each a fully-parseable turn (no interleaving).
|
||||
let raw = std::fs::read_to_string(tmp.log_path(c)).unwrap();
|
||||
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
assert_eq!(lines.len(), 2, "two concurrent appends => two lines, got: {raw:?}");
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
2,
|
||||
"two concurrent appends => two lines, got: {raw:?}"
|
||||
);
|
||||
for line in &lines {
|
||||
serde_json::from_str::<ConversationTurn>(line)
|
||||
.unwrap_or_else(|e| panic!("interleaved/corrupted line {line:?}: {e}"));
|
||||
@ -391,15 +437,23 @@ async fn handoff_round_trip_multiline_summary_with_objective() {
|
||||
|
||||
// summary_md délibérément multi-ligne, avec un `---` interne et des espaces de fin
|
||||
// pour éprouver la fidélité octet-pour-octet du corps.
|
||||
let summary = "# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n";
|
||||
let summary =
|
||||
"# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n";
|
||||
let handoff = Handoff::new(summary, turn_id(42), Some("livrer le lot P3".to_string()));
|
||||
|
||||
store.save(c, handoff.clone()).await.unwrap();
|
||||
let loaded = store.load(c).await.unwrap();
|
||||
|
||||
assert_eq!(loaded, Some(handoff.clone()), "round-trip doit redonner le Handoff exact");
|
||||
assert_eq!(
|
||||
loaded,
|
||||
Some(handoff.clone()),
|
||||
"round-trip doit redonner le Handoff exact"
|
||||
);
|
||||
let loaded = loaded.unwrap();
|
||||
assert_eq!(loaded.summary_md, summary, "summary_md conservé octet pour octet");
|
||||
assert_eq!(
|
||||
loaded.summary_md, summary,
|
||||
"summary_md conservé octet pour octet"
|
||||
);
|
||||
assert_eq!(loaded.up_to, turn_id(42), "up_to conservé à l'identique");
|
||||
assert_eq!(loaded.objective.as_deref(), Some("livrer le lot P3"));
|
||||
}
|
||||
@ -428,8 +482,14 @@ async fn handoff_round_trip_without_objective() {
|
||||
|
||||
// Sanity sur le format brut : pas de ligne `objective:` quand None.
|
||||
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
|
||||
assert!(!raw.contains("objective:"), "aucune clé objective ne doit être écrite, got: {raw:?}");
|
||||
assert!(raw.contains("upTo: "), "upTo toujours présent, got: {raw:?}");
|
||||
assert!(
|
||||
!raw.contains("objective:"),
|
||||
"aucune clé objective ne doit être écrite, got: {raw:?}"
|
||||
);
|
||||
assert!(
|
||||
raw.contains("upTo: "),
|
||||
"upTo toujours présent, got: {raw:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -464,8 +524,15 @@ async fn handoff_save_is_atomic_no_tmp_left_behind() {
|
||||
"le fichier temporaire handoff.md.tmp ne doit pas subsister après save"
|
||||
);
|
||||
// Le fichier final existe et est complet/relisible.
|
||||
assert!(tmp.handoff_path(c).exists(), "handoff.md final doit exister");
|
||||
assert_eq!(store.load(c).await.unwrap(), Some(handoff), "contenu final lisible et complet");
|
||||
assert!(
|
||||
tmp.handoff_path(c).exists(),
|
||||
"handoff.md final doit exister"
|
||||
);
|
||||
assert_eq!(
|
||||
store.load(c).await.unwrap(),
|
||||
Some(handoff),
|
||||
"contenu final lisible et complet"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -485,10 +552,20 @@ async fn handoff_overwrite_keeps_only_the_last() {
|
||||
store.save(c, second.clone()).await.unwrap();
|
||||
|
||||
// load renvoie le dernier, aucune trace du premier.
|
||||
assert_eq!(store.load(c).await.unwrap(), Some(second), "load doit renvoyer le dernier save");
|
||||
assert_eq!(
|
||||
store.load(c).await.unwrap(),
|
||||
Some(second),
|
||||
"load doit renvoyer le dernier save"
|
||||
);
|
||||
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
|
||||
assert!(!raw.contains("première version"), "le contenu du premier save ne doit pas subsister");
|
||||
assert!(!raw.contains("obj-1"), "l'objective du premier save ne doit pas subsister");
|
||||
assert!(
|
||||
!raw.contains("première version"),
|
||||
"le contenu du premier save ne doit pas subsister"
|
||||
);
|
||||
assert!(
|
||||
!raw.contains("obj-1"),
|
||||
"l'objective du premier save ne doit pas subsister"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -515,11 +592,20 @@ async fn handoff_is_isolated_per_conversation() {
|
||||
// Deux fichiers distincts sur disque.
|
||||
let p1 = tmp.handoff_path(c1);
|
||||
let p2 = tmp.handoff_path(c2);
|
||||
assert_ne!(p1, p2, "deux conversations => deux fichiers handoff distincts");
|
||||
assert_ne!(
|
||||
p1, p2,
|
||||
"deux conversations => deux fichiers handoff distincts"
|
||||
);
|
||||
assert!(p1.exists() && p2.exists());
|
||||
let raw2 = std::fs::read_to_string(&p2).unwrap();
|
||||
assert!(!raw2.contains("résumé c1"), "aucune fuite de c1 dans le handoff de c2");
|
||||
assert!(!raw2.contains("obj-c1"), "aucune fuite de l'objective de c1 dans c2");
|
||||
assert!(
|
||||
!raw2.contains("résumé c1"),
|
||||
"aucune fuite de c1 dans le handoff de c2"
|
||||
);
|
||||
assert!(
|
||||
!raw2.contains("obj-c1"),
|
||||
"aucune fuite de l'objective de c1 dans c2"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -537,11 +623,23 @@ async fn handoff_corruption_yields_serialization_error_no_panic() {
|
||||
// (4) `upTo` non-UUID.
|
||||
// (5) ligne de front-matter sans `:`.
|
||||
let cases: [(u128, &str, &str); 5] = [
|
||||
(101, "pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n", "fence ouvrant absent"),
|
||||
(102, "---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n", "fence fermant absent"),
|
||||
(
|
||||
101,
|
||||
"pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n",
|
||||
"fence ouvrant absent",
|
||||
),
|
||||
(
|
||||
102,
|
||||
"---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n",
|
||||
"fence fermant absent",
|
||||
),
|
||||
(103, "---\nobjective: but\n---\ncorps\n", "upTo absent"),
|
||||
(104, "---\nupTo: pas-un-uuid\n---\ncorps\n", "upTo non-UUID"),
|
||||
(105, "---\nligne sans deux-points\n---\ncorps\n", "ligne front-matter sans `:`"),
|
||||
(
|
||||
105,
|
||||
"---\nligne sans deux-points\n---\ncorps\n",
|
||||
"ligne front-matter sans `:`",
|
||||
),
|
||||
];
|
||||
|
||||
for (n, content, label) in cases {
|
||||
@ -575,7 +673,10 @@ async fn handoff_corruption_yields_serialization_error_no_panic() {
|
||||
|
||||
/// Compte les lignes-tours (`- **…`) dans un `summary_md` rendu.
|
||||
fn turn_lines(summary_md: &str) -> Vec<&str> {
|
||||
summary_md.lines().filter(|l| l.starts_with("- **")).collect()
|
||||
summary_md
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("- **"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `fold(None, turns)` = calcul de base : tours rendus, curseur = dernier id,
|
||||
@ -595,7 +696,8 @@ async fn fold_none_renders_base_window_objective_and_cursor() {
|
||||
// Objectif extrait du 1er Prompt.
|
||||
assert_eq!(h.objective.as_deref(), Some("Implémente la feature X"));
|
||||
assert!(
|
||||
h.summary_md.contains("**Objectif :** Implémente la feature X"),
|
||||
h.summary_md
|
||||
.contains("**Objectif :** Implémente la feature X"),
|
||||
"ligne d'objectif attendue, got:\n{}",
|
||||
h.summary_md
|
||||
);
|
||||
@ -603,7 +705,12 @@ async fn fold_none_renders_base_window_objective_and_cursor() {
|
||||
assert_eq!(h.up_to, turn_id(3));
|
||||
// Les 3 tours rendus, un par ligne, avec le bon label.
|
||||
let lines = turn_lines(&h.summary_md);
|
||||
assert_eq!(lines.len(), 3, "3 lignes-tours attendues, got:\n{}", h.summary_md);
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
3,
|
||||
"3 lignes-tours attendues, got:\n{}",
|
||||
h.summary_md
|
||||
);
|
||||
assert_eq!(lines[0], "- **Prompt:** Implémente la feature X");
|
||||
assert_eq!(lines[1], "- **Tool:** ran grep");
|
||||
assert_eq!(lines[2], "- **Response:** fait");
|
||||
@ -626,11 +733,18 @@ async fn fold_is_incremental_does_not_re_pass_old_turns() {
|
||||
|
||||
// Le 2e appel ne re-fournit QUE t6.
|
||||
let h2 = s
|
||||
.fold(Some(h1.clone()), &[turn(c, turn_id(6), TurnRole::Response, "r6")])
|
||||
.fold(
|
||||
Some(h1.clone()),
|
||||
&[turn(c, turn_id(6), TurnRole::Response, "r6")],
|
||||
)
|
||||
.await;
|
||||
|
||||
let lines = turn_lines(&h2.summary_md);
|
||||
assert_eq!(lines.len(), 6, "t1..t6 reconstitués depuis prev + incrément");
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
6,
|
||||
"t1..t6 reconstitués depuis prev + incrément"
|
||||
);
|
||||
assert_eq!(lines[0], "- **Prompt:** obj un");
|
||||
assert_eq!(lines[5], "- **Response:** r6");
|
||||
assert_eq!(h2.up_to, turn_id(6), "curseur = dernier de l'incrément");
|
||||
@ -686,7 +800,10 @@ async fn fold_keeps_existing_objective_over_new_prompt() {
|
||||
);
|
||||
|
||||
let h = s
|
||||
.fold(Some(prev), &[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")])
|
||||
.fold(
|
||||
Some(prev),
|
||||
&[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(h.objective.as_deref(), Some("but initial"), "objectif figé");
|
||||
@ -752,10 +869,13 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
|
||||
|
||||
// Le tour piégeux est aplati en une seule ligne (whitespace collapsé).
|
||||
let lines = turn_lines(&h1.summary_md);
|
||||
assert_eq!(lines.len(), 2, "2 lignes-tours seulement, pas de ligne fantôme");
|
||||
assert_eq!(
|
||||
lines[1],
|
||||
"- **Response:** ligne une - **Prompt:** faux marqueur ligne trois",
|
||||
lines.len(),
|
||||
2,
|
||||
"2 lignes-tours seulement, pas de ligne fantôme"
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1], "- **Response:** ligne une - **Prompt:** faux marqueur ligne trois",
|
||||
"texte multi-lignes + marqueur aplati en une ligne"
|
||||
);
|
||||
|
||||
@ -765,10 +885,17 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
|
||||
// (préfixée par `- **Response:** ligne une `), le reparse par `starts_with("- **")`
|
||||
// la compte comme UNE seule ligne — la fenêtre reste cohérente.
|
||||
let h2 = s
|
||||
.fold(Some(h1.clone()), &[turn(c, turn_id(3), TurnRole::Response, "suite")])
|
||||
.fold(
|
||||
Some(h1.clone()),
|
||||
&[turn(c, turn_id(3), TurnRole::Response, "suite")],
|
||||
)
|
||||
.await;
|
||||
let lines2 = turn_lines(&h2.summary_md);
|
||||
assert_eq!(lines2.len(), 3, "fenêtre cohérente après repli (pas de split parasite)");
|
||||
assert_eq!(
|
||||
lines2.len(),
|
||||
3,
|
||||
"fenêtre cohérente après repli (pas de split parasite)"
|
||||
);
|
||||
assert_eq!(lines2[2], "- **Response:** suite");
|
||||
assert_eq!(h2.up_to, turn_id(3));
|
||||
}
|
||||
@ -902,8 +1029,16 @@ async fn provider_corrupted_file_yields_serialization_error_no_panic() {
|
||||
|
||||
// Cas de contenus non-désérialisables en map<String,String>.
|
||||
let cases: [(u128, &str, &str); 3] = [
|
||||
(201, "{ ceci n'est pas du json", "JSON syntaxiquement invalide"),
|
||||
(202, "[\"pas\", \"un\", \"objet\"]", "JSON valide mais pas un objet map"),
|
||||
(
|
||||
201,
|
||||
"{ ceci n'est pas du json",
|
||||
"JSON syntaxiquement invalide",
|
||||
),
|
||||
(
|
||||
202,
|
||||
"[\"pas\", \"un\", \"objet\"]",
|
||||
"JSON valide mais pas un objet map",
|
||||
),
|
||||
(203, "{\"claude\": 123}", "valeur non-String"),
|
||||
];
|
||||
|
||||
@ -986,8 +1121,14 @@ async fn provider_is_isolated_per_conversation() {
|
||||
store.set(c2, "claude", "c2-id").await.unwrap();
|
||||
|
||||
// Chacune relit la sienne.
|
||||
assert_eq!(store.get(c1, "claude").await.unwrap(), Some("c1-id".to_string()));
|
||||
assert_eq!(store.get(c2, "claude").await.unwrap(), Some("c2-id".to_string()));
|
||||
assert_eq!(
|
||||
store.get(c1, "claude").await.unwrap(),
|
||||
Some("c1-id".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
store.get(c2, "claude").await.unwrap(),
|
||||
Some("c2-id".to_string())
|
||||
);
|
||||
|
||||
// Deux fichiers distincts sur disque, sans fuite de contenu.
|
||||
let p1 = tmp.providers_path(c1);
|
||||
@ -995,5 +1136,8 @@ async fn provider_is_isolated_per_conversation() {
|
||||
assert_ne!(p1, p2, "deux conversations ⇒ deux providers.json distincts");
|
||||
assert!(p1.exists() && p2.exists());
|
||||
let raw2 = std::fs::read_to_string(&p2).unwrap();
|
||||
assert!(!raw2.contains("c1-id"), "aucune fuite de c1 dans le providers.json de c2");
|
||||
assert!(
|
||||
!raw2.contains("c1-id"),
|
||||
"aucune fuite de c1 dans le providers.json de c2"
|
||||
);
|
||||
}
|
||||
|
||||
@ -27,9 +27,9 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::ids::NodeId;
|
||||
use domain::ids::SkillId;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::ids::NodeId;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
@ -52,11 +52,11 @@ use application::{
|
||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
|
||||
OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||
};
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::{error_codes, Transport, TransportError};
|
||||
use infrastructure::{
|
||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||
SystemMillisClock,
|
||||
};
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@ -242,6 +242,7 @@ impl AgentRuntime for FakeRuntime {
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: Some(ContextInjectionPlan::Stdin),
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -296,7 +297,6 @@ impl PtyPort for FakePty {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct NoopBus;
|
||||
impl EventBus for NoopBus {
|
||||
@ -468,7 +468,9 @@ fn tools_call(id: i64, tool: &str, arguments: Value) -> Vec<u8> {
|
||||
|
||||
/// Extracts the single text content block of a successful `tools/call` result.
|
||||
fn result_text(result: &Value) -> &str {
|
||||
result["content"][0]["text"].as_str().expect("text content block")
|
||||
result["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("text content block")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -485,15 +487,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 +511,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 +525,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 +550,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 +640,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 +669,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 +680,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 +772,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"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -836,6 +860,45 @@ async fn initialize_answers_minimal_handshake() {
|
||||
assert_eq!(result["serverInfo"]["name"], json!("idea-orchestrator"));
|
||||
}
|
||||
|
||||
/// Readiness de démarrage (fix race cold-launch, signal MCP) : un `initialize` reçu sur
|
||||
/// un serveur per-connexion (`for_requester(id)`) déclenche le `ready_sink` avec l'id du
|
||||
/// peer. Et un serveur SANS requester (anonyme) ne déclenche PAS le sink (peer legacy).
|
||||
#[tokio::test]
|
||||
async fn initialize_fires_ready_sink_with_requester() {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink_buf = Arc::clone(&captured);
|
||||
let ready_sink: Arc<dyn Fn(&str) + Send + Sync> =
|
||||
Arc::new(move |req: &str| sink_buf.lock().unwrap().push(req.to_owned()));
|
||||
|
||||
let base = McpServer::new(service, project()).with_ready_sink(ready_sink);
|
||||
|
||||
let init = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0", "id": 0, "method": "initialize",
|
||||
"params": { "protocolVersion": "2024-11-05" }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
// Cas 1 : peer identifié (requester non vide) ⇒ le sink reçoit son id.
|
||||
let peer = base.for_requester("agent-42");
|
||||
let response = peer.handle_raw(&init).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "initialize must still answer");
|
||||
assert_eq!(
|
||||
*captured.lock().unwrap(),
|
||||
vec!["agent-42".to_owned()],
|
||||
"initialize d'un peer identifié ⇒ ready_sink appelé avec son requester"
|
||||
);
|
||||
|
||||
// Cas 2 : peer anonyme (requester vide, le serveur de base) ⇒ sink NON appelé.
|
||||
captured.lock().unwrap().clear();
|
||||
let response = base.handle_raw(&init).await.expect("reply owed");
|
||||
assert!(response.error.is_none());
|
||||
assert!(
|
||||
captured.lock().unwrap().is_empty(),
|
||||
"requester vide ⇒ ready_sink jamais appelé (peer legacy/anonyme)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Shared validation: a tools/call missing a required argument is rejected by
|
||||
// the SAME OrchestratorRequest::validate, with no dispatch.
|
||||
@ -898,7 +961,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 +993,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 +1021,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 +1049,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,8 +1080,174 @@ 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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Anti-wedge (régression du bug serveur lockstep) + timeout du rendezvous.
|
||||
//
|
||||
// Cœur de la régression : un `idea_ask_agent` en attente de son `idea_reply`
|
||||
// ne doit PLUS parquer toute la connexion. La preuve : pendant qu'un ask est
|
||||
// bloqué (rendezvous jamais résolu), un second appel (`tools/list`) sur la
|
||||
// MÊME connexion reçoit sa réponse. L'ancien `serve` lockstep ne lisait plus
|
||||
// rien tant que `handle_raw` de l'ask n'avait pas rendu → ce test bouclait.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Transport scriptable qui **reste ouvert** : il livre `inbound` dans l'ordre,
|
||||
/// puis, une fois la file vide, `recv` reste en attente (au lieu de fermer) tant
|
||||
/// que le test n'a pas appelé `close()`. Cela laisse la boucle `serve` vivante —
|
||||
/// donc capable de drainer les réponses des tâches encore en vol — pendant qu'un
|
||||
/// `idea_ask_agent` est parqué. Les `send` sont capturés sur un canal `mpsc`.
|
||||
struct GatedTransport {
|
||||
inbound: std::collections::VecDeque<Vec<u8>>,
|
||||
outbound: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
||||
close: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl GatedTransport {
|
||||
fn new(
|
||||
messages: Vec<Vec<u8>>,
|
||||
) -> (
|
||||
Self,
|
||||
tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
Arc<tokio::sync::Notify>,
|
||||
) {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let close = Arc::new(tokio::sync::Notify::new());
|
||||
(
|
||||
Self {
|
||||
inbound: messages.into(),
|
||||
outbound: tx,
|
||||
close: Arc::clone(&close),
|
||||
},
|
||||
rx,
|
||||
close,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for GatedTransport {
|
||||
async fn recv(&mut self) -> Result<Vec<u8>, TransportError> {
|
||||
if let Some(next) = self.inbound.pop_front() {
|
||||
return Ok(next);
|
||||
}
|
||||
// File vide : on n'émet PAS Closed tout de suite — on attend le signal de
|
||||
// fermeture pour ne pas tuer la boucle pendant qu'un ask est encore parqué.
|
||||
self.close.notified().await;
|
||||
Err(TransportError::Closed)
|
||||
}
|
||||
|
||||
async fn send(&mut self, message: &[u8]) -> Result<(), TransportError> {
|
||||
self.outbound
|
||||
.send(message.to_vec())
|
||||
.map_err(|_| TransportError::Closed)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_ask_does_not_wedge_the_connection_concurrent_call_still_answered() {
|
||||
// Un `idea_ask_agent` vers une cible vivante bloque sur la mailbox (aucun
|
||||
// `idea_reply` ne viendra). Sur la MÊME connexion, un `tools/list` qui suit
|
||||
// doit recevoir sa réponse SANS attendre la résolution de l'ask.
|
||||
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(909)),
|
||||
);
|
||||
let server = server(service);
|
||||
|
||||
let ask = tools_call(
|
||||
1,
|
||||
"idea_ask_agent",
|
||||
json!({ "target": "architect", "task": "blocking..." }),
|
||||
);
|
||||
let list = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0", "id": 2, "method": "tools/list"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let (transport, mut rx, close) = GatedTransport::new(vec![ask, list]);
|
||||
let serve = tokio::spawn(async move {
|
||||
let mut transport = transport;
|
||||
server.serve(&mut transport).await;
|
||||
});
|
||||
|
||||
// L'ask a bien été enqueué (donc il est parqué en attente de reply).
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
while mailbox.pending(&agent_id) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("ask must enqueue a ticket");
|
||||
|
||||
// La réponse au `tools/list` arrive AVANT que l'ask soit résolu : c'est la
|
||||
// preuve anti-wedge. (Sur l'ancien code lockstep, ce recv timeout-ait.)
|
||||
let response = tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv())
|
||||
.await
|
||||
.expect("tools/list must be answered while the ask is still pending")
|
||||
.expect("a response payload");
|
||||
let response: Value = serde_json::from_slice(&response).unwrap();
|
||||
assert_eq!(response["id"], json!(2), "the answered call is tools/list");
|
||||
assert!(
|
||||
response["result"]["tools"].is_array(),
|
||||
"tools/list result, got {response}"
|
||||
);
|
||||
|
||||
// L'ask est toujours en vol (non résolu, aucun `idea_reply` ne viendra) : la
|
||||
// boucle restera en attente de sa réponse, c'est attendu. On la ferme et on
|
||||
// abandonne la tâche serve (le rendezvous parqué ne se résoudra jamais ici).
|
||||
assert_eq!(mailbox.pending(&agent_id), 1, "ask still pending");
|
||||
close.notify_one();
|
||||
serve.abort();
|
||||
let _ = serve.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ask_agent_rendezvous_times_out_with_a_jsonrpc_error() {
|
||||
// La cible ne répondra jamais. Avec une borne courte injectée, l'ask doit
|
||||
// finir par renvoyer une ERREUR JSON-RPC propre (filet de sécurité serveur),
|
||||
// au lieu de pendre indéfiniment.
|
||||
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(910)),
|
||||
);
|
||||
let server = server(service)
|
||||
.with_ask_rendezvous_timeout(std::time::Duration::from_millis(50));
|
||||
|
||||
let raw = tools_call(
|
||||
1,
|
||||
"idea_ask_agent",
|
||||
json!({ "target": "architect", "task": "never answered" }),
|
||||
);
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
server.handle_raw(&raw),
|
||||
)
|
||||
.await
|
||||
.expect("must not hang past the injected timeout")
|
||||
.expect("reply owed");
|
||||
|
||||
let error = response.error.expect("a JSON-RPC error on timeout");
|
||||
assert_eq!(error.code, error_codes::INTERNAL_ERROR, "got {error:?}");
|
||||
assert!(
|
||||
error.message.contains("timeout"),
|
||||
"explicit timeout message, got {error:?}"
|
||||
);
|
||||
assert!(response.result.is_none(), "error responses carry no result");
|
||||
}
|
||||
|
||||
@ -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,
|
||||
@ -241,6 +241,7 @@ impl AgentRuntime for FakeRuntime {
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: Some(ContextInjectionPlan::Stdin),
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -295,7 +296,6 @@ impl PtyPort for FakePty {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct NoopBus;
|
||||
impl EventBus for NoopBus {
|
||||
@ -471,11 +471,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 +646,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 +770,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 +874,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");
|
||||
|
||||
86
crates/infrastructure/tests/permission_store.rs
Normal file
86
crates/infrastructure/tests/permission_store.rs
Normal file
@ -0,0 +1,86 @@
|
||||
//! L2 integration tests for [`FsPermissionStore`] against a real temp project.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ids::{AgentId, ProjectId};
|
||||
use domain::ports::{FileSystem, PermissionStore};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{
|
||||
AgentPermissionOverride, PermissionSet, Posture, ProjectPermissions, PERMISSIONS_VERSION,
|
||||
};
|
||||
use infrastructure::{FsPermissionStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A unique scratch project directory under the OS temp dir, cleaned up on drop.
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-l2-permissions-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
|
||||
fn project_root(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn store() -> FsPermissionStore {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
FsPermissionStore::new(fs)
|
||||
}
|
||||
|
||||
fn project(tmp: &TempDir) -> Project {
|
||||
Project::new(
|
||||
ProjectId::new_random(),
|
||||
"permissions",
|
||||
ProjectPath::new(tmp.project_root()).unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_permissions_file_returns_default_document() {
|
||||
let tmp = TempDir::new();
|
||||
let project = project(&tmp);
|
||||
|
||||
let loaded = store().load_permissions(&project).await.unwrap();
|
||||
|
||||
assert_eq!(loaded, ProjectPermissions::default());
|
||||
assert_eq!(loaded.version, PERMISSIONS_VERSION);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_then_load_roundtrips_project_defaults_and_agent_override() {
|
||||
let tmp = TempDir::new();
|
||||
let project = project(&tmp);
|
||||
let agent = AgentId::new_random();
|
||||
let doc = ProjectPermissions::new(
|
||||
Some(PermissionSet::new(vec![], Posture::Ask)),
|
||||
vec![AgentPermissionOverride::new(
|
||||
agent,
|
||||
PermissionSet::new(vec![], Posture::Deny),
|
||||
)],
|
||||
);
|
||||
|
||||
let store = store();
|
||||
store.save_permissions(&project, &doc).await.unwrap();
|
||||
|
||||
let loaded = store.load_permissions(&project).await.unwrap();
|
||||
assert_eq!(loaded, doc);
|
||||
assert_eq!(loaded.resolve_for(agent).unwrap().fallback(), Posture::Deny);
|
||||
|
||||
let path = tmp.0.join(".ideai").join("permissions.json");
|
||||
assert!(path.exists(), "store writes under .ideai/permissions.json");
|
||||
}
|
||||
@ -26,6 +26,7 @@ fn sh_spec(script: &str) -> SpawnSpec {
|
||||
cwd: ProjectPath::new("/").unwrap(),
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
sandbox: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ import { TauriSkillGateway } from "./skill";
|
||||
import { TauriMemoryGateway } from "./memory";
|
||||
import { TauriEmbedderGateway } from "./embedder";
|
||||
import { TauriGitGateway } from "./git";
|
||||
import { TauriPermissionGateway } from "./permission";
|
||||
|
||||
function notImplemented(what: string): never {
|
||||
const err: GatewayError = {
|
||||
@ -55,6 +56,7 @@ export function createTauriGateways(): Gateways {
|
||||
skill: new TauriSkillGateway(),
|
||||
memory: new TauriMemoryGateway(),
|
||||
embedder: new TauriEmbedderGateway(),
|
||||
permission: new TauriPermissionGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -71,4 +73,5 @@ export {
|
||||
TauriMemoryGateway,
|
||||
TauriEmbedderGateway,
|
||||
TauriGitGateway,
|
||||
TauriPermissionGateway,
|
||||
};
|
||||
|
||||
@ -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,25 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import type { InputGateway } from "@/ports";
|
||||
|
||||
export class TauriInputGateway implements InputGateway {
|
||||
async submit(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
await invoke("submit_agent_input", {
|
||||
request: { projectId, agentId, text },
|
||||
});
|
||||
}
|
||||
|
||||
async interrupt(projectId: string, agentId: string): Promise<void> {
|
||||
await invoke("interrupt_agent", {
|
||||
request: { projectId, agentId },
|
||||
});
|
||||
}
|
||||
|
||||
async delegationDelivered(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
ticket: string,
|
||||
): Promise<void> {
|
||||
await invoke("delegation_delivered", {
|
||||
request: { projectId, agentId, ticket },
|
||||
});
|
||||
}
|
||||
|
||||
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
|
||||
await invoke("set_front_attached", {
|
||||
request: { agentId, attached },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,10 @@ import type {
|
||||
MemoryIndexEntry,
|
||||
MemoryLink,
|
||||
MemoryType,
|
||||
EffectivePermissions,
|
||||
PermissionSet,
|
||||
Project,
|
||||
ProjectPermissions,
|
||||
ProfileAvailability,
|
||||
ResumableAgent,
|
||||
Skill,
|
||||
@ -53,6 +56,7 @@ import type {
|
||||
OpenTerminalOptions,
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
PermissionGateway,
|
||||
ReattachResult,
|
||||
RemoteGateway,
|
||||
SkillGateway,
|
||||
@ -1533,39 +1537,106 @@ export class MockEmbedderGateway implements EmbedderGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/** One recorded mediated-input call (for test assertions). */
|
||||
/** One recorded agent-input control call (for test assertions). */
|
||||
export interface MockInputCall {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
/** Present for `submit`, absent for `interrupt`. */
|
||||
text?: string;
|
||||
/** Present for `delegationDelivered`, absent for `interrupt`. */
|
||||
ticket?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory {@link InputGateway} (lot F1). Records every `submit`/`interrupt`
|
||||
* so tests can assert the component routed through the port with the right
|
||||
* args — no backend. `submit` always resolves (the FIFO never refuses an
|
||||
* enqueue; the forward/fallback rule lives backend-side, §4.2/§6).
|
||||
* In-memory {@link InputGateway} (ARCHITECTURE §20.3). Records every
|
||||
* `interrupt`/`delegationDelivered` so tests can assert the component routed
|
||||
* through the port with the right args — no backend.
|
||||
*
|
||||
* Exported so tests can instantiate it directly (same pattern as the other mocks).
|
||||
*/
|
||||
export class MockInputGateway implements InputGateway {
|
||||
/** All recorded submit calls, in order. */
|
||||
readonly submits: MockInputCall[] = [];
|
||||
/** All recorded interrupt calls, in order. */
|
||||
readonly interrupts: MockInputCall[] = [];
|
||||
|
||||
async submit(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
this.submits.push({ projectId, agentId, text });
|
||||
}
|
||||
/** All recorded delegation-delivered acks, in order. */
|
||||
readonly delivered: MockInputCall[] = [];
|
||||
/** All recorded front-attached reports, in order. */
|
||||
readonly frontAttached: { agentId: string; attached: boolean }[] = [];
|
||||
|
||||
async interrupt(projectId: string, agentId: string): Promise<void> {
|
||||
this.interrupts.push({ projectId, agentId });
|
||||
}
|
||||
|
||||
async delegationDelivered(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
ticket: string,
|
||||
): Promise<void> {
|
||||
this.delivered.push({ projectId, agentId, ticket });
|
||||
}
|
||||
|
||||
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
|
||||
this.frontAttached.push({ agentId, attached });
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory permissions gateway. */
|
||||
export class MockPermissionGateway implements PermissionGateway {
|
||||
private docs = new Map<string, ProjectPermissions>();
|
||||
|
||||
private doc(projectId: string): ProjectPermissions {
|
||||
if (!this.docs.has(projectId)) {
|
||||
this.docs.set(projectId, { version: 1, agents: [] });
|
||||
}
|
||||
return this.docs.get(projectId)!;
|
||||
}
|
||||
|
||||
async getProjectPermissions(projectId: string): Promise<ProjectPermissions> {
|
||||
return structuredClone(this.doc(projectId));
|
||||
}
|
||||
|
||||
async updateProjectPermissions(
|
||||
projectId: string,
|
||||
permissions: PermissionSet | null,
|
||||
): Promise<ProjectPermissions> {
|
||||
const doc = this.doc(projectId);
|
||||
if (permissions) doc.projectDefaults = structuredClone(permissions);
|
||||
else delete doc.projectDefaults;
|
||||
return structuredClone(doc);
|
||||
}
|
||||
|
||||
async updateAgentPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
permissions: PermissionSet | null,
|
||||
): Promise<ProjectPermissions> {
|
||||
const doc = this.doc(projectId);
|
||||
const agents = (doc.agents ?? []).filter((entry) => entry.agentId !== agentId);
|
||||
if (permissions) agents.push({ agentId, permissions: structuredClone(permissions) });
|
||||
doc.agents = agents;
|
||||
return structuredClone(doc);
|
||||
}
|
||||
|
||||
async resolveAgentPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<EffectivePermissions | null> {
|
||||
const doc = this.doc(projectId);
|
||||
const project = doc.projectDefaults;
|
||||
const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions;
|
||||
if (!project && !agent) return null;
|
||||
return {
|
||||
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
|
||||
fallback: mostRestrictive(project?.fallback, agent?.fallback),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mostRestrictive(
|
||||
project?: PermissionSet["fallback"],
|
||||
agent?: PermissionSet["fallback"],
|
||||
): PermissionSet["fallback"] {
|
||||
const rank = { allow: 0, ask: 1, deny: 2 } as const;
|
||||
const fallback = project ?? agent ?? "ask";
|
||||
if (!agent) return fallback;
|
||||
return rank[agent] >= rank[fallback] ? agent : fallback;
|
||||
}
|
||||
|
||||
/** Builds the full set of mock gateways. */
|
||||
@ -1585,6 +1656,7 @@ export function createMockGateways(): Gateways {
|
||||
skill: new MockSkillGateway(agentGateway),
|
||||
memory: new MockMemoryGateway(),
|
||||
embedder: new MockEmbedderGateway(),
|
||||
permission: new MockPermissionGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
43
frontend/src/adapters/permission.ts
Normal file
43
frontend/src/adapters/permission.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
EffectivePermissions,
|
||||
PermissionSet,
|
||||
ProjectPermissions,
|
||||
} from "@/domain";
|
||||
import type { PermissionGateway } from "@/ports";
|
||||
|
||||
/** Tauri-backed permissions gateway. */
|
||||
export class TauriPermissionGateway implements PermissionGateway {
|
||||
getProjectPermissions(projectId: string): Promise<ProjectPermissions> {
|
||||
return invoke<ProjectPermissions>("get_project_permissions", { projectId });
|
||||
}
|
||||
|
||||
updateProjectPermissions(
|
||||
projectId: string,
|
||||
permissions: PermissionSet | null,
|
||||
): Promise<ProjectPermissions> {
|
||||
return invoke<ProjectPermissions>("update_project_permissions", {
|
||||
request: { projectId, permissions },
|
||||
});
|
||||
}
|
||||
|
||||
updateAgentPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
permissions: PermissionSet | null,
|
||||
): Promise<ProjectPermissions> {
|
||||
return invoke<ProjectPermissions>("update_agent_permissions", {
|
||||
request: { projectId, agentId, permissions },
|
||||
});
|
||||
}
|
||||
|
||||
resolveAgentPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<EffectivePermissions | null> {
|
||||
return invoke<EffectivePermissions | null>("resolve_agent_permissions", {
|
||||
request: { projectId, agentId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
@ -68,6 +84,67 @@ export interface GatewayError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permissions (LP1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Agent capability governed by IdeA permissions. */
|
||||
export type Capability = "read" | "write" | "delete" | "executeBash";
|
||||
|
||||
/** Permission rule verdict. */
|
||||
export type PermissionEffect = "allow" | "deny";
|
||||
|
||||
/** Fallback stance when no permission rule matches. */
|
||||
export type PermissionPosture = "ask" | "allow" | "deny";
|
||||
|
||||
/** Glob path scope, relative to the project root. */
|
||||
export type PathScope = string[];
|
||||
|
||||
/** Shell command matcher. */
|
||||
export type CommandMatcher =
|
||||
| { kind: "exact"; value: string }
|
||||
| { kind: "prefix"; value: string }
|
||||
| { kind: "glob"; value: string };
|
||||
|
||||
/** Per-command bash verdict. */
|
||||
export interface CommandRule {
|
||||
matcher: CommandMatcher;
|
||||
effect: PermissionEffect;
|
||||
}
|
||||
|
||||
/** One permission rule. */
|
||||
export interface PermissionRule {
|
||||
capability: Capability;
|
||||
effect: PermissionEffect;
|
||||
paths?: PathScope;
|
||||
commands?: CommandRule[];
|
||||
}
|
||||
|
||||
/** Permission policy bundle. */
|
||||
export interface PermissionSet {
|
||||
rules: PermissionRule[];
|
||||
fallback: PermissionPosture;
|
||||
}
|
||||
|
||||
/** One sparse agent override in `.ideai/permissions.json`. */
|
||||
export interface AgentPermissionOverride {
|
||||
agentId: string;
|
||||
permissions: PermissionSet;
|
||||
}
|
||||
|
||||
/** Full project permission document. */
|
||||
export interface ProjectPermissions {
|
||||
version: number;
|
||||
projectDefaults?: PermissionSet;
|
||||
agents?: AgentPermissionOverride[];
|
||||
}
|
||||
|
||||
/** Effective project+agent policy. */
|
||||
export interface EffectivePermissions {
|
||||
rules: PermissionRule[];
|
||||
fallback: PermissionPosture;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout (L4) — mirror of the domain `LayoutTree` (ARCHITECTURE §3, §7).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,22 +1,20 @@
|
||||
/**
|
||||
* F2 — mediated input for agent cells (cadrage §4.2/§7).
|
||||
* Agent cell = NATIVE terminal (ARCHITECTURE §20) — supersedes the F2 mediated
|
||||
* input contract.
|
||||
*
|
||||
* Two contracts are exercised here, with the real {@link DIProvider} and the
|
||||
* in-memory mocks:
|
||||
* The agent cell no longer mediates keystrokes through a separate strip. It is a
|
||||
* real terminal:
|
||||
*
|
||||
* 1. **Keystrokes are mediated in agent mode.** A cell that pins an agent puts
|
||||
* `TerminalView` in `agentMode`: human keystrokes (`term.onData`) MUST NOT be
|
||||
* written to the PTY (input flows through {@link MediatedInput}). A plain
|
||||
* (agent-less) cell keeps the raw-shell behaviour (keystrokes → PTY).
|
||||
* 2. **The PTY *output* is always painted** (both modes) — xterm stays the raw
|
||||
* output view, unchanged by F2.
|
||||
* 3. **`MediatedInput` is mounted under the terminal** for an agent cell, and
|
||||
* **never** an `AgentChatView` (the structured chat surface stays removed).
|
||||
* 1. **Keystrokes reach the PTY in BOTH modes.** An agent cell and a plain cell
|
||||
* alike forward `term.onData` keystrokes straight to `handle.write`.
|
||||
* 2. **The PTY *output* is always painted** — xterm stays the raw output view.
|
||||
* 3. **No `MediatedInput` strip and no `AgentChatView`** are ever mounted; the
|
||||
* agent cell renders only the raw {@link TerminalView}.
|
||||
*
|
||||
* xterm is stubbed (as in the sibling chat test) so `term.open` succeeds under
|
||||
* jsdom and the opener/keystroke wiring genuinely runs. The stub captures the
|
||||
* `onData` keystroke handler so the test can fire a keystroke and assert whether
|
||||
* it reached the PTY handle (`handle.write`).
|
||||
* `onData` keystroke handler so the test can fire a keystroke and assert it
|
||||
* reached the PTY handle (`handle.write`).
|
||||
*/
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
@ -79,7 +77,7 @@ import { DIProvider } from "@/app/di";
|
||||
import { leaves } from "./layout";
|
||||
import { LayoutGrid } from "./LayoutGrid";
|
||||
|
||||
/** Fresh mock set (input gateway included so MediatedInput is fully wired). */
|
||||
/** Fresh mock set (input gateway included so the write-portal is fully wired). */
|
||||
function makeGateways(agentGateway: MockAgentGateway): Gateways {
|
||||
return {
|
||||
layout: new MockLayoutGateway(),
|
||||
@ -123,42 +121,43 @@ function spyTerminalWrite(gw: MockTerminalGateway): ReturnType<typeof vi.fn> {
|
||||
return writeSpy;
|
||||
}
|
||||
|
||||
async function pinAgent(gateways: Gateways, agentId: string): Promise<void> {
|
||||
const layout = gateways.layout as MockLayoutGateway;
|
||||
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: agentId,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
xtermState.keyHandler = null;
|
||||
xtermState.writes = [];
|
||||
});
|
||||
|
||||
describe("LayoutGrid F2 — mediated input for agent cells", () => {
|
||||
it("an AGENT cell does NOT write keystrokes to the PTY (input is mediated)", async () => {
|
||||
describe("LayoutGrid — agent cell is a native terminal (§20)", () => {
|
||||
it("an AGENT cell DOES write keystrokes to the PTY (native terminal)", async () => {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const agent = await agentGateway.createAgent("p1", {
|
||||
name: "Worker",
|
||||
profileId: "claude",
|
||||
});
|
||||
const gateways = makeGateways(agentGateway);
|
||||
// Pin the agent onto the leaf in this run's layout gateway.
|
||||
const layout = gateways.layout as MockLayoutGateway;
|
||||
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: agent.id,
|
||||
});
|
||||
await pinAgent(gateways, agent.id);
|
||||
|
||||
const writeSpy = spyAgentWrite(agentGateway);
|
||||
|
||||
renderGrid(gateways);
|
||||
|
||||
// Wait for the launch to settle (the opener ran and adopted a handle).
|
||||
await waitFor(() => expect(agentGateway.launchAgent).toHaveBeenCalled());
|
||||
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
|
||||
|
||||
// Simulate a human keystroke into xterm.
|
||||
// Simulate human keystrokes into xterm; in native mode they reach the PTY.
|
||||
xtermState.keyHandler!("h");
|
||||
xtermState.keyHandler!("i");
|
||||
|
||||
// In agent mode the keystroke must be swallowed — never forwarded to the PTY.
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(writeSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("a PLAIN cell still writes keystrokes to the PTY (raw shell, unchanged)", async () => {
|
||||
@ -176,64 +175,48 @@ describe("LayoutGrid F2 — mediated input for agent cells", () => {
|
||||
xtermState.keyHandler!("l");
|
||||
xtermState.keyHandler!("s");
|
||||
|
||||
// Plain shell: keystrokes flow straight to the PTY (current behaviour).
|
||||
await waitFor(() => expect(writeSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("paints PTY output in BOTH modes (xterm output view unchanged)", async () => {
|
||||
// Agent cell: the mock greets on open → output must reach term.write.
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const agent = await agentGateway.createAgent("p1", {
|
||||
name: "Worker",
|
||||
profileId: "claude",
|
||||
});
|
||||
const gateways = makeGateways(agentGateway);
|
||||
const layout = gateways.layout as MockLayoutGateway;
|
||||
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: agent.id,
|
||||
});
|
||||
await pinAgent(gateways, agent.id);
|
||||
|
||||
renderGrid(gateways);
|
||||
|
||||
// The mock agent greets on open; that output must be painted by xterm even
|
||||
// though keystroke input is mediated.
|
||||
await waitFor(() => expect(xtermState.writes.length).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it("mounts MediatedInput under an agent cell, never an AgentChatView", async () => {
|
||||
it("renders only the raw TerminalView — no MediatedInput, no AgentChatView", async () => {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const agent = await agentGateway.createAgent("p1", {
|
||||
name: "Worker",
|
||||
profileId: "claude",
|
||||
});
|
||||
const gateways = makeGateways(agentGateway);
|
||||
const layout = gateways.layout as MockLayoutGateway;
|
||||
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: agent.id,
|
||||
});
|
||||
|
||||
renderGrid(gateways);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
expect(screen.getByTestId("mediated-input")).toBeTruthy();
|
||||
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
|
||||
});
|
||||
|
||||
it("a PLAIN cell has NO mediated input strip", async () => {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const gateways = makeGateways(agentGateway);
|
||||
await pinAgent(gateways, agent.id);
|
||||
|
||||
renderGrid(gateways);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
expect(screen.queryByTestId("mediated-input")).toBeNull();
|
||||
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
|
||||
});
|
||||
|
||||
it("a PLAIN cell has no portal overlay", async () => {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const gateways = makeGateways(agentGateway);
|
||||
|
||||
renderGrid(gateways);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
expect(screen.queryByTestId("write-portal-overlay")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -27,10 +27,9 @@ import type {
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
import {
|
||||
MediatedInput,
|
||||
ResumeConversationPopup,
|
||||
TerminalView,
|
||||
useAgentBusy,
|
||||
useWritePortal,
|
||||
} from "@/features/terminals";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
|
||||
@ -203,10 +202,12 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
|
||||
const { agent: agentGateway, system } = useGateways();
|
||||
|
||||
// Per-agent busy map (lot F1) feeds the mediated input strip: while the agent
|
||||
// is processing a turn, "Envoyer" is dimmed (but the enqueue stays open) and
|
||||
// "Interrompre" is active. Absent key ⇒ idle.
|
||||
const busyMap = useAgentBusy();
|
||||
// The single write-portal of this cell (ARCHITECTURE §20). It owns the human
|
||||
// line counter, the local delegation FIFO, the handshake (b→e) and the overlay
|
||||
// shown while a delegation is being injected. For a plain (agent-less) cell
|
||||
// `agent` is null: the portal stays inert (no subscription, no overlay) and is
|
||||
// simply not passed to the terminal.
|
||||
const { portal, overlay } = useWritePortal(projectId, agent ?? null);
|
||||
|
||||
// Load the project's agents for the dropdown.
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
@ -552,28 +553,23 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
)}
|
||||
</div>
|
||||
{/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the
|
||||
raw xterm {@link TerminalView}. Agent cells are interactive PTYs; their
|
||||
cross-model delegation happens through MCP tools, not a chat view.
|
||||
Re-key on the agent so the right opener is captured at mount; the
|
||||
persisted session drives re-attach (never a re-spawn) when navigating.
|
||||
|
||||
Lot F2 (cadrage §4.2): in an **agent** cell the terminal is output-only
|
||||
for the human (`agentMode`) and the {@link MediatedInput} strip is
|
||||
mounted **under** it — keystrokes route through IdeA, not the PTY. A
|
||||
**plain** cell keeps the raw-shell behaviour (keystrokes → PTY) and has
|
||||
no input strip. We stack terminal + strip in a column so the strip sits
|
||||
beneath the live output. */}
|
||||
raw xterm {@link TerminalView}. Agent cells are **native terminals**
|
||||
(ARCHITECTURE §20): every human keystroke (Enter included) reaches the
|
||||
PTY. There is no mediated-input strip; cross-model delegation flows
|
||||
through the write-portal (which injects at a clean line boundary) and
|
||||
MCP tools. Re-key on the agent so the right opener is captured at mount;
|
||||
the persisted session drives re-attach (never a re-spawn) when
|
||||
navigating. The agent cell passes its {@link WritePortal} so keystroke
|
||||
counting + injection suspension are wired; a plain cell passes none. */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0 }}>
|
||||
<TerminalView
|
||||
key={`${id}-${agentId ?? "plain"}`}
|
||||
cwd={cwd}
|
||||
@ -582,14 +578,41 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
sessionId={session}
|
||||
onSessionId={(sid) => void vm.setSession(id, sid)}
|
||||
agentMode={agentId != null}
|
||||
portal={agentId != null ? portal : undefined}
|
||||
/>
|
||||
{/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation
|
||||
is being injected into the agent's PTY, a grey veil with a centred
|
||||
message sits above the terminal. Only ever shown for an agent cell. */}
|
||||
{agentId != null && overlay && (
|
||||
<div
|
||||
data-testid="write-portal-overlay"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0, 0, 0, 0.45)",
|
||||
color: "var(--color-content, #e0e0e0)",
|
||||
fontSize: 13,
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 4,
|
||||
padding: "6px 12px",
|
||||
}}
|
||||
>
|
||||
Un agent est en train de parler…
|
||||
</span>
|
||||
</div>
|
||||
{agentId != null && (
|
||||
<MediatedInput
|
||||
projectId={projectId}
|
||||
agentId={agentId}
|
||||
busy={busyMap[agentId] ?? false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{busyNotice && (
|
||||
|
||||
381
frontend/src/features/permissions/PermissionsPanel.tsx
Normal file
381
frontend/src/features/permissions/PermissionsPanel.tsx
Normal file
@ -0,0 +1,381 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button, Panel, Spinner, cn } from "@/shared";
|
||||
import type { PermissionSet, PermissionPosture } from "@/domain";
|
||||
import {
|
||||
type CapabilityChoice,
|
||||
type PolicyDraft,
|
||||
draftFromSet,
|
||||
isCustomPolicy,
|
||||
usePermissions,
|
||||
} from "./usePermissions";
|
||||
|
||||
export interface PermissionsPanelProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
type EditorTarget =
|
||||
| { type: "project" }
|
||||
| { type: "agent"; agentId: string; agentName: string };
|
||||
|
||||
const CAPABILITY_ROWS: {
|
||||
key: keyof Omit<PolicyDraft, "fallback">;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ key: "read", label: "Read" },
|
||||
{ key: "write", label: "Write" },
|
||||
{ key: "delete", label: "Delete" },
|
||||
{ key: "executeBash", label: "Bash" },
|
||||
];
|
||||
|
||||
const POSTURE_LABELS: Record<PermissionPosture, string> = {
|
||||
ask: "Ask",
|
||||
allow: "Allow",
|
||||
deny: "Deny",
|
||||
};
|
||||
|
||||
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
const vm = usePermissions(projectId);
|
||||
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
|
||||
|
||||
const selectedAgent = target.type === "agent"
|
||||
? vm.rows.find((row) => row.agent.id === target.agentId) ?? null
|
||||
: null;
|
||||
const activePolicy = selectedAgent?.override ?? vm.document?.projectDefaults ?? null;
|
||||
const activeDraft = target.type === "project"
|
||||
? vm.projectDraft
|
||||
: draftFromSet(selectedAgent?.override ?? vm.document?.projectDefaults ?? null);
|
||||
const activeCustom = target.type === "project"
|
||||
? isCustomPolicy(vm.document?.projectDefaults)
|
||||
: isCustomPolicy(selectedAgent?.override);
|
||||
const hasProjectDefaults = vm.document?.projectDefaults != null;
|
||||
|
||||
async function handleSave(draft: PolicyDraft) {
|
||||
if (target.type === "project") {
|
||||
await vm.saveProjectDefaults(draft);
|
||||
} else {
|
||||
await vm.saveAgentOverride(target.agentId, draft);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (target.type === "project") {
|
||||
await vm.clearProjectDefaults();
|
||||
} else {
|
||||
await vm.clearAgentOverride(target.agentId);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Permissions"
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.refresh()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
className="flex flex-col"
|
||||
flush
|
||||
>
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<PolicyCard
|
||||
title="Project defaults"
|
||||
subtitle={hasProjectDefaults ? "Configured" : "Native CLI behavior"}
|
||||
active={target.type === "project"}
|
||||
custom={isCustomPolicy(vm.document?.projectDefaults)}
|
||||
onSelect={() => setTarget({ type: "project" })}
|
||||
policy={vm.document?.projectDefaults ?? null}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Agents
|
||||
</h4>
|
||||
{vm.busy && <Spinner size={14} />}
|
||||
</div>
|
||||
{vm.rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">No agents yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{vm.rows.map((row) => {
|
||||
const active =
|
||||
target.type === "agent" && target.agentId === row.agent.id;
|
||||
return (
|
||||
<li key={row.agent.id}>
|
||||
<PolicyCard
|
||||
title={row.agent.name}
|
||||
subtitle={row.override ? "Override" : "Inherited"}
|
||||
active={active}
|
||||
custom={isCustomPolicy(row.override)}
|
||||
onSelect={() =>
|
||||
setTarget({
|
||||
type: "agent",
|
||||
agentId: row.agent.id,
|
||||
agentName: row.agent.name,
|
||||
})
|
||||
}
|
||||
policy={row.override}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PermissionEditor
|
||||
key={target.type === "project" ? "project" : target.agentId}
|
||||
title={
|
||||
target.type === "project"
|
||||
? "Project defaults"
|
||||
: `Override — ${target.agentName}`
|
||||
}
|
||||
draft={activeDraft}
|
||||
sourcePolicy={activePolicy}
|
||||
custom={activeCustom}
|
||||
canClear={
|
||||
target.type === "project"
|
||||
? vm.document?.projectDefaults != null
|
||||
: selectedAgent?.override != null
|
||||
}
|
||||
busy={vm.busy}
|
||||
onSave={(draft) => void handleSave(draft)}
|
||||
onClear={() => void handleClear()}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
interface PolicyCardProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
active: boolean;
|
||||
custom: boolean;
|
||||
policy: PermissionSet | null;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function PolicyCard({
|
||||
title,
|
||||
subtitle,
|
||||
active,
|
||||
custom,
|
||||
policy,
|
||||
onSelect,
|
||||
}: PolicyCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center justify-between gap-3 rounded-md border px-3 py-2 text-left",
|
||||
"transition-colors hover:border-border-strong hover:bg-raised",
|
||||
active ? "border-primary bg-raised" : "border-border bg-surface",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium text-content">{title}</span>
|
||||
<span className="text-xs text-muted">{subtitle}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{custom && (
|
||||
<span className="rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
{policy && (
|
||||
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{POSTURE_LABELS[policy.fallback]}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface PermissionEditorProps {
|
||||
title: string;
|
||||
draft: PolicyDraft;
|
||||
sourcePolicy: PermissionSet | null;
|
||||
custom: boolean;
|
||||
canClear: boolean;
|
||||
busy: boolean;
|
||||
onSave: (draft: PolicyDraft) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function PermissionEditor({
|
||||
title,
|
||||
draft,
|
||||
sourcePolicy,
|
||||
custom,
|
||||
canClear,
|
||||
busy,
|
||||
onSave,
|
||||
onClear,
|
||||
}: PermissionEditorProps) {
|
||||
const [local, setLocal] = useState<PolicyDraft>(draft);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(draft);
|
||||
}, [draft]);
|
||||
|
||||
const changed = useMemo(
|
||||
() => JSON.stringify(local) !== JSON.stringify(draft),
|
||||
[draft, local],
|
||||
);
|
||||
|
||||
function setCapability(
|
||||
capability: keyof Omit<PolicyDraft, "fallback">,
|
||||
choice: CapabilityChoice,
|
||||
) {
|
||||
setLocal((prev) => ({ ...prev, [capability]: choice }));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-border bg-surface">
|
||||
<header className="flex items-start justify-between gap-3 border-b border-border px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<h4 className="truncate text-sm font-semibold text-content">{title}</h4>
|
||||
<p className="text-xs text-muted">
|
||||
{sourcePolicy ? `${sourcePolicy.rules.length} rules` : "Not configured"}
|
||||
</p>
|
||||
</div>
|
||||
{custom && (
|
||||
<span className="shrink-0 rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
|
||||
Advanced
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Fallback</span>
|
||||
<select
|
||||
aria-label={`${title} fallback`}
|
||||
value={local.fallback}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
fallback: e.target.value as PermissionPosture,
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
"h-9 rounded-md border border-border bg-raised px-3 text-sm text-content",
|
||||
"outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<option value="ask">Ask</option>
|
||||
<option value="allow">Allow</option>
|
||||
<option value="deny">Deny</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{CAPABILITY_ROWS.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className="grid grid-cols-[4.5rem_1fr] items-center gap-2"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted">{row.label}</span>
|
||||
<SegmentedChoice
|
||||
value={local[row.key]}
|
||||
disabled={busy}
|
||||
label={row.label}
|
||||
onChange={(choice) => setCapability(row.key, choice)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy || !canClear}
|
||||
onClick={onClear}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={busy || !changed}
|
||||
loading={busy && changed}
|
||||
onClick={() => onSave(local)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface SegmentedChoiceProps {
|
||||
value: CapabilityChoice;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onChange: (choice: CapabilityChoice) => void;
|
||||
}
|
||||
|
||||
function SegmentedChoice({
|
||||
value,
|
||||
disabled,
|
||||
label,
|
||||
onChange,
|
||||
}: SegmentedChoiceProps) {
|
||||
const choices: { value: CapabilityChoice; label: string }[] = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "allow", label: "Allow" },
|
||||
{ value: "deny", label: "Deny" },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`${label} permission`}
|
||||
className="grid grid-cols-3 overflow-hidden rounded-md border border-border"
|
||||
>
|
||||
{choices.map((choice) => (
|
||||
<button
|
||||
key={choice.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={value === choice.value}
|
||||
onClick={() => onChange(choice.value)}
|
||||
className={cn(
|
||||
"h-8 min-w-0 border-r border-border px-2 text-xs font-medium last:border-r-0",
|
||||
"transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
value === choice.value
|
||||
? choice.value === "deny"
|
||||
? "bg-danger/20 text-danger"
|
||||
: choice.value === "allow"
|
||||
? "bg-success/20 text-success"
|
||||
: "bg-raised text-content"
|
||||
: "bg-surface text-muted hover:bg-raised hover:text-content",
|
||||
)}
|
||||
>
|
||||
{choice.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/features/permissions/index.ts
Normal file
1
frontend/src/features/permissions/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./PermissionsPanel";
|
||||
89
frontend/src/features/permissions/permissions.test.tsx
Normal file
89
frontend/src/features/permissions/permissions.test.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockPermissionGateway,
|
||||
MockProfileGateway,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { PermissionsPanel } from "./PermissionsPanel";
|
||||
|
||||
const PROJECT_ID = "proj-permissions-test";
|
||||
|
||||
async function renderPanel() {
|
||||
const agent = new MockAgentGateway();
|
||||
const permission = new MockPermissionGateway();
|
||||
await agent.createAgent(PROJECT_ID, { name: "Builder", profileId: "p1" });
|
||||
const gateways = {
|
||||
agent,
|
||||
permission,
|
||||
profile: new MockProfileGateway(),
|
||||
} as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PermissionsPanel projectId={PROJECT_ID} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Builder")).toBeTruthy();
|
||||
});
|
||||
|
||||
return { agent, permission };
|
||||
}
|
||||
|
||||
function clickChoice(groupName: string, choice: string) {
|
||||
const group = screen.getByRole("group", { name: groupName });
|
||||
fireEvent.click(within(group).getByRole("button", { name: choice }));
|
||||
}
|
||||
|
||||
describe("PermissionsPanel", () => {
|
||||
it("saves project defaults from the compact policy editor", async () => {
|
||||
const { permission } = await renderPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Project defaults fallback"), {
|
||||
target: { value: "allow" },
|
||||
});
|
||||
clickChoice("Read permission", "Allow");
|
||||
clickChoice("Bash permission", "Deny");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const doc = await permission.getProjectPermissions(PROJECT_ID);
|
||||
expect(doc.projectDefaults?.fallback).toBe("allow");
|
||||
expect(doc.projectDefaults?.rules).toEqual([
|
||||
{ capability: "read", effect: "allow", paths: ["**"] },
|
||||
{ capability: "executeBash", effect: "deny", commands: [] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("saves and clears an agent override", async () => {
|
||||
const { permission } = await renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByText("Builder"));
|
||||
fireEvent.change(screen.getByLabelText("Override — Builder fallback"), {
|
||||
target: { value: "deny" },
|
||||
});
|
||||
clickChoice("Write permission", "Allow");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const doc = await permission.getProjectPermissions(PROJECT_ID);
|
||||
expect(doc.agents?.[0]?.permissions.fallback).toBe("deny");
|
||||
expect(doc.agents?.[0]?.permissions.rules).toEqual([
|
||||
{ capability: "write", effect: "allow", paths: ["**"] },
|
||||
]);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const doc = await permission.getProjectPermissions(PROJECT_ID);
|
||||
expect(doc.agents).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
236
frontend/src/features/permissions/usePermissions.ts
Normal file
236
frontend/src/features/permissions/usePermissions.ts
Normal file
@ -0,0 +1,236 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import type {
|
||||
Agent,
|
||||
Capability,
|
||||
PermissionEffect,
|
||||
PermissionPosture,
|
||||
PermissionRule,
|
||||
PermissionSet,
|
||||
ProjectPermissions,
|
||||
} from "@/domain";
|
||||
|
||||
export type CapabilityChoice = "none" | PermissionEffect;
|
||||
|
||||
export interface PolicyDraft {
|
||||
fallback: PermissionPosture;
|
||||
read: CapabilityChoice;
|
||||
write: CapabilityChoice;
|
||||
delete: CapabilityChoice;
|
||||
executeBash: CapabilityChoice;
|
||||
}
|
||||
|
||||
export interface AgentPermissionRow {
|
||||
agent: Agent;
|
||||
override: PermissionSet | null;
|
||||
}
|
||||
|
||||
export interface PermissionsViewModel {
|
||||
agents: Agent[];
|
||||
rows: AgentPermissionRow[];
|
||||
document: ProjectPermissions | null;
|
||||
projectDraft: PolicyDraft;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
saveProjectDefaults: (draft: PolicyDraft) => Promise<void>;
|
||||
clearProjectDefaults: () => Promise<void>;
|
||||
saveAgentOverride: (agentId: string, draft: PolicyDraft) => Promise<void>;
|
||||
clearAgentOverride: (agentId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_DRAFT: PolicyDraft = {
|
||||
fallback: "ask",
|
||||
read: "none",
|
||||
write: "none",
|
||||
delete: "none",
|
||||
executeBash: "none",
|
||||
};
|
||||
|
||||
const CAPABILITIES: Capability[] = ["read", "write", "delete", "executeBash"];
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as { message: unknown }).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function draftFromSet(set?: PermissionSet | null): PolicyDraft {
|
||||
if (!set) return { ...DEFAULT_DRAFT };
|
||||
const draft: PolicyDraft = {
|
||||
...DEFAULT_DRAFT,
|
||||
fallback: set.fallback,
|
||||
};
|
||||
for (const capability of CAPABILITIES) {
|
||||
draft[capability] = ruleChoice(set.rules, capability);
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function ruleChoice(
|
||||
rules: PermissionRule[],
|
||||
capability: Capability,
|
||||
): CapabilityChoice {
|
||||
const matching = rules.filter((rule) => rule.capability === capability);
|
||||
if (matching.some((rule) => rule.effect === "deny")) return "deny";
|
||||
if (matching.some((rule) => rule.effect === "allow")) return "allow";
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function setFromDraft(draft: PolicyDraft): PermissionSet {
|
||||
const rules: PermissionRule[] = [];
|
||||
for (const capability of CAPABILITIES) {
|
||||
const effect = draft[capability];
|
||||
if (effect === "none") continue;
|
||||
rules.push(ruleFromChoice(capability, effect));
|
||||
}
|
||||
return { fallback: draft.fallback, rules };
|
||||
}
|
||||
|
||||
function ruleFromChoice(
|
||||
capability: Capability,
|
||||
effect: PermissionEffect,
|
||||
): PermissionRule {
|
||||
if (capability === "executeBash") {
|
||||
return { capability, effect, commands: [] };
|
||||
}
|
||||
return { capability, effect, paths: ["**"] };
|
||||
}
|
||||
|
||||
export function isCustomPolicy(set?: PermissionSet | null): boolean {
|
||||
if (!set) return false;
|
||||
return set.rules.some((rule) => {
|
||||
if (rule.capability === "executeBash") {
|
||||
return (rule.commands ?? []).length > 0;
|
||||
}
|
||||
const paths = rule.paths ?? [];
|
||||
return paths.length !== 1 || paths[0] !== "**";
|
||||
});
|
||||
}
|
||||
|
||||
export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
const { agent, permission } = useGateways();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [document, setDocument] = useState<ProjectPermissions | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [agentList, permissionDoc] = await Promise.all([
|
||||
agent.listAgents(projectId),
|
||||
permission.getProjectPermissions(projectId),
|
||||
]);
|
||||
setAgents(agentList);
|
||||
setDocument(permissionDoc);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [agent, permission, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const rows = useMemo<AgentPermissionRow[]>(() => {
|
||||
const overrides = new Map(
|
||||
(document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]),
|
||||
);
|
||||
return agents.map((candidate) => ({
|
||||
agent: candidate,
|
||||
override: overrides.get(candidate.id) ?? null,
|
||||
}));
|
||||
}, [agents, document]);
|
||||
|
||||
const projectDraft = useMemo(
|
||||
() => draftFromSet(document?.projectDefaults ?? null),
|
||||
[document],
|
||||
);
|
||||
|
||||
const saveProjectDefaults = useCallback(
|
||||
async (draft: PolicyDraft) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setDocument(
|
||||
await permission.updateProjectPermissions(projectId, setFromDraft(draft)),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[permission, projectId],
|
||||
);
|
||||
|
||||
const clearProjectDefaults = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setDocument(await permission.updateProjectPermissions(projectId, null));
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [permission, projectId]);
|
||||
|
||||
const saveAgentOverride = useCallback(
|
||||
async (agentId: string, draft: PolicyDraft) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setDocument(
|
||||
await permission.updateAgentPermissions(
|
||||
projectId,
|
||||
agentId,
|
||||
setFromDraft(draft),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[permission, projectId],
|
||||
);
|
||||
|
||||
const clearAgentOverride = useCallback(
|
||||
async (agentId: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setDocument(
|
||||
await permission.updateAgentPermissions(projectId, agentId, null),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[permission, projectId],
|
||||
);
|
||||
|
||||
return {
|
||||
agents,
|
||||
rows,
|
||||
document,
|
||||
projectDraft,
|
||||
busy,
|
||||
error,
|
||||
refresh,
|
||||
saveProjectDefaults,
|
||||
clearProjectDefaults,
|
||||
saveAgentOverride,
|
||||
clearAgentOverride,
|
||||
};
|
||||
}
|
||||
@ -37,6 +37,7 @@ import { TemplatesPanel } from "@/features/templates";
|
||||
import { SkillsPanel } from "@/features/skills";
|
||||
import { MemoryPanel } from "@/features/memory";
|
||||
import { EmbedderSettings } from "@/features/embedder";
|
||||
import { PermissionsPanel } from "@/features/permissions";
|
||||
import { GitPanel, GitGraphView } from "@/features/git";
|
||||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
@ -49,6 +50,7 @@ type SidebarTab =
|
||||
| "agents"
|
||||
| "templates"
|
||||
| "skills"
|
||||
| "permissions"
|
||||
| "memory"
|
||||
| "git";
|
||||
|
||||
@ -58,6 +60,7 @@ const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||
{ id: "agents", label: "Agents" },
|
||||
{ id: "templates", label: "Templates" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "permissions", label: "Perms" },
|
||||
{ id: "memory", label: "Memory" },
|
||||
{ id: "git", label: "Git" },
|
||||
];
|
||||
@ -289,6 +292,14 @@ export function ProjectsView() {
|
||||
<p className="text-sm text-muted">Open a project to manage skills.</p>
|
||||
)}
|
||||
|
||||
{/* Permissions panel */}
|
||||
{sidebarTab === "permissions" && active && (
|
||||
<PermissionsPanel projectId={active.id} />
|
||||
)}
|
||||
{sidebarTab === "permissions" && !active && (
|
||||
<p className="text-sm text-muted">Open a project to manage permissions.</p>
|
||||
)}
|
||||
|
||||
{/* Memory panel + embedder settings */}
|
||||
{sidebarTab === "memory" && active && (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
/**
|
||||
* F1 — {@link MediatedInput} wired to {@link MockInputGateway} through the real
|
||||
* {@link DIProvider}. Asserts the mediated-input contract (cadrage §4.2/§6):
|
||||
*
|
||||
* - "Envoyer" routes to `InputGateway.submit` with the right args;
|
||||
* - "Interrompre" routes to `InputGateway.interrupt`;
|
||||
* - while busy, "Envoyer" is dimmed/aria-disabled but the enqueue STILL goes
|
||||
* through (forward/fallback — never block the user), and "Interrompre" stays
|
||||
* active.
|
||||
*
|
||||
* Fully offline: gateways are mocks, no backend.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { MockInputGateway, MockSystemGateway } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { MediatedInput } from "./MediatedInput";
|
||||
|
||||
function renderInput(
|
||||
input: MockInputGateway,
|
||||
props: Partial<React.ComponentProps<typeof MediatedInput>> = {},
|
||||
) {
|
||||
const gateways = {
|
||||
input,
|
||||
system: new MockSystemGateway(),
|
||||
} as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<MediatedInput projectId="p1" agentId="a1" {...props} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MediatedInput (with MockInputGateway)", () => {
|
||||
it("mounts and renders the input strip", () => {
|
||||
renderInput(new MockInputGateway());
|
||||
expect(screen.getByTestId("mediated-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Envoyer routes to gateway.submit with project/agent/text", async () => {
|
||||
const input = new MockInputGateway();
|
||||
renderInput(input);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message à l'agent"), {
|
||||
target: { value: "hello agent" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.submits).toEqual([
|
||||
{ projectId: "p1", agentId: "a1", text: "hello agent" },
|
||||
]);
|
||||
});
|
||||
// No interrupt was triggered.
|
||||
expect(input.interrupts).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not submit blank/whitespace-only text", async () => {
|
||||
const input = new MockInputGateway();
|
||||
renderInput(input);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message à l'agent"), {
|
||||
target: { value: " " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
|
||||
expect(input.submits).toEqual([]);
|
||||
});
|
||||
|
||||
it("Interrompre routes to gateway.interrupt (not an enqueue)", async () => {
|
||||
const input = new MockInputGateway();
|
||||
renderInput(input);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Interrompre" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.interrupts).toEqual([{ projectId: "p1", agentId: "a1" }]);
|
||||
});
|
||||
expect(input.submits).toEqual([]);
|
||||
});
|
||||
|
||||
it("while busy: Envoyer is aria-disabled but the enqueue still goes through, Interrompre stays active", async () => {
|
||||
const input = new MockInputGateway();
|
||||
renderInput(input, { busy: true });
|
||||
|
||||
const send = screen.getByRole("button", { name: "Envoyer" });
|
||||
const interrupt = screen.getByRole("button", { name: "Interrompre" });
|
||||
|
||||
// Visually disabled (forward/fallback: dimmed, not hard-disabled).
|
||||
expect(send.getAttribute("aria-disabled")).toBe("true");
|
||||
// Interrompre is never disabled.
|
||||
expect(interrupt.hasAttribute("disabled")).toBe(false);
|
||||
|
||||
// The enqueue path is NOT blocked while busy.
|
||||
fireEvent.change(screen.getByLabelText("Message à l'agent"), {
|
||||
target: { value: "queued while busy" },
|
||||
});
|
||||
fireEvent.click(send);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.submits).toEqual([
|
||||
{ projectId: "p1", agentId: "a1", text: "queued while busy" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,93 +0,0 @@
|
||||
/**
|
||||
* MediatedInput (lot F1, cadrage §4.2/§4.3).
|
||||
*
|
||||
* The mediated input strip rendered **under** an agent cell's terminal. Human
|
||||
* keystrokes for an agent no longer go straight to the PTY — they are typed here
|
||||
* and routed through the {@link InputGateway} port:
|
||||
*
|
||||
* - **Envoyer** → `submit` (enqueue in the agent's single FIFO).
|
||||
* - **Interrompre** → `interrupt` (preempt the current turn — NOT an enqueue).
|
||||
*
|
||||
* Busy semantics (forward/fallback, §4.2/§6): while the agent is `busy`,
|
||||
* "Envoyer" is **disabled visually** but the enqueue path is never blocked — the
|
||||
* backend FIFO is the single point that serialises. So `busy` only dims the
|
||||
* button; "Interrompre" stays active so the user can always preempt.
|
||||
*
|
||||
* Hexagonal: this component talks to {@link InputGateway} via the DI provider,
|
||||
* never to `invoke()` directly. The busy flag is supplied by the caller (fed by
|
||||
* {@link useAgentBusy}); this keeps the component pure and easy to test.
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
import { Button, Input } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
export interface MediatedInputProps {
|
||||
/** Owning project. */
|
||||
projectId: string;
|
||||
/** The agent this input strip targets. */
|
||||
agentId: string;
|
||||
/**
|
||||
* Whether the agent is currently processing a turn. Dims "Envoyer" (without
|
||||
* blocking the enqueue) and is irrelevant to "Interrompre" (always active).
|
||||
* Defaults to `false`.
|
||||
*/
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
/** The mediated input strip for an agent cell. */
|
||||
export function MediatedInput({
|
||||
projectId,
|
||||
agentId,
|
||||
busy = false,
|
||||
}: MediatedInputProps) {
|
||||
const { input } = useGateways();
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const send = async () => {
|
||||
const value = text;
|
||||
if (value.trim() === "") return;
|
||||
// Clear optimistically: the enqueue is fire-and-forward; never block the
|
||||
// user (forward/fallback). Even while busy the submit goes through.
|
||||
setText("");
|
||||
await input.submit(projectId, agentId, value);
|
||||
};
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
};
|
||||
|
||||
const onInterrupt = () => {
|
||||
void input.interrupt(projectId, agentId);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid="mediated-input"
|
||||
className="flex items-center gap-2 p-2 border-t border-border bg-surface"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
aria-label="Message à l'agent"
|
||||
placeholder={busy ? "Agent occupé — sera mis en file…" : "Message…"}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
// Visually disabled while busy, but the enqueue path stays open: the
|
||||
// user can still press Enter to forward into the FIFO (§4.2/§6).
|
||||
aria-disabled={busy || undefined}
|
||||
className={busy ? "opacity-50" : undefined}
|
||||
>
|
||||
Envoyer
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={onInterrupt}>
|
||||
Interrompre
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
174
frontend/src/features/terminals/TerminalView.portal.test.tsx
Normal file
174
frontend/src/features/terminals/TerminalView.portal.test.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* L3 — TerminalView agent cell is a **native terminal** wired to a
|
||||
* {@link WritePortal} (ARCHITECTURE §20).
|
||||
*
|
||||
* Contracts exercised (xterm stubbed so `term.open` succeeds under jsdom and the
|
||||
* keystroke wiring genuinely runs; the stub captures the `onData` handler):
|
||||
* - In agent mode a keystroke is written to the PTY (native), AND reported to
|
||||
* the portal for line counting.
|
||||
* - The portal's `isSuspended()` gates the relay: while suspended, keystrokes
|
||||
* are NOT forwarded to the PTY (but are still reported for counting).
|
||||
* - The live handle is bound to the portal on adopt, unbound on unmount.
|
||||
*/
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
|
||||
const xtermState: { keyHandler: ((data: string) => void) | null } = {
|
||||
keyHandler: null,
|
||||
};
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: class {
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
onData(cb: (data: string) => void) {
|
||||
xtermState.keyHandler = cb;
|
||||
return { dispose() {} };
|
||||
}
|
||||
onResize() {
|
||||
return { dispose() {} };
|
||||
}
|
||||
write() {}
|
||||
dispose() {}
|
||||
get cols() {
|
||||
return 80;
|
||||
}
|
||||
get rows() {
|
||||
return 24;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: class {
|
||||
fit() {}
|
||||
},
|
||||
}));
|
||||
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
||||
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
import type { Gateways, TerminalHandle, WritePortal } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
|
||||
function makeHandle(write = vi.fn().mockResolvedValue(undefined)): TerminalHandle {
|
||||
return {
|
||||
sessionId: "s1",
|
||||
write,
|
||||
resize: vi.fn().mockResolvedValue(undefined),
|
||||
detach: vi.fn(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function makePortal(overrides: Partial<WritePortal> = {}): WritePortal {
|
||||
return {
|
||||
onHumanData: vi.fn(),
|
||||
isSuspended: vi.fn(() => false),
|
||||
bindHandle: vi.fn(),
|
||||
unbindHandle: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function decode(b: Uint8Array): string {
|
||||
return new TextDecoder().decode(b);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
xtermState.keyHandler = null;
|
||||
});
|
||||
|
||||
describe("TerminalView native agent terminal + portal (§20)", () => {
|
||||
it("writes keystrokes to the PTY in agent mode and reports them to the portal", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const handle = makeHandle(write);
|
||||
const open = vi.fn(async () => handle);
|
||||
const portal = makePortal();
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
|
||||
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalledWith(handle));
|
||||
|
||||
xtermState.keyHandler!("a");
|
||||
|
||||
expect(portal.onHumanData).toHaveBeenCalledWith("a");
|
||||
await waitFor(() => expect(write).toHaveBeenCalled());
|
||||
expect(decode(write.mock.calls[0][0])).toBe("a");
|
||||
});
|
||||
|
||||
it("does NOT forward keystrokes while the portal is suspended (but still counts them)", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const handle = makeHandle(write);
|
||||
const open = vi.fn(async () => handle);
|
||||
const onHumanData = vi.fn();
|
||||
const portal = makePortal({ isSuspended: () => true, onHumanData });
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
|
||||
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalled());
|
||||
|
||||
xtermState.keyHandler!("x");
|
||||
|
||||
// Reported for counting, but the relay is suspended ⇒ not written.
|
||||
expect(onHumanData).toHaveBeenCalledWith("x");
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unbinds the handle from the portal on unmount", async () => {
|
||||
const handle = makeHandle();
|
||||
const open = vi.fn(async () => handle);
|
||||
const portal = makePortal();
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
const { unmount } = render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalled());
|
||||
unmount();
|
||||
expect(portal.unbindHandle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a PLAIN cell ignores the portal and writes keystrokes natively", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const handle = makeHandle(write);
|
||||
const open = vi.fn(async () => handle);
|
||||
const portal = makePortal();
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
{/* agentMode false: portal must never be touched */}
|
||||
<TerminalView cwd="/c" open={open} portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
|
||||
xtermState.keyHandler!("l");
|
||||
|
||||
await waitFor(() => expect(write).toHaveBeenCalled());
|
||||
expect(portal.onHumanData).not.toHaveBeenCalled();
|
||||
expect(portal.bindHandle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user