feat(agent): backend hot-swap profil (A0+A1) + inventaire reprise session (B1) — L15

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:55:44 +02:00
parent 62bd5130fb
commit 2433e173a1
15 changed files with 2630 additions and 11 deletions

View File

@ -338,6 +338,8 @@ RemoteRef =
| `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` | | `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` |
| `SyncAgentWithTemplate` | Applique la MAJ template→agent si `synchronized`. | `TemplateStore`, `AgentContextStore`, `EventBus` | | `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` | | `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` | | `OpenTerminal` | Ouvre un PTY simple dans une cellule. | `RemoteHost``PtyPort`, `EventBus` |
| `WriteToTerminal` / `ResizeTerminal` / `CloseTerminal` | I/O PTY. | `PtyPort` | | `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) | | `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` | | 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` | | 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 | | 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}` |
--- ---

View File

@ -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` | | 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` | | 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` | | 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/<agent>.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<TerminalSession>, // 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<TerminalSessionDto> }`. 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<ResumableAgent> }
pub struct ResumableAgent {
pub agent_id: AgentId,
pub name: String,
pub node_id: NodeId, // cellule hôte (où relancer)
pub conversation_id: Option<String>, // 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> }`, `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<ResumableAgent[]>` (+ 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) ## 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. 1. **PTY cross-platform** : portable-pty + xterm.js OK sur les 3 OS, mais signaux/resize/exit codes diffèrent (Windows ConPTY). **Spike** L3.

View File

@ -47,6 +47,14 @@ pub enum DomainEventDto {
/// Exit code. /// Exit code.
code: i32, 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. /// A template was updated.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
TemplateUpdated { TemplateUpdated {
@ -171,6 +179,13 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
code: *code, code: *code,
}, },
DomainEvent::AgentProfileChanged {
agent_id,
profile_id,
} => Self::AgentProfileChanged {
agent_id: agent_id.to_string(),
profile_id: profile_id.to_string(),
},
DomainEvent::TemplateUpdated { DomainEvent::TemplateUpdated {
template_id, template_id,
version, version,

View File

@ -13,7 +13,7 @@ use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
use application::{AppError, HealthInput}; use application::{AppError, HealthInput};
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ids::{AgentId, SessionId}; use domain::ids::{AgentId, ProfileId, SessionId};
use domain::ProjectId; use domain::ProjectId;
use domain::TemplateId; use domain::TemplateId;
use domain::TemplateVersion; use domain::TemplateVersion;
@ -93,6 +93,26 @@ fn domain_event_dto_maps_agent_launched() {
assert_eq!(v["sessionId"], sid.to_string()); 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] #[test]
fn domain_event_dto_maps_template_updated_version() { fn domain_event_dto_maps_template_updated_version() {
let tid = TemplateId::from_uuid(Uuid::nil()); let tid = TemplateId::from_uuid(Uuid::nil());

View File

@ -15,8 +15,8 @@ use std::sync::Arc;
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError, AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError,
IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, ProjectStore, PtyPort,
SessionPlan, SkillStore, SpawnSpec, StoreError, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
}; };
use domain::{ use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
@ -25,6 +25,7 @@ use domain::{
}; };
use crate::error::AppError; use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc};
use crate::project::project_context_path; use crate::project::project_context_path;
use crate::terminal::TerminalSessions; 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<TerminalSession>,
}
/// 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<LaunchAgent>`] for the hot relaunch, and
/// [`EventBus`] to publish.
pub struct ChangeAgentProfile {
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
sessions: Arc<TerminalSessions>,
pty: Arc<dyn PtyPort>,
launch: Arc<LaunchAgent>,
events: Arc<dyn EventBus>,
}
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<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
sessions: Arc<TerminalSessions>,
pty: Arc<dyn PtyPort>,
launch: Arc<LaunchAgent>,
events: Arc<dyn EventBus>,
) -> 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<ChangeAgentProfileOutput, AppError> {
// 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<Option<TerminalSession>, 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 // DeleteAgent
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -9,17 +9,22 @@
mod catalogue; mod catalogue;
mod inspect; mod inspect;
mod lifecycle; mod lifecycle;
mod resume;
mod usecases; mod usecases;
pub(crate) use lifecycle::unique_md_path; pub(crate) use lifecycle::unique_md_path;
pub use catalogue::{reference_profile_id, reference_profiles}; pub use catalogue::{reference_profile_id, reference_profiles};
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
};
pub use lifecycle::{ pub use lifecycle::{
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, LaunchAgent,
ListAgentsOutput, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext,
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
}; };
pub use usecases::{ pub use usecases::{
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,

View File

@ -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<String>,
/// 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<ResumableAgent>,
}
/// 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<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
}
impl ListResumableAgents {
/// Construit le use case à partir de ses ports injectés.
#[must_use]
pub fn new(
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
) -> 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<ListResumableAgentsOutput, AppError> {
// 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 })
}
}

View File

@ -34,6 +34,7 @@ pub use snapshot::{
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
}; };
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE}; pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
pub(crate) use store::{persist_doc, resolve_doc};
pub use usecases::{ pub use usecases::{
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout, LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
MutateLayoutInput, MutateLayoutOutput, MutateLayoutInput, MutateLayoutOutput,

View File

@ -27,14 +27,16 @@ pub mod terminal;
pub mod window; pub mod window;
pub use agent::{ pub use agent::{
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput, reference_profile_id, reference_profiles, ChangeAgentProfile, ChangeAgentProfileInput,
ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent, InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput, ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile, ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile,
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput, SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, AGENT_MEMORY_RECALL_BUDGET,
}; };

View File

@ -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<String, String>,
/// Number of `save_manifest` calls observed.
saves: usize,
}
#[derive(Clone)]
struct FakeContexts(Arc<Mutex<ContextsInner>>);
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<ProfileId> {
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<String> {
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<MarkdownDoc, StoreError> {
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<AgentManifest, StoreError> {
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<Vec<AgentProfile>>);
impl FakeProfiles {
fn new(profiles: Vec<AgentProfile>) -> Self {
Self(Arc::new(profiles))
}
}
#[async_trait]
impl ProfileStore for FakeProfiles {
async fn list(&self) -> Result<Vec<AgentProfile>, 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<bool, StoreError> {
Ok(true)
}
async fn mark_configured(&self) -> Result<(), StoreError> {
Ok(())
}
}
// ---------------------------------------------------------------------------
// FakeStore (ProjectStore)
// ---------------------------------------------------------------------------
#[derive(Default, Clone)]
struct FakeStore(Arc<Mutex<Vec<Project>>>);
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<Vec<Project>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
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<Workspace, StoreError> {
Ok(Workspace::default())
}
}
// ---------------------------------------------------------------------------
// FakeFs (FileSystem) — HashMap-backed: serves layouts.json + records writes
// ---------------------------------------------------------------------------
#[derive(Default)]
struct FakeFsInner {
files: HashMap<String, Vec<u8>>,
dirs: HashSet<String>,
write_count: usize,
}
#[derive(Default, Clone)]
struct FakeFs(Arc<Mutex<FakeFsInner>>);
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<Vec<u8>> {
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<Vec<u8>, 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<bool, FsError> {
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<Vec<DirEntry>, 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<bool, RuntimeError> {
Ok(true)
}
fn prepare_invocation(
&self,
profile: &AgentProfile,
_ctx: &PreparedContext,
cwd: &ProjectPath,
_session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
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<Mutex<Vec<SpawnSpec>>>,
kills: Arc<Mutex<Vec<SessionId>>>,
}
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<SessionId> {
self.kills.lock().unwrap().clone()
}
}
#[async_trait]
impl PtyPort for FakePty {
async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
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<OutputStream, PtyError> {
Ok(Box::new(std::iter::empty()))
}
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
Ok(Vec::new())
}
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
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<Vec<Skill>, StoreError> {
Ok(Vec::new())
}
async fn get(
&self,
_scope: SkillScope,
_root: &ProjectPath,
_id: SkillId,
) -> Result<Skill, StoreError> {
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<Vec<MemoryIndexEntry>, MemoryError> {
Ok(Vec::new())
}
}
#[derive(Default, Clone)]
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
fn events(&self) -> Vec<DomainEvent> {
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<u128>);
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<AgentId>,
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<String>, 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<String>, 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<TerminalSessions>,
}
/// 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<AgentProfile>) -> 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"
);
}

View File

@ -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<String, Vec<u8>>,
dirs: HashSet<String>,
}
#[derive(Default, Clone)]
struct FakeFs(Arc<Mutex<FakeFsInner>>);
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<Vec<u8>, 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<bool, FsError> {
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<Vec<DirEntry>, 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<Mutex<Vec<Project>>>);
#[async_trait]
impl ProjectStore for FakeStore {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
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<Workspace, StoreError> {
Ok(Workspace::default())
}
}
// ---------------------------------------------------------------------------
// FakeContexts (AgentContextStore) — manifest only (name + profile_id)
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct FakeContexts {
manifest: Arc<Mutex<AgentManifest>>,
/// When true, `load_manifest` fails (best-effort path test).
fail: bool,
}
impl FakeContexts {
fn new(entries: Vec<ManifestEntry>) -> 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<MarkdownDoc, StoreError> {
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<AgentManifest, StoreError> {
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<Vec<AgentProfile>>,
fail: bool,
}
impl FakeProfiles {
fn new(profiles: Vec<AgentProfile>) -> 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<Vec<AgentProfile>, 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<bool, StoreError> {
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<AgentId>,
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<dyn ProjectStore>,
Arc::new(fs.clone()) as Arc<dyn FileSystem>,
Arc::new(contexts.clone()) as Arc<dyn AgentContextStore>,
Arc::new(profiles.clone()) as Arc<dyn ProfileStore>,
)
}
async fn run(uc: &ListResumableAgents) -> Vec<application::ResumableAgent> {
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);
}

View File

@ -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, /// Returns a copy of this agent carrying the given assigned skills,
/// deduplicated by `skill_id` (keeping first occurrence). /// deduplicated by `skill_id` (keeping first occurrence).
#[must_use] #[must_use]

View File

@ -1,7 +1,7 @@
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the //! Domain events published on the [`crate::ports::EventBus`] and relayed to the
//! presentation layer (ARCHITECTURE §3.2). //! 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::memory::MemorySlug;
use crate::template::TemplateVersion; use crate::template::TemplateVersion;
@ -32,6 +32,13 @@ pub enum DomainEvent {
/// Exit code. /// Exit code.
code: i32, 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). /// A template was updated (content changed, version bumped).
TemplateUpdated { TemplateUpdated {
/// The template. /// The template.

View File

@ -620,6 +620,24 @@ impl LayoutTree {
out 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 /// Returns `Ok(Some(session))` / `Ok(None)` for the session held by the leaf
/// `id`, or [`LayoutError::NodeNotFound`] if no such leaf exists. /// `id`, or [`LayoutError::NodeNotFound`] if no such leaf exists.
fn session_in_leaf(&self, id: NodeId) -> Result<Option<SessionId>, LayoutError> { fn session_in_leaf(&self, id: NodeId) -> Result<Option<SessionId>, LayoutError> {

View File

@ -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<u128>) -> 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),
}
);
}