From 2433e173a144e5add0f999a940cd6b6577375d0d Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 9 Jun 2026 09:55:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(agent):=20backend=20hot-swap=20profil=20(A?= =?UTF-8?q?0+A1)=20+=20inventaire=20reprise=20session=20(B1)=20=E2=80=94?= =?UTF-8?q?=20L15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cadrage Architecture §15 (figé) : « agent = entité à session persistante ». - A0 (domaine) : Agent::with_profile, LayoutTree::leaf, event AgentProfileChanged (+ DTO miroir DomainEventDto camelCase). - A1 (application) : use case ChangeAgentProfile — no-op si profil identique, mutation manifeste, nettoyage conversation_id/agent_was_running sur layouts persistés, swap à chaud (kill PTY + relance même cellule via composition de LaunchAgent), event AgentProfileChanged. Décision : repartir à neuf (on garde .md + mémoire, on jette l'historique de conversation). - B1 (application) : use case ListResumableAgents (lecture seule) — inventaire des cellules was_running||conversation_id, resume_supported selon profil, best-effort. Aucun nouveau port/adapter (composition de l'existant). Hexagonal strict. Tests : domaine 11 + app-tauri dto + ChangeAgentProfile 9 + ListResumableAgents 8, suite application complète verte (0 régression). Co-Authored-By: Claude Opus 4.8 --- .ideai/agents/architect.md | 3 + ARCHITECTURE.md | 198 ++++ crates/app-tauri/src/events.rs | 15 + crates/app-tauri/tests/dto.rs | 22 +- crates/application/src/agent/lifecycle.rs | 241 ++++- crates/application/src/agent/mod.rs | 13 +- crates/application/src/agent/resume.rs | 185 ++++ crates/application/src/layout/mod.rs | 1 + crates/application/src/lib.rs | 8 +- .../application/tests/change_agent_profile.rs | 929 ++++++++++++++++++ .../tests/list_resumable_agents.rs | 743 ++++++++++++++ crates/domain/src/agent.rs | 10 + crates/domain/src/events.rs | 9 +- crates/domain/src/layout.rs | 18 + crates/domain/tests/agent_profile_a0.rs | 246 +++++ 15 files changed, 2630 insertions(+), 11 deletions(-) create mode 100644 crates/application/src/agent/resume.rs create mode 100644 crates/application/tests/change_agent_profile.rs create mode 100644 crates/application/tests/list_resumable_agents.rs create mode 100644 crates/domain/tests/agent_profile_a0.rs diff --git a/.ideai/agents/architect.md b/.ideai/agents/architect.md index c2c06a6..2f10cf0 100644 --- a/.ideai/agents/architect.md +++ b/.ideai/agents/architect.md @@ -338,6 +338,8 @@ RemoteRef = | `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` | | `SyncAgentWithTemplate` | Applique la MAJ template→agent si `synchronized`. | `TemplateStore`, `AgentContextStore`, `EventBus` | | `LaunchAgent` | Résout profil+contexte, prépare injection, ouvre cellule PTY au bon `cwd`, spawn CLI. | `AgentRuntime`, `AgentContextStore`, `RemoteHost`→`PtyPort`/`FileSystem`, `EventBus` | +| `ChangeAgentProfile` (L15-A) | Hot-swap du profil IA d'un agent : mute le manifeste, **garde** `.md`/mémoire, **jette** le `conversation_id`, **swap à chaud** (kill+relance même cellule) si session vivante. Compose `LaunchAgent`. | `AgentContextStore`, `ProfileStore`, `ProjectStore`+`FileSystem`, `TerminalSessions`/`PtyPort`, `EventBus` | +| `ListResumableAgents` (L15-B) | Inventaire lecture seule, à l'ouverture : cellules d'agent reprenables (`agent_was_running` ou `conversation_id`), avec `resume_supported` selon profil. Aucun spawn. | `ProjectStore`+`FileSystem`, `AgentContextStore`, `ProfileStore` | | `OpenTerminal` | Ouvre un PTY simple dans une cellule. | `RemoteHost`→`PtyPort`, `EventBus` | | `WriteToTerminal` / `ResizeTerminal` / `CloseTerminal` | I/O PTY. | `PtyPort` | | `MutateLayout` (split/merge/resize/move) | Applique une opération sur le `LayoutTree` (logique **pure** dans le domaine, persistée ici). | `ProjectStore` (persistance) | @@ -581,6 +583,7 @@ IdeA/ | L9 | **Remote (SSH + WSL)** | `RemoteHost` stratégie, `SshHost`/`WslHost`, adapters FS/PTY/Spawner distants, `RemoteGitRepository`. UI connexion. | `infrastructure/remote`, `application/remote`, `frontend/features/remote` | | L10 | **Fenêtres & multi-window** | `Workspace`/`Window`/`Tab`, `MoveTabToNewWindow`, drag d'onglet → nouvelle fenêtre OS Tauri. | `application`, `app-tauri`, `frontend/app` | | L11 | **Packaging & livraison** | Tauri bundle : NSIS `setup.exe`, **AppImage** multi-distro, CI Linux+Windows. | `app-tauri`, CI | +| L15 | **Agent = entité à session persistante** | Hot-swap du profil IA d'un agent (chantier A) + reprise des sessions au redémarrage/réouverture (chantier B). Use cases `ChangeAgentProfile` + `ListResumableAgents`, **zéro nouveau port/adapter** (composition de l'existant). Détail figé dans `ARCHITECTURE.md` §15. | `domain/{agent,layout,events}`, `application/agent`, `app-tauri`, `frontend/features/{agents,terminals,layout}` | --- diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4d9073d..6f3c546 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -632,6 +632,7 @@ IdeA/ | L12 | **Skills** | Entité `Skill`, `SkillStore`, CRUD skills global+projet, assignation agent↔skills, injection dans convention file à l'activation. UI onglet Skills. | `domain/skill`, `application/skill`, `infrastructure/store`, `frontend/features/skills` | | L13 | **OrchestratorApi** | File-watcher `.ideai/requests/`, port `OrchestratorApi`, adapter `FsOrchestratorAdapter`, protocole `agent.run`/`agent.stop`/`agent.attach`/`agent.detach`/`agent.message`, sessions agent visibles ou arrière-plan. | `domain/agent`, `application/agent`, `infrastructure/orchestrator`, `app-tauri`, `frontend/features/agents` | | L14 | **Mémoire** | Base de connaissance projet model-agnostic. Modèle 2 étages : `.md` source de vérité (port `MemoryStore`), rappel adaptatif (port `MemoryRecall`), embeddings déclaratifs (port `Embedder`), bascule auto sur seuil. Découpé en sous-lots **A/B/C** (voir §14.5). | `domain/memory`, `application/memory`, `infrastructure/store`, `frontend/features/memory` | +| L15 | **Agent = entité à session persistante** | Hot-swap de l'AI profile d'un agent existant (chantier A) + reprise des sessions au redémarrage d'IdeA / réouverture projet (chantier B). Fondation commune « l'agent porte un cycle de vie de session ». Découpé en sous-lots **A0/A1/A2 + B0/B1/B2** (voir §15). | `domain/agent`, `application/agent`, `application/layout`, `infrastructure/store`, `app-tauri`, `frontend/features/agents`, `frontend/features/layout` | --- @@ -997,6 +998,203 @@ Deux dernières pièces ferment L14 : le **câblage adaptatif backend** (rendre --- +## 15. Agent = entité à session persistante (L15 — chantiers A & B) — figé 2026-06-09 + +> **Fondation commune** : A et B reposent sur le même principe — **un `Agent` est une définition stable (`.ideai/agents/.md` + `profile_id` + origine), et son exécution est une session reprenable**, dont la liaison à une cellule est une simple vue (§14.3). A *change le profil* d'un agent existant ; B *reprend ses sessions* au redémarrage. Les deux manipulent le même triplet d'état : `profile_id` (manifeste), `conversation_id` (cellule), `agent_was_running` (cellule). On les cadre ensemble pour figer ce triplet une fois. +> +> Cette section **complète** §14.3 (registre de sessions visible/arrière-plan) et §6 (use cases agent). Elle ne réécrit aucun port existant ; elle ajoute deux use cases, deux commandes, un champ de domaine et le câblage frontend. + +### 15.0 État du terrain (lu dans le code, pas présumé) + +| Pièce | Existe ? | Référence code | +|---|---|---| +| `Agent.profile_id` / `ManifestEntry.profile_id` | ✅ champ, **aucun mutateur** | `domain/src/agent.rs` | +| `LeafCell.conversation_id` (persistant) | ✅ + ops pures `set_cell_conversation` | `domain/src/layout.rs` | +| `LeafCell.agent_was_running` | ✅ + op pure `set_agent_running` | `domain/src/layout.rs` | +| `SnapshotRunningAgents` (gèle `agent_was_running` à la fermeture, T5) | ✅ appelé avant le kill PTY | `application/src/layout/snapshot.rs`, `app-tauri` `CloseRequested` | +| `SessionPlan::{None,Assign,Resume}` + `resolve_session_plan` | ✅ pleinement câblé dans `LaunchAgent` | `application/src/agent/lifecycle.rs`, `domain/src/ports.rs` | +| `InspectConversation` (T7, enrichit le popup) | ✅ best-effort | `application/src/agent/inspect.rs` | +| `ResumeConversationPopup` (popup Reprendre / Nouvelle conversation) | ✅ branché au **mount d'une cellule** dont le PTY est mort | `frontend/.../LayoutGrid.tsx`, `features/terminals/ResumeConversationPopup.tsx` | +| **Trigger** qui, à la réouverture, relance/propose la reprise des cellules `agent_was_running` | ❌ **inerte** — rien ne consomme `agent_was_running` à l'ouverture | — | +| **Mutation de `profile_id`** (use case / commande / UI) | ❌ totalement absent | — | + +**Conclusion** : B = **brancher un terrain déjà construit** (un trigger d'ouverture + une commande de query). A = **construire une tranche neuve** (mutation de profil), mais minimale grâce au socle existant. + +--- + +### 15.1 Chantier A — Hot-swap de l'AI profile d'un agent existant + +#### Décision verrouillée rappelée +On **garde** le contexte `.md` + la mémoire projet ; on **abandonne** l'historique de conversation (un `conversation_id` Claude n'a aucun sens pour Codex). Le `.md` est *possédé par l'agent*, indépendant du moteur ; seul le moteur d'exécution change. + +#### Décision tranchée par l'Agent Architecture : **swap à chaud** (kill + relance) +> **Recommandation : à chaud.** Si l'agent a une session vivante au moment du changement de profil, IdeA **arrête** cette session (kill PTY) **puis relance** immédiatement sous le nouveau profil, **dans la même cellule** si elle était visible. Justification : +> - **Cohérence produit** (principe fondateur §0/§14.3) : « on ne code pas, on gère des IA » — changer le moteur d'un agent vivant doit *se voir* tout de suite, comme un hot-reload. Un refus « ferme d'abord l'agent » casse le flux. +> - **Coût technique nul** : tout l'outillage existe déjà — `StopAgentSession`/`session_for_agent` (kill) + `LaunchAgent` (relance) + `rebind_agent_node` (même cellule). Le swap à chaud = *séquencer* deux use cases existants, pas en écrire de nouveaux pour le PTY. +> - **Invariant respecté** : « 1 session vivante par agent » (déjà enforce dans `LaunchAgent`) reste vrai car on tue avant de relancer. +> - **Sécurité** : la relance n'est **pas** silencieuse-destructive — le `.md`/mémoire survivent (décision verrouillée) ; seul le process CLI et son `conversation_id` sont jetés. +> +> **Garde-fou** : si le nouveau `profile_id` == l'actuel, c'est un **no-op** (pas de kill/relance) — le use case court-circuite. Si l'agent n'a **pas** de session vivante, on mute juste le manifeste (pas de relance — l'agent repartira au prochain lancement avec son nouveau profil). + +#### Abandon du `conversation_id` — où et comment +Le `conversation_id` vit sur la **cellule** (`LeafCell`), pas sur l'agent. Changer de profil doit **effacer le `conversation_id` de la (ou des) cellule(s) hébergeant cet agent**, sinon une relance ultérieure tenterait un `SessionPlan::Resume` d'une conversation d'un autre moteur (incohérent). C'est une opération **pure** déjà existante : `LayoutTree::set_cell_conversation(node, None)` + `set_agent_running(node, false)`. Le use case applicatif orchestre ce nettoyage sur les layouts persistés du projet (réutilise le pattern de `SnapshotRunningAgents` : `resolve_doc` → walk `agent_leaves()` filtrés sur l'agent ciblé → `set_cell_conversation(None)` → `persist_doc`). + +#### Modèle de domaine (ajout minimal) +Un **seul** ajout : un mutateur validé sur `Agent` et le miroir sur `ManifestEntry`. + +```rust +// domain/src/agent.rs — ajout +impl Agent { + /// Change le profil runtime de l'agent. Le contexte `.md`, l'origine template + /// et la synchronisation sont **inchangés** (décision verrouillée : on garde le + /// `.md`/mémoire, on ne touche qu'au moteur). Pur, infaillible (un ProfileId est + /// déjà un VO validé). + #[must_use] + pub fn with_profile(mut self, profile_id: ProfileId) -> Self { + self.profile_id = profile_id; + self + } +} +``` +> Pas de nouvel invariant : `profile_id` doit *référencer un profil connu*, mais c'est une invariante **cross-agrégat** vérifiée par l'application (résolution via `ProfileStore`), exactement comme à l'activation aujourd'hui (cf. doc de `Agent`). `ManifestEntry::from_agent` reporte déjà `profile_id` ⇒ rien à ajouter côté persistance. + +#### Use case applicatif — `ChangeAgentProfile` +Nouveau, dans `application/src/agent/lifecycle.rs` (voisin de `UpdateAgentContext`). **Single Responsibility** : muter le profil, nettoyer la conversation, et (à chaud) re-séquencer la session vivante. + +```rust +pub struct ChangeAgentProfileInput { + pub project: Project, + pub agent_id: AgentId, + pub profile_id: ProfileId, // nouveau profil + pub rows: u16, pub cols: u16, // pour une relance à chaud éventuelle +} +pub struct ChangeAgentProfileOutput { + pub agent: Agent, // agent muté (nouveau profil) + pub relaunched: Option, // Some si une session vivante a été relancée +} +``` + +Ports consommés (tous déjà existants — **ISP** : on ne prend que le nécessaire) : +`AgentContextStore` (charger/sauver le manifeste), `ProjectStore` + `FileSystem` (nettoyer le `conversation_id` sur les layouts persistés, comme `SnapshotRunningAgents`), `TerminalSessions` (`session_for_agent`/`node_for_agent`/kill via `PtyPort`), et — pour la relance à chaud — **la composition réutilise `LaunchAgent`** (le use case `ChangeAgentProfile` *appelle* `LaunchAgent::execute`, il ne ré-implémente pas le spawn). `EventBus` pour publier. + +Algorithme : +``` +1. Charger le manifeste, résoudre l'entrée de l'agent (NotFound sinon). +2. Si profile_id == entry.profile_id ⇒ no-op : retourner l'agent inchangé, relaunched=None. +3. Valider que profile_id référence un profil connu (ProfileStore.list) ⇒ NotFound sinon. +4. Muter l'entrée (entry.profile_id = nouveau) + revalider + save_manifest. +5. Nettoyer la conversation : sur chaque layout persisté, pour chaque leaf hébergeant + cet agent → set_cell_conversation(None) + set_agent_running(false) ; persist si changé. +6. Détecter une session vivante (sessions.session_for_agent(agent_id)) : + a. Aucune ⇒ relaunched=None (l'agent repartira au prochain lancement, nouveau profil). + b. Une session vivante en cellule N ⇒ kill PTY (sessions.remove + pty.kill), + puis LaunchAgent::execute(node_id=N, conversation_id=None /* jetée */). +7. publish(AgentProfileChanged { agent_id, profile_id }). Retourner (agent, relaunched). +``` +> **Note de réutilisation** : étapes 5+6b *sont* déjà couvertes par des opérations existantes — on n'introduit **aucun** nouveau port. Le use case est un **orchestrateur** (cf. `LaunchAgent` lui-même qui orchestre `prepare_invocation`+`pty.spawn`). + +#### Domaine event (ajout) +`DomainEvent::AgentProfileChanged { agent_id: AgentId, profile_id: ProfileId }` (calqué sur `AgentLaunched`). Relayé par `TauriEventRelay` → event front `agentProfileChanged` (l'onglet Agents et la cellule rafraîchissent ; cf. mémoire « Refresh live Agents/Skills »). + +#### Commande Tauri + DTO +``` +| Commande Tauri | Request DTO (camelCase) | Réponse | +|-----------------------|--------------------------------------------------|----------------------| +| change_agent_profile | { projectId, agentId, profileId, rows, cols } | ChangeAgentProfileDto| +``` +`ChangeAgentProfileDto { agent: AgentDto, relaunchedSession: Option }`. Parser `profileId` via `parse_profile_id` ; `resolve_project` comme les autres. Enregistrer dans `generate_handler!` (`app-tauri/src/lib.rs`), câbler `ChangeAgentProfile` dans `state.rs` (réutilise l'instance `LaunchAgent` déjà construite + `terminal_sessions` + `pty` + stores). + +#### Frontend +- **Port UI** `AgentGateway.changeAgentProfile(projectId, agentId, profileId, rows, cols): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }>` (dans `ports/index.ts`). Adapter `TauriAgentGateway` + `MockAgentGateway`. +- **UI** : dans l'onglet Agents (`features/agents/`), sur la carte d'un agent, un sélecteur de profil (liste des profils connus via le gateway profils) déclenchant `changeAgentProfile`. À la relance à chaud, le hook `useAgents` écoute `agentProfileChanged` et la cellule rebind via le flux existant (`relaunchedSession`). Si relance à chaud, persister sur la cellule : `setCellConversation(node, null)` (le backend a déjà nettoyé côté layouts persistés ; le front reflète l'état pour la session courante). +- Confirmation UX : un dialog « Changer le moteur abandonne l'historique de conversation (le contexte et la mémoire sont conservés). Continuer ? » avant l'appel (la décision est irréversible côté `conversation_id`). + +--- + +### 15.2 Chantier B — Reprise des sessions au redémarrage d'IdeA / réouverture projet + +#### Le vrai manque (précis) +À la réouverture, `OpenProject` recharge le manifeste + les layouts (donc `conversation_id` et `agent_was_running` reviennent sur les cellules). Le **popup de reprise existe déjà** mais il n'est déclenché que **quand une cellule est montée et que son PTY est mort** (flux `terminalOpener` dans `LayoutGrid.tsx`). Il **manque le déclencheur d'ouverture** : aujourd'hui, à la réouverture, les cellules ne ré-ouvrent pas leur PTY automatiquement, donc rien ne relance les agents ni ne propose la reprise tant que l'utilisateur ne clique pas. B = **fournir l'inventaire des cellules reprenables à l'ouverture** et **piloter leur reprise**. + +#### Décisions tranchées par l'Agent Architecture + +> **1. Popup de reprise, pas relance automatique aveugle — mais une seule décision groupée.** +> **Recommandation : popup de reprise (opt-in), au niveau projet, à l'ouverture.** À l'`OpenProject`, IdeA calcule l'inventaire des cellules d'agent reprenables (cf. use case ci-dessous) et, **s'il y en a**, affiche **un** panneau de reprise listant les agents qui « tournaient » (`agent_was_running == true`) avec, pour chacun, le choix Reprendre / Nouvelle conversation / Ignorer. Justification : +> - **Cohérence avec l'existant** : le `ResumeConversationPopup` par cellule est déjà la primitive ; B la *pilote en lot* à l'ouverture au lieu d'attendre un clic. +> - **Pas de surprise / pas de coût caché** : relancer automatiquement N agents CLI (coût tokens, processus, fenêtres qui s'animent) sans consentement viole l'esprit « rien d'imposé » (cf. mémoire mémoire/embedder `none` par défaut). L'utilisateur peut avoir fermé volontairement. +> - **Granularité** : un agent par ligne ⇒ on reprend ceux qu'on veut. +> - **Réglage futur** : un toggle projet « reprendre automatiquement à l'ouverture » pourra court-circuiter le popup plus tard (hors périmètre L15, tracé comme évolution) sans changer les contrats. +> +> **2. Profil sans `resumeFlag` ⇒ relancer à neuf (pas « ne pas relancer »).** +> **Recommandation : repartir à neuf.** Pour un agent dont le profil n'a **pas** de `SessionStrategy` (ou pas de `resume_flag` exploitable), choisir « Reprendre » dans le panneau lance l'agent **sans** `conversation_id` (fresh) — exactement la sémantique `SessionPlan::None`/`Assign` déjà gérée par `resolve_session_plan`. Justification : +> - **Universalité (principe fondateur)** : un agent doit *toujours* pouvoir redémarrer quel que soit son moteur ; « ne pas relancer » créerait des agents « morts au démarrage » selon le profil, incohérent. +> - **Zéro régression** : `resolve_session_plan` retourne déjà `None` proprement pour un profil sans bloc `session` — on ne fait que *l'autoriser depuis le panneau de reprise*. Le `.md`/mémoire (re-injectés à chaque `LaunchAgent`) garantissent que l'agent retrouve son rôle même sans historique CLI. +> - **UI honnête** : pour un tel agent, la ligne du panneau affiche « historique non disponible pour ce moteur — relance à neuf » au lieu de « Reprendre la conversation ». + +#### Use case applicatif — `ListResumableAgents` +Nouveau, lecture seule, dans `application/src/agent/` (voisin de `inspect.rs`). Calcule l'inventaire à l'ouverture **sans** I/O lourde ni spawn. + +```rust +pub struct ListResumableAgentsInput { pub project: Project } +pub struct ListResumableAgentsOutput { pub resumable: Vec } + +pub struct ResumableAgent { + pub agent_id: AgentId, + pub name: String, + pub node_id: NodeId, // cellule hôte (où relancer) + pub conversation_id: Option, // None ⇒ relance à neuf + pub was_running: bool, // agent_was_running gelé à la fermeture + pub resume_supported: bool, // profil a un SessionStrategy exploitable +} +``` +Ports : `ProjectStore` + `FileSystem` (charger les layouts persistés — `resolve_doc`), `AgentContextStore` (manifeste → nom + `profile_id`), `ProfileStore` (déterminer `resume_supported`). **Aucun PTY, aucun spawn** : pur inventaire. Algorithme = walk `agent_leaves()` de chaque layout ; pour chaque leaf portant un agent, lire `conversation_id`/`agent_was_running` de la cellule (via une lecture du `LeafCell` — ajouter un petit accessor pur `LayoutTree::leaf(node_id) -> Option<&LeafCell>` au domaine si absent), résoudre nom + `resume_supported`. + +> **Décision (filtre)** : on ne liste que les leaves dont `agent_was_running == true` **ou** qui portent un `conversation_id` (reprenables au sens strict). Une cellule d'agent jamais lancée (`was_running=false`, pas d'id) n'apparaît pas — elle se lancera normalement au clic, sans popup. + +#### Reprise pilotée — réutilisation pure du frontend existant +La **reprise effective** d'un agent choisi dans le panneau **ne crée aucun nouveau use case backend** : elle appelle `launch_agent` avec le `node_id` et le `conversation_id` de la ligne (Reprendre) ou `conversation_id=None` après `set_cell_conversation(node,None)` (Nouvelle conversation / profil sans resume). C'est **exactement** ce que fait déjà `doLaunch`/`onResume`/`onNewConversation` dans `LayoutGrid.tsx`. B *réutilise* ces handlers, déclenchés depuis un panneau d'ouverture au lieu du mount de cellule. + +#### Commande Tauri + DTO +``` +| Commande Tauri | Request DTO (camelCase) | Réponse | +|-------------------------|-------------------------|----------------------| +| list_resumable_agents | { projectId } | ResumableAgentListDto| +``` +`ResumableAgentListDto { agents: Vec }`, `ResumableAgentDto { agentId, name, nodeId, conversationId?, wasRunning, resumeSupported }`. Lecture seule, pas d'event. Enregistrer dans `generate_handler!`, câbler `ListResumableAgents` dans `state.rs` (réutilise stores + `ProfileStore` déjà injectés). + +#### Frontend +- **Port UI** `AgentGateway.listResumableAgents(projectId): Promise` (+ adapter Tauri + mock). +- **UI** : à l'`open` d'un projet (hook projet, après chargement du layout), appeler `listResumableAgents`. Si non vide ⇒ monter un **`ResumeProjectPanel`** (`features/agents/` ou `features/terminals/`) listant les agents reprenables ; chaque ligne réutilise la logique du `ResumeConversationPopup` (statut « en cours »/« clôt » dérivé de `wasRunning`, et pour `resumeSupported==false` le libellé « relance à neuf »). Boutons par ligne : Reprendre / Nouvelle conversation / Ignorer ; plus un « Tout reprendre » / « Tout ignorer ». Chaque choix invoque le flux `launch_agent` existant sur le `nodeId`. +- **Pas de double popup** : une fois le panneau d'ouverture traité pour un agent, le flux par-cellule (`terminalOpener`) reste le fallback naturel pour une ouverture manuelle ultérieure (inchangé). + +--- + +### 15.3 Conformité hexagonale & SOLID (A + B) + +- **Règle de dépendance** : aucun nouvel accès I/O dans le domaine. Les ajouts domaine sont **purs** (`Agent::with_profile`, accessor `LayoutTree::leaf`, event `AgentProfileChanged`). Toute I/O (kill/spawn/persist) reste dans l'application (orchestration) et l'infrastructure (adapters existants). +- **S** : `ChangeAgentProfile` = une intention ; `ListResumableAgents` = une query lecture seule ; aucune fonction fourre-tout. +- **O** : aucun nouveau port, aucun nouvel adapter — A et B *composent* l'existant (`LaunchAgent`, `SnapshotRunningAgents`-pattern, `TerminalSessions`, `resolve_session_plan`). Ajouter un moteur reste « une donnée » (profil). +- **L** : la reprise « profil sans resumeFlag ⇒ fresh » respecte le contrat déjà documenté de `resolve_session_plan` (substituabilité des profils). +- **I** : `ChangeAgentProfile`/`ListResumableAgents` ne reçoivent que les ports consommés. +- **D** : les deux use cases parlent aux ports/instances injectés par le composition root (`state.rs`), jamais à un adapter concret. + +### 15.4 Découpage en LOTS testables (cycle dev↔QA) — ordonné + +> Fondation d'abord (le triplet d'état partagé), puis A et B s'entrelacent. Chaque lot = binôme dev+test, vert avant le suivant. + +| Lot | Périmètre | Crates/dossiers | Contrats (ports/DTO) | Tests attendus | +|---|---|---|---|---| +| **A0 (fondation)** | Domaine : `Agent::with_profile` (pur), accessor pur `LayoutTree::leaf(node)`, event `DomainEvent::AgentProfileChanged`. | `domain/src/agent.rs`, `domain/src/layout.rs`, `domain/src/events.rs` | mutateur pur, accessor `Option<&LeafCell>`, variante d'event | unit purs : `with_profile` ne touche que `profile_id` ; `leaf` retrouve/loupe un node ; event sérialisé. | +| **A1** | Use case `ChangeAgentProfile` (no-op si même profil ; mute manifeste ; nettoie `conversation_id`/`agent_was_running` sur layouts persistés ; relance à chaud via `LaunchAgent` si session vivante ; publie l'event). | `application/src/agent/lifecycle.rs` | `ChangeAgentProfileInput/Output`, ports déjà existants | unit avec mocks/fakes : no-op profil identique ; profil inconnu ⇒ NotFound ; manifeste muté ; conversation nettoyée ; **agent vivant ⇒ kill+relance même cellule** ; agent mort ⇒ pas de relance ; event émis. | +| **A2** | Commande `change_agent_profile` + DTO + relais event ; gateway UI + sélecteur de profil + dialog de confirmation. | `app-tauri/src/{commands,dto,events,lib,state}.rs`, `frontend/src/ports`, `frontend/src/adapters`, `frontend/src/features/agents` | `ChangeAgentProfileRequestDto`/`ChangeAgentProfileDto`, `AgentGateway.changeAgentProfile` | app-tauri : mapping DTO↔use case (wiring in-memory). Vitest : gateway mock, sélecteur déclenche l'appel, dialog confirme avant ; refresh sur `agentProfileChanged`. | +| **B0 (fondation)** | (Réutilise A0 `LayoutTree::leaf`.) Si A0 non encore livré, B0 livre l'accessor. Sinon B0 est vide → fusion dans B1. | `domain/src/layout.rs` | — | couvert par A0. | +| **B1** | Use case `ListResumableAgents` (lecture seule : walk layouts, résout nom + `resume_supported`, filtre `was_running\|\|conversation_id`). | `application/src/agent/` (ex. `resume.rs`) | `ListResumableAgentsInput/Output`, `ResumableAgent` | unit avec fakes : inventaire correct ; filtre (jamais-lancé exclu) ; `resume_supported` selon profil ; mémoire/agent absent ⇒ liste vide, jamais d'erreur. | +| **B2** | Commande `list_resumable_agents` + DTO ; déclenchement à l'open projet ; `ResumeProjectPanel` réutilisant le flux `launch_agent` (Reprendre / Nouvelle / Ignorer / Tout). | `app-tauri/src/{commands,dto,lib,state}.rs`, `frontend/src/ports`, `frontend/src/adapters`, `frontend/src/features/{agents,terminals,layout}` | `ResumableAgentListDto`/`ResumableAgentDto`, `AgentGateway.listResumableAgents` | app-tauri : mapping DTO↔use case. Vitest : panneau monté si liste non vide / absent sinon ; Reprendre → `launch_agent(nodeId, convId)` ; Nouvelle → `setCellConversation(null)` puis launch ; `resumeSupported==false` ⇒ libellé « relance à neuf ». | + +**Ordre conseillé** : A0 → (A1 ∥ B1) → A2 → B2. A0 débloque les deux ; A1 et B1 sont indépendants ; les lots UI ferment chaque chantier. + +--- + ## 13. Risques techniques & points ouverts (spikes) 1. **PTY cross-platform** : portable-pty + xterm.js OK sur les 3 OS, mais signaux/resize/exit codes diffèrent (Windows ConPTY). **Spike** L3. diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 148f5f7..de32522 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -47,6 +47,14 @@ pub enum DomainEventDto { /// Exit code. code: i32, }, + /// An agent's runtime profile was changed (hot-swap of the AI engine). + #[serde(rename_all = "camelCase")] + AgentProfileChanged { + /// Agent id. + agent_id: String, + /// The new runtime profile id. + profile_id: String, + }, /// A template was updated. #[serde(rename_all = "camelCase")] TemplateUpdated { @@ -171,6 +179,13 @@ impl From<&DomainEvent> for DomainEventDto { agent_id: agent_id.to_string(), code: *code, }, + DomainEvent::AgentProfileChanged { + agent_id, + profile_id, + } => Self::AgentProfileChanged { + agent_id: agent_id.to_string(), + profile_id: profile_id.to_string(), + }, DomainEvent::TemplateUpdated { template_id, version, diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs index bd204d7..5b21614 100644 --- a/crates/app-tauri/tests/dto.rs +++ b/crates/app-tauri/tests/dto.rs @@ -13,7 +13,7 @@ use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId}; use application::{AppError, HealthInput}; use domain::events::DomainEvent; -use domain::ids::{AgentId, SessionId}; +use domain::ids::{AgentId, ProfileId, SessionId}; use domain::ProjectId; use domain::TemplateId; use domain::TemplateVersion; @@ -93,6 +93,26 @@ fn domain_event_dto_maps_agent_launched() { assert_eq!(v["sessionId"], sid.to_string()); } +#[test] +fn domain_event_dto_maps_agent_profile_changed() { + // LOT A0 : round-trip camelCase agentId/profileId du miroir DTO. + let aid = AgentId::from_uuid(Uuid::from_u128(1)); + let pid = ProfileId::from_uuid(Uuid::from_u128(2)); + let dto = DomainEventDto::from(&DomainEvent::AgentProfileChanged { + agent_id: aid, + profile_id: pid, + }); + let v = serde_json::to_value(&dto).unwrap(); + assert_eq!( + v, + json!({ + "type": "agentProfileChanged", + "agentId": aid.to_string(), + "profileId": pid.to_string(), + }) + ); +} + #[test] fn domain_event_dto_maps_template_updated_version() { let tid = TemplateId::from_uuid(Uuid::nil()); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index a883833..1f5b72c 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -15,8 +15,8 @@ use std::sync::Arc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError, - IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, - SessionPlan, SkillStore, SpawnSpec, StoreError, + IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, ProjectStore, PtyPort, + RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent, @@ -25,6 +25,7 @@ use domain::{ }; use crate::error::AppError; +use crate::layout::{persist_doc, resolve_doc}; use crate::project::project_context_path; use crate::terminal::TerminalSessions; @@ -269,6 +270,242 @@ impl UpdateAgentContext { } } +// --------------------------------------------------------------------------- +// ChangeAgentProfile +// --------------------------------------------------------------------------- + +/// Input for [`ChangeAgentProfile::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangeAgentProfileInput { + /// The owning project. + pub project: Project, + /// The agent whose runtime profile to hot-swap. + pub agent_id: AgentId, + /// The new runtime profile. + pub profile_id: ProfileId, + /// Terminal height in rows for a possible hot relaunch. + pub rows: u16, + /// Terminal width in columns for a possible hot relaunch. + pub cols: u16, +} + +/// Output of [`ChangeAgentProfile::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangeAgentProfileOutput { + /// The mutated agent (now carrying the new profile). + pub agent: Agent, + /// The freshly relaunched session, when a live session was hot-swapped. + pub relaunched: Option, +} + +/// Hot-swaps an existing agent's runtime profile (ARCHITECTURE §15.1). +/// +/// **Single Responsibility**: mutate the profile in the manifest, clear the now +/// foreign conversation id on every persisted layout cell hosting the agent, and +/// — if the agent is live — kill its PTY and re-sequence the session in the same +/// cell with the new engine. The relaunch is **composed**, not duplicated: this +/// use case *calls* [`LaunchAgent::execute`] rather than re-implementing the spawn. +/// +/// Ports consumed (ISP — only what is needed): [`AgentContextStore`] (manifest), +/// [`ProfileStore`] (validate the target profile), [`ProjectStore`] + +/// [`FileSystem`] (clean the conversation id on persisted layouts, exactly like +/// [`crate::layout::SnapshotRunningAgents`]), [`TerminalSessions`] + [`PtyPort`] +/// (detect/kill a live session), an [`Arc`] for the hot relaunch, and +/// [`EventBus`] to publish. +pub struct ChangeAgentProfile { + contexts: Arc, + profiles: Arc, + projects: Arc, + fs: Arc, + sessions: Arc, + pty: Arc, + launch: Arc, + events: Arc, +} + +impl ChangeAgentProfile { + /// Builds the use case from its injected ports (and the composed launcher). + #[must_use] + #[allow(clippy::too_many_arguments)] + pub fn new( + contexts: Arc, + profiles: Arc, + projects: Arc, + fs: Arc, + sessions: Arc, + pty: Arc, + launch: Arc, + events: Arc, + ) -> Self { + Self { + contexts, + profiles, + projects, + fs, + sessions, + pty, + launch, + events, + } + } + + /// Executes the hot-swap, following the 7-step algorithm of §15.1. + /// + /// # Errors + /// - [`AppError::NotFound`] if the agent or the target profile is unknown, + /// - [`AppError::Invalid`] on a manifest/layout invariant violation, + /// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on + /// the respective port failures (manifest, layouts, PTY kill, relaunch). + pub async fn execute( + &self, + input: ChangeAgentProfileInput, + ) -> Result { + // 1. Load the manifest and resolve the agent's entry (NotFound otherwise). + let manifest = self.contexts.load_manifest(&input.project).await?; + let entry = manifest + .entries + .iter() + .find(|e| e.agent_id == input.agent_id) + .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; + + // 2. Same profile ⇒ no-op: return the agent unchanged, no kill/relaunch, + // no event. + if entry.profile_id == input.profile_id { + let agent = entry + .to_agent() + .map_err(|e| AppError::Invalid(e.to_string()))?; + return Ok(ChangeAgentProfileOutput { + agent, + relaunched: None, + }); + } + + // 3. Validate that the target profile is a known one (ProfileStore.list). + let known = self + .profiles + .list() + .await? + .into_iter() + .any(|p| p.id == input.profile_id); + if !known { + return Err(AppError::NotFound(format!("profile {}", input.profile_id))); + } + + // 4. Mutate the entry (new profile), re-validate (to_agent + manifest) + // and persist. + let mut entries = manifest.entries; + let mut mutated_agent = None; + for e in &mut entries { + if e.agent_id == input.agent_id { + e.profile_id = input.profile_id; + let agent = e + .to_agent() + .map_err(|err| AppError::Invalid(err.to_string()))?; + mutated_agent = Some(agent); + } + } + let agent = mutated_agent + .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; + let manifest = AgentManifest::new(manifest.version, entries) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.contexts + .save_manifest(&input.project, &manifest) + .await?; + + // 5. Clean the conversation on every persisted layout: for each leaf + // hosting this agent, drop the (now foreign) conversation id and reset + // the running flag. Mirrors `SnapshotRunningAgents`: + // resolve_doc → walk agent_leaves → mutate → persist_doc (if changed). + self.clean_conversation(&input.project, &input.agent_id) + .await?; + + // 6. A live session? Kill its PTY then relaunch in the same cell with the + // new profile and a discarded conversation id. + let relaunched = self.relaunch_if_live(&input).await?; + + // 7. Publish the profile change and return. + self.events.publish(DomainEvent::AgentProfileChanged { + agent_id: input.agent_id, + profile_id: input.profile_id, + }); + + Ok(ChangeAgentProfileOutput { agent, relaunched }) + } + + /// Clears the conversation id and resets the running flag on every persisted + /// layout leaf hosting `agent_id` (step 5). Persists only when something + /// actually changed (a project with no such leaf is a no-op write). + async fn clean_conversation( + &self, + project: &Project, + agent_id: &AgentId, + ) -> Result<(), AppError> { + let project = self.projects.load_project(project.id).await?; + let mut doc = resolve_doc(self.fs.as_ref(), &project).await?; + let mut changed = false; + + for named in &mut doc.layouts { + for (leaf_id, leaf_agent) in named.tree.agent_leaves() { + if &leaf_agent != agent_id { + continue; + } + // Pure ops — only NodeNotFound is possible, which cannot happen + // since `leaf_id` came from this very tree. + named.tree = named + .tree + .set_cell_conversation(leaf_id, None) + .map_err(|e| AppError::Invalid(e.to_string()))?; + named.tree = named + .tree + .set_agent_running(leaf_id, false) + .map_err(|e| AppError::Invalid(e.to_string()))?; + changed = true; + } + } + + if changed { + persist_doc(self.fs.as_ref(), &project, &doc).await?; + } + Ok(()) + } + + /// Kills the agent's live PTY (if any) and relaunches it in the same cell with + /// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the + /// relaunched session, or `None` when the agent had no live session. + async fn relaunch_if_live( + &self, + input: &ChangeAgentProfileInput, + ) -> Result, AppError> { + let Some(session_id) = self.sessions.session_for_agent(&input.agent_id) else { + return Ok(None); + }; + // The hosting cell of the live session — the relaunch reopens here. + let node_id = self.sessions.node_for_agent(&input.agent_id); + + // Kill the PTY: remove from the registry first (so the relaunch's + // one-live-session-per-agent guard sees no live session), then kill the + // process. + if let Some(handle) = self.sessions.remove(&session_id) { + self.pty.kill(&handle).await?; + } + + let output = self + .launch + .execute(LaunchAgentInput { + project: input.project.clone(), + agent_id: input.agent_id, + rows: input.rows, + cols: input.cols, + node_id, + // Conversation id discarded: the previous one belonged to the old + // engine; the new profile starts (or assigns) a fresh one. + conversation_id: None, + }) + .await?; + Ok(Some(output.session)) + } +} + // --------------------------------------------------------------------------- // DeleteAgent // --------------------------------------------------------------------------- diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index ef3abb0..00cd81f 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -9,17 +9,22 @@ mod catalogue; mod inspect; mod lifecycle; +mod resume; mod usecases; pub(crate) use lifecycle::unique_md_path; pub use catalogue::{reference_profile_id, reference_profiles}; pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; +pub use resume::{ + ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, +}; pub use lifecycle::{ - CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, - LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, - ListAgentsOutput, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, - UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, + ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, + CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, LaunchAgent, + LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, + ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext, + UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, }; pub use usecases::{ ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile, diff --git a/crates/application/src/agent/resume.rs b/crates/application/src/agent/resume.rs new file mode 100644 index 0000000..53d7d54 --- /dev/null +++ b/crates/application/src/agent/resume.rs @@ -0,0 +1,185 @@ +//! [`ListResumableAgents`] — inventaire, en lecture seule, des cellules d'agent +//! reprenables à la réouverture d'un projet (ARCHITECTURE §15.2, chantier B, +//! lot B1). +//! +//! À la réouverture d'un projet, [`crate::OpenProject`] a déjà rechargé le +//! manifeste et les layouts persistés : chaque [`domain::LeafCell`] retrouve +//! donc son `conversation_id` et son `agent_was_running` gelés à la fermeture. +//! Ce use case **calcule l'inventaire** des cellules d'agent reprenables pour +//! que la couche supérieure (commande Tauri + `ResumeProjectPanel`, lot B2) +//! puisse proposer un panneau de reprise groupé. +//! +//! Contraintes (§15.2) : +//! - **Lecture seule** : aucun PTY, aucun spawn, aucune persistance. On +//! compose `resolve_doc` (lecture des layouts), le manifeste (nom + +//! `profile_id`) et la liste des profils (`resume_supported`). +//! - **Best-effort, jamais d'erreur** : projet / mémoire / agent absent ⇒ +//! **liste vide**, jamais de panique ni d'`AppError`. C'est un inventaire +//! indicatif à l'ouverture, pas une opération critique. +//! - **Filtre** : on ne retient qu'une leaf d'agent dont `agent_was_running` +//! est vrai **ou** qui porte un `conversation_id`. Une cellule d'agent jamais +//! lancée (ni id, ni flag) n'apparaît pas : elle se lancera normalement au +//! clic, sans popup. + +use std::sync::Arc; + +use domain::ports::{AgentContextStore, FileSystem, ProfileStore, ProjectStore}; +use domain::{AgentId, NodeId, Project}; + +use crate::error::AppError; +use crate::layout::resolve_doc; + +/// Input de [`ListResumableAgents::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListResumableAgentsInput { + /// Le projet dont on inventorie les cellules reprenables. + pub project: Project, +} + +/// Une cellule d'agent reprenable, telle qu'exposée à la couche supérieure. +/// +/// Champs alignés sur la spec §15.2 : l'identité de l'agent et de sa cellule +/// hôte, l'id de conversation à reprendre (`None` ⇒ relance à neuf), l'état +/// « tournait » gelé à la fermeture, et si son profil sait reprendre une +/// conversation CLI. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResumableAgent { + /// Identifiant de l'agent. + pub agent_id: AgentId, + /// Nom d'affichage de l'agent (résolu via le manifeste). + pub name: String, + /// Cellule hôte où relancer/reprendre l'agent. + pub node_id: NodeId, + /// Id de conversation CLI persistant porté par la cellule. `None` ⇒ relance + /// à neuf (pas d'historique à reprendre). + pub conversation_id: Option, + /// Valeur de `agent_was_running` gelée à la fermeture de la cellule. + pub was_running: bool, + /// `true` si le profil de l'agent possède une [`domain::SessionStrategy`] + /// exploitable (présence d'un `resume_flag`). + pub resume_supported: bool, +} + +/// Output de [`ListResumableAgents::execute`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ListResumableAgentsOutput { + /// Les cellules d'agent reprenables, dans l'ordre de parcours des layouts. + pub resumable: Vec, +} + +/// Inventorie, en lecture seule, les cellules d'agent reprenables d'un projet. +/// +/// Compose trois ports déjà injectés au composition root, **sans** en ajouter +/// de nouveau : +/// - [`ProjectStore`] + [`FileSystem`] : charger les layouts persistés +/// (`resolve_doc`), +/// - [`AgentContextStore`] : le manifeste, pour le nom et le `profile_id` de +/// chaque agent, +/// - [`ProfileStore`] : pour déterminer `resume_supported`. +pub struct ListResumableAgents { + #[allow(dead_code)] + store: Arc, + fs: Arc, + contexts: Arc, + profiles: Arc, +} + +impl ListResumableAgents { + /// Construit le use case à partir de ses ports injectés. + #[must_use] + pub fn new( + store: Arc, + fs: Arc, + contexts: Arc, + profiles: Arc, + ) -> Self { + Self { + store, + fs, + contexts, + profiles, + } + } + + /// Calcule l'inventaire des cellules reprenables. + /// + /// Parcourt chaque layout du projet et, pour chaque leaf portant un agent, + /// lit `conversation_id`/`agent_was_running` (via l'accessor pur + /// [`domain::LayoutTree::leaf`]). Ne retient que les leaves passant le + /// filtre `was_running || conversation_id.is_some()`, résout le nom via le + /// manifeste et `resume_supported` via le profil. + /// + /// **Best-effort** : toute défaillance de lecture (projet/mémoire absent, + /// layouts illisibles, manifeste/profils en erreur) dégrade vers une + /// **liste vide** ; cette fonction ne renvoie donc jamais d'erreur, mais sa + /// signature reste `Result` pour rester homogène avec les autres use cases. + /// + /// # Errors + /// N'échoue jamais en pratique (best-effort) ; la signature `Result` est + /// conservée par cohérence. + pub async fn execute( + &self, + input: ListResumableAgentsInput, + ) -> Result { + // Layouts persistés. Toute erreur ⇒ inventaire vide (best-effort). + let Ok(doc) = resolve_doc(self.fs.as_ref(), &input.project).await else { + return Ok(ListResumableAgentsOutput::default()); + }; + + // Manifeste (nom + profile_id). Absent/illisible ⇒ inventaire vide : + // sans manifeste on ne peut résoudre ni nom ni profil. + let Ok(manifest) = self.contexts.load_manifest(&input.project).await else { + return Ok(ListResumableAgentsOutput::default()); + }; + + // Profils disponibles, pour `resume_supported`. Indisponibles ⇒ on + // considère qu'aucun profil ne sait reprendre (best-effort), sans + // pour autant masquer les agents reprenables par `conversation_id`. + let profiles = self.profiles.list().await.unwrap_or_default(); + + let mut resumable = Vec::new(); + + for named in &doc.layouts { + for (node_id, agent_id) in named.tree.agent_leaves() { + // Lecture pure de la cellule hôte pour ses champs de reprise. + let Some(leaf) = named.tree.leaf(node_id) else { + continue; + }; + + let conversation_id = leaf.conversation_id.clone(); + let was_running = leaf.agent_was_running; + + // Filtre §15.2 : reprenable au sens strict uniquement. + if !was_running && conversation_id.is_none() { + continue; + } + + // Nom via le manifeste ; agent absent ⇒ on ignore la leaf + // (best-effort : pas d'entrée orpheline dans l'inventaire). + let Some(entry) = manifest.entries.iter().find(|e| e.agent_id == agent_id) else { + continue; + }; + let name = entry.name.clone(); + + // `resume_supported` : le profil de l'agent porte-t-il une + // SessionStrategy exploitable (resume_flag présent) ? + let resume_supported = entry + .to_agent() + .ok() + .and_then(|agent| profiles.iter().find(|p| p.id == agent.profile_id)) + .is_some_and(|profile| profile.session.is_some()); + + resumable.push(ResumableAgent { + agent_id, + name, + node_id, + conversation_id, + was_running, + resume_supported, + }); + } + } + + Ok(ListResumableAgentsOutput { resumable }) + } +} diff --git a/crates/application/src/layout/mod.rs b/crates/application/src/layout/mod.rs index 33c0980..089ead2 100644 --- a/crates/application/src/layout/mod.rs +++ b/crates/application/src/layout/mod.rs @@ -34,6 +34,7 @@ pub use snapshot::{ SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, }; pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE}; +pub(crate) use store::{persist_doc, resolve_doc}; pub use usecases::{ LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout, MutateLayoutInput, MutateLayoutOutput, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index a613c82..6bb1f72 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -27,14 +27,16 @@ pub mod terminal; pub mod window; pub use agent::{ - reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput, + reference_profile_id, reference_profiles, ChangeAgentProfile, ChangeAgentProfileInput, + ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, - ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput, - ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile, + ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, + ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput, + ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, }; diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs new file mode 100644 index 0000000..30edfeb --- /dev/null +++ b/crates/application/tests/change_agent_profile.rs @@ -0,0 +1,929 @@ +//! A1 tests for [`ChangeAgentProfile`] (ARCHITECTURE §15.1, §15.4 line A1). +//! +//! The hot-swap of an agent's runtime profile is a *composed* use case: it mutates +//! the manifest, cleans the (now foreign) `conversation_id` / `agent_was_running` +//! on every persisted layout cell hosting the agent, and — when the agent is live — +//! kills its PTY and relaunches it in the same cell via [`LaunchAgent`]. +//! +//! Every port is faked in-memory (100 % without real I/O): +//! - [`FakeContexts`] — [`AgentContextStore`] (manifest + `md_path → content`), +//! - [`FakeProfiles`] — [`ProfileStore`] returning a fixed profile list, +//! - [`FakeStore`] — [`ProjectStore`] holding the project, +//! - [`FakeFs`] — [`FileSystem`] serving/recording files (the `layouts.json` the +//! conversation-cleanup walks, plus the run-dir/convention writes of a relaunch), +//! - [`FakeRuntime`] / [`FakePty`] — the runtime + PTY, the PTY recording **kills** +//! so we can assert a live session is torn down before the relaunch, +//! - [`SpyBus`] / [`SeqIds`] / [`FakeSkills`] / [`FakeRecall`] — event spy, ids, +//! empty skill store and empty memory recall (behaviour unchanged). +//! +//! The cleanup helpers ([`seed_layouts`], [`leaf_state`]) are borrowed from the +//! `snapshot_running_agents` style: the FakeFs serves a real serialized +//! `LayoutsDoc`, so the use case's `resolve_doc → walk → persist_doc` round-trips +//! through genuine serde, exactly as in production. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; +use domain::events::DomainEvent; +use domain::ids::{AgentId, ProfileId, ProjectId}; +use domain::layout::Workspace; +use domain::markdown::MarkdownDoc; +use domain::ports::{ + AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, + ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, + OutputStream, PreparedContext, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, + RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, +}; +use domain::profile::{AgentProfile, ContextInjection}; +use domain::project::{Project, ProjectPath}; +use domain::remote::RemoteRef; +use domain::skill::{Skill, SkillScope}; +use domain::{ + LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PtySize, SessionId, + SessionKind, SkillId, +}; +use uuid::Uuid; + +use application::{ + ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, TerminalSessions, +}; + +// --------------------------------------------------------------------------- +// FakeContexts (AgentContextStore) — manifest + md_path → content +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct ContextsInner { + manifest: AgentManifest, + contents: HashMap, + /// Number of `save_manifest` calls observed. + saves: usize, +} + +#[derive(Clone)] +struct FakeContexts(Arc>); + +impl FakeContexts { + fn with_agent(agent: &Agent, content: &str) -> Self { + let me = Self(Arc::new(Mutex::new(ContextsInner { + manifest: AgentManifest { + version: 1, + entries: Vec::new(), + }, + contents: HashMap::new(), + saves: 0, + }))); + { + let mut inner = me.0.lock().unwrap(); + inner + .manifest + .entries + .push(ManifestEntry::from_agent(agent)); + inner + .contents + .insert(agent.context_path.clone(), content.to_owned()); + } + me + } + /// The persisted profile id currently recorded for `agent` in the manifest. + fn profile_of(&self, agent: &AgentId) -> Option { + self.0 + .lock() + .unwrap() + .manifest + .entries + .iter() + .find(|e| &e.agent_id == agent) + .map(|e| e.profile_id) + } + fn md_path_of(&self, agent: &AgentId) -> Option { + self.0 + .lock() + .unwrap() + .manifest + .entries + .iter() + .find(|e| &e.agent_id == agent) + .map(|e| e.md_path.clone()) + } + /// Count of `save_manifest` calls — proves a no-op path leaves the manifest + /// untouched (no mutating write). + fn manifest_saves(&self) -> usize { + self.0.lock().unwrap().saves + } +} + +#[async_trait] +impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _project: &Project, + agent: &AgentId, + ) -> Result { + let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + self.0 + .lock() + .unwrap() + .contents + .get(&md_path) + .cloned() + .map(MarkdownDoc::new) + .ok_or(StoreError::NotFound) + } + async fn write_context( + &self, + _project: &Project, + agent: &AgentId, + md: &MarkdownDoc, + ) -> Result<(), StoreError> { + let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + self.0 + .lock() + .unwrap() + .contents + .insert(md_path, md.as_str().to_owned()); + Ok(()) + } + async fn load_manifest(&self, _project: &Project) -> Result { + Ok(self.0.lock().unwrap().manifest.clone()) + } + async fn save_manifest( + &self, + _project: &Project, + manifest: &AgentManifest, + ) -> Result<(), StoreError> { + let mut inner = self.0.lock().unwrap(); + inner.manifest = manifest.clone(); + inner.saves += 1; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FakeProfiles (ProfileStore) +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct FakeProfiles(Arc>); + +impl FakeProfiles { + fn new(profiles: Vec) -> Self { + Self(Arc::new(profiles)) + } +} + +#[async_trait] +impl ProfileStore for FakeProfiles { + async fn list(&self) -> Result, StoreError> { + Ok((*self.0).clone()) + } + async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> { + Ok(()) + } + async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> { + Ok(()) + } + async fn is_configured(&self) -> Result { + Ok(true) + } + async fn mark_configured(&self) -> Result<(), StoreError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FakeStore (ProjectStore) +// --------------------------------------------------------------------------- + +#[derive(Default, Clone)] +struct FakeStore(Arc>>); + +impl FakeStore { + async fn save(&self, project: &Project) { + self.0.lock().unwrap().push(project.clone()); + } +} + +#[async_trait] +impl ProjectStore for FakeStore { + async fn list_projects(&self) -> Result, StoreError> { + Ok(self.0.lock().unwrap().clone()) + } + async fn load_project(&self, id: ProjectId) -> Result { + self.0 + .lock() + .unwrap() + .iter() + .find(|p| p.id == id) + .cloned() + .ok_or(StoreError::NotFound) + } + async fn save_project(&self, project: &Project) -> Result<(), StoreError> { + self.0.lock().unwrap().push(project.clone()); + Ok(()) + } + async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> { + Ok(()) + } + async fn load_workspace(&self) -> Result { + Ok(Workspace::default()) + } +} + +// --------------------------------------------------------------------------- +// FakeFs (FileSystem) — HashMap-backed: serves layouts.json + records writes +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct FakeFsInner { + files: HashMap>, + dirs: HashSet, + write_count: usize, +} + +#[derive(Default, Clone)] +struct FakeFs(Arc>); + +impl FakeFs { + fn put(&self, path: &str, data: &[u8]) { + self.0 + .lock() + .unwrap() + .files + .insert(path.to_owned(), data.to_vec()); + } + fn read_file(&self, path: &str) -> Option> { + self.0.lock().unwrap().files.get(path).cloned() + } + /// Number of `write` calls observed (used to assert a no-op path writes + /// nothing through the filesystem). + fn write_count(&self) -> usize { + self.0.lock().unwrap().write_count + } +} + +#[async_trait] +impl FileSystem for FakeFs { + async fn read(&self, path: &RemotePath) -> Result, FsError> { + self.0 + .lock() + .unwrap() + .files + .get(path.as_str()) + .cloned() + .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())) + } + async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { + let mut inner = self.0.lock().unwrap(); + inner.write_count += 1; + inner.files.insert(path.as_str().to_owned(), data.to_vec()); + Ok(()) + } + async fn exists(&self, path: &RemotePath) -> Result { + let inner = self.0.lock().unwrap(); + Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str())) + } + async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { + self.0.lock().unwrap().dirs.insert(path.as_str().to_owned()); + Ok(()) + } + async fn list(&self, _path: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FakeRuntime (AgentRuntime) — minimal; only exercised on a relaunch +// --------------------------------------------------------------------------- + +struct FakeRuntime; + +impl AgentRuntime for FakeRuntime { + fn detect(&self, _profile: &AgentProfile) -> Result { + Ok(true) + } + fn prepare_invocation( + &self, + profile: &AgentProfile, + _ctx: &PreparedContext, + cwd: &ProjectPath, + _session: &SessionPlan, + ) -> Result { + Ok(SpawnSpec { + command: profile.command.clone(), + args: profile.args.clone(), + cwd: cwd.clone(), + env: Vec::new(), + context_plan: Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + }) + } +} + +// --------------------------------------------------------------------------- +// FakePty (PtyPort) — records spawns and kills +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct FakePty { + next_id: SessionId, + spawns: Arc>>, + kills: Arc>>, +} + +impl FakePty { + fn new(next_id: SessionId) -> Self { + Self { + next_id, + spawns: Arc::new(Mutex::new(Vec::new())), + kills: Arc::new(Mutex::new(Vec::new())), + } + } + fn spawn_count(&self) -> usize { + self.spawns.lock().unwrap().len() + } + fn kills(&self) -> Vec { + self.kills.lock().unwrap().clone() + } +} + +#[async_trait] +impl PtyPort for FakePty { + async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result { + self.spawns.lock().unwrap().push(spec); + Ok(PtyHandle { + session_id: self.next_id, + }) + } + fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> { + Ok(()) + } + fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> { + Ok(()) + } + fn subscribe_output(&self, _handle: &PtyHandle) -> Result { + Ok(Box::new(std::iter::empty())) + } + fn scrollback(&self, _handle: &PtyHandle) -> Result, PtyError> { + Ok(Vec::new()) + } + async fn kill(&self, handle: &PtyHandle) -> Result { + self.kills.lock().unwrap().push(handle.session_id); + Ok(ExitStatus { code: Some(0) }) + } +} + +// --------------------------------------------------------------------------- +// FakeSkills / FakeRecall / SpyBus / SeqIds +// --------------------------------------------------------------------------- + +#[derive(Clone, Default)] +struct FakeSkills; + +#[async_trait] +impl SkillStore for FakeSkills { + async fn list( + &self, + _scope: SkillScope, + _root: &ProjectPath, + ) -> Result, StoreError> { + Ok(Vec::new()) + } + async fn get( + &self, + _scope: SkillScope, + _root: &ProjectPath, + _id: SkillId, + ) -> Result { + Err(StoreError::NotFound) + } + async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { + Ok(()) + } + async fn delete( + &self, + _scope: SkillScope, + _root: &ProjectPath, + _id: SkillId, + ) -> Result<(), StoreError> { + Ok(()) + } +} + +#[derive(Clone, Default)] +struct FakeRecall; + +#[async_trait] +impl MemoryRecall for FakeRecall { + async fn recall( + &self, + _root: &ProjectPath, + _query: &MemoryQuery, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } +} + +#[derive(Default, Clone)] +struct SpyBus(Arc>>); +impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} +impl EventBus for SpyBus { + fn publish(&self, event: DomainEvent) { + self.0.lock().unwrap().push(event); + } + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +struct SeqIds(Mutex); +impl SeqIds { + fn new() -> Self { + Self(Mutex::new(1)) + } +} +impl IdGenerator for SeqIds { + fn new_uuid(&self) -> Uuid { + let mut n = self.0.lock().unwrap(); + let id = Uuid::from_u128(*n); + *n += 1; + id + } +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +const ROOT: &str = "/home/me/proj"; +const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json"; + +fn pid(n: u128) -> ProfileId { + ProfileId::from_uuid(Uuid::from_u128(n)) +} +fn aid(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} +fn sid(n: u128) -> SessionId { + SessionId::from_uuid(Uuid::from_u128(n)) +} +fn nid(n: u128) -> NodeId { + NodeId::from_uuid(Uuid::from_u128(n)) +} +fn lid(n: u128) -> LayoutId { + LayoutId::from_uuid(Uuid::from_u128(n)) +} +fn proj_id(n: u128) -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(n)) +} + +fn project() -> Project { + Project::new( + proj_id(1000), + "demo", + ProjectPath::new(ROOT).unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +fn profile(id: ProfileId) -> AgentProfile { + AgentProfile::new( + id, + "Some CLI", + "claude", + Vec::new(), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some("claude --version".to_owned()), + "{agentRunDir}", + None, + ) + .unwrap() +} + +fn scratch_agent(id: AgentId, name: &str, md: &str, profile_id: ProfileId) -> Agent { + Agent::new(id, name, md, profile_id, AgentOrigin::Scratch, false).unwrap() +} + +/// A leaf cell hosting `agent`, optionally carrying a conversation + running flag. +fn agent_leaf( + node: NodeId, + agent: Option, + conversation_id: Option<&str>, + agent_was_running: bool, +) -> LeafCell { + LeafCell { + id: node, + session: None, + agent, + conversation_id: conversation_id.map(str::to_owned), + agent_was_running, + } +} + +/// Seeds a valid `layouts.json` (a single active layout holding `tree`). +fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) { + let doc = serde_json::json!({ + "version": 1, + "activeId": id.to_string(), + "layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ], + }); + fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap()); +} + +/// Reads back the persisted `(conversation_id, agent_was_running)` of leaf `node`. +fn leaf_state(fs: &FakeFs, node: NodeId) -> Option<(Option, bool)> { + let bytes = fs.read_file(LAYOUTS_PATH)?; + let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let tree: LayoutTree = serde_json::from_value(doc["layouts"][0]["tree"].clone()).unwrap(); + fn find(n: &LayoutNode, target: NodeId) -> Option<(Option, bool)> { + match n { + LayoutNode::Leaf(l) if l.id == target => { + Some((l.conversation_id.clone(), l.agent_was_running)) + } + LayoutNode::Leaf(_) => None, + LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)), + LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)), + } + } + find(&tree.root, node) +} + +/// Seeds a live agent session pinned on `node` into the registry. +fn seed_live_agent_session( + sessions: &TerminalSessions, + agent_id: AgentId, + node: NodeId, + session_id: SessionId, +) { + let size = PtySize::new(24, 80).unwrap(); + let mut session = domain::TerminalSession::starting( + session_id, + node, + ProjectPath::new("/home/me/proj/.ideai/run/x").unwrap(), + SessionKind::Agent { agent_id }, + size, + ); + session.status = domain::SessionStatus::Running; + sessions.insert(PtyHandle { session_id }, session); +} + +// --------------------------------------------------------------------------- +// Fixture +// --------------------------------------------------------------------------- + +/// Everything a change-profile test needs. +struct Fixture { + swap: ChangeAgentProfile, + contexts: FakeContexts, + fs: FakeFs, + pty: FakePty, + bus: SpyBus, + sessions: Arc, +} + +/// Wires a [`ChangeAgentProfile`] over fakes. The agent starts on `pid(1)`; the +/// profile store knows both `pid(1)` and `pid(2)` (the valid swap target). +async fn fixture(agent: &Agent) -> Fixture { + fixture_with_profiles(agent, vec![profile(pid(1)), profile(pid(2))]).await +} + +async fn fixture_with_profiles(agent: &Agent, profiles: Vec) -> Fixture { + let contexts = FakeContexts::with_agent(agent, "# persona"); + let profiles = FakeProfiles::new(profiles); + let store = FakeStore::default(); + let fs = FakeFs::default(); + let pty = FakePty::new(sid(777)); + let sessions = Arc::new(TerminalSessions::new()); + let bus = SpyBus::default(); + + // Register the project so ProjectStore::load_project resolves. + store.save(&project()).await; + + let launch = LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::new(profiles.clone()), + Arc::new(FakeRuntime), + Arc::new(fs.clone()), + Arc::new(pty.clone()), + Arc::new(FakeSkills), + Arc::clone(&sessions), + Arc::new(bus.clone()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall), + None, + ); + + let swap = ChangeAgentProfile::new( + Arc::new(contexts.clone()), + Arc::new(profiles), + Arc::new(store), + Arc::new(fs.clone()), + Arc::clone(&sessions), + Arc::new(pty.clone()), + Arc::new(launch), + Arc::new(bus.clone()), + ); + + Fixture { + swap, + contexts, + fs, + pty, + bus, + sessions, + } +} + +fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileInput { + ChangeAgentProfileInput { + project: project(), + agent_id, + profile_id, + rows: 24, + cols: 80, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// No-op: swapping to the *same* profile leaves the agent unchanged, relaunches +/// nothing, publishes no event, kills nothing, and writes no manifest/layout. +#[tokio::test] +async fn same_profile_is_noop() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture(&agent).await; + + let out = f + .swap + .execute(change_input(agent.id, pid(1))) + .await + .expect("no-op succeeds"); + + // Agent returned unchanged, no relaunch. + assert_eq!(out.agent.profile_id, pid(1)); + assert!(out.relaunched.is_none(), "no relaunch on a no-op"); + // No event published. + assert!(f.bus.events().is_empty(), "no-op publishes no event"); + // No kill, no spawn. + assert!(f.pty.kills().is_empty(), "no-op kills nothing"); + assert_eq!(f.pty.spawn_count(), 0, "no-op spawns nothing"); + // Manifest never re-saved (no observable mutation). + assert_eq!( + f.contexts.manifest_saves(), + 0, + "no-op must not rewrite the manifest" + ); + // No filesystem write at all. + assert_eq!(f.fs.write_count(), 0, "no-op must not write any layout"); + // Persisted profile is still the original. + assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(1))); +} + +/// Unknown target profile ⇒ NotFound, and the agent is **not** mutated (the +/// manifest keeps the original profile id). +#[tokio::test] +async fn unknown_profile_is_not_found_and_does_not_mutate() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture(&agent).await; + + // pid(99) is not in the store (only pid(1), pid(2)). + let err = f + .swap + .execute(change_input(agent.id, pid(99))) + .await + .unwrap_err(); + + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); + // Manifest unchanged. + assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(1))); + assert_eq!(f.contexts.manifest_saves(), 0); + assert!(f.pty.kills().is_empty()); + assert!(f.bus.events().is_empty()); +} + +/// Unknown agent ⇒ NotFound. +#[tokio::test] +async fn unknown_agent_is_not_found() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture(&agent).await; + + let err = f + .swap + .execute(change_input(aid(404), pid(2))) + .await + .unwrap_err(); + + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); + assert_eq!(f.contexts.manifest_saves(), 0); + assert!(f.bus.events().is_empty()); +} + +/// Success: the persisted manifest carries the **new** profile id, and the +/// returned agent reflects it. +#[tokio::test] +async fn success_mutates_manifest_to_new_profile() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture(&agent).await; + + let out = f + .swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds"); + + assert_eq!(out.agent.profile_id, pid(2), "returned agent carries new profile"); + assert_eq!( + f.contexts.profile_of(&agent.id), + Some(pid(2)), + "manifest persisted with the new profile" + ); + assert_eq!(f.contexts.manifest_saves(), 1, "exactly one manifest save"); + // Dead agent (no live session) ⇒ no relaunch. + assert!(out.relaunched.is_none()); +} + +/// Conversation cleanup: a layout cell hosting the agent with a `conversation_id` +/// and `agent_was_running = true` is reset to `(None, false)` on the persisted +/// layouts after the swap. +#[tokio::test] +async fn cleans_conversation_on_persisted_layouts() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture(&agent).await; + + // Seed a layout: leaf nid(10) hosts the agent with a live conversation. + let leaf = nid(10); + seed_layouts( + &f.fs, + lid(1), + &LayoutTree::single(agent_leaf(leaf, Some(agent.id), Some("conv-old"), true)), + ); + + f.swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds"); + + // The leaf's conversation id is cleared and the running flag reset. + assert_eq!( + leaf_state(&f.fs, leaf), + Some((None, false)), + "conversation_id and agent_was_running must be reset" + ); +} + +/// Cleanup leaves foreign agents untouched: a second agent's cell keeps its own +/// conversation id. +#[tokio::test] +async fn cleanup_leaves_other_agents_untouched() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture(&agent).await; + + let mine = nid(10); + let other = nid(11); + let other_agent = aid(2); + let tree = LayoutTree::new(LayoutNode::Split(domain::SplitContainer { + id: nid(1), + direction: domain::Direction::Row, + children: vec![ + domain::WeightedChild { + node: LayoutNode::Leaf(agent_leaf(mine, Some(agent.id), Some("conv-mine"), true)), + weight: 1.0, + }, + domain::WeightedChild { + node: LayoutNode::Leaf(agent_leaf( + other, + Some(other_agent), + Some("conv-other"), + true, + )), + weight: 1.0, + }, + ], + })); + seed_layouts(&f.fs, lid(1), &tree); + + f.swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds"); + + assert_eq!(leaf_state(&f.fs, mine), Some((None, false)), "mine cleaned"); + assert_eq!( + leaf_state(&f.fs, other), + Some((Some("conv-other".to_owned()), true)), + "the other agent's cell is untouched" + ); +} + +/// Live agent ⇒ the PTY is killed and the session is relaunched in the SAME cell +/// (node N) with a discarded conversation id (`LaunchAgent` invoked at node N). +#[tokio::test] +async fn live_agent_is_killed_and_relaunched_in_same_cell() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture(&agent).await; + + // The agent is live in cell N, session sid(42). + let host = nid(5); + seed_live_agent_session(&f.sessions, agent.id, host, sid(42)); + + // Seed a layout cell for that node carrying the now-foreign conversation. + seed_layouts( + &f.fs, + lid(1), + &LayoutTree::single(agent_leaf(host, Some(agent.id), Some("conv-old"), true)), + ); + + let out = f + .swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("hot swap succeeds"); + + // The old PTY was killed. + assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed"); + // Exactly one relaunch spawn. + assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once"); + // The relaunched session is returned and pinned on the SAME cell N. + let relaunched = out.relaunched.expect("a live agent is relaunched"); + assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell"); + assert_eq!(relaunched.id, sid(777), "relaunch adopts the new PTY id"); + // The relaunched session is registered and tagged for this agent. + assert!(matches!( + relaunched.kind, + SessionKind::Agent { agent_id } if agent_id == agent.id + )); + assert_eq!( + f.sessions.session_for_agent(&agent.id), + Some(sid(777)), + "the registry now holds the relaunched session" + ); + // Manifest carries the new profile. + assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); +} + +/// Dead agent (no live session) ⇒ no kill, no relaunch; the manifest is still +/// mutated to the new profile. +#[tokio::test] +async fn dead_agent_mutates_without_relaunch() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture(&agent).await; + + // No live session seeded. + let out = f + .swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds"); + + assert!(out.relaunched.is_none(), "no relaunch for a dead agent"); + assert!(f.pty.kills().is_empty(), "nothing to kill"); + assert_eq!(f.pty.spawn_count(), 0, "no spawn for a dead agent"); + // Manifest still mutated. + assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); +} + +/// Event: a successful mutating swap publishes `AgentProfileChanged` exactly once, +/// carrying the agent id and the NEW profile id. +#[tokio::test] +async fn publishes_agent_profile_changed_once_on_success() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture(&agent).await; + + f.swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds"); + + let profile_changed: Vec<_> = f + .bus + .events() + .into_iter() + .filter(|e| { + matches!( + e, + DomainEvent::AgentProfileChanged { agent_id, profile_id } + if *agent_id == agent.id && *profile_id == pid(2) + ) + }) + .collect(); + + assert_eq!( + profile_changed.len(), + 1, + "AgentProfileChanged published exactly once with the new profile" + ); +} diff --git a/crates/application/tests/list_resumable_agents.rs b/crates/application/tests/list_resumable_agents.rs new file mode 100644 index 0000000..f8b3bb4 --- /dev/null +++ b/crates/application/tests/list_resumable_agents.rs @@ -0,0 +1,743 @@ +//! B1 tests for [`ListResumableAgents`] (ARCHITECTURE §15.2, §15.4 line B1). +//! +//! `ListResumableAgents` is a **read-only** inventory built at project reopen: +//! it walks every persisted layout's `agent_leaves()`, reads each hosting +//! `LeafCell`'s `(conversation_id, agent_was_running)` via the pure +//! `LayoutTree::leaf` accessor, keeps only the leaves passing the §15.2 filter +//! (`was_running || conversation_id.is_some()`), resolves the display name via +//! the manifest, and derives `resume_supported` from the agent's profile +//! (`SessionStrategy` present ⇒ `true`). It is **best-effort**: any read failure +//! degrades to an empty list, never an `Err`, never a panic. +//! +//! Every port is faked in-memory (100 % without real I/O), reusing the harness +//! style of `snapshot_running_agents.rs` (FakeFs + `seed_layouts`) and +//! `change_agent_profile.rs` (FakeContexts + FakeProfiles + FakeStore). + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; +use domain::layout::Workspace; +use domain::ports::{ + AgentContextStore, DirEntry, FileSystem, FsError, ProfileStore, ProjectStore, RemotePath, + StoreError, +}; +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, +}; +use uuid::Uuid; + +use application::{ListResumableAgents, ListResumableAgentsInput}; + +// --------------------------------------------------------------------------- +// FakeFs (FileSystem) — HashMap-backed, serves layouts.json +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct FakeFsInner { + files: HashMap>, + dirs: HashSet, +} + +#[derive(Default, Clone)] +struct FakeFs(Arc>); + +impl FakeFs { + fn put(&self, path: &str, data: &[u8]) { + self.0 + .lock() + .unwrap() + .files + .insert(path.to_owned(), data.to_vec()); + } +} + +#[async_trait] +impl FileSystem for FakeFs { + async fn read(&self, path: &RemotePath) -> Result, FsError> { + self.0 + .lock() + .unwrap() + .files + .get(path.as_str()) + .cloned() + .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())) + } + async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { + self.0 + .lock() + .unwrap() + .files + .insert(path.as_str().to_owned(), data.to_vec()); + Ok(()) + } + async fn exists(&self, path: &RemotePath) -> Result { + let inner = self.0.lock().unwrap(); + Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str())) + } + async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { + self.0.lock().unwrap().dirs.insert(path.as_str().to_owned()); + Ok(()) + } + async fn list(&self, _path: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FakeStore (ProjectStore) — holds the project +// --------------------------------------------------------------------------- + +#[derive(Default, Clone)] +struct FakeStore(Arc>>); + +#[async_trait] +impl ProjectStore for FakeStore { + async fn list_projects(&self) -> Result, StoreError> { + Ok(self.0.lock().unwrap().clone()) + } + async fn load_project(&self, id: ProjectId) -> Result { + self.0 + .lock() + .unwrap() + .iter() + .find(|p| p.id == id) + .cloned() + .ok_or(StoreError::NotFound) + } + async fn save_project(&self, project: &Project) -> Result<(), StoreError> { + self.0.lock().unwrap().push(project.clone()); + Ok(()) + } + async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> { + Ok(()) + } + async fn load_workspace(&self) -> Result { + Ok(Workspace::default()) + } +} + +// --------------------------------------------------------------------------- +// FakeContexts (AgentContextStore) — manifest only (name + profile_id) +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct FakeContexts { + manifest: Arc>, + /// When true, `load_manifest` fails (best-effort path test). + fail: bool, +} + +impl FakeContexts { + fn new(entries: Vec) -> Self { + Self { + manifest: Arc::new(Mutex::new(AgentManifest { + version: 1, + entries, + })), + fail: false, + } + } + fn failing() -> Self { + Self { + manifest: Arc::new(Mutex::new(AgentManifest { + version: 1, + entries: Vec::new(), + })), + fail: true, + } + } +} + +#[async_trait] +impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _project: &Project, + _agent: &AgentId, + ) -> Result { + Err(StoreError::NotFound) + } + async fn write_context( + &self, + _project: &Project, + _agent: &AgentId, + _md: &MarkdownDoc, + ) -> Result<(), StoreError> { + Ok(()) + } + async fn load_manifest(&self, _project: &Project) -> Result { + if self.fail { + return Err(StoreError::NotFound); + } + Ok(self.manifest.lock().unwrap().clone()) + } + async fn save_manifest( + &self, + _project: &Project, + manifest: &AgentManifest, + ) -> Result<(), StoreError> { + *self.manifest.lock().unwrap() = manifest.clone(); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FakeProfiles (ProfileStore) — fixed list, optionally failing on `list` +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct FakeProfiles { + profiles: Arc>, + fail: bool, +} + +impl FakeProfiles { + fn new(profiles: Vec) -> Self { + Self { + profiles: Arc::new(profiles), + fail: false, + } + } + fn failing() -> Self { + Self { + profiles: Arc::new(Vec::new()), + fail: true, + } + } +} + +#[async_trait] +impl ProfileStore for FakeProfiles { + async fn list(&self) -> Result, StoreError> { + if self.fail { + return Err(StoreError::NotFound); + } + Ok((*self.profiles).clone()) + } + async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> { + Ok(()) + } + async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> { + Ok(()) + } + async fn is_configured(&self) -> Result { + Ok(true) + } + async fn mark_configured(&self) -> Result<(), StoreError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Helpers / builders +// --------------------------------------------------------------------------- + +const ROOT: &str = "/home/me/proj"; +const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json"; + +fn pid(n: u128) -> ProfileId { + ProfileId::from_uuid(Uuid::from_u128(n)) +} +fn aid(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} +fn nid(n: u128) -> NodeId { + NodeId::from_uuid(Uuid::from_u128(n)) +} +fn lid(n: u128) -> LayoutId { + LayoutId::from_uuid(Uuid::from_u128(n)) +} +fn proj_id(n: u128) -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(n)) +} + +fn project() -> Project { + Project::new( + proj_id(1000), + "demo", + ProjectPath::new(ROOT).unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +/// A profile WITH a resumable `SessionStrategy` (resume_flag present). +fn profile_with_session(id: ProfileId) -> AgentProfile { + AgentProfile::new( + id, + "Resumable CLI", + "claude", + Vec::new(), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some("claude --version".to_owned()), + "{agentRunDir}", + Some(SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap()), + ) + .unwrap() +} + +/// A profile WITHOUT a `SessionStrategy` (no resume support). +fn profile_no_session(id: ProfileId) -> AgentProfile { + AgentProfile::new( + id, + "Plain CLI", + "aider", + Vec::new(), + ContextInjection::convention_file("AGENTS.md").unwrap(), + None, + "{agentRunDir}", + None, + ) + .unwrap() +} + +/// Builds a manifest entry for `agent` named `name` on profile `profile_id`. +fn entry(agent: AgentId, name: &str, profile_id: ProfileId) -> ManifestEntry { + let a = Agent::new( + agent, + name, + format!("agents/{name}.md"), + profile_id, + AgentOrigin::Scratch, + false, + ) + .unwrap(); + ManifestEntry::from_agent(&a) +} + +/// A leaf cell hosting `agent`, optionally carrying a conversation + run flag. +fn agent_leaf( + node: NodeId, + agent: Option, + conversation_id: Option<&str>, + agent_was_running: bool, +) -> LeafCell { + LeafCell { + id: node, + session: None, + agent, + conversation_id: conversation_id.map(str::to_owned), + agent_was_running, + } +} + +/// Seeds a valid `layouts.json` with one active layout holding `tree`. +fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) { + let doc = serde_json::json!({ + "version": 1, + "activeId": id.to_string(), + "layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ], + }); + fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap()); +} + +/// Wires the use case over the three fakes. +fn make_use_case( + store: &FakeStore, + fs: &FakeFs, + contexts: &FakeContexts, + profiles: &FakeProfiles, +) -> ListResumableAgents { + ListResumableAgents::new( + Arc::new(store.clone()) as Arc, + Arc::new(fs.clone()) as Arc, + Arc::new(contexts.clone()) as Arc, + Arc::new(profiles.clone()) as Arc, + ) +} + +async fn run(uc: &ListResumableAgents) -> Vec { + uc.execute(ListResumableAgentsInput { project: project() }) + .await + .expect("best-effort: never errors") + .resumable +} + +// --------------------------------------------------------------------------- +// 1. Correct inventory: both resumable cells appear with the right fields +// --------------------------------------------------------------------------- + +/// A layout with two agent cells — one `was_running=true` (no conv), one with a +/// `conversation_id` (not running) — yields both entries, each with the right +/// `agent_id`, `name` (from manifest), `node_id`, `conversation_id`, `was_running`. +#[tokio::test] +async fn inventory_lists_both_resumable_cells() { + let store = FakeStore::default(); + store.save_project(&project()).await.unwrap(); + let fs = FakeFs::default(); + + let running_leaf = nid(10); + let conv_leaf = nid(11); + let running_agent = aid(100); + let conv_agent = aid(101); + + let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { + id: nid(1), + direction: Direction::Row, + children: vec![ + WeightedChild { + node: LayoutNode::Leaf(agent_leaf(running_leaf, Some(running_agent), None, true)), + weight: 1.0, + }, + WeightedChild { + node: LayoutNode::Leaf(agent_leaf( + conv_leaf, + Some(conv_agent), + Some("conv-xyz"), + false, + )), + weight: 1.0, + }, + ], + })); + seed_layouts(&fs, lid(1), &tree); + + let contexts = FakeContexts::new(vec![ + entry(running_agent, "Backend", pid(1)), + entry(conv_agent, "Tester", pid(1)), + ]); + let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]); + let uc = make_use_case(&store, &fs, &contexts, &profiles); + + let out = run(&uc).await; + assert_eq!(out.len(), 2, "both resumable cells listed: {out:?}"); + + let running = out + .iter() + .find(|r| r.agent_id == running_agent) + .expect("running agent present"); + assert_eq!(running.name, "Backend"); + assert_eq!(running.node_id, running_leaf); + assert_eq!(running.conversation_id, None); + assert!(running.was_running); + assert!(running.resume_supported, "profile has a SessionStrategy"); + + let conv = out + .iter() + .find(|r| r.agent_id == conv_agent) + .expect("conversation agent present"); + assert_eq!(conv.name, "Tester"); + assert_eq!(conv.node_id, conv_leaf); + assert_eq!(conv.conversation_id, Some("conv-xyz".to_owned())); + assert!(!conv.was_running); + assert!(conv.resume_supported); +} + +// --------------------------------------------------------------------------- +// 2. Filter: a never-launched agent cell is excluded +// --------------------------------------------------------------------------- + +/// An agent cell with neither `was_running` nor `conversation_id` is filtered out +/// (it launches normally on click, no popup), while a sibling resumable cell stays. +#[tokio::test] +async fn never_launched_cell_is_excluded() { + let store = FakeStore::default(); + store.save_project(&project()).await.unwrap(); + let fs = FakeFs::default(); + + let fresh_leaf = nid(10); + let resumable_leaf = nid(11); + let fresh_agent = aid(100); + let resumable_agent = aid(101); + + let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { + id: nid(1), + direction: Direction::Row, + children: vec![ + WeightedChild { + node: LayoutNode::Leaf(agent_leaf(fresh_leaf, Some(fresh_agent), None, false)), + weight: 1.0, + }, + WeightedChild { + node: LayoutNode::Leaf(agent_leaf( + resumable_leaf, + Some(resumable_agent), + Some("c1"), + false, + )), + weight: 1.0, + }, + ], + })); + seed_layouts(&fs, lid(1), &tree); + + let contexts = FakeContexts::new(vec![ + entry(fresh_agent, "Fresh", pid(1)), + entry(resumable_agent, "Resumable", pid(1)), + ]); + let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]); + let uc = make_use_case(&store, &fs, &contexts, &profiles); + + let out = run(&uc).await; + assert_eq!(out.len(), 1, "only the resumable cell: {out:?}"); + assert_eq!(out[0].agent_id, resumable_agent); + assert!( + !out.iter().any(|r| r.agent_id == fresh_agent), + "never-launched agent must not appear" + ); +} + +// --------------------------------------------------------------------------- +// 3. resume_supported reflects the agent's profile +// --------------------------------------------------------------------------- + +/// `resume_supported` is `true` for an agent on a profile with a SessionStrategy +/// and `false` for one without — but the latter is STILL listed (it carries a +/// `conversation_id`). +#[tokio::test] +async fn resume_supported_follows_profile() { + let store = FakeStore::default(); + store.save_project(&project()).await.unwrap(); + let fs = FakeFs::default(); + + let supported_leaf = nid(10); + let plain_leaf = nid(11); + let supported_agent = aid(100); + let plain_agent = aid(101); + + let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { + id: nid(1), + direction: Direction::Row, + children: vec![ + WeightedChild { + node: LayoutNode::Leaf(agent_leaf( + supported_leaf, + Some(supported_agent), + Some("c1"), + true, + )), + weight: 1.0, + }, + WeightedChild { + node: LayoutNode::Leaf(agent_leaf( + plain_leaf, + Some(plain_agent), + Some("c2"), + true, + )), + weight: 1.0, + }, + ], + })); + seed_layouts(&fs, lid(1), &tree); + + let contexts = FakeContexts::new(vec![ + 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 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(); + assert!(supported.resume_supported, "profile pid(1) has a session"); + + let plain = out.iter().find(|r| r.agent_id == plain_agent).unwrap(); + assert!( + !plain.resume_supported, + "profile pid(2) has no session ⇒ resume_supported=false" + ); + assert_eq!( + plain.conversation_id, + Some("c2".to_owned()), + "still listed by its conversation_id" + ); +} + +// --------------------------------------------------------------------------- +// 4. Best-effort / never an error +// --------------------------------------------------------------------------- + +/// No `layouts.json` at all ⇒ empty inventory, `Ok` (best-effort, no panic). +#[tokio::test] +async fn missing_layouts_yields_empty_ok() { + let store = FakeStore::default(); + store.save_project(&project()).await.unwrap(); + let fs = FakeFs::default(); // nothing seeded + + let contexts = FakeContexts::new(vec![entry(aid(100), "Backend", pid(1))]); + let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]); + let uc = make_use_case(&store, &fs, &contexts, &profiles); + + let out = run(&uc).await; + assert!(out.is_empty(), "no layouts ⇒ empty: {out:?}"); +} + +/// Failing manifest load ⇒ empty inventory, `Ok` (cannot resolve names/profiles). +#[tokio::test] +async fn failing_manifest_yields_empty_ok() { + let store = FakeStore::default(); + store.save_project(&project()).await.unwrap(); + let fs = FakeFs::default(); + + seed_layouts( + &fs, + lid(1), + &LayoutTree::single(agent_leaf(nid(10), Some(aid(100)), Some("c1"), true)), + ); + + let contexts = FakeContexts::failing(); + let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]); + let uc = make_use_case(&store, &fs, &contexts, &profiles); + + let out = run(&uc).await; + assert!(out.is_empty(), "manifest unreadable ⇒ empty: {out:?}"); +} + +/// An agent present in a layout but ABSENT from the manifest is ignored — no +/// orphan entry — while a sibling agent that IS in the manifest is still listed. +#[tokio::test] +async fn agent_absent_from_manifest_is_ignored() { + let store = FakeStore::default(); + store.save_project(&project()).await.unwrap(); + let fs = FakeFs::default(); + + let orphan_leaf = nid(10); + let known_leaf = nid(11); + let orphan_agent = aid(100); // NOT in manifest + let known_agent = aid(101); // in manifest + + let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { + id: nid(1), + direction: Direction::Row, + children: vec![ + WeightedChild { + node: LayoutNode::Leaf(agent_leaf(orphan_leaf, Some(orphan_agent), Some("c1"), true)), + weight: 1.0, + }, + WeightedChild { + node: LayoutNode::Leaf(agent_leaf(known_leaf, Some(known_agent), Some("c2"), true)), + weight: 1.0, + }, + ], + })); + seed_layouts(&fs, lid(1), &tree); + + let contexts = FakeContexts::new(vec![entry(known_agent, "Known", pid(1))]); + let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]); + let uc = make_use_case(&store, &fs, &contexts, &profiles); + + let out = run(&uc).await; + assert_eq!(out.len(), 1, "orphan ignored, known listed: {out:?}"); + assert_eq!(out[0].agent_id, known_agent); + assert!( + !out.iter().any(|r| r.agent_id == orphan_agent), + "orphan agent (not in manifest) must not appear" + ); +} + +/// A failing `ProfileStore::list` ⇒ agents are STILL listed (resumable by their +/// `conversation_id`/`was_running`), but each with `resume_supported = false`. +#[tokio::test] +async fn failing_profile_store_lists_agents_without_resume_support() { + let store = FakeStore::default(); + store.save_project(&project()).await.unwrap(); + let fs = FakeFs::default(); + + let leaf = nid(10); + let agent = aid(100); + seed_layouts( + &fs, + lid(1), + &LayoutTree::single(agent_leaf(leaf, Some(agent), Some("c1"), true)), + ); + + // The agent is on a profile that DOES support resume, but the profile store + // is unavailable — so resume_supported must degrade to false. + let contexts = FakeContexts::new(vec![entry(agent, "Backend", pid(1))]); + let profiles = FakeProfiles::failing(); + 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[0].agent_id, agent); + assert!( + !out[0].resume_supported, + "profiles unavailable ⇒ resume_supported=false" + ); +} + +// --------------------------------------------------------------------------- +// 5. Non-agent / split / grid cells produce no spurious entries +// --------------------------------------------------------------------------- + +/// A mixed tree with a plain (non-agent) leaf nested in splits and grids yields +/// no parasitic entries: only the genuine resumable agent cell is reported. +#[tokio::test] +async fn non_agent_split_and_grid_cells_are_ignored() { + let store = FakeStore::default(); + store.save_project(&project()).await.unwrap(); + let fs = FakeFs::default(); + + let plain_leaf = nid(10); + let grid_plain_leaf = nid(12); + let agent_cell = nid(13); + let agent = aid(100); + + // A grid holding a plain leaf and the resumable agent leaf. + let grid = LayoutNode::Grid(GridContainer { + id: nid(2), + col_weights: vec![1.0, 1.0], + row_weights: vec![1.0], + cells: vec![ + GridCell { + node: LayoutNode::Leaf(agent_leaf(grid_plain_leaf, None, None, false)), + row: 0, + col: 0, + row_span: 1, + col_span: 1, + }, + GridCell { + node: LayoutNode::Leaf(agent_leaf(agent_cell, Some(agent), Some("c1"), true)), + row: 0, + col: 1, + row_span: 1, + col_span: 1, + }, + ], + }); + + // A split holding a plain leaf and the grid above. + let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { + id: nid(1), + direction: Direction::Column, + children: vec![ + WeightedChild { + node: LayoutNode::Leaf(agent_leaf(plain_leaf, None, None, false)), + weight: 1.0, + }, + WeightedChild { + node: grid, + weight: 1.0, + }, + ], + })); + seed_layouts(&fs, lid(1), &tree); + + let contexts = FakeContexts::new(vec![entry(agent, "Backend", pid(1))]); + let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]); + let uc = make_use_case(&store, &fs, &contexts, &profiles); + + let out = run(&uc).await; + assert_eq!(out.len(), 1, "only the agent grid-cell counts: {out:?}"); + assert_eq!(out[0].agent_id, agent); + assert_eq!(out[0].node_id, agent_cell); + assert!(out[0].resume_supported); +} diff --git a/crates/domain/src/agent.rs b/crates/domain/src/agent.rs index 8ba060d..46d6a13 100644 --- a/crates/domain/src/agent.rs +++ b/crates/domain/src/agent.rs @@ -107,6 +107,16 @@ impl Agent { }) } + /// Change le profil runtime de l'agent. Le contexte `.md`, l'origine template + /// et la synchronisation sont **inchangés** (décision verrouillée : on garde le + /// `.md`/mémoire, on ne touche qu'au moteur). Pur, infaillible (un `ProfileId` + /// est déjà un VO validé). + #[must_use] + pub fn with_profile(mut self, profile_id: ProfileId) -> Self { + self.profile_id = profile_id; + self + } + /// Returns a copy of this agent carrying the given assigned skills, /// deduplicated by `skill_id` (keeping first occurrence). #[must_use] diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 2b4eeb7..3b65f26 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -1,7 +1,7 @@ //! Domain events published on the [`crate::ports::EventBus`] and relayed to the //! presentation layer (ARCHITECTURE §3.2). -use crate::ids::{AgentId, ProjectId, SessionId, SkillId, TemplateId}; +use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId}; use crate::memory::MemorySlug; use crate::template::TemplateVersion; @@ -32,6 +32,13 @@ pub enum DomainEvent { /// Exit code. code: i32, }, + /// An agent's runtime profile was changed (hot-swap of the AI engine). + AgentProfileChanged { + /// The agent. + agent_id: AgentId, + /// The new runtime profile it now uses. + profile_id: ProfileId, + }, /// A template was updated (content changed, version bumped). TemplateUpdated { /// The template. diff --git a/crates/domain/src/layout.rs b/crates/domain/src/layout.rs index f134d88..d2f76ef 100644 --- a/crates/domain/src/layout.rs +++ b/crates/domain/src/layout.rs @@ -620,6 +620,24 @@ impl LayoutTree { out } + /// Retrouve la [`LeafCell`] portant l'identifiant `node`. + /// + /// Renvoie `None` si aucun node ne porte cet id, **ou** si le node existe mais + /// est un split/grid (pas une feuille). Traversée pure en lecture seule, dans + /// le même esprit que [`Self::agent_leaves`] / [`Self::session_in_leaf`]. + #[must_use] + pub fn leaf(&self, node: NodeId) -> Option<&LeafCell> { + fn find(n: &LayoutNode, id: NodeId) -> Option<&LeafCell> { + match n { + LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf), + LayoutNode::Leaf(_) => None, + LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)), + LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)), + } + } + find(&self.root, node) + } + /// Returns `Ok(Some(session))` / `Ok(None)` for the session held by the leaf /// `id`, or [`LayoutError::NodeNotFound`] if no such leaf exists. fn session_in_leaf(&self, id: NodeId) -> Result, LayoutError> { diff --git a/crates/domain/tests/agent_profile_a0.rs b/crates/domain/tests/agent_profile_a0.rs new file mode 100644 index 0000000..d92caff --- /dev/null +++ b/crates/domain/tests/agent_profile_a0.rs @@ -0,0 +1,246 @@ +//! LOT A0 (fondation) — tests purs du domaine : +//! - `Agent::with_profile` ne change **que** `profile_id` ; +//! - `LayoutTree::leaf` retrouve/loupe un node (feuille vs split/grid) ; +//! - variante d'event `DomainEvent::AgentProfileChanged`. +//! +//! Réutilise les fixtures/helpers existants (`helpers::node`, `helpers::session`) +//! et colle au style des tests `entities.rs` / `layout.rs` (ARCHITECTURE §15.4 A0). + +mod helpers; + +use domain::events::DomainEvent; +use domain::ids::{AgentId, ProfileId, SkillId, TemplateId}; +use domain::{ + Agent, AgentOrigin, Direction, LayoutNode, LayoutTree, LeafCell, SkillRef, SkillScope, + SplitContainer, TemplateVersion, WeightedChild, +}; +use helpers::{node, session}; +use uuid::Uuid; + +fn agent_id(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} + +fn profile_id(n: u128) -> ProfileId { + ProfileId::from_uuid(Uuid::from_u128(n)) +} + +fn template_id(n: u128) -> TemplateId { + TemplateId::from_uuid(Uuid::from_u128(n)) +} + +fn skill_ref(n: u128) -> SkillRef { + SkillRef::new(SkillId::from_uuid(Uuid::from_u128(n)), SkillScope::Project) +} + +// --------------------------------------------------------------------------- +// Agent::with_profile — ne change QUE profile_id +// --------------------------------------------------------------------------- + +/// Un agent issu d'un template, synchronisé, avec des skills : cas le plus riche +/// pour prouver que `with_profile` ne touche à rien d'autre que `profile_id`. +fn rich_agent() -> Agent { + Agent::new( + agent_id(1), + "Architect", + "agents/architect.md", + profile_id(10), + AgentOrigin::FromTemplate { + template_id: template_id(7), + synced_template_version: TemplateVersion::INITIAL, + }, + true, + ) + .expect("valid rich agent") + .with_skills(vec![skill_ref(100), skill_ref(101)]) +} + +#[test] +fn with_profile_changes_only_profile_id() { + let before = rich_agent(); + let new_profile = profile_id(99); + let after = before.clone().with_profile(new_profile); + + // Le seul champ muté : + assert_eq!(after.profile_id, new_profile); + assert_ne!(after.profile_id, before.profile_id); + + // Tous les autres champs sont strictement inchangés : + assert_eq!(after.id, before.id); + assert_eq!(after.name, before.name); + assert_eq!(after.context_path, before.context_path); + assert_eq!(after.origin, before.origin); + assert_eq!(after.synchronized, before.synchronized); + assert_eq!(after.skills, before.skills); +} + +#[test] +fn with_profile_preserves_scratch_origin_and_unsynced() { + let before = Agent::new( + agent_id(2), + "Scratchy", + "agents/scratchy.md", + profile_id(10), + AgentOrigin::Scratch, + false, + ) + .expect("valid scratch agent"); + + let after = before.clone().with_profile(profile_id(11)); + + assert_eq!(after.profile_id, profile_id(11)); + assert_eq!(after.origin, AgentOrigin::Scratch); + assert!(!after.synchronized); + assert!(after.skills.is_empty()); + // Tout sauf le profil identique → comparer un clone "patché" prouve l'égalité. + assert_eq!(after, before.with_profile(profile_id(11))); +} + +#[test] +fn with_profile_to_same_profile_is_identity() { + // Idempotence / égalité attendue : re-poser le même profil ⇒ agent inchangé. + let before = rich_agent(); + let after = before.clone().with_profile(before.profile_id); + assert_eq!(after, before); +} + +#[test] +fn with_profile_is_idempotent_when_repeated() { + let base = rich_agent(); + let once = base.clone().with_profile(profile_id(42)); + let twice = once.clone().with_profile(profile_id(42)); + assert_eq!(once, twice); +} + +// --------------------------------------------------------------------------- +// LayoutTree::leaf — retrouve / loupe (feuille vs split/grid) +// --------------------------------------------------------------------------- + +fn leaf_cell(id: u128, sess: Option) -> LeafCell { + LeafCell { + id: node(id), + session: sess.map(session), + agent: None, + conversation_id: None, + agent_was_running: false, + } +} + +/// Arbre contenant **au moins un split** : racine = split (id 9) de deux feuilles +/// (id 1 avec session, id 2 sans). +fn split_tree() -> LayoutTree { + LayoutTree::new(LayoutNode::Split(SplitContainer { + id: node(9), + direction: Direction::Row, + children: vec![ + WeightedChild { + node: LayoutNode::Leaf(leaf_cell(1, Some(100))), + weight: 1.0, + }, + WeightedChild { + node: LayoutNode::Leaf(leaf_cell(2, None)), + weight: 1.0, + }, + ], + })) +} + +#[test] +fn leaf_finds_existing_leaf_in_split_tree() { + let tree = split_tree(); + + // Feuille 1 : retrouvée, et c'est exactement le LeafCell attendu. + let expected = leaf_cell(1, Some(100)); + let found = tree.leaf(node(1)).expect("leaf 1 exists"); + assert_eq!(*found, expected); + + // Feuille 2 (sans session) aussi. + let found2 = tree.leaf(node(2)).expect("leaf 2 exists"); + assert_eq!(*found2, leaf_cell(2, None)); +} + +#[test] +fn leaf_returns_none_for_absent_node() { + let tree = split_tree(); + assert!(tree.leaf(node(404)).is_none()); +} + +#[test] +fn leaf_returns_none_for_split_node_id() { + // L'id 9 existe mais désigne un split, pas une feuille ⇒ None. + let tree = split_tree(); + assert!(tree.leaf(node(9)).is_none()); +} + +#[test] +fn leaf_returns_none_for_grid_node_id() { + use domain::{GridCell, GridContainer}; + + // Grille 1x1 (id 50) contenant une feuille (id 3). + let grid = LayoutTree::new(LayoutNode::Grid(GridContainer { + id: node(50), + col_weights: vec![1.0], + row_weights: vec![1.0], + cells: vec![GridCell { + node: LayoutNode::Leaf(leaf_cell(3, None)), + row: 0, + col: 0, + row_span: 1, + col_span: 1, + }], + })); + + // 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)); +} + +#[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!(tree.leaf(node(8)).is_none()); +} + +// --------------------------------------------------------------------------- +// DomainEvent::AgentProfileChanged — construction & champs +// --------------------------------------------------------------------------- + +#[test] +fn agent_profile_changed_carries_agent_and_profile() { + let aid = agent_id(1); + let pid = profile_id(2); + let event = DomainEvent::AgentProfileChanged { + agent_id: aid, + profile_id: pid, + }; + + match event { + DomainEvent::AgentProfileChanged { + agent_id, + profile_id, + } => { + assert_eq!(agent_id, aid); + assert_eq!(profile_id, pid); + } + other => panic!("expected AgentProfileChanged, got {other:?}"), + } +} + +#[test] +fn agent_profile_changed_equality_and_clone() { + let e1 = DomainEvent::AgentProfileChanged { + agent_id: agent_id(1), + profile_id: profile_id(2), + }; + // Clone == original ; un profil différent rompt l'égalité. + assert_eq!(e1.clone(), e1); + assert_ne!( + e1, + DomainEvent::AgentProfileChanged { + agent_id: agent_id(1), + profile_id: profile_id(3), + } + ); +}