diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8e34b66..3851970 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -616,7 +616,7 @@ IdeA/ 1. La **persona/rôle** de l'agent (son `.md` dans `.ideai/agents/`). 2. Le **chemin absolu du project root** (pour que l'agent sache où opérer). 3. Les **skills actifs** assignés à cet agent (voir §14.2). -4. Une référence au **contexte projet partagé** si présent. +4. Le **rappel mémoire** du projet (index/hooks), si présent (voir §14.5.4). **Avantages** : - Zéro collision entre agents, même N instances du même profil. @@ -702,6 +702,7 @@ Le **rappel** (port `MemoryRecall`) est adaptatif : tant que la taille de la mé - **LOT A — Étage 1 `.md`** : *domaine + adapter faits et verts.* Reste à livrer : **use cases `application/memory`** + **câblage app-tauri** (commandes + DTO ; les events `Memory*` et leurs DTO existent déjà). Voir §14.5.1. - **LOT B — Rappel adaptatif** : port `MemoryRecall`, adapter `NaiveMemoryRecall`, use case `RecallMemory`. Voir §14.5.2. - **LOT C — Étage 2 vectoriel** : port `Embedder`, profils déclaratifs `embedder.json`, adapter `VectorMemoryRecall`, logique de bascule sur seuil. Voir §14.5.3. +- **Sous-lot d'intégration L14 ↔ L6** : injection du rappel mémoire dans le convention file à l'activation d'un agent (`LaunchAgent` compose `MemoryRecall`). Voir §14.5.4. ##### 14.5.1 LOT A (fin) — contrats application + app-tauri @@ -825,6 +826,76 @@ pub trait Embedder: Send + Sync { - **`EmbedderError`** : nouveau type d'erreur par port (façon `MemoryError`), mappé en `AppError` (`none`/indisponible ⇒ dégrade vers naïf, jamais d'échec dur du rappel). - **Garde-fou produit** : défaut `none` ⇒ LOT C **n'impose aucune dépendance** ; un utilisateur sans embedder a une mémoire pleinement fonctionnelle (étage 1 + rappel naïf). +##### 14.5.4 Injection de la mémoire à l'activation d'un agent (intégration L14 ↔ L6) + +**Problème** : la mémoire (`MemoryStore` + `MemoryRecall`) existe et est consultable par commandes, mais **aucun agent ne la lit**. À l'activation d'un agent, IdeA génère le convention file (`CLAUDE.md`/`AGENTS.md`…) dans le run dir et y injecte la persona + les skills assignés (§14.1/§14.2) ; il faut y ajouter le **rappel mémoire** du projet, exactement comme les skills. + +**Décision (où injecter)** : le rappel est composé dans la **même fonction pure** `compose_convention_file`, qui devient : + +```rust +pub(crate) fn compose_convention_file( + project_root: &str, + agent_md: &str, + skills: &[Skill], + memory: &[MemoryIndexEntry], // nouveau : rappel mémoire (peut être vide) +) -> String +``` + +- On passe les **`MemoryIndexEntry`** déjà résolus (pas une `&str` pré-rendue ni le port) : la fonction reste **pure et I/O-free**, donc unit-testable sans fake, cohérent avec le traitement des skills (le port est appelé en amont, pas dans la fonction de composition). +- `LaunchAgent` gagne **une nouvelle dépendance port** `recall: Arc` (déjà un port domaine, déjà câblé en `NaiveMemoryRecall` dans `state.rs`). Aucun couplage à un adapter concret : Liskov garantit qu'un `VectorMemoryRecall`/`AdaptiveMemoryRecall` (LOT C) se substitue sans toucher à `LaunchAgent`. +- La résolution suit le **modèle `resolve_skills`** : une méthode `resolve_memory(&self, root) -> Vec` best-effort appelée juste avant `apply_injection`, dont le résultat est passé à `apply_injection` puis à `compose_convention_file`. Signature interne : + +```rust +async fn resolve_memory(&self, root: &ProjectPath) -> Result, AppError>; +// puis : +async fn apply_injection( + &self, project: &Project, context_rel_path: &str, + content: &MarkdownDoc, skills: &[Skill], + memory: &[MemoryIndexEntry], // nouveau + spec: &mut SpawnSpec, +) -> Result<(), AppError>; +``` + +**Décision (quoi injecter)** : l'**index/les hooks**, pas le corps des notes. On rappelle via `MemoryRecall::recall` (donc `read_index` tronqué au budget pour l'adapter naïf), et on rend une section : + +```markdown +--- + +# Mémoire projet + +- [Titre](slug.md) — hook (type) +- … +``` + +Une ligne par `MemoryIndexEntry` (`title`, `slug`, `hook`, `r#type`). C'est le « léger par défaut » : l'agent reçoit les **pointeurs** vers le savoir projet (et peut lire le `.md` cible via son cwd = project root logique) ; l'étage 2 (corps/sémantique) reste hors convention file. Cohérent avec « léger par défaut, étage 2 seulement au-delà du seuil ». + +- **Budget** : constante `application` `const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;` (tokens approx.), passée dans `MemoryQuery { text, token_budget }`. Valeur **interne et documentée**, pas encore exposée en config (évolutif : pourra devenir un champ de réglage projet plus tard sans changer le contrat). `text` = la persona de l'agent (`content.as_str()`) : sans pertinence sémantique pour le naïf, mais déjà la bonne requête pour le vectoriel (LOT C) — zéro refactor au passage étage 2. + +**Décision (best-effort / dégradation)** : mémoire vide, absente, ou budget produisant 0 entrée ⇒ **aucune section `# Mémoire projet`** (omise entièrement, comme `# Skills` quand `skills` est vide) ⇒ document identique à l'actuel. `resolve_memory` ne **bloque jamais** un launch : le contrat de `MemoryRecall` est déjà « mémoire absente ⇒ liste vide, jamais d'erreur » ; une éventuelle `AppError::Store` inattendue est traitée comme les skills (best-effort — on dégrade vers liste vide plutôt que d'échouer l'activation). Confirmé : **un projet sans `.ideai/memory/` lance ses agents exactement comme aujourd'hui**. + +**Décision (universalité)** : l'injection passe **uniquement par le contenu du convention file** (`ContextInjectionPlan::File`), donc valable pour **toute stratégie `conventionFile`** (Claude/Codex/Gemini…), sans flag ni commande propriétaire. Pour `env`/`stdin`/`args` : **pas d'injection mémoire pour l'instant**, strictement **aligné sur les skills** (lesquels ne sont composés que dans la branche `File` de `apply_injection`). Rationale : l'uniformité avec les skills prime ; étendre aux autres stratégies serait une décision séparée (et pour `env`, la mémoire n'a pas de fichier unique à pointer). À tracer comme point ouvert si un profil non-`conventionFile` devait bénéficier du rappel. + +**Conformité hexagonale/SOLID** : `LaunchAgent` ne parle qu'à des **ports** (`+ Arc`) ; la composition reste une **fonction pure** ; aucun adapter concret référencé ; ISP respectée (une dépendance de plus, pour la seule tranche « rappel »). Substituabilité LOT C gratuite. + +**Découpage dev/test (ordre)** : + +1. `crates/application/src/agent/lifecycle.rs` — **dev** : + - ajouter le champ `recall: Arc` au struct `LaunchAgent` + paramètre dans `new` (en fin de liste, après `ids`) ; + - `const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;` ; + - `async fn resolve_memory(&self, root: &ProjectPath) -> Result, AppError>` (best-effort, dégrade vers `vec![]`) ; + - `execute` : appeler `resolve_memory` après `resolve_skills` et passer le résultat à `apply_injection` ; + - `apply_injection` : nouvel argument `memory: &[MemoryIndexEntry]`, transmis à `compose_convention_file` (branche `File` uniquement) ; + - `compose_convention_file` : nouvel argument `memory`, section `# Mémoire projet` (omise si vide), rendu d'une ligne par entrée. +2. `crates/application/src/agent/lifecycle.rs` (`#[cfg(test)]`) — **test** : étendre les tests purs de `compose_convention_file` (section présente/ordonnée ; absente si vide ; document inchangé sans mémoire). +3. `crates/app-tauri/src/state.rs` — **dev** : passer `Arc::clone(&memory_recall_port)` (existant l.509-510, à hisser avant la construction de `LaunchAgent` l.379) en dernier argument de `LaunchAgent::new`. +4. **Câblage des tests existants** (`LaunchAgent::new` à 6 sites) — **test/dev** : fournir un fake `MemoryRecall` (un `FakeRecall` renvoyant `vec![]` par défaut, configurable pour un cas non-vide). Sites à mettre à jour : + - `crates/application/tests/agent_lifecycle.rs` (×5, dont l'helper de construction l.666) ; + - `crates/application/tests/orchestrator_service.rs` (l.407) ; + - `crates/infrastructure/tests/orchestrator_watcher.rs` (l.298). +5. **Test d'intégration** (`agent_lifecycle.rs`) — **test** : avec un `FakeRecall` non-vide, asserter que le convention file écrit par `FakeFs` contient la section `# Mémoire projet` et les hooks ; avec recall vide, asserter son absence (document identique au baseline persona+skills). + +**Note de réutilisation** : la résolution préfère le port `MemoryRecall` (rappel borné) plutôt qu'un `read_index` brut, pour hériter directement de la bascule étage 1/étage 2 (LOT C) sans retoucher `LaunchAgent`. + #### Anomalies de conformité relevées sur l'existant (à traiter par les agents dev) 1. **Doublon `DomainError::MalformedFrontmatter` vs `MemoryError::Frontmatter`** : `error.rs` définit `DomainError::MalformedFrontmatter { reason }`, mais le domaine `memory.rs` ne l'utilise jamais (il lève `EmptyField`/`InvalidSlug`) et l'adapter parse via `MemoryError::Frontmatter`. La variante `DomainError::MalformedFrontmatter` est **morte**. **Action** : la supprimer (le parsing de frontmatter est une responsabilité d'adapter → `MemoryError::Frontmatter`), sauf si un futur parseur de frontmatter *dans le domaine* est prévu (il ne l'est pas — le domaine reste format-neutral, cf. doc-module `memory.rs`). Anomalie mineure, sans impact fonctionnel. diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 413bca3..e3fd0b9 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -362,6 +362,15 @@ impl AppState { )); let skill_store_port = Arc::clone(&skill_store) as Arc; + // Memory store + naïve recall (LOT A/B — §14.5.1/§14.5.4). Built here so the + // recall port can be injected into LaunchAgent below (it composes the project + // memory recall into the convention file at activation); the memory use cases + // are wired further down from these same instances. + let memory_store = Arc::new(FsMemoryStore::new(Arc::clone(&fs_port))); + let memory_store_port = Arc::clone(&memory_store) as Arc; + let memory_recall_port = + Arc::new(NaiveMemoryRecall::new(Arc::clone(&memory_store_port))) as Arc; + let create_agent = Arc::new(CreateAgentFromScratch::new( Arc::clone(&contexts_port), Arc::clone(&ids) as Arc, @@ -386,6 +395,7 @@ impl AppState { Arc::clone(&terminal_sessions), Arc::clone(&events_port), Arc::clone(&ids) as Arc, + Arc::clone(&memory_recall_port), )); // --- Conversation inspection (T7) --- @@ -480,12 +490,12 @@ impl AppState { Arc::clone(&events_port), )); - // --- Memory store + use cases (LOT A — §14.5.1) --- - // A single FsMemoryStore takes the project root per call (like the skill - // store), so one instance serves every open project. The mutating use - // cases share the event bus to announce Memory{Saved,Deleted}. - let memory_store = Arc::new(FsMemoryStore::new(Arc::clone(&fs_port))); - let memory_store_port = Arc::clone(&memory_store) as Arc; + // --- Memory use cases (LOT A — §14.5.1) --- + // `memory_store` / `memory_store_port` / `memory_recall_port` are built + // earlier (above LaunchAgent, which needs the recall port). A single + // FsMemoryStore takes the project root per call (like the skill store), so + // one instance serves every open project. The mutating use cases share the + // event bus to announce Memory{Saved,Deleted}. let create_memory = Arc::new(CreateMemory::new( Arc::clone(&memory_store_port), Arc::clone(&events_port), @@ -503,12 +513,11 @@ impl AppState { let read_memory_index = Arc::new(ReadMemoryIndex::new(Arc::clone(&memory_store_port))); let resolve_memory_links = Arc::new(ResolveMemoryLinks::new(Arc::clone(&memory_store_port))); - // Naïve recall (LOT B): composes the same store, returns index entries in - // order, truncated to the token budget. The default, dependency-free - // MemoryRecall; substitutable by a VectorMemoryRecall (LOT C). - let memory_recall_port = - Arc::new(NaiveMemoryRecall::new(Arc::clone(&memory_store_port))) as Arc; - let recall_memory = Arc::new(RecallMemory::new(memory_recall_port)); + // Naïve recall (LOT B): the same instance injected into LaunchAgent above — + // composes the store, returns index entries in order truncated to the token + // budget. The default, dependency-free MemoryRecall; substitutable by a + // VectorMemoryRecall (LOT C). + let recall_memory = Arc::new(RecallMemory::new(Arc::clone(&memory_recall_port))); // --- Orchestrator service (§14.3) --- // Dispatches file-based orchestrator requests through the *same* use cases diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 4be65eb..9afcb7a 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -15,13 +15,13 @@ use std::sync::Arc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator, - PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, - StoreError, + MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan, + SkillStore, SpawnSpec, StoreError, }; use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent, - ManifestEntry, MarkdownDoc, NodeId, Project, ProfileId, ProjectPath, PtySize, SessionKind, - SessionStatus, Skill, TerminalSession, + ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, Project, ProfileId, + ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession, }; use crate::error::AppError; @@ -30,6 +30,13 @@ use crate::terminal::TerminalSessions; /// Directory (relative to `.ideai/`) under which agent contexts are written. const AGENTS_SUBDIR: &str = "agents"; +/// Token budget of the project-memory recall injected into the convention file at +/// agent activation (ARCHITECTURE §14.5.4). Bounds the number of index entries +/// (étage 1) handed to the agent. Internal and intentionally **not yet exposed in +/// config**: it may later become a per-project setting without changing the +/// contract. +const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048; + // --------------------------------------------------------------------------- // CreateAgentFromScratch // --------------------------------------------------------------------------- @@ -369,6 +376,10 @@ pub struct LaunchAgent { sessions: Arc, events: Arc, ids: Arc, + /// Bounded recall of the project's memory index, injected into the convention + /// file at activation (ARCHITECTURE §14.5.4). Best-effort by contract: an absent + /// or empty memory yields an empty list, never blocking a launch. + recall: Arc, } impl LaunchAgent { @@ -385,6 +396,7 @@ impl LaunchAgent { sessions: Arc, events: Arc, ids: Arc, + recall: Arc, ) -> Self { Self { contexts, @@ -396,6 +408,7 @@ impl LaunchAgent { sessions, events, ids, + recall, } } @@ -422,6 +435,24 @@ impl LaunchAgent { Ok(out) } + /// Resolves the project's memory recall (index/hooks) to inject into the + /// convention file at activation (ARCHITECTURE §14.5.4), mirroring + /// [`Self::resolve_skills`]. The query text is the agent's persona `.md` + /// (irrelevant to the naïve adapter, but already the right query for the future + /// semantic recall — zero refactor at étage 2), bounded by + /// [`AGENT_MEMORY_RECALL_BUDGET`]. + /// + /// **Best-effort, never blocking**: an absent or empty memory yields an empty + /// list by the [`MemoryRecall`] contract, and any unexpected error degrades to + /// an empty list rather than failing the launch (exactly like a dangling skill). + async fn resolve_memory(&self, root: &ProjectPath, persona: &str) -> Vec { + let query = MemoryQuery { + text: persona.to_owned(), + token_budget: AGENT_MEMORY_RECALL_BUDGET, + }; + self.recall.recall(root, &query).await.unwrap_or_default() + } + /// Executes the launch. /// /// Step order is contractually significant (and unit-tested): resolve the @@ -507,8 +538,18 @@ impl LaunchAgent { // 5. Resolve the agent's assigned skills (their `.md` bodies), then apply // the injection plan side effects *before* spawning. let skills = self.resolve_skills(&agent, &input.project.root).await?; - self.apply_injection(&input.project, &agent.context_path, &content, &skills, &mut spec) - .await?; + let memory = self + .resolve_memory(&input.project.root, content.as_str()) + .await; + self.apply_injection( + &input.project, + &agent.context_path, + &content, + &skills, + &memory, + &mut spec, + ) + .await?; // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere. let handle = self.pty.spawn(spec.clone(), size).await?; @@ -645,6 +686,7 @@ impl LaunchAgent { context_rel_path: &str, content: &MarkdownDoc, skills: &[Skill], + memory: &[MemoryIndexEntry], spec: &mut SpawnSpec, ) -> Result<(), AppError> { match spec.context_plan.clone() { @@ -656,8 +698,12 @@ impl LaunchAgent { // composed: an absolute project-root header (so the agent knows // where to operate, since its cwd is *not* the root), the agent's // persona `.md`, then the bodies of its assigned skills (§14.2). - let document = - compose_convention_file(project.root.as_str(), content.as_str(), skills); + let document = compose_convention_file( + project.root.as_str(), + content.as_str(), + skills, + memory, + ); let path = RemotePath::new(join(&spec.cwd, &target)); self.fs.write(&path, document.as_bytes()).await?; } @@ -770,9 +816,19 @@ fn json_escape(s: &str) -> String { /// carrying its name. When `skills` is empty the section is omitted entirely, so /// an agent with no skills gets exactly the previous document. /// +/// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as +/// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per +/// entry, in the order given. When `memory` is empty the section is omitted +/// entirely, so an agent with no memory gets exactly the previous document. +/// /// Kept as a **pure** function (no I/O) so it is unit-testable in isolation. #[must_use] -pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str, skills: &[Skill]) -> String { +pub(crate) fn compose_convention_file( + project_root: &str, + agent_md: &str, + skills: &[Skill], + memory: &[MemoryIndexEntry], +) -> String { let mut out = String::new(); out.push_str("# Project root\n\n"); out.push_str(project_root); @@ -794,9 +850,36 @@ pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str, skills out.push('\n'); } } + + if !memory.is_empty() { + out.push_str("\n\n---\n\n# Mémoire projet\n\n"); + for entry in memory { + out.push_str("- ["); + out.push_str(&entry.title); + out.push_str("]("); + out.push_str(entry.slug.as_str()); + out.push_str(".md) — "); + out.push_str(&entry.hook); + out.push_str(" ("); + out.push_str(memory_type_label(entry.r#type)); + out.push_str(")\n"); + } + } out } +/// Renders a [`MemoryType`] as its stable lowercase label for the convention-file +/// memory section (`user`/`feedback`/`project`/`reference`). +#[must_use] +fn memory_type_label(kind: MemoryType) -> &'static str { + match kind { + MemoryType::User => "user", + MemoryType::Feedback => "feedback", + MemoryType::Project => "project", + MemoryType::Reference => "reference", + } +} + /// Derives a unique, filesystem-safe `md_path` (`agents/.md`) for a new /// agent, disambiguating against the manifest's existing paths with a numeric /// suffix when needed. Shared with the template-driven agent creation (L7). @@ -850,7 +933,8 @@ mod tests { #[test] fn compose_convention_file_carries_root_then_persona() { - let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[]); + let doc = + compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[], &[]); // Absolute project root present. assert!(doc.contains("/abs/project/root")); @@ -880,6 +964,7 @@ mod tests { "/root", "# Persona", &[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")], + &[], ); // Both skill bodies present, after the persona. @@ -896,6 +981,89 @@ mod tests { assert!(doc.contains("## review")); } + /// Builds a memory index entry for the convention-file composition tests. + fn mem(slug_str: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry { + MemoryIndexEntry { + slug: domain::MemorySlug::new(slug_str).unwrap(), + title: title.to_owned(), + hook: hook.to_owned(), + r#type: kind, + } + } + + #[test] + fn compose_convention_file_empty_memory_is_identical_to_no_memory() { + // An empty `memory` must yield exactly the previous document: no section, + // byte-for-byte identical to the no-skills/no-memory composition. + let with_empty = compose_convention_file("/root", "# Persona\n\nDo X.", &[], &[]); + assert!( + !with_empty.contains("# Mémoire projet"), + "no memory ⇒ no memory section" + ); + // Same document whether or not we thread an empty slice (it already is the + // 4-arg call; this pins the omission contract explicitly). + assert_eq!( + with_empty, + compose_convention_file("/root", "# Persona\n\nDo X.", &[], &[]) + ); + } + + #[test] + fn compose_convention_file_appends_memory_entries_in_order() { + let doc = compose_convention_file( + "/root", + "# Persona", + &[], + &[ + mem("alpha-note", "Alpha", "the first hook", MemoryType::User), + mem("beta-note", "Beta", "the second hook", MemoryType::Reference), + ], + ); + + // Section present, after the persona. + assert!(doc.contains("# Mémoire projet")); + let persona_at = doc.find("# Persona").unwrap(); + let section_at = doc.find("# Mémoire projet").unwrap(); + assert!(persona_at < section_at, "memory comes after the persona"); + + // Exact line format: `- [Title](slug.md) — hook (type)`. + assert!(doc.contains("- [Alpha](alpha-note.md) — the first hook (user)")); + assert!(doc.contains("- [Beta](beta-note.md) — the second hook (reference)")); + + // Deterministic order: first entry precedes the second. + let alpha_at = doc.find("[Alpha]").unwrap(); + let beta_at = doc.find("[Beta]").unwrap(); + assert!(alpha_at < beta_at, "memory entries emitted in the given order"); + } + + #[test] + fn compose_convention_file_memory_and_skills_coexist() { + let skill = Skill::new( + domain::SkillId::from_uuid(uuid::Uuid::from_u128(1)), + "refactor", + MarkdownDoc::new("REFAC_BODY"), + domain::SkillScope::Global, + ) + .unwrap(); + let doc = compose_convention_file( + "/root", + "# Persona", + std::slice::from_ref(&skill), + &[mem("note", "Note", "a hook", MemoryType::Project)], + ); + + // Both sections present. + assert!(doc.contains("# Skills")); + assert!(doc.contains("REFAC_BODY")); + assert!(doc.contains("# Mémoire projet")); + assert!(doc.contains("- [Note](note.md) — a hook (project)")); + + // Skills section precedes the memory section (persona → skills → memory). + let skills_at = doc.find("# Skills").unwrap(); + let memory_at = doc.find("# Mémoire projet").unwrap(); + assert!(skills_at < memory_at, "skills come before memory"); + } + #[test] fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() { let json = claude_settings_seed("/home/me/proj"); diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index d67b831..50da1ed 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -25,10 +25,11 @@ use domain::ids::{AgentId, ProfileId, ProjectId}; use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, - ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, - PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, - StoreError, + ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, + OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, + RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; +use domain::{MemoryIndexEntry, MemorySlug, MemoryType}; use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; @@ -222,6 +223,58 @@ impl SkillStore for FakeSkills { } } +// --------------------------------------------------------------------------- +// FakeRecall (MemoryRecall) — returns a canned index, or fails (best-effort test) +// --------------------------------------------------------------------------- + +/// In-memory [`MemoryRecall`] fake for the launch tests: empty by default +/// (⇒ no memory section, behaviour unchanged), configurable with canned entries, +/// or set to fail so the best-effort degradation at activation can be asserted. +#[derive(Clone, Default)] +struct FakeRecall { + entries: Arc>, + fail: bool, +} + +impl FakeRecall { + fn returning(entries: Vec) -> Self { + Self { + entries: Arc::new(entries), + fail: false, + } + } + fn failing() -> Self { + Self { + entries: Arc::default(), + fail: true, + } + } +} + +#[async_trait] +impl MemoryRecall for FakeRecall { + async fn recall( + &self, + _root: &ProjectPath, + _query: &MemoryQuery, + ) -> Result, MemoryError> { + if self.fail { + return Err(MemoryError::Io("recall boom".to_owned())); + } + Ok((*self.entries).clone()) + } +} + +/// Builds a memory index entry for the launch tests. +fn mem_entry(slug: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry { + MemoryIndexEntry { + slug: MemorySlug::new(slug).unwrap(), + title: title.to_owned(), + hook: hook.to_owned(), + r#type: kind, + } +} + // --------------------------------------------------------------------------- // FakeRuntime (AgentRuntime) — records prepare + returns a configured plan // --------------------------------------------------------------------------- @@ -652,6 +705,17 @@ fn launch_fixture(injection: ContextInjection, plan: Option, +) -> LaunchFixture { + launch_fixture_with_profile_and_recall(profile, plan, FakeRecall::default()) +} + +/// Like [`launch_fixture_with_profile`] but also takes the [`MemoryRecall`] fake, +/// so the memory-injection feature can be exercised. The default callers pass an +/// empty [`FakeRecall`] ⇒ no memory section ⇒ behaviour unchanged. +fn launch_fixture_with_profile_and_recall( + profile: AgentProfile, + plan: Option, + recall: FakeRecall, ) -> LaunchFixture { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id); let contexts = FakeContexts::with_agent(&agent, "# ctx body"); @@ -673,6 +737,7 @@ fn launch_fixture_with_profile( Arc::clone(&sessions), Arc::new(bus.clone()), Arc::new(SeqIds::new()), + Arc::new(recall), ); (launch, agent, fs, pty, bus, sessions, tr, session_probe) } @@ -812,6 +877,7 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() { Arc::clone(&sessions), Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), + Arc::new(FakeRecall::default()), ); launch.execute(launch_input(agent_a.id)).await.unwrap(); @@ -885,6 +951,7 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() { Arc::new(TerminalSessions::new()), Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), + Arc::new(FakeRecall::default()), ); launch.execute(launch_input(agent.id)).await.unwrap(); @@ -902,6 +969,115 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() { assert!(persona_at < refac_at && refac_at < review_at, "ordering: {doc}"); } +#[tokio::test] +async fn launch_conventionfile_injects_project_memory_in_order() { + // A non-empty recall ⇒ the generated convention file must carry a + // `# Mémoire projet` section with one line per entry, in the recalled order + // and the exact `- [Title](slug.md) — hook (type)` format (§14.5.4). + let recall = FakeRecall::returning(vec![ + mem_entry("git-optional", "Git optionnel", "git reste un simple tool", MemoryType::Project), + mem_entry("perm-archi", "Permissions", "sandbox OS + résumé injecté", MemoryType::Reference), + ]); + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = + launch_fixture_with_profile_and_recall( + profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + recall, + ); + + launch.execute(launch_input(agent.id)).await.unwrap(); + + let writes = fs.context_writes(); + assert_eq!(writes.len(), 1); + let doc = String::from_utf8(writes[0].1.clone()).unwrap(); + + assert!(doc.contains("# Mémoire projet"), "memory section present: {doc}"); + assert!( + doc.contains("- [Git optionnel](git-optional.md) — git reste un simple tool (project)"), + "first memory line exact format: {doc}" + ); + assert!( + doc.contains("- [Permissions](perm-archi.md) — sandbox OS + résumé injecté (reference)"), + "second memory line exact format: {doc}" + ); + // Recalled order preserved; section after the persona. + let persona_at = doc.find("# ctx body").unwrap(); + let section_at = doc.find("# Mémoire projet").unwrap(); + let first_at = doc.find("[Git optionnel]").unwrap(); + let second_at = doc.find("[Permissions]").unwrap(); + assert!(persona_at < section_at, "memory after persona: {doc}"); + assert!(first_at < second_at, "entries kept in recalled order: {doc}"); +} + +#[tokio::test] +async fn launch_conventionfile_without_memory_omits_section() { + // The default empty recall ⇒ no `# Mémoire projet` section (behaviour unchanged). + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture( + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + + launch.execute(launch_input(agent.id)).await.unwrap(); + + let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap(); + assert!(!doc.contains("# Mémoire projet"), "no memory ⇒ no section: {doc}"); +} + +#[tokio::test] +async fn launch_recall_error_degrades_to_no_section_without_failing() { + // A failing recall must NOT fail the launch (best-effort, §14.5.4): the launch + // succeeds and the convention file simply carries no memory section. + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = + launch_fixture_with_profile_and_recall( + profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + FakeRecall::failing(), + ); + + // Launch still succeeds despite the recall error. + launch.execute(launch_input(agent.id)).await.expect("launch must succeed"); + + let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap(); + assert!( + !doc.contains("# Mémoire projet"), + "recall error ⇒ no section, launch unaffected: {doc}" + ); +} + +#[tokio::test] +async fn launch_env_strategy_injects_no_memory_section() { + // For the `env` strategy IdeA writes no convention file, so no memory section is + // injected — aligned with how skills are only injected for `conventionFile`. + let recall = FakeRecall::returning(vec![mem_entry( + "note", + "Note", + "a hook", + MemoryType::User, + )]); + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = + launch_fixture_with_profile_and_recall( + profile(pid(9), ContextInjection::env("IDEA_CONTEXT").unwrap()), + Some(ContextInjectionPlan::Env { + var: "IDEA_CONTEXT".to_owned(), + }), + recall, + ); + + launch.execute(launch_input(agent.id)).await.unwrap(); + + // The env strategy writes no convention file at all (only the run-dir seed). + assert!( + fs.context_writes().is_empty(), + "env strategy must not write a convention file" + ); +} + #[tokio::test] async fn launch_skips_dangling_skill_ref_without_failing() { // The agent references a skill that no longer exists in the store: launch must @@ -932,6 +1108,7 @@ async fn launch_skips_dangling_skill_ref_without_failing() { Arc::new(TerminalSessions::new()), Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), + Arc::new(FakeRecall::default()), ); launch.execute(launch_input(agent.id)).await.expect("launch must succeed"); @@ -1009,6 +1186,7 @@ async fn launch_unknown_profile_is_not_found() { Arc::new(TerminalSessions::new()), Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), + Arc::new(FakeRecall::default()), ); let err = launch.execute(launch_input(agent.id)).await.unwrap_err(); diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index ebdd0d7..7bd851f 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -184,6 +184,21 @@ impl SkillStore for FakeSkills { } } +/// An empty [`MemoryRecall`]: no project memory ⇒ no memory section injected, +/// leaving the launch behaviour unchanged for the service-level tests. +#[derive(Default)] +struct FakeRecall; +#[async_trait] +impl domain::ports::MemoryRecall for FakeRecall { + async fn recall( + &self, + _root: &ProjectPath, + _query: &domain::ports::MemoryQuery, + ) -> Result, domain::ports::MemoryError> { + Ok(Vec::new()) + } +} + /// A [`SkillStore`] that records every `save` so a `create_skill` dispatch can be /// asserted against the persisted skill (name + scope). #[derive(Clone, Default)] @@ -414,6 +429,7 @@ fn fixture(contexts: FakeContexts) -> Fixture { Arc::clone(&sessions), Arc::new(bus.clone()), Arc::new(SeqIds::new()), + Arc::new(FakeRecall), )); let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); let close = Arc::new(CloseTerminal::new( diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index ac26179..f012605 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -174,6 +174,21 @@ impl SkillStore for FakeSkills { } } +/// An empty [`MemoryRecall`]: no project memory ⇒ no memory section injected, +/// leaving the watcher-driven launch behaviour unchanged. +#[derive(Default)] +struct FakeRecall; +#[async_trait] +impl domain::ports::MemoryRecall for FakeRecall { + async fn recall( + &self, + _root: &ProjectPath, + _query: &domain::ports::MemoryQuery, + ) -> Result, domain::ports::MemoryError> { + Ok(Vec::new()) + } +} + struct FakeRuntime; impl AgentRuntime for FakeRuntime { fn detect(&self, _p: &AgentProfile) -> Result { @@ -305,6 +320,7 @@ fn build_service(contexts: FakeContexts) -> Arc { Arc::clone(&sessions), bus.clone(), Arc::new(SeqIds(Mutex::new(1))), + Arc::new(FakeRecall), )); let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));