diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 6e2ab8b..441808e 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -23,15 +23,17 @@ use application::{ LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext, RecallMemory, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveMemoryLinks, - SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, SuggestedThisSession, + SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions, + SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use domain::ports::{ - AgentContextStore, AgentRuntime, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, - EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, MemoryRecall, MemoryStore, - ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore, + AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector, + EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, + MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, + TemplateStore, }; use domain::{DomainEvent, EmbedderProfile, Project, ProjectId}; @@ -40,7 +42,8 @@ use infrastructure::{ EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall, - OrchestratorWatchHandle, PortablePtyAdapter, SystemClock, TokioBroadcastEventBus, + OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock, + TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; @@ -291,6 +294,14 @@ impl AppState { let pty_port = Arc::clone(&pty) as Arc; let terminal_sessions = Arc::new(TerminalSessions::new()); + // --- Sessions structurées (IA / cellules chat, §17) --- + // Registre jumeau de TerminalSessions + fabrique infra routée par + // `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et + // ChangeAgentProfile (shutdown polymorphe au hot-swap). + let structured_sessions = Arc::new(StructuredSessions::new()); + let session_factory = + Arc::new(StructuredSessionFactory::new()) as Arc; + let open_terminal = Arc::new(OpenTerminal::new( Arc::clone(&pty_port), Arc::clone(&terminal_sessions), @@ -507,33 +518,45 @@ impl AppState { )); // LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal // use cases — indispensable for the PtyBridge to work correctly. - let launch_agent = Arc::new(LaunchAgent::new( - Arc::clone(&contexts_port), - Arc::clone(&profile_store_port), - Arc::clone(&runtime_port), - Arc::clone(&fs_port), - Arc::clone(&pty_port), - Arc::clone(&skill_store_port), - Arc::clone(&terminal_sessions), - Arc::clone(&events_port), - Arc::clone(&ids) as Arc, - Arc::clone(&memory_recall_port), - Some(Arc::clone(&check_embedder_suggestion)), - )); + let launch_agent = Arc::new( + LaunchAgent::new( + Arc::clone(&contexts_port), + Arc::clone(&profile_store_port), + Arc::clone(&runtime_port), + Arc::clone(&fs_port), + Arc::clone(&pty_port), + Arc::clone(&skill_store_port), + Arc::clone(&terminal_sessions), + Arc::clone(&events_port), + Arc::clone(&ids) as Arc, + Arc::clone(&memory_recall_port), + Some(Arc::clone(&check_embedder_suggestion)), + ) + // Routage structuré §17.4 : un profil avec `structured_adapter` lance une + // AgentSession (cellule chat) au lieu d'un PTY ; sinon chemin PTY inchangé. + .with_structured( + Arc::clone(&session_factory), + Arc::clone(&structured_sessions), + ), + ); // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ // profile/project/fs stores, the live-session registry and PTY port, and // *composes* the launcher above for the in-place relaunch (no duplication). - let change_agent_profile = Arc::new(ChangeAgentProfile::new( - Arc::clone(&contexts_port), - Arc::clone(&profile_store_port), - Arc::clone(&store_port), - Arc::clone(&fs_port), - Arc::clone(&terminal_sessions), - Arc::clone(&pty_port), - Arc::clone(&launch_agent), - Arc::clone(&events_port), - )); + // Voit aussi le registre structuré pour un « kill » polymorphe (§17.4). + let change_agent_profile = Arc::new( + ChangeAgentProfile::new( + Arc::clone(&contexts_port), + Arc::clone(&profile_store_port), + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&terminal_sessions), + Arc::clone(&pty_port), + Arc::clone(&launch_agent), + Arc::clone(&events_port), + ) + .with_structured(Arc::clone(&structured_sessions)), + ); // Read-only inventory of resumable agent cells (§15.2). Reuses the shared // project/fs/context/profile stores already injected above — no new port. diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 1f5b72c..d0cff83 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -14,20 +14,20 @@ use std::sync::Arc; use domain::ports::{ - AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError, - IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, ProjectStore, PtyPort, - RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError, + AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus, + FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, + ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, - ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession, + ProjectPath, PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession, }; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; use crate::project::project_context_path; -use crate::terminal::TerminalSessions; +use crate::terminal::{StructuredSessions, TerminalSessions}; /// Directory (relative to `.ideai/`) under which agent contexts are written. const AGENTS_SUBDIR: &str = "agents"; @@ -321,6 +321,11 @@ pub struct ChangeAgentProfile { pty: Arc, launch: Arc, events: Arc, + /// Registre des sessions **structurées** (§17.5), pour un « kill » polymorphe + /// (§17.4) : si l'agent a une session structurée vivante, on la `shutdown()` au + /// lieu de tuer un PTY. Injecté au câblage via [`Self::with_structured`] ; `None` + /// ⇒ seul le registre PTY est consulté (mode legacy / tests existants). + structured: Option>, } impl ChangeAgentProfile { @@ -346,9 +351,20 @@ impl ChangeAgentProfile { pty, launch, events, + structured: None, } } + /// Branche le registre des sessions **structurées** (§17.4) pour un kill + /// polymorphe au hot-swap. Builder additif : signature de [`Self::new`] + /// **inchangée** (les tests A existants restent verts), le câblage fait + /// `ChangeAgentProfile::new(...).with_structured(registry)`. + #[must_use] + pub fn with_structured(mut self, structured: Arc) -> Self { + self.structured = Some(structured); + self + } + /// Executes the hot-swap, following the 7-step algorithm of §15.1. /// /// # Errors @@ -476,18 +492,14 @@ impl ChangeAgentProfile { &self, input: &ChangeAgentProfileInput, ) -> Result, AppError> { - let Some(session_id) = self.sessions.session_for_agent(&input.agent_id) else { + // Résolution **polymorphe** de la session vivante sur les deux registres + // (§17.4) : structuré d'abord, puis PTY. Un agent ne vit que dans un seul des + // deux à la fois (invariant « 1 session/agent »). + let killed = self.kill_live_session(&input.agent_id).await?; + let Some(node_id) = killed else { + // Aucune session vivante (ni structurée, ni PTY) ⇒ rien à relancer. 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 @@ -504,6 +516,44 @@ impl ChangeAgentProfile { .await?; Ok(Some(output.session)) } + + /// Tue la session vivante de `agent_id` de façon **polymorphe** (§17.4) : + /// `shutdown()` si elle est structurée, kill PTY sinon. Retire la session de son + /// registre **avant** l'arrêt, pour que la garde d'unicité du relance (sur les + /// deux registres) ne voie plus de session vivante. + /// + /// Retourne `Some(node_id_hôte)` quand une session a été tuée (le node où + /// relancer), `None` si l'agent n'avait aucune session vivante. La cellule hôte + /// peut elle-même être absente (session de fond) ⇒ `Some(None)` est replié sur un + /// node neuf côté relance via `LaunchAgentInput.node_id = None`. + async fn kill_live_session( + &self, + agent_id: &AgentId, + ) -> Result>, AppError> { + // 1. Session structurée vivante ? ⇒ shutdown polymorphe. + if let Some(structured) = &self.structured { + if let Some(session_id) = structured.session_id_for_agent(agent_id) { + let node_id = structured.node_for_agent(agent_id); + if let Some(session) = structured.remove(&session_id) { + session + .shutdown() + .await + .map_err(|e| AppError::Process(e.to_string()))?; + } + return Ok(Some(node_id)); + } + } + + // 2. Sinon, session PTY vivante ? ⇒ kill PTY (chemin historique). + let Some(session_id) = self.sessions.session_for_agent(agent_id) else { + return Ok(None); + }; + let node_id = self.sessions.node_for_agent(agent_id); + if let Some(handle) = self.sessions.remove(&session_id) { + self.pty.kill(&handle).await?; + } + Ok(Some(node_id)) + } } // --------------------------------------------------------------------------- @@ -589,10 +639,32 @@ pub struct LaunchAgentInput { pub conversation_id: Option, } +/// Descripteur d'une session **structurée** (IA, cellule chat) démarrée par +/// [`LaunchAgent`] (ARCHITECTURE §17.4). Renvoyé en plus du snapshot +/// [`TerminalSession`] quand le profil porte un `structured_adapter` : il identifie +/// la session structurée vivante (registre [`StructuredSessions`]) sans faire fuiter +/// l'`Arc` à travers la frontière de sortie du use case. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StructuredSessionDescriptor { + /// L'id de session IdeA de la session structurée (clé du registre). + pub session_id: SessionId, + /// L'agent IA pilotant cette session. + pub agent_id: AgentId, + /// La cellule (feuille de layout) qui héberge la vue chat. + pub node_id: NodeId, + /// L'id de conversation du moteur, s'il a déjà été attribué. + pub conversation_id: Option, +} + /// Output of [`LaunchAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LaunchAgentOutput { /// The created agent terminal session. + /// + /// **Toujours présent**, y compris pour un agent IA structuré (cellule chat) : + /// dans ce cas c'est un snapshot `kind = Agent` portant l'id de la session + /// structurée (cf. [`Self::structured`]), conformément à §17.4. Garde la sortie + /// **non cassante** pour A/B et le câblage existant. pub session: TerminalSession, /// The conversation id **assigned** by this launch, when the profile supports /// session assignment and the cell had none yet. The caller persists it on the @@ -601,6 +673,13 @@ pub struct LaunchAgentOutput { /// (resume of an existing id, degraded mode, or a profile without a session /// block) — the caller has nothing to persist. pub assigned_conversation_id: Option, + /// Descripteur de la session **structurée**, présent **uniquement** quand ce + /// lancement a routé vers un agent IA structuré (`profile.structured_adapter = + /// Some(_)`, ARCHITECTURE §17.4). `None` pour le chemin PTY/terminal brut + /// historique. Champ **additionnel et optionnel** : la sortie reste compatible + /// avec les use cases A/B et le câblage qui ne lisent que `session` / + /// `assigned_conversation_id`. + pub structured: Option, } /// Launches an agent: resolve profile + context, prepare the invocation, apply @@ -630,6 +709,15 @@ pub struct LaunchAgent { /// reads the project memory. `None` keeps the launcher independent of it (legacy /// wiring / tests). A failure here never affects the launch. embedder_suggestion: Option>, + /// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage + /// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4 + /// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy / + /// tests existants, qui restent verts sans changement de signature). + session_factory: Option>, + /// Registre des sessions structurées vivantes (§17.5), jumeau de + /// [`TerminalSessions`]. Peuplé quand un agent IA structuré est lancé. `None` en + /// même temps que [`Self::session_factory`]. + structured: Option>, } impl LaunchAgent { @@ -661,9 +749,30 @@ impl LaunchAgent { ids, recall, embedder_suggestion, + session_factory: None, + structured: None, } } + /// Branche le **routage structuré (§17.4)** sur ce launcher : fournit la fabrique + /// [`AgentSessionFactory`] et le registre [`StructuredSessions`] injectés au + /// composition root. Sans cet appel (cas legacy / tests existants), `execute` + /// route **tout** vers le PTY — signature de [`Self::new`] **inchangée**, donc + /// aucun appelant existant ne casse. + /// + /// Builder (consomme `self`, retourne `Self`) pour rester additif : le câblage + /// fait `LaunchAgent::new(...).with_structured(factory, registry)`. + #[must_use] + pub fn with_structured( + mut self, + session_factory: Arc, + structured: Arc, + ) -> Self { + self.session_factory = Some(session_factory); + self.structured = Some(structured); + self + } + /// Resolves the Markdown bodies of an agent's assigned skills, in the /// **manifest order** (deterministic). A skill that no longer exists in its /// store (deleted out from under the assignment) is silently skipped — a @@ -760,12 +869,18 @@ impl LaunchAgent { // - without a requested node → idempotent background/no-op launch: // return the existing session without respawning. // The resume path (agent dead ⇒ no live session) is unaffected. + // + // Invariant « 1 session vivante/agent » **généralisé aux deux registres** + // (§17.4) : un agent est vivant s'il a une session PTY *ou* structurée. + // On consulte d'abord le registre PTY (chemin historique), puis le + // registre structuré (le cas échéant). if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) { if let Some(node_id) = input.node_id { if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) { return Ok(LaunchAgentOutput { session, assigned_conversation_id: None, + structured: None, }); } } @@ -775,6 +890,31 @@ impl LaunchAgent { return Ok(LaunchAgentOutput { session, assigned_conversation_id: None, + structured: None, + }); + } + } + // Garde structurée (§17.4) : même sémantique côté registre IA. Rebind de la + // cellule-vue si un node est demandé, sinon idempotence (pas de redémarrage). + if let Some(structured) = &self.structured { + if let Some(existing) = structured.session_for_agent(&input.agent_id) { + // Cellule-vue : le node demandé, sinon le node hôte courant, sinon un + // node neuf (rebind sans redémarrer le process, « la cellule est une + // vue »). + let node_id = input.node_id.or_else(|| structured.node_for_agent(&input.agent_id)); + if let Some(node_id) = node_id { + let _ = structured.rebind_agent_node(&input.agent_id, node_id); + } + let node_id = node_id.unwrap_or_else(NodeId::new_random); + return Ok(LaunchAgentOutput { + session: structured_snapshot(&existing, input.agent_id, node_id, size), + assigned_conversation_id: None, + structured: Some(StructuredSessionDescriptor { + session_id: existing.id(), + agent_id: input.agent_id, + node_id, + conversation_id: existing.conversation_id(), + }), }); } } @@ -861,6 +1001,34 @@ impl LaunchAgent { ) .await?; + // 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ── + // Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le + // run dir (étape 5) ; la CLI structurée le lira à chaque tour + // (incarnation « un run par tour »). Si le profil porte un + // `structured_adapter` ET que la fabrique structurée est câblée, on + // démarre une `AgentSession` via le port — **pas** de `pty.spawn` — et on + // l'enregistre dans `StructuredSessions`. Sinon : chemin PTY inchangé. + if let (Some(factory), Some(structured), true) = ( + self.session_factory.as_ref(), + self.structured.as_ref(), + profile.structured_adapter.is_some(), + ) { + return self + .launch_structured( + factory.as_ref(), + structured, + &agent, + &profile, + &prepared, + &run_dir, + &session_plan, + assigned_conversation_id, + input.node_id, + size, + ) + .await; + } + // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere. let handle = self.pty.spawn(spec.clone(), size).await?; let session_id = handle.session_id; @@ -889,6 +1057,67 @@ impl LaunchAgent { Ok(LaunchAgentOutput { session, assigned_conversation_id, + structured: None, + }) + } + + /// Démarre un agent IA **structuré** (§17.4) : crée une [`AgentSession`] via la + /// fabrique, l'enregistre dans [`StructuredSessions`] avec son `agent_id`/`node_id` + /// et publie [`DomainEvent::AgentLaunched`]. **Aucun `pty.spawn`** — la cellule est + /// de type chat. + /// + /// L'`assigned_conversation_id` calculé en amont (`resolve_session_plan`) est + /// préservé, mais l'id réellement attribué par le moteur (s'il diffère ou s'il + /// apparaît seulement après le démarrage) prime quand il est disponible : c'est + /// `session.conversation_id()` qui fait foi pour la persistance sur la cellule. + #[allow(clippy::too_many_arguments)] + async fn launch_structured( + &self, + factory: &dyn AgentSessionFactory, + structured: &Arc, + agent: &Agent, + profile: &AgentProfile, + prepared: &PreparedContext, + run_dir: &ProjectPath, + session_plan: &SessionPlan, + assigned_conversation_id: Option, + node_id: Option, + size: PtySize, + ) -> Result { + let session = factory + .start(profile, prepared, run_dir, session_plan) + .await + .map_err(|e| AppError::Process(e.to_string()))?; + + let session_id = session.id(); + let node_id = node_id.unwrap_or_else(NodeId::new_random); + + // Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en + // amont sur les deux registres). + structured.insert(Arc::clone(&session), agent.id, node_id); + + // L'id de conversation à persister : celui que le moteur expose s'il en a un, + // sinon l'id pré-attribué par le plan de session (§15). + let engine_conversation_id = session.conversation_id(); + let conversation_id = engine_conversation_id.clone().or(assigned_conversation_id); + + let snapshot = structured_snapshot(&session, agent.id, node_id, size); + + self.events.publish(DomainEvent::AgentLaunched { + agent_id: agent.id, + session_id, + }); + + Ok(LaunchAgentOutput { + session: snapshot, + // Le caller persiste cet id sur la cellule (pivot de reprise §15.2). + assigned_conversation_id: conversation_id.clone(), + structured: Some(StructuredSessionDescriptor { + session_id, + agent_id: agent.id, + node_id, + conversation_id: engine_conversation_id, + }), }) } @@ -912,8 +1141,17 @@ impl LaunchAgent { profile: &AgentProfile, cell_conversation_id: Option, ) -> (SessionPlan, Option) { - // No session strategy at all: behave exactly as before. + // No session strategy at all: behave exactly as before — **sauf** pour un + // profil **structuré** (§17), dont la reprise ne dépend pas d'un bloc + // `session` mais de l'adapter (Claude `--resume` / Codex `exec resume`). Pour + // un tel profil, une conversation présente sur la cellule ⇒ `Resume`, l'id + // étant capté/attribué par le moteur au premier tour (rien à pré-attribuer). let Some(session) = &profile.session else { + if profile.structured_adapter.is_some() { + if let Some(conversation_id) = cell_conversation_id { + return (SessionPlan::Resume { conversation_id }, None); + } + } return (SessionPlan::None, None); }; @@ -1053,6 +1291,34 @@ pub(crate) fn agent_run_dir( ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}"))) } +/// Construit le snapshot [`TerminalSession`] qui représente une session +/// **structurée** (cellule chat) dans la sortie de [`LaunchAgent`] (§17.4). +/// +/// Le modèle de sortie reste le même que pour le PTY (non cassant pour A/B) : un +/// snapshot `kind = Agent` portant l'id de la session structurée. Le `cwd` n'a pas +/// de sens process ici (la cellule chat n'a pas de PTY) ; on réutilise le run dir +/// au câblage si besoin, mais le snapshot ne porte qu'un placeholder cohérent. +fn structured_snapshot( + session: &Arc, + agent_id: AgentId, + node_id: NodeId, + size: PtySize, +) -> TerminalSession { + // La cellule chat n'a pas de cwd PTY ; un chemin racine neutre suffit au snapshot + // (le run dir réel reste connu de l'adapter). `ProjectPath::new("/")` ne peut pas + // échouer (chemin absolu trivial). + let cwd = ProjectPath::new("/").expect("/ est un ProjectPath absolu valide"); + let mut snapshot = TerminalSession::starting( + session.id(), + node_id, + cwd, + SessionKind::Agent { agent_id }, + size, + ); + snapshot.status = SessionStatus::Running; + snapshot +} + /// Builds the Claude Code permission seed (`.claude/settings.local.json`) written /// into an agent's run dir: full project autonomy (`bypassPermissions` + broad /// Read/Edit/Write/Bash) with the project root granted as an additional working diff --git a/crates/application/src/agent/resume.rs b/crates/application/src/agent/resume.rs index 53d7d54..19ed8e8 100644 --- a/crates/application/src/agent/resume.rs +++ b/crates/application/src/agent/resume.rs @@ -161,13 +161,21 @@ impl ListResumableAgents { }; let name = entry.name.clone(); - // `resume_supported` : le profil de l'agent porte-t-il une - // SessionStrategy exploitable (resume_flag présent) ? + // `resume_supported` : le profil de l'agent sait-il reprendre une + // conversation ? Vrai si : + // - il porte une `SessionStrategy` (TUI/PTY avec `resume_flag`, + // sémantique §15), **ou** + // - il est **structuré** (`structured_adapter`, §17) : l'adapter + // Claude (`--resume`) / Codex (`exec resume`) passe le flag de + // reprise du moteur via `SessionPlan::Resume`, donc la reprise est + // intrinsèquement supportée. 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()); + .is_some_and(|profile| { + profile.session.is_some() || profile.structured_adapter.is_some() + }); resumable.push(ResumableAgent { agent_id, diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs new file mode 100644 index 0000000..22c356b --- /dev/null +++ b/crates/application/tests/structured_launch_d3.rs @@ -0,0 +1,1077 @@ +//! LOT D3 (ARCHITECTURE §17, ligne §17.9 D3) — tests du **chemin structuré** de +//! [`LaunchAgent`] et de la réconciliation A/B, 100 % via des fakes (jamais le vrai +//! claude/codex). +//! +//! Couvre, en injectant une fake [`AgentSessionFactory`] (+ fake [`AgentSession`]) +//! via les builders additifs `with_structured(...)` : +//! +//! 1. **Routage `LaunchAgent`** : un profil porteur d'un `structured_adapter` + une +//! factory câblée ⇒ `factory.start` appelé, session enregistrée dans +//! [`StructuredSessions`] (retrouvable par `session_for_agent`), **aucun** +//! `pty.spawn`, `AgentLaunched` publié, `LaunchAgentOutput.structured = Some(..)` +//! avec les bons `agent_id`/`node_id`/`conversation_id` ; un profil non structuré +//! suit le chemin PTY inchangé ; invariant « 1 session vivante/agent » côté +//! structuré (rebind/idempotence, pas de 2e `start`). +//! 2. **Réconciliation A** (`ChangeAgentProfile`) : session structurée vivante ⇒ +//! `shutdown()` polymorphe (pas un kill PTY) puis relance via la factory ; +//! session PTY vivante ⇒ A1 d'origine (kill PTY) inchangé ; la détection consulte +//! les deux registres. +//! 3. **Réconciliation B** : `resolve_session_plan` renvoie `Resume{conversation_id}` +//! pour un profil structuré dont la cellule porte une conversation (le runtime +//! fake capture le `SessionPlan` reçu). +//! +//! Le fake `AgentSession` enregistre ses `shutdown()` dans un compteur partagé, et +//! le fake `AgentSessionFactory` enregistre chaque `start` (profil + plan de session) +//! — c'est ainsi qu'on prouve « start appelé une seule fois », « shutdown appelé », +//! « le bon SessionPlan transmis ». + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +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, AgentSession, AgentSessionError, AgentSessionFactory, + ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, + IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PreparedContext, + ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, ReplyStream, + RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, +}; +use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; +use domain::project::{Project, ProjectPath}; +use domain::remote::RemoteRef; +use domain::skill::{Skill, SkillScope}; +use domain::{MemoryIndexEntry, NodeId, PtySize, SessionId, SessionKind, SkillId}; +use uuid::Uuid; + +use application::{ + ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput, StructuredSessions, + TerminalSessions, +}; + +// --------------------------------------------------------------------------- +// FakeContexts (AgentContextStore) +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct ContextsInner { + manifest: AgentManifest, + contents: HashMap, +} + +#[derive(Clone)] +struct FakeContexts(Arc>); + +impl FakeContexts { + fn with_agent(agent: &Agent, content: &str) -> Self { + let me = Self(Arc::new(Mutex::new(ContextsInner { + manifest: AgentManifest { + version: 1, + entries: Vec::new(), + }, + contents: HashMap::new(), + }))); + { + 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 + } + fn md_path_of(&self, agent: &AgentId) -> Option { + self.0 + .lock() + .unwrap() + .manifest + .entries + .iter() + .find(|e| &e.agent_id == agent) + .map(|e| e.md_path.clone()) + } + fn profile_of(&self, agent: &AgentId) -> Option { + self.0 + .lock() + .unwrap() + .manifest + .entries + .iter() + .find(|e| &e.agent_id == agent) + .map(|e| e.profile_id) + } +} + +#[async_trait] +impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _project: &Project, + agent: &AgentId, + ) -> Result { + let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + self.0 + .lock() + .unwrap() + .contents + .get(&md_path) + .cloned() + .map(MarkdownDoc::new) + .ok_or(StoreError::NotFound) + } + async fn write_context( + &self, + _project: &Project, + agent: &AgentId, + md: &MarkdownDoc, + ) -> Result<(), StoreError> { + let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + self.0 + .lock() + .unwrap() + .contents + .insert(md_path, md.as_str().to_owned()); + Ok(()) + } + async fn load_manifest(&self, _project: &Project) -> Result { + Ok(self.0.lock().unwrap().manifest.clone()) + } + async fn save_manifest( + &self, + _project: &Project, + manifest: &AgentManifest, + ) -> Result<(), StoreError> { + self.0.lock().unwrap().manifest = manifest.clone(); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FakeProfiles (ProfileStore) +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct FakeProfiles(Arc>); + +impl FakeProfiles { + fn new(profiles: Vec) -> Self { + Self(Arc::new(profiles)) + } +} + +#[async_trait] +impl ProfileStore for FakeProfiles { + async fn list(&self) -> Result, StoreError> { + Ok((*self.0).clone()) + } + async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> { + Ok(()) + } + async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> { + Ok(()) + } + async fn is_configured(&self) -> Result { + Ok(true) + } + async fn mark_configured(&self) -> Result<(), StoreError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FakeStore (ProjectStore) +// --------------------------------------------------------------------------- + +#[derive(Default, Clone)] +struct FakeStore(Arc>>); + +#[async_trait] +impl ProjectStore for FakeStore { + async fn list_projects(&self) -> Result, StoreError> { + Ok(self.0.lock().unwrap().clone()) + } + async fn load_project(&self, id: ProjectId) -> Result { + self.0 + .lock() + .unwrap() + .iter() + .find(|p| p.id == id) + .cloned() + .ok_or(StoreError::NotFound) + } + async fn save_project(&self, project: &Project) -> Result<(), StoreError> { + self.0.lock().unwrap().push(project.clone()); + Ok(()) + } + async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> { + Ok(()) + } + async fn load_workspace(&self) -> Result { + Ok(Workspace::default()) + } +} + +// --------------------------------------------------------------------------- +// FakeFs (FileSystem) — HashMap-backed +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct FakeFsInner { + files: HashMap>, +} + +#[derive(Default, Clone)] +struct FakeFs(Arc>); + +#[async_trait] +impl FileSystem for FakeFs { + async fn read(&self, path: &RemotePath) -> Result, FsError> { + self.0 + .lock() + .unwrap() + .files + .get(path.as_str()) + .cloned() + .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())) + } + async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { + self.0 + .lock() + .unwrap() + .files + .insert(path.as_str().to_owned(), data.to_vec()); + Ok(()) + } + async fn exists(&self, _path: &RemotePath) -> Result { + Ok(false) + } + async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + async fn list(&self, _path: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FakeRuntime (AgentRuntime) — capture le SessionPlan reçu (B) +// --------------------------------------------------------------------------- + +struct FakeRuntime { + last_session: Arc>>, +} + +impl FakeRuntime { + fn new() -> Self { + Self { + last_session: Arc::new(Mutex::new(None)), + } + } + fn session_probe(&self) -> Arc>> { + Arc::clone(&self.last_session) + } +} + +impl AgentRuntime for FakeRuntime { + fn detect(&self, _profile: &AgentProfile) -> Result { + Ok(true) + } + fn prepare_invocation( + &self, + profile: &AgentProfile, + _ctx: &PreparedContext, + cwd: &ProjectPath, + session: &SessionPlan, + ) -> Result { + *self.last_session.lock().unwrap() = Some(session.clone()); + Ok(SpawnSpec { + command: profile.command.clone(), + args: profile.args.clone(), + cwd: cwd.clone(), + env: Vec::new(), + context_plan: Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + }) + } +} + +// --------------------------------------------------------------------------- +// FakePty (PtyPort) — records spawns and kills +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct FakePty { + next_id: SessionId, + spawns: Arc>>, + kills: Arc>>, +} + +impl FakePty { + fn new(next_id: SessionId) -> Self { + Self { + next_id, + spawns: Arc::new(Mutex::new(Vec::new())), + kills: Arc::new(Mutex::new(Vec::new())), + } + } + fn spawn_count(&self) -> usize { + self.spawns.lock().unwrap().len() + } + fn kills(&self) -> Vec { + self.kills.lock().unwrap().clone() + } +} + +#[async_trait] +impl PtyPort for FakePty { + async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result { + self.spawns.lock().unwrap().push(spec); + Ok(PtyHandle { + session_id: self.next_id, + }) + } + fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> { + Ok(()) + } + fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> { + Ok(()) + } + fn subscribe_output(&self, _handle: &PtyHandle) -> Result { + Ok(Box::new(std::iter::empty())) + } + fn scrollback(&self, _handle: &PtyHandle) -> Result, PtyError> { + Ok(Vec::new()) + } + async fn kill(&self, handle: &PtyHandle) -> Result { + self.kills.lock().unwrap().push(handle.session_id); + Ok(ExitStatus { code: Some(0) }) + } +} + +// --------------------------------------------------------------------------- +// FakeSkills / FakeRecall / SpyBus / SeqIds +// --------------------------------------------------------------------------- + +#[derive(Clone, Default)] +struct FakeSkills; + +#[async_trait] +impl SkillStore for FakeSkills { + async fn list(&self, _s: SkillScope, _r: &ProjectPath) -> Result, StoreError> { + Ok(Vec::new()) + } + async fn get( + &self, + _s: SkillScope, + _r: &ProjectPath, + _id: SkillId, + ) -> Result { + Err(StoreError::NotFound) + } + async fn save(&self, _skill: &Skill, _r: &ProjectPath) -> Result<(), StoreError> { + Ok(()) + } + async fn delete( + &self, + _s: SkillScope, + _r: &ProjectPath, + _id: SkillId, + ) -> Result<(), StoreError> { + Ok(()) + } +} + +#[derive(Clone, Default)] +struct FakeRecall; + +#[async_trait] +impl MemoryRecall for FakeRecall { + async fn recall( + &self, + _root: &ProjectPath, + _query: &MemoryQuery, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } +} + +#[derive(Default, Clone)] +struct SpyBus(Arc>>); +impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} +impl EventBus for SpyBus { + fn publish(&self, event: DomainEvent) { + self.0.lock().unwrap().push(event); + } + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +struct SeqIds(Mutex); +impl SeqIds { + fn new() -> Self { + Self(Mutex::new(1)) + } +} +impl IdGenerator for SeqIds { + fn new_uuid(&self) -> Uuid { + let mut n = self.0.lock().unwrap(); + let id = Uuid::from_u128(*n); + *n += 1; + id + } +} + +// --------------------------------------------------------------------------- +// FakeAgentSession + FakeAgentSessionFactory (le cœur du chemin structuré) +// --------------------------------------------------------------------------- + +/// Session structurée fake : id + conversation id figés, `shutdown()` enregistré +/// dans un compteur partagé (preuve du kill polymorphe), `send()` borné minimal. +struct FakeAgentSession { + id: SessionId, + conversation_id: Option, + shutdowns: Arc, +} + +#[async_trait] +impl AgentSession for FakeAgentSession { + fn id(&self) -> SessionId { + self.id + } + fn conversation_id(&self) -> Option { + self.conversation_id.clone() + } + async fn send(&self, prompt: &str) -> Result { + let stream: ReplyStream = Box::new( + vec![ReplyEvent::Final { + content: prompt.to_owned(), + }] + .into_iter(), + ); + Ok(stream) + } + async fn shutdown(&self) -> Result<(), AgentSessionError> { + self.shutdowns.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +/// Fabrique fake : enregistre chaque `start` (profil + SessionPlan reçus) et rend une +/// [`FakeAgentSession`] avec l'id/conversation configurés. Partage le compteur de +/// `shutdown` avec les sessions qu'elle crée, pour prouver le kill polymorphe. +#[derive(Clone)] +struct FakeFactory { + /// Id de session attribué aux sessions créées (incrémenté à chaque start). + next_id: Arc>, + /// L'id de conversation moteur que la session exposera (`None` = neuf). + conversation_id: Option, + /// Trace des `(command, SessionPlan)` reçus par `start`. + starts: Arc>>, + /// Compteur de `shutdown()` partagé avec les sessions créées. + shutdowns: Arc, +} + +impl FakeFactory { + fn new(first_id: u128, conversation_id: Option<&str>) -> Self { + Self { + next_id: Arc::new(Mutex::new(first_id)), + conversation_id: conversation_id.map(str::to_owned), + starts: Arc::new(Mutex::new(Vec::new())), + shutdowns: Arc::new(AtomicUsize::new(0)), + } + } + fn start_count(&self) -> usize { + self.starts.lock().unwrap().len() + } + fn starts(&self) -> Vec<(String, SessionPlan)> { + self.starts.lock().unwrap().clone() + } + fn shutdown_count(&self) -> usize { + self.shutdowns.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl AgentSessionFactory for FakeFactory { + fn supports(&self, profile: &AgentProfile) -> bool { + profile.structured_adapter.is_some() + } + async fn start( + &self, + profile: &AgentProfile, + _ctx: &PreparedContext, + _cwd: &ProjectPath, + session: &SessionPlan, + ) -> Result, AgentSessionError> { + self.starts + .lock() + .unwrap() + .push((profile.command.clone(), session.clone())); + let id = { + let mut n = self.next_id.lock().unwrap(); + let id = SessionId::from_uuid(Uuid::from_u128(*n)); + *n += 1; + id + }; + Ok(Arc::new(FakeAgentSession { + id, + conversation_id: self.conversation_id.clone(), + shutdowns: Arc::clone(&self.shutdowns), + })) + } +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +const ROOT: &str = "/home/me/proj"; + +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 project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1000)), + "demo", + ProjectPath::new(ROOT).unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +/// Profil **structuré** (porte un `structured_adapter`), convention file CLAUDE.md. +fn structured_profile(id: ProfileId) -> AgentProfile { + AgentProfile::new( + id, + "Claude Structuré", + "claude", + Vec::new(), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some("claude --version".to_owned()), + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::Claude) +} + +/// Profil **non structuré** (chemin PTY), convention file CLAUDE.md. +fn pty_profile(id: ProfileId) -> AgentProfile { + AgentProfile::new( + id, + "Claude PTY", + "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() +} + +fn launch_input(agent_id: AgentId) -> LaunchAgentInput { + LaunchAgentInput { + project: project(), + agent_id, + rows: 24, + cols: 80, + node_id: None, + conversation_id: None, + } +} + +/// Inserts a live PTY agent session into the registry, pinned on `node`. +fn seed_live_pty_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 (LaunchAgent câblé structuré) +// --------------------------------------------------------------------------- + +struct LaunchFixture { + launch: Arc, + agent: Agent, + pty: FakePty, + bus: SpyBus, + sessions: Arc, + structured: Arc, + factory: FakeFactory, + session_probe: Arc>>, +} + +/// Wires a `LaunchAgent.with_structured(...)` for a given profile + factory. +fn launch_fixture(profile: AgentProfile, factory: FakeFactory) -> LaunchFixture { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id); + let contexts = FakeContexts::with_agent(&agent, "# ctx body"); + let profiles = FakeProfiles::new(vec![profile]); + let runtime = FakeRuntime::new(); + let session_probe = runtime.session_probe(); + let fs = FakeFs::default(); + let pty = FakePty::new(sid(777)); + let sessions = Arc::new(TerminalSessions::new()); + let structured = Arc::new(StructuredSessions::new()); + let bus = SpyBus::default(); + let launch = LaunchAgent::new( + Arc::new(contexts), + Arc::new(profiles), + Arc::new(runtime), + Arc::new(fs), + Arc::new(pty.clone()), + Arc::new(FakeSkills), + Arc::clone(&sessions), + Arc::new(bus.clone()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall), + None, + ) + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + LaunchFixture { + launch: Arc::new(launch), + agent, + pty, + bus, + sessions, + structured, + factory, + session_probe, + } +} + +// =========================================================================== +// 1. Routage LaunchAgent : chemin structuré +// =========================================================================== + +/// Profil structuré + factory câblée ⇒ `factory.start` appelé une fois, session +/// enregistrée dans `StructuredSessions`, AUCUN `pty.spawn`, `AgentLaunched` publié, +/// `output.structured = Some(descriptor)` avec les bons champs. +#[tokio::test] +async fn structured_launch_starts_session_registers_no_pty_spawn() { + // Factory : 1re session id = 500, conversation moteur "engine-conv". + let factory = FakeFactory::new(500, Some("engine-conv")); + let f = launch_fixture(structured_profile(pid(9)), factory); + + let mut input = launch_input(f.agent.id); + input.node_id = Some(nid(3)); + let out = f.launch.execute(input).await.expect("structured launch"); + + // factory.start appelé exactement une fois. + assert_eq!(f.factory.start_count(), 1, "factory.start called once"); + // AUCUN spawn PTY. + assert_eq!(f.pty.spawn_count(), 0, "no pty spawn on structured path"); + + // La session est enregistrée dans le registre structuré, retrouvable par agent. + let registered = f + .structured + .session_for_agent(&f.agent.id) + .expect("structured session registered"); + assert_eq!(registered.id(), sid(500), "session id is the factory's"); + assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(nid(3))); + // Rien côté registre PTY. + assert!(f.sessions.session_for_agent(&f.agent.id).is_none()); + + // AgentLaunched publié avec l'id de session structurée. + assert_eq!( + f.bus.events(), + vec![DomainEvent::AgentLaunched { + agent_id: f.agent.id, + session_id: sid(500), + }] + ); + + // output.structured renseigné avec les bons agent/node/conversation. + let desc = out.structured.expect("structured descriptor present"); + assert_eq!(desc.session_id, sid(500)); + assert_eq!(desc.agent_id, f.agent.id); + assert_eq!(desc.node_id, nid(3)); + assert_eq!(desc.conversation_id.as_deref(), Some("engine-conv")); + + // Le snapshot de session reste cohérent (kind = Agent, id = celui de la session). + assert_eq!(out.session.id, sid(500)); + assert!(matches!( + out.session.kind, + SessionKind::Agent { agent_id } if agent_id == f.agent.id + )); + // L'id de conversation moteur est exposé pour persistance sur la cellule. + assert_eq!(out.assigned_conversation_id.as_deref(), Some("engine-conv")); +} + +/// Profil **non structuré** (même câblage structuré présent) ⇒ chemin PTY inchangé : +/// `pty.spawn` appelé, factory jamais sollicitée, `output.structured = None`. +#[tokio::test] +async fn non_structured_profile_takes_pty_path_unchanged() { + let factory = FakeFactory::new(500, Some("engine-conv")); + let f = launch_fixture(pty_profile(pid(9)), factory); + + let out = f + .launch + .execute(launch_input(f.agent.id)) + .await + .expect("pty launch"); + + // PTY spawn appelé, factory jamais sollicitée. + assert_eq!(f.pty.spawn_count(), 1, "pty path spawns"); + assert_eq!(f.factory.start_count(), 0, "factory not called for pty profile"); + + // Session côté registre PTY, rien côté structuré. + assert_eq!(f.sessions.session_for_agent(&f.agent.id), Some(sid(777))); + assert!(f.structured.session_for_agent(&f.agent.id).is_none()); + + // output.structured = None ; session PTY classique. + assert!(out.structured.is_none(), "no structured descriptor on pty path"); + assert_eq!(out.session.id, sid(777)); +} + +/// Invariant « 1 session vivante/agent » côté structuré : relancer un agent +/// structuré déjà vivant ⇒ rebind/idempotent (pas de 2e `factory.start`). +#[tokio::test] +async fn structured_relaunch_is_idempotent_no_second_start() { + let factory = FakeFactory::new(500, Some("engine-conv")); + let f = launch_fixture(structured_profile(pid(9)), factory); + + // 1er lancement sur la cellule A. + let mut first = launch_input(f.agent.id); + first.node_id = Some(nid(1)); + f.launch.execute(first).await.expect("first launch"); + assert_eq!(f.factory.start_count(), 1); + assert_eq!(f.structured.len(), 1); + + // Relance dans une cellule B : rebind de la vue, PAS de 2e start. + let mut second = launch_input(f.agent.id); + second.node_id = Some(nid(2)); + let out = f.launch.execute(second).await.expect("relaunch"); + + assert_eq!( + f.factory.start_count(), + 1, + "no second factory.start on relaunch of a live structured agent" + ); + assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn"); + assert_eq!(f.structured.len(), 1, "still a single live structured session"); + // La vue est rebindée sur la cellule B. + assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(nid(2))); + let desc = out.structured.expect("descriptor on rebind"); + assert_eq!(desc.session_id, sid(500), "same live session id"); + assert_eq!(desc.node_id, nid(2)); +} + +// =========================================================================== +// 2. Réconciliation A : ChangeAgentProfile (kill polymorphe) +// =========================================================================== + +/// Câble un `ChangeAgentProfile.with_structured(...)` partageant les mêmes registres +/// (PTY + structuré) et la même factory que le `LaunchAgent` composé. +struct SwapFixture { + swap: ChangeAgentProfile, + contexts: FakeContexts, + pty: FakePty, + sessions: Arc, + structured: Arc, + factory: FakeFactory, +} + +/// Wires the swap use case. Both `pid(1)` (current) and `pid(2)` (target) are known; +/// `pid(2)` is structured so a relaunch routes through the factory. +fn swap_fixture(agent: &Agent, target_structured: bool, factory: FakeFactory) -> SwapFixture { + let target = if target_structured { + structured_profile(pid(2)) + } else { + pty_profile(pid(2)) + }; + let profiles_vec = vec![structured_profile(pid(1)), target]; + + let contexts = FakeContexts::with_agent(agent, "# persona"); + let profiles = FakeProfiles::new(profiles_vec); + let store = FakeStore::default(); + let fs = FakeFs::default(); + let pty = FakePty::new(sid(777)); + let sessions = Arc::new(TerminalSessions::new()); + let structured = Arc::new(StructuredSessions::new()); + let bus = SpyBus::default(); + + // Register the project so ProjectStore::load_project resolves (for conv cleanup). + { + let mut v = store.0.lock().unwrap(); + v.push(project()); + } + + let launch = LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::new(profiles.clone()), + Arc::new(FakeRuntime::new()), + 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, + ) + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + + let swap = ChangeAgentProfile::new( + Arc::new(contexts.clone()), + Arc::new(profiles), + Arc::new(store), + Arc::new(fs), + Arc::clone(&sessions), + Arc::new(pty.clone()), + Arc::new(launch), + Arc::new(bus), + ) + .with_structured(Arc::clone(&structured)); + + SwapFixture { + swap, + contexts, + pty, + sessions, + structured, + factory, + } +} + +fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileInput { + ChangeAgentProfileInput { + project: project(), + agent_id, + profile_id, + rows: 24, + cols: 80, + } +} + +/// Agent avec session **structurée** vivante ⇒ changement de profil ⇒ `shutdown()` +/// appelé sur la session structurée (PAS un kill PTY), puis relance via la factory. +#[tokio::test] +async fn swap_structured_live_session_shuts_down_then_relaunches() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + // La factory crée la session de relance (id 600) ; la session vivante initiale + // partage le même compteur de shutdown via la factory. + let factory = FakeFactory::new(600, Some("relaunch-conv")); + let f = swap_fixture(&agent, true, factory); + + // Pré-seed : une session structurée vivante (id 500) sur la cellule N, via la + // MÊME factory pour partager le compteur de shutdown. + let host = nid(5); + { + // Démarre une session "manuellement" pour la pré-seeder dans le registre. + let profile = structured_profile(pid(1)); + let ctx = PreparedContext { + content: MarkdownDoc::new("# persona"), + relative_path: "agents/backend.md".to_owned(), + }; + let cwd = ProjectPath::new(ROOT).unwrap(); + let session = f + .factory + .start(&profile, &ctx, &cwd, &SessionPlan::None) + .await + .expect("seed structured session"); + f.structured.insert(session, agent.id, host); + } + // La factory a maintenant été appelée 1 fois (le seed) ; reset logique : on + // comptera les start APRÈS, donc on mémorise la base. + let starts_before = f.factory.start_count(); + assert_eq!(starts_before, 1, "seed used one start"); + assert_eq!(f.structured.len(), 1); + + let out = f + .swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("hot swap succeeds"); + + // shutdown() appelé sur la session structurée (kill polymorphe), PAS de kill PTY. + assert_eq!( + f.factory.shutdown_count(), + 1, + "structured session shut down exactly once" + ); + assert!( + f.pty.kills().is_empty(), + "no PTY kill for a structured live session" + ); + assert_eq!(f.pty.spawn_count(), 0, "no PTY spawn (target is structured)"); + + // Relance via la factory : un nouveau start (donc total 2). + assert_eq!( + f.factory.start_count(), + 2, + "exactly one relaunch start after the seed" + ); + // La nouvelle session structurée (id 600) est enregistrée sur la même cellule. + // `ChangeAgentProfileOutput` ne surface qu'un snapshot `relaunched` (TerminalSession) + // — on vérifie donc le registre structuré + le snapshot. + // Seed consumed id 600; the relaunch's session is the factory's next id (601). + let relaunched = out.relaunched.expect("a live structured agent is relaunched"); + assert_eq!(relaunched.id, sid(601), "relaunch adopts the new structured id"); + assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell"); + assert_eq!(f.structured.session_id_for_agent(&agent.id), Some(sid(601))); + assert_eq!(f.structured.node_for_agent(&agent.id), Some(host)); + assert_eq!(f.structured.len(), 1, "single live structured session after swap"); + // Manifeste muté vers le nouveau profil. + assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); +} + +/// Agent avec session **PTY** vivante ⇒ comportement A1 d'origine (kill PTY) +/// inchangé (non-régression), même quand le registre structuré est branché. +#[tokio::test] +async fn swap_pty_live_session_keeps_a1_kill_behaviour() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + // Cible PTY (pid(2) non structuré) ⇒ relance par spawn PTY. + let factory = FakeFactory::new(600, None); + let f = swap_fixture(&agent, false, factory); + + // Pré-seed : session PTY vivante (id 42) sur la cellule N. + let host = nid(5); + seed_live_pty_session(&f.sessions, agent.id, host, sid(42)); + + let out = f + .swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("hot swap succeeds"); + + // A1 d'origine : le PTY vivant est tué. + assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed"); + // Aucune session structurée n'a été créée ni shutdown. + assert_eq!(f.factory.start_count(), 0, "no structured start on pty swap"); + assert_eq!(f.factory.shutdown_count(), 0, "no structured shutdown"); + // Relance PTY : un spawn, même cellule. + assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once"); + let relaunched = out.relaunched.expect("a live agent is relaunched"); + assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell"); + assert_eq!(relaunched.id, sid(777)); + // The relaunched session lives in the PTY registry, not the structured one. + assert_eq!(f.sessions.session_for_agent(&agent.id), Some(sid(777))); + assert!(f.structured.session_for_agent(&agent.id).is_none()); + assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); +} + +/// Détection de session vivante : un agent **dead** (aucune session dans aucun des +/// deux registres) ⇒ pas de kill, pas de shutdown, pas de relance ; manifeste muté. +#[tokio::test] +async fn swap_dead_agent_no_kill_no_shutdown_no_relaunch() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let factory = FakeFactory::new(600, Some("c")); + let f = swap_fixture(&agent, true, factory); + + let out = f + .swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds"); + + assert!(out.relaunched.is_none()); + assert!(f.structured.is_empty(), "no structured session created"); + assert!(f.pty.kills().is_empty()); + assert_eq!(f.factory.shutdown_count(), 0); + assert_eq!(f.factory.start_count(), 0, "nothing to relaunch"); + assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); +} + +// =========================================================================== +// 3. Réconciliation B : resolve_session_plan pour un profil structuré +// =========================================================================== + +/// Une cellule structurée portant une conversation ⇒ le plan de session transmis au +/// runtime est `Resume{conversation_id}` (le runtime fake le capture), même sans +/// bloc `session` sur le profil (la reprise structurée dépend de l'adapter). +#[tokio::test] +async fn structured_profile_with_cell_conversation_resolves_to_resume() { + let factory = FakeFactory::new(500, Some("engine-conv")); + let f = launch_fixture(structured_profile(pid(9)), factory); + + let mut input = launch_input(f.agent.id); + input.conversation_id = Some("conv-existing".to_owned()); + f.launch.execute(input).await.expect("launch resumes"); + + // Le runtime (prepare_invocation) a reçu un SessionPlan::Resume avec l'id cellule. + assert_eq!( + *f.session_probe.lock().unwrap(), + Some(SessionPlan::Resume { + conversation_id: "conv-existing".to_owned() + }), + "structured profile with a cell conversation must resume" + ); + // Le plan Resume est aussi celui transmis à la factory.start (preuve bout-en-bout). + let starts = f.factory.starts(); + assert_eq!(starts.len(), 1); + assert_eq!( + starts[0].1, + SessionPlan::Resume { + conversation_id: "conv-existing".to_owned() + } + ); +} + +/// Une cellule structurée **neuve** (pas de conversation) sur un profil sans bloc +/// `session` ⇒ `SessionPlan::None` (rien à reprendre ; l'id moteur sera capté au 1er +/// tour). +#[tokio::test] +async fn structured_profile_fresh_cell_resolves_to_none() { + let factory = FakeFactory::new(500, None); + let f = launch_fixture(structured_profile(pid(9)), factory); + + f.launch + .execute(launch_input(f.agent.id)) + .await + .expect("fresh launch"); + + let starts = f.factory.starts(); + assert_eq!(starts.len(), 1); + assert_eq!( + starts[0].1, + SessionPlan::None, + "fresh structured cell without a session block plans None" + ); +} diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index a358be0..8e329f4 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -142,13 +142,18 @@ impl CodexExecSession { /// Compose la ligne de commande d'un tour. /// /// Format RÉEL vérifié 2026-06-09 : - /// - Conversation neuve : `codex exec --json --skip-git-repo-check `. + /// - Conversation neuve : `codex exec --json --skip-git-repo-check + /// --sandbox workspace-write --ask-for-approval never `. /// - Reprise (id connu) : `codex exec resume --json - /// --skip-git-repo-check `. + /// --skip-git-repo-check --sandbox workspace-write --ask-for-approval never + /// `. /// - /// NOTE intégration (D3, hors parsing) : pour qu'un agent puisse **écrire**, le - /// lancement devra ajouter `--sandbox workspace-write` + une politique - /// d'approbation non bloquante. Ce n'est pas le rôle de cette composition. + /// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à + /// écrire dans son workspace et `--ask-for-approval never` évite tout blocage sur + /// une demande d'approbation interactive (un tour structuré non-interactif ne peut + /// répondre à un prompt). Défaut raisonnable, aligné sur l'autonomie projet + /// (CLAUDE.md §12) ; à terme **piloté par les permissions de l'agent** + /// (`.ideai/permissions.json` + sandbox OS) — non implémenté ici. fn build_spawn_line(&self, prompt: &str) -> SpawnLine { let mut args = vec!["exec".to_owned()]; if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() { @@ -157,6 +162,10 @@ impl CodexExecSession { } args.push("--json".to_owned()); args.push("--skip-git-repo-check".to_owned()); + args.push("--sandbox".to_owned()); + args.push("workspace-write".to_owned()); + args.push("--ask-for-approval".to_owned()); + args.push("never".to_owned()); args.push(prompt.to_owned()); SpawnLine { command: self.command.clone(), diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index d62b4ae..70edf42 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -1127,4 +1127,93 @@ mod tests { let _ = std::fs::remove_file(&cmd); let _ = std::fs::remove_file(&argv); } + + // ===================================================================== + // DURCISSEMENT QA (lot D3, §17.9 D3) — autonomie d'écriture Codex : + // la commande générée porte `--sandbox workspace-write` ET + // `--ask-for-approval never`, conversation NEUVE comme en REPRISE. + // Prouvé via le sidecar argv du fake enregistreur (jamais le vrai codex). + // ===================================================================== + + /// Conversation NEUVE : `codex exec --json --skip-git-repo-check + /// --sandbox workspace-write --ask-for-approval never `. On prouve la + /// présence des deux flags d'autonomie ET leur appariement valeur (sandbox= + /// workspace-write, ask-for-approval=never), sans sous-commande `resume`. + #[tokio::test] + async fn codex_new_conversation_command_carries_autonomy_flags() { + let (cmd, argv) = make_recording_fake(&[ + r#"{"type":"thread.started","thread_id":"cx-new"}"#, + r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#, + ]); + let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None); + let _ = session.send("salut").await.expect("send ok"); + let recorded = std::fs::read_to_string(&argv).expect("argv"); + let args: Vec<&str> = recorded.lines().collect(); + + // Conversation neuve : pas de sous-commande resume. + assert!( + !args.contains(&"resume"), + "1er tour NEUF ne doit PAS porter resume, vu: {args:?}" + ); + // Flags d'autonomie (D3), avec leur valeur appariée. + assert!(args.contains(&"--sandbox"), "vu: {args:?}"); + assert!(args.contains(&"workspace-write"), "vu: {args:?}"); + assert!(args.contains(&"--ask-for-approval"), "vu: {args:?}"); + assert!(args.contains(&"never"), "vu: {args:?}"); + // `--sandbox` est immédiatement suivi de `workspace-write`. + let sb = args.iter().position(|a| *a == "--sandbox").unwrap(); + assert_eq!( + args.get(sb + 1), + Some(&"workspace-write"), + "--sandbox doit être suivi de workspace-write, vu: {args:?}" + ); + // `--ask-for-approval` est immédiatement suivi de `never`. + let af = args.iter().position(|a| *a == "--ask-for-approval").unwrap(); + assert_eq!( + args.get(af + 1), + Some(&"never"), + "--ask-for-approval doit être suivi de never, vu: {args:?}" + ); + // La base + le prompt restent présents. + assert!(args.contains(&"--json") && args.contains(&"--skip-git-repo-check")); + assert!(args.contains(&"salut"), "vu: {args:?}"); + let _ = std::fs::remove_file(&cmd); + let _ = std::fs::remove_file(&argv); + } + + /// REPRISE (seed d'id) : `codex exec resume --json --skip-git-repo-check + /// --sandbox workspace-write --ask-for-approval never `. Les flags + /// d'autonomie sont AUSSI présents lors d'une reprise. + #[tokio::test] + async fn codex_resume_command_carries_autonomy_flags() { + let (cmd, argv) = make_recording_fake(&[ + r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#, + ]); + let session = CodexExecSession::new( + SessionId::new_random(), + cmd.clone(), + "/", + Some("cx-id".to_owned()), + ); + let _ = session.send("vas-y").await.expect("send ok"); + let recorded = std::fs::read_to_string(&argv).expect("argv"); + let args: Vec<&str> = recorded.lines().collect(); + + // Reprise : sous-commande resume + id. + assert!(args.contains(&"resume"), "vu: {args:?}"); + assert!(args.contains(&"cx-id"), "vu: {args:?}"); + // Flags d'autonomie présents même en reprise. + let sb = args + .iter() + .position(|a| *a == "--sandbox") + .expect("--sandbox présent en reprise"); + assert_eq!(args.get(sb + 1), Some(&"workspace-write"), "vu: {args:?}"); + let af = args + .iter() + .position(|a| *a == "--ask-for-approval") + .expect("--ask-for-approval présent en reprise"); + assert_eq!(args.get(af + 1), Some(&"never"), "vu: {args:?}"); + let _ = std::fs::remove_file(&cmd); + let _ = std::fs::remove_file(&argv); + } }