feat(memory): injecter le rappel mémoire dans le convention file à l'activation (§14.5.4)
À l'activation d'un agent, LaunchAgent compose désormais une section « # Mémoire projet » dans le convention file généré (CLAUDE.md/AGENTS.md…), au même titre que les skills assignés (§14.2). Les agents lisent ainsi la mémoire projet sans aucun flag ni mécanisme propre à une CLI. - LaunchAgent reçoit le port MemoryRecall (Arc<dyn MemoryRecall>), résout le rappel (budget AGENT_MEMORY_RECALL_BUDGET=2048, requête = persona de l'agent) en best-effort : mémoire vide/absente ou erreur ⇒ section omise, le launch n'est jamais bloqué (comme un SkillRef dangling). - compose_convention_file gagne un argument `memory: &[MemoryIndexEntry]` (reste pure) ; section injectée seulement pour la stratégie conventionFile. - state.rs : une seule instance NaiveMemoryRecall partagée (recall + LaunchAgent). Tests: 57 binaires verts. compose_convention_file (vide ⇒ inchangé, ordre, cohabitation skills) + intégration LaunchAgent (section présente / absente / best-effort sur erreur / pas d'injection en stratégie env). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -616,7 +616,7 @@ IdeA/
|
|||||||
1. La **persona/rôle** de l'agent (son `.md` dans `.ideai/agents/`).
|
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).
|
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).
|
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** :
|
**Avantages** :
|
||||||
- Zéro collision entre agents, même N instances du même profil.
|
- 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 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 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.
|
- **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
|
##### 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).
|
- **`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).
|
- **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<dyn MemoryRecall>` (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<MemoryIndexEntry>` 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<Vec<MemoryIndexEntry>, 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<dyn MemoryRecall>`) ; 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<dyn MemoryRecall>` 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<Vec<MemoryIndexEntry>, 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)
|
#### 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.
|
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.
|
||||||
|
|||||||
@ -362,6 +362,15 @@ impl AppState {
|
|||||||
));
|
));
|
||||||
let skill_store_port = Arc::clone(&skill_store) as Arc<dyn SkillStore>;
|
let skill_store_port = Arc::clone(&skill_store) as Arc<dyn SkillStore>;
|
||||||
|
|
||||||
|
// 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<dyn MemoryStore>;
|
||||||
|
let memory_recall_port =
|
||||||
|
Arc::new(NaiveMemoryRecall::new(Arc::clone(&memory_store_port))) as Arc<dyn MemoryRecall>;
|
||||||
|
|
||||||
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
||||||
Arc::clone(&contexts_port),
|
Arc::clone(&contexts_port),
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
@ -386,6 +395,7 @@ impl AppState {
|
|||||||
Arc::clone(&terminal_sessions),
|
Arc::clone(&terminal_sessions),
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
Arc::clone(&memory_recall_port),
|
||||||
));
|
));
|
||||||
|
|
||||||
// --- Conversation inspection (T7) ---
|
// --- Conversation inspection (T7) ---
|
||||||
@ -480,12 +490,12 @@ impl AppState {
|
|||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
));
|
));
|
||||||
|
|
||||||
// --- Memory store + use cases (LOT A — §14.5.1) ---
|
// --- Memory use cases (LOT A — §14.5.1) ---
|
||||||
// A single FsMemoryStore takes the project root per call (like the skill
|
// `memory_store` / `memory_store_port` / `memory_recall_port` are built
|
||||||
// store), so one instance serves every open project. The mutating use
|
// earlier (above LaunchAgent, which needs the recall port). A single
|
||||||
// cases share the event bus to announce Memory{Saved,Deleted}.
|
// FsMemoryStore takes the project root per call (like the skill store), so
|
||||||
let memory_store = Arc::new(FsMemoryStore::new(Arc::clone(&fs_port)));
|
// one instance serves every open project. The mutating use cases share the
|
||||||
let memory_store_port = Arc::clone(&memory_store) as Arc<dyn MemoryStore>;
|
// event bus to announce Memory{Saved,Deleted}.
|
||||||
let create_memory = Arc::new(CreateMemory::new(
|
let create_memory = Arc::new(CreateMemory::new(
|
||||||
Arc::clone(&memory_store_port),
|
Arc::clone(&memory_store_port),
|
||||||
Arc::clone(&events_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 read_memory_index = Arc::new(ReadMemoryIndex::new(Arc::clone(&memory_store_port)));
|
||||||
let resolve_memory_links =
|
let resolve_memory_links =
|
||||||
Arc::new(ResolveMemoryLinks::new(Arc::clone(&memory_store_port)));
|
Arc::new(ResolveMemoryLinks::new(Arc::clone(&memory_store_port)));
|
||||||
// Naïve recall (LOT B): composes the same store, returns index entries in
|
// Naïve recall (LOT B): the same instance injected into LaunchAgent above —
|
||||||
// order, truncated to the token budget. The default, dependency-free
|
// composes the store, returns index entries in order truncated to the token
|
||||||
// MemoryRecall; substitutable by a VectorMemoryRecall (LOT C).
|
// budget. The default, dependency-free MemoryRecall; substitutable by a
|
||||||
let memory_recall_port =
|
// VectorMemoryRecall (LOT C).
|
||||||
Arc::new(NaiveMemoryRecall::new(Arc::clone(&memory_store_port))) as Arc<dyn MemoryRecall>;
|
let recall_memory = Arc::new(RecallMemory::new(Arc::clone(&memory_recall_port)));
|
||||||
let recall_memory = Arc::new(RecallMemory::new(memory_recall_port));
|
|
||||||
|
|
||||||
// --- Orchestrator service (§14.3) ---
|
// --- Orchestrator service (§14.3) ---
|
||||||
// Dispatches file-based orchestrator requests through the *same* use cases
|
// Dispatches file-based orchestrator requests through the *same* use cases
|
||||||
|
|||||||
@ -15,13 +15,13 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator,
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator,
|
||||||
PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec,
|
MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan,
|
||||||
StoreError,
|
SkillStore, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
|
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
|
||||||
ManifestEntry, MarkdownDoc, NodeId, Project, ProfileId, ProjectPath, PtySize, SessionKind,
|
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, Project, ProfileId,
|
||||||
SessionStatus, Skill, TerminalSession,
|
ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@ -30,6 +30,13 @@ use crate::terminal::TerminalSessions;
|
|||||||
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
||||||
const AGENTS_SUBDIR: &str = "agents";
|
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
|
// CreateAgentFromScratch
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -369,6 +376,10 @@ pub struct LaunchAgent {
|
|||||||
sessions: Arc<TerminalSessions>,
|
sessions: Arc<TerminalSessions>,
|
||||||
events: Arc<dyn EventBus>,
|
events: Arc<dyn EventBus>,
|
||||||
ids: Arc<dyn IdGenerator>,
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
/// 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<dyn MemoryRecall>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LaunchAgent {
|
impl LaunchAgent {
|
||||||
@ -385,6 +396,7 @@ impl LaunchAgent {
|
|||||||
sessions: Arc<TerminalSessions>,
|
sessions: Arc<TerminalSessions>,
|
||||||
events: Arc<dyn EventBus>,
|
events: Arc<dyn EventBus>,
|
||||||
ids: Arc<dyn IdGenerator>,
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
recall: Arc<dyn MemoryRecall>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
contexts,
|
contexts,
|
||||||
@ -396,6 +408,7 @@ impl LaunchAgent {
|
|||||||
sessions,
|
sessions,
|
||||||
events,
|
events,
|
||||||
ids,
|
ids,
|
||||||
|
recall,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -422,6 +435,24 @@ impl LaunchAgent {
|
|||||||
Ok(out)
|
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<MemoryIndexEntry> {
|
||||||
|
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.
|
/// Executes the launch.
|
||||||
///
|
///
|
||||||
/// Step order is contractually significant (and unit-tested): resolve the
|
/// Step order is contractually significant (and unit-tested): resolve the
|
||||||
@ -507,7 +538,17 @@ impl LaunchAgent {
|
|||||||
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
|
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
|
||||||
// the injection plan side effects *before* spawning.
|
// the injection plan side effects *before* spawning.
|
||||||
let skills = self.resolve_skills(&agent, &input.project.root).await?;
|
let skills = self.resolve_skills(&agent, &input.project.root).await?;
|
||||||
self.apply_injection(&input.project, &agent.context_path, &content, &skills, &mut spec)
|
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?;
|
.await?;
|
||||||
|
|
||||||
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
||||||
@ -645,6 +686,7 @@ impl LaunchAgent {
|
|||||||
context_rel_path: &str,
|
context_rel_path: &str,
|
||||||
content: &MarkdownDoc,
|
content: &MarkdownDoc,
|
||||||
skills: &[Skill],
|
skills: &[Skill],
|
||||||
|
memory: &[MemoryIndexEntry],
|
||||||
spec: &mut SpawnSpec,
|
spec: &mut SpawnSpec,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
match spec.context_plan.clone() {
|
match spec.context_plan.clone() {
|
||||||
@ -656,8 +698,12 @@ impl LaunchAgent {
|
|||||||
// composed: an absolute project-root header (so the agent knows
|
// composed: an absolute project-root header (so the agent knows
|
||||||
// where to operate, since its cwd is *not* the root), the agent's
|
// where to operate, since its cwd is *not* the root), the agent's
|
||||||
// persona `.md`, then the bodies of its assigned skills (§14.2).
|
// persona `.md`, then the bodies of its assigned skills (§14.2).
|
||||||
let document =
|
let document = compose_convention_file(
|
||||||
compose_convention_file(project.root.as_str(), content.as_str(), skills);
|
project.root.as_str(),
|
||||||
|
content.as_str(),
|
||||||
|
skills,
|
||||||
|
memory,
|
||||||
|
);
|
||||||
let path = RemotePath::new(join(&spec.cwd, &target));
|
let path = RemotePath::new(join(&spec.cwd, &target));
|
||||||
self.fs.write(&path, document.as_bytes()).await?;
|
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
|
/// carrying its name. When `skills` is empty the section is omitted entirely, so
|
||||||
/// an agent with no skills gets exactly the previous document.
|
/// 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.
|
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation.
|
||||||
#[must_use]
|
#[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();
|
let mut out = String::new();
|
||||||
out.push_str("# Project root\n\n");
|
out.push_str("# Project root\n\n");
|
||||||
out.push_str(project_root);
|
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');
|
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
|
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/<slug>.md`) for a new
|
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
|
||||||
/// agent, disambiguating against the manifest's existing paths with a numeric
|
/// agent, disambiguating against the manifest's existing paths with a numeric
|
||||||
/// suffix when needed. Shared with the template-driven agent creation (L7).
|
/// suffix when needed. Shared with the template-driven agent creation (L7).
|
||||||
@ -850,7 +933,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compose_convention_file_carries_root_then_persona() {
|
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.
|
// Absolute project root present.
|
||||||
assert!(doc.contains("/abs/project/root"));
|
assert!(doc.contains("/abs/project/root"));
|
||||||
@ -880,6 +964,7 @@ mod tests {
|
|||||||
"/root",
|
"/root",
|
||||||
"# Persona",
|
"# Persona",
|
||||||
&[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")],
|
&[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")],
|
||||||
|
&[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Both skill bodies present, after the persona.
|
// Both skill bodies present, after the persona.
|
||||||
@ -896,6 +981,89 @@ mod tests {
|
|||||||
assert!(doc.contains("## review"));
|
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]
|
#[test]
|
||||||
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
|
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
|
||||||
let json = claude_settings_seed("/home/me/proj");
|
let json = claude_settings_seed("/home/me/proj");
|
||||||
|
|||||||
@ -25,10 +25,11 @@ use domain::ids::{AgentId, ProfileId, ProjectId};
|
|||||||
use domain::markdown::MarkdownDoc;
|
use domain::markdown::MarkdownDoc;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||||
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
|
||||||
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
|
OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath,
|
||||||
StoreError,
|
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
|
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
|
||||||
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
||||||
use domain::project::{Project, ProjectPath};
|
use domain::project::{Project, ProjectPath};
|
||||||
use domain::remote::RemoteRef;
|
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<Vec<MemoryIndexEntry>>,
|
||||||
|
fail: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeRecall {
|
||||||
|
fn returning(entries: Vec<MemoryIndexEntry>) -> 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<Vec<MemoryIndexEntry>, 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
|
// FakeRuntime (AgentRuntime) — records prepare + returns a configured plan
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -652,6 +705,17 @@ fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan
|
|||||||
fn launch_fixture_with_profile(
|
fn launch_fixture_with_profile(
|
||||||
profile: AgentProfile,
|
profile: AgentProfile,
|
||||||
plan: Option<ContextInjectionPlan>,
|
plan: Option<ContextInjectionPlan>,
|
||||||
|
) -> 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<ContextInjectionPlan>,
|
||||||
|
recall: FakeRecall,
|
||||||
) -> LaunchFixture {
|
) -> LaunchFixture {
|
||||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||||
@ -673,6 +737,7 @@ fn launch_fixture_with_profile(
|
|||||||
Arc::clone(&sessions),
|
Arc::clone(&sessions),
|
||||||
Arc::new(bus.clone()),
|
Arc::new(bus.clone()),
|
||||||
Arc::new(SeqIds::new()),
|
Arc::new(SeqIds::new()),
|
||||||
|
Arc::new(recall),
|
||||||
);
|
);
|
||||||
(launch, agent, fs, pty, bus, sessions, tr, session_probe)
|
(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::clone(&sessions),
|
||||||
Arc::new(SpyBus::default()),
|
Arc::new(SpyBus::default()),
|
||||||
Arc::new(SeqIds::new()),
|
Arc::new(SeqIds::new()),
|
||||||
|
Arc::new(FakeRecall::default()),
|
||||||
);
|
);
|
||||||
|
|
||||||
launch.execute(launch_input(agent_a.id)).await.unwrap();
|
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(TerminalSessions::new()),
|
||||||
Arc::new(SpyBus::default()),
|
Arc::new(SpyBus::default()),
|
||||||
Arc::new(SeqIds::new()),
|
Arc::new(SeqIds::new()),
|
||||||
|
Arc::new(FakeRecall::default()),
|
||||||
);
|
);
|
||||||
|
|
||||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
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}");
|
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]
|
#[tokio::test]
|
||||||
async fn launch_skips_dangling_skill_ref_without_failing() {
|
async fn launch_skips_dangling_skill_ref_without_failing() {
|
||||||
// The agent references a skill that no longer exists in the store: launch must
|
// 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(TerminalSessions::new()),
|
||||||
Arc::new(SpyBus::default()),
|
Arc::new(SpyBus::default()),
|
||||||
Arc::new(SeqIds::new()),
|
Arc::new(SeqIds::new()),
|
||||||
|
Arc::new(FakeRecall::default()),
|
||||||
);
|
);
|
||||||
|
|
||||||
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
|
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(TerminalSessions::new()),
|
||||||
Arc::new(SpyBus::default()),
|
Arc::new(SpyBus::default()),
|
||||||
Arc::new(SeqIds::new()),
|
Arc::new(SeqIds::new()),
|
||||||
|
Arc::new(FakeRecall::default()),
|
||||||
);
|
);
|
||||||
|
|
||||||
let err = launch.execute(launch_input(agent.id)).await.unwrap_err();
|
let err = launch.execute(launch_input(agent.id)).await.unwrap_err();
|
||||||
|
|||||||
@ -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<Vec<domain::MemoryIndexEntry>, domain::ports::MemoryError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A [`SkillStore`] that records every `save` so a `create_skill` dispatch can be
|
/// A [`SkillStore`] that records every `save` so a `create_skill` dispatch can be
|
||||||
/// asserted against the persisted skill (name + scope).
|
/// asserted against the persisted skill (name + scope).
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
@ -414,6 +429,7 @@ fn fixture(contexts: FakeContexts) -> Fixture {
|
|||||||
Arc::clone(&sessions),
|
Arc::clone(&sessions),
|
||||||
Arc::new(bus.clone()),
|
Arc::new(bus.clone()),
|
||||||
Arc::new(SeqIds::new()),
|
Arc::new(SeqIds::new()),
|
||||||
|
Arc::new(FakeRecall),
|
||||||
));
|
));
|
||||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||||
let close = Arc::new(CloseTerminal::new(
|
let close = Arc::new(CloseTerminal::new(
|
||||||
|
|||||||
@ -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<Vec<domain::MemoryIndexEntry>, domain::ports::MemoryError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct FakeRuntime;
|
struct FakeRuntime;
|
||||||
impl AgentRuntime for FakeRuntime {
|
impl AgentRuntime for FakeRuntime {
|
||||||
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
@ -305,6 +320,7 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
|
|||||||
Arc::clone(&sessions),
|
Arc::clone(&sessions),
|
||||||
bus.clone(),
|
bus.clone(),
|
||||||
Arc::new(SeqIds(Mutex::new(1))),
|
Arc::new(SeqIds(Mutex::new(1))),
|
||||||
|
Arc::new(FakeRecall),
|
||||||
));
|
));
|
||||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||||
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
|
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
|
||||||
|
|||||||
Reference in New Issue
Block a user