feat(memory): récolte automatique contrôlée de la mémoire (Lot E1)

Câble la récolte automatique de mémoire de bout en bout, sous contrôle
explicite, du domaine jusqu'à l'UI :

- domain : modèle `memory_harvest` (candidats de récolte, décision) +
  exports `lib.rs`.
- application : use case `memory/harvest` branché dans `memory/mod`,
  `lib.rs`, le cycle de vie d'agent (`agent/lifecycle`) et
  l'orchestrateur (`orchestrator/service`).
- app-tauri : wiring runtime dans `state`.
- frontend : DTO `domain/index` + hook `useMemory`.

Couverture : domain `memory_harvest` (14), application `memory_harvest`
(5) et `orchestrator_service` (récolte, 4), plus patch test-only du mock
gateway `workState` (`mock.test.ts`) et UI `memory.test`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 02:00:05 +02:00
parent 33e65dec74
commit b12081be18
14 changed files with 1155 additions and 20 deletions

View File

@ -2624,6 +2624,7 @@ pub(crate) fn compose_convention_file(
);
}
out.push_str(skill_awareness(mcp_enabled));
out.push_str(memory_awareness());
out.push_str("---\n\n");
if !project_context.trim().is_empty() {
@ -2697,6 +2698,18 @@ fn skill_awareness(mcp_enabled: bool) -> &'static str {
}
}
/// The auto-memory harvest directive (Lot E1) injected in the `# Orchestration
/// IdeA` block, right after [`skill_awareness`]. Independent of MCP: the agent emits
/// the directive in its final reply text either way (IdeA harvests it from the
/// recorded `Response`, not via a tool call), so a single wording serves both modes.
fn memory_awareness() -> &'static str {
"**Mémoire IdeA** : si une décision, convention, préférence utilisateur ou fait projet \
durable mérite d'être capitalisé, ajoute à ta réponse finale un bloc fenced `idea-memory` \
avec `slug`, `title`, `type`, `description`, puis `---` et le corps Markdown. N'en écris pas \
pour les tâches temporaires, logs, tickets, résultats transitoires ou données sensibles ; \
réutilise le même `slug` pour remplacer une note existante.\n\n"
}
/// Renders a [`MemoryType`] as its stable lowercase label for the convention-file
/// memory section (`user`/`feedback`/`project`/`reference`).
#[must_use]
@ -2885,6 +2898,35 @@ mod tests {
);
}
#[test]
fn compose_convention_file_carries_memory_harvest_directive_both_modes() {
// The auto-memory harvest directive (Lot E1) is present in both MCP and
// file-protocol modes, positioned right after the Skills awareness and
// before the orchestration block's closing separator / project context.
for mcp_enabled in [true, false] {
let doc =
compose_convention_file("/root", "ctx", "# Persona", &[], &[], None, mcp_enabled);
assert!(
doc.contains("**Mémoire IdeA**"),
"memory harvest directive present (mcp_enabled={mcp_enabled})"
);
assert!(
doc.contains("bloc fenced `idea-memory`")
&& doc.contains("réutilise le même `slug`"),
"directive carries the idea-memory wording (mcp_enabled={mcp_enabled})"
);
// Position: after Skills awareness, before the project-context section.
let skills_at = doc.find("**Skills IdeA**").unwrap();
let memory_at = doc.find("**Mémoire IdeA**").unwrap();
let context_at = doc.find("# Contexte projet").unwrap();
assert!(
skills_at < memory_at && memory_at < context_at,
"order: skills awareness -> memory directive -> project context \
(mcp_enabled={mcp_enabled})"
);
}
}
#[test]
fn compose_convention_file_skill_awareness_uses_mcp_or_file_creation_path() {
let mcp_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);

View File

@ -74,10 +74,11 @@ pub use layout::{
};
pub use memory::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
GetMemory, GetMemoryInput, GetMemoryOutput, HarvestMemoryFromTurn, ListMemories,
ListMemoriesInput, ListMemoriesOutput, MemoryHarvestOutcome, ReadMemoryIndex,
ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput,
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};
pub use orchestrator::{
ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService,

View File

@ -0,0 +1,114 @@
//! Auto-memory harvest use case (Lot E1).
//!
//! [`HarvestMemoryFromTurn`] turns the text of a finished agent turn into persisted
//! memory notes when it embeds ` ```idea-memory ` directives (parsed by the pure
//! [`domain::memory_harvest`] module). It is **best-effort strict**: it only acts on
//! a `Response` turn, parses **only the current turn's text** (never the full log),
//! and never fails — parse/store errors are collected into the returned
//! [`MemoryHarvestOutcome`] so the orchestrator can drop them without ever turning a
//! successful reply/ticket/handoff into an error.
use std::sync::Arc;
use domain::conversation_log::TurnRole;
use domain::ports::{EventBus, MemoryStore};
use domain::{
parse_memory_directives, DomainEvent, MarkdownDoc, Memory, MemoryFrontmatter,
MemoryHarvestDirective, MemorySlug, ProjectPath,
};
/// The outcome of harvesting one turn: a report the caller may log or ignore.
///
/// `found` is the number of `idea-memory` directives the agent emitted; `saved`
/// the slugs successfully persisted; `skipped` how many were dropped (parse or
/// store failures); `errors` the human-readable reasons (parse + store).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MemoryHarvestOutcome {
/// Number of `idea-memory` directives detected in the turn text.
pub found: usize,
/// Slugs of the notes that were saved (and announced).
pub saved: Vec<MemorySlug>,
/// Number of directives dropped (invalid, over-limit, or store failure).
pub skipped: usize,
/// One message per dropped directive (parse + persistence reasons).
pub errors: Vec<String>,
}
/// Validates and persists the memory directives embedded in a finished turn.
///
/// Talks only to ports ([`MemoryStore`], [`EventBus`]); announces
/// [`DomainEvent::MemorySaved`] per saved note like the manual CRUD use cases.
pub struct HarvestMemoryFromTurn {
memories: Arc<dyn MemoryStore>,
events: Arc<dyn EventBus>,
}
impl HarvestMemoryFromTurn {
/// Builds the use case from its ports.
#[must_use]
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
Self { memories, events }
}
/// Harvests `text` (the current turn's content) into `root`'s memory store.
///
/// Best-effort: returns a [`MemoryHarvestOutcome`] and **never** an error. A
/// non-`Response` turn is a no-op (empty outcome) — prompts and tool activity
/// never carry harvest directives.
pub async fn harvest(
&self,
root: &ProjectPath,
role: TurnRole,
text: &str,
) -> MemoryHarvestOutcome {
if role != TurnRole::Response {
return MemoryHarvestOutcome::default();
}
let report = parse_memory_directives(text);
let mut outcome = MemoryHarvestOutcome {
found: report.found,
// Parse-level skips are already final; store-level skips are added below.
skipped: report.errors.len(),
errors: report.errors,
saved: Vec::new(),
};
for directive in report.directives {
match self.persist(root, directive).await {
Ok(slug) => outcome.saved.push(slug),
Err(err) => {
outcome.skipped += 1;
outcome.errors.push(err);
}
}
}
outcome
}
/// Persists one validated directive and announces it, mapping the (today
/// title-less) [`Memory`] entity from the directive. Returns the saved slug or
/// a message on a build/store failure (never panics).
async fn persist(
&self,
root: &ProjectPath,
directive: MemoryHarvestDirective,
) -> Result<MemorySlug, String> {
let slug = directive.slug.clone();
// The persisted `Memory` has no title field today; the directive's `title`
// is intentionally not stored separately (the index derives its title from
// the slug). See the Lot E1 report's écart note.
let frontmatter = MemoryFrontmatter {
name: slug.clone(),
description: directive.description,
r#type: directive.r#type,
};
let memory = Memory::new(frontmatter, MarkdownDoc::new(directive.body.as_str()))
.map_err(|e| format!("invalid memory {slug}: {e}"))?;
self.memories
.save(root, &memory)
.await
.map_err(|e| format!("store failed for {slug}: {e}"))?;
self.events
.publish(DomainEvent::MemorySaved { slug: slug.clone() });
Ok(slug)
}
}

View File

@ -12,8 +12,11 @@
//! [`UpdateMemory`], [`DeleteMemory`]) announce a [`domain::DomainEvent`]; the
//! read use cases emit nothing.
mod harvest;
mod usecases;
pub use harvest::{HarvestMemoryFromTurn, MemoryHarvestOutcome};
pub use usecases::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,

View File

@ -31,6 +31,7 @@ use domain::{
};
use crate::conversation::RecordTurn;
use crate::memory::HarvestMemoryFromTurn;
use crate::agent::{
drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
@ -296,6 +297,12 @@ pub struct OrchestratorService {
/// PTY/mailbox legacy (zéro régression pour les call sites/tests qui ne le branchent
/// pas).
structured: Option<Arc<StructuredSessions>>,
/// Harvester d'auto-mémoire (Lot E1), branché **après** le checkpoint Response
/// best-effort d'un `ask` réussi : parse les blocs ` ```idea-memory ` de la
/// réponse et persiste les notes valides via le `MemoryStore`. Injecté via
/// [`Self::with_memory_harvest`] ; `None` ⇒ aucun harvest (zéro régression). Un
/// échec de harvest ne transforme **jamais** un succès de délégation en erreur.
memory_harvest: Option<Arc<HarvestMemoryFromTurn>>,
}
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
@ -359,6 +366,7 @@ impl OrchestratorService {
record_turn: None,
clock: None,
structured: None,
memory_harvest: None,
}
}
@ -456,6 +464,16 @@ impl OrchestratorService {
self
}
/// Branche le harvester d'auto-mémoire (Lot E1), appelé **après** le checkpoint
/// `RecordTurn` de la réponse d'un `ask` réussi. `None` (défaut) ⇒ aucun harvest.
/// Le `MemoryStore` prend le `root` par appel, donc un seul use case sert tous
/// les projets (pas de provider par root nécessaire, contrairement au handoff).
#[must_use]
pub fn with_memory_harvest(mut self, harvester: Arc<HarvestMemoryFromTurn>) -> Self {
self.memory_harvest = Some(harvester);
self
}
/// Persiste **best-effort** un tour (`Prompt`/`Response`) dans `conversation`.
///
/// No-op silencieux quand le provider/horloge ne sont pas câblés, quand le provider
@ -492,6 +510,29 @@ impl OrchestratorService {
let _ = record.record(conversation, turn).await;
}
/// Récolte **best-effort** les notes d'auto-mémoire (Lot E1) d'une réponse.
///
/// No-op silencieux quand le harvester n'est pas câblé. Ne parse **que** le texte
/// du tour courant (jamais le log complet) et n'agit que sur un `Response`. Tout
/// échec parse/store reste dans l'`outcome` (jamais propagé) : un succès de
/// délégation n'est **jamais** transformé en erreur. Branché **après** le
/// checkpoint `RecordTurn` de la réponse (append/handoff déjà effectués).
async fn harvest_memory_best_effort(&self, root: &ProjectPath, text: &str) {
let Some(harvester) = &self.memory_harvest else {
return;
};
let outcome = harvester.harvest(root, TurnRole::Response, text).await;
if !outcome.saved.is_empty() || !outcome.errors.is_empty() {
crate::diag!(
"[harvest] auto-memory: found={} saved={} skipped={} errors={}",
outcome.found,
outcome.saved.len(),
outcome.skipped,
outcome.errors.len(),
);
}
}
/// Dispatches a validated command against `project`.
///
/// # Errors
@ -1000,6 +1041,11 @@ impl OrchestratorService {
result.clone(),
)
.await;
// Auto-memory harvest (Lot E1), APRÈS l'append/handoff de la réponse :
// parse les blocs ` ```idea-memory ` et persiste les notes valides.
// Best-effort strict — n'altère jamais ce succès de délégation.
self.harvest_memory_best_effort(&project.root, &result)
.await;
// Succès : désarmer le garde AVANT de retourner. Le `mark_idle` propre
// sur cette branche est porté par le médiateur (prompt-ready / idea_reply
// qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket
@ -1153,6 +1199,10 @@ impl OrchestratorService {
result.clone(),
)
.await;
// Auto-memory harvest (Lot E1), après l'append/handoff de la réponse.
// Best-effort strict — un échec n'altère jamais ce succès de délégation.
self.harvest_memory_best_effort(&project.root, &result)
.await;
Ok(self.reply_outcome(agent_id, target, result))
}

View File

@ -0,0 +1,242 @@
//! Tests for the Lot E1 auto-memory harvest use case (`HarvestMemoryFromTurn`).
//!
//! Best-effort, port-faked: a `Response` turn carrying valid ` ```idea-memory `
//! blocks is persisted + announced; non-`Response` turns are no-ops; invalid blocks
//! are skipped individually; a store failure is reported, never propagated.
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::conversation_log::TurnRole;
use domain::events::DomainEvent;
use domain::ports::{EventBus, EventStream, MemoryError, MemoryStore};
use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug, ProjectPath};
use application::HarvestMemoryFromTurn;
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
/// In-memory store keyed by slug, with an optional forced `save` failure.
#[derive(Default)]
struct FakeMemories {
saved: Mutex<Vec<Memory>>,
fail_save: bool,
}
impl FakeMemories {
fn failing() -> Self {
Self {
saved: Mutex::new(Vec::new()),
fail_save: true,
}
}
fn slugs(&self) -> Vec<String> {
self.saved
.lock()
.unwrap()
.iter()
.map(|m| m.slug().as_str().to_owned())
.collect()
}
}
#[async_trait]
impl MemoryStore for FakeMemories {
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
Ok(self.saved.lock().unwrap().clone())
}
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
self.saved
.lock()
.unwrap()
.iter()
.find(|m| m.slug() == slug)
.cloned()
.ok_or(MemoryError::NotFound)
}
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
if self.fail_save {
return Err(MemoryError::Frontmatter("forced failure".to_owned()));
}
self.saved.lock().unwrap().push(memory.clone());
Ok(())
}
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
Ok(())
}
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<MemoryLink>, MemoryError> {
Ok(Vec::new())
}
}
#[derive(Default, Clone)]
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
fn saved_slugs(&self) -> Vec<String> {
self.0
.lock()
.unwrap()
.iter()
.filter_map(|e| match e {
DomainEvent::MemorySaved { slug } => Some(slug.as_str().to_owned()),
_ => None,
})
.collect()
}
}
impl EventBus for SpyBus {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn root() -> ProjectPath {
ProjectPath::new("/home/me/demo").unwrap()
}
fn block(slug: &str, body: &str) -> String {
format!(
"```idea-memory\nslug: {slug}\ntitle: Title\ntype: project\ndescription: hook\n---\n{body}\n```"
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ignores_non_response_turns() {
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let text = block("note-a", "Body");
for role in [TurnRole::Prompt, TurnRole::ToolActivity] {
let outcome = harvester.harvest(&root(), role, &text).await;
assert_eq!(outcome.found, 0, "non-response turns are not parsed");
assert!(outcome.saved.is_empty());
}
assert!(store.slugs().is_empty(), "nothing persisted");
assert!(bus.saved_slugs().is_empty(), "nothing announced");
}
#[tokio::test]
async fn response_with_valid_block_saves_and_announces() {
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let text = format!(
"Sure, noting that.\n\n{}",
block("api-url", "Use the staging URL.")
);
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
assert_eq!(outcome.found, 1);
assert_eq!(outcome.saved.len(), 1);
assert_eq!(outcome.saved[0].as_str(), "api-url");
assert_eq!(outcome.skipped, 0);
assert!(outcome.errors.is_empty());
assert_eq!(store.slugs(), vec!["api-url"], "persisted via the store");
assert_eq!(bus.saved_slugs(), vec!["api-url"], "MemorySaved announced");
}
#[tokio::test]
async fn partial_invalid_blocks_are_skipped_individually() {
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
// One valid, one with an invalid slug.
let text = format!(
"{}\n{}",
block("good-one", "Body"),
block("Bad Slug", "Body")
);
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
assert_eq!(outcome.found, 2);
assert_eq!(outcome.saved.len(), 1, "only the valid one persisted");
assert_eq!(outcome.saved[0].as_str(), "good-one");
assert_eq!(outcome.skipped, 1);
assert_eq!(outcome.errors.len(), 1);
assert_eq!(store.slugs(), vec!["good-one"]);
assert_eq!(bus.saved_slugs(), vec!["good-one"]);
}
#[tokio::test]
async fn store_failure_is_reported_not_propagated() {
let store = Arc::new(FakeMemories::failing());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let text = block("doomed", "Body");
// No panic, no Result — the call simply returns an outcome carrying the error.
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
assert_eq!(outcome.found, 1);
assert!(outcome.saved.is_empty(), "save failed ⇒ nothing saved");
assert_eq!(outcome.skipped, 1);
assert_eq!(outcome.errors.len(), 1);
assert!(
outcome.errors[0].contains("store failed"),
"{:?}",
outcome.errors
);
// A failed save must not announce a MemorySaved event.
assert!(bus.saved_slugs().is_empty());
}
#[tokio::test]
async fn parses_only_current_text_no_full_log() {
// The use case takes only the turn's `text`; it has no `ConversationLog`
// dependency, so it structurally cannot read the full thread. We assert it
// acts purely on the text passed in: a response with no directive saves
// nothing even though the store already holds (unrelated) notes.
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let outcome = harvester
.harvest(
&root(),
TurnRole::Response,
"A plain reply with no directive.",
)
.await;
assert_eq!(outcome.found, 0);
assert!(outcome.saved.is_empty());
assert!(store.slugs().is_empty());
}

View File

@ -22,9 +22,9 @@ use domain::ids::{AgentId, NodeId, 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, MemoryStore, OutputStream,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError,
SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
@ -34,12 +34,13 @@ use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, TerminalSessions, UpdateAgentContext,
CloseTerminal, CreateAgentFromScratch, CreateSkill, HarvestMemoryFromTurn, LaunchAgent,
ListAgents, OrchestratorService, TerminalSessions, UpdateAgentContext,
};
// ---------------------------------------------------------------------------
@ -382,6 +383,54 @@ impl EventBus for SpyBus {
}
}
/// In-memory [`MemoryStore`] for the auto-memory harvest hook (Lot E1): records the
/// saved slugs, with an optional forced `save` failure to prove harvest errors stay
/// best-effort (never break the reply).
#[derive(Default)]
struct HarvestMemories {
saved: Mutex<Vec<MemorySlug>>,
fail_save: bool,
}
impl HarvestMemories {
fn slugs(&self) -> Vec<String> {
self.saved
.lock()
.unwrap()
.iter()
.map(|s| s.as_str().to_owned())
.collect()
}
}
#[async_trait]
impl MemoryStore for HarvestMemories {
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
Ok(Vec::new())
}
async fn get(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<Memory, MemoryError> {
Err(MemoryError::NotFound)
}
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
if self.fail_save {
return Err(MemoryError::Frontmatter("forced failure".to_owned()));
}
self.saved.lock().unwrap().push(memory.slug().clone());
Ok(())
}
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
Ok(())
}
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<MemoryLink>, MemoryError> {
Ok(Vec::new())
}
}
struct SeqIds(Mutex<u128>);
impl SeqIds {
fn new() -> Self {
@ -1047,17 +1096,30 @@ struct AskFixture {
/// The mediated inbox the service holds — lets a test observe `busy_state`
/// transitions (e.g. an `idea_reply` flips the emitter back to Idle — lot C5).
mediator: Arc<TestMediator>,
/// The auto-memory store wired into the harvest hook (Lot E1): lets a test
/// assert which notes a successful reply persisted.
memories: Arc<HarvestMemories>,
}
/// Builds an orchestrator wired with a real [`InMemoryMailbox`] + PTY (B-3) and a
/// plain (non-structured) [`LaunchAgent`] — so a dead target is launched as a PTY,
/// exactly like production after B-2.
fn ask_fixture(contexts: FakeContexts) -> AskFixture {
ask_fixture_with_memory(contexts, false)
}
/// Like [`ask_fixture`] but lets a test force the auto-memory store to fail its
/// `save`, proving the harvest stays best-effort (the reply still succeeds).
fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixture {
let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()]));
let sessions = Arc::new(TerminalSessions::new());
let pty = FakePty::new(sid(777));
let bus = SpyBus::default();
let mailbox = Arc::new(TestMailbox::new());
let memories = Arc::new(HarvestMemories {
saved: Mutex::new(Vec::new()),
fail_save: fail_memory,
});
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
@ -1109,7 +1171,11 @@ fn ask_fixture(contexts: FakeContexts) -> AskFixture {
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
)
.with_conversations(conversations)
.with_events(Arc::new(bus.clone())),
.with_events(Arc::new(bus.clone()))
.with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new(
Arc::clone(&memories) as Arc<dyn MemoryStore>,
Arc::new(bus.clone()),
))),
);
AskFixture {
@ -1119,6 +1185,7 @@ fn ask_fixture(contexts: FakeContexts) -> AskFixture {
bus,
pty,
mediator,
memories,
}
}
@ -1220,6 +1287,134 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() {
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "ticket drained on reply");
}
// --- Lot E1: auto-memory harvest on a successful ask reply -----------------
/// A reply carrying an `idea-memory` block is harvested **after** the reply
/// resolves: the note is persisted via the store and a `MemorySaved` event fires.
#[tokio::test]
async fn ask_reply_with_directive_harvests_memory_after_reply() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
let reply = "Here is the answer.\n\n```idea-memory\nslug: section-17-rule\ntitle: §17 rule\ntype: project\ndescription: How §17 routing works\n---\nRoute structured asks straight to the session.\n```";
fx.service
.dispatch(&project(), reply_cmd(aid(1), reply))
.await
.expect("reply ok");
let out = timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok")
.expect("ask ok");
// The reply is returned unchanged …
assert_eq!(out.reply.as_deref(), Some(reply));
// … and the embedded note was harvested into the store + announced.
assert_eq!(fx.memories.slugs(), vec!["section-17-rule"]);
let saved: Vec<_> = fx
.bus
.events()
.into_iter()
.filter_map(|e| match e {
DomainEvent::MemorySaved { slug } => Some(slug.as_str().to_owned()),
_ => None,
})
.collect();
assert!(
saved.contains(&"section-17-rule".to_owned()),
"MemorySaved announced, got {saved:?}"
);
}
/// A store failure during harvest must not break the reply: the ask still returns
/// its content, and nothing is persisted.
#[tokio::test]
async fn ask_reply_harvest_store_failure_does_not_break_reply() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture_with_memory(FakeContexts::with_agent(&agent, "# persona"), true);
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
let reply = "Reply.\n\n```idea-memory\nslug: doomed-note\ntitle: T\ntype: project\ndescription: hook\n---\nBody.\n```";
fx.service
.dispatch(&project(), reply_cmd(aid(1), reply))
.await
.expect("reply ok despite failing memory store");
let out = timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok")
.expect("ask still ok despite harvest store failure");
assert_eq!(
out.reply.as_deref(),
Some(reply),
"reply unaffected by harvest"
);
assert!(
fx.memories.slugs().is_empty(),
"failed save persisted nothing"
);
}
/// A reply with no directive harvests nothing (and the reply is unchanged) — the
/// common case must not touch the memory store.
#[tokio::test]
async fn ask_reply_without_directive_harvests_nothing() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.service
.dispatch(&project(), reply_cmd(aid(1), "Just a plain answer."))
.await
.expect("reply ok");
timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok")
.expect("ask ok");
assert!(fx.memories.slugs().is_empty());
}
/// A cancelled turn (head ticket dropped before any reply) yields no reply, so the
/// harvest never runs — nothing is persisted even with a would-be directive pending.
#[tokio::test]
async fn ask_cancelled_turn_does_not_harvest() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
// Cancel the head ticket (drops the reply sender) ⇒ the ask resolves as a
// channel-closed error: no Response turn, hence no harvest.
let ticket = fx.mailbox.ticket_ids(&aid(1))[0];
fx.mailbox.cancel_head(aid(1), ticket);
let out = timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok");
assert!(out.is_err(), "cancelled turn ⇒ ask errors");
assert!(fx.memories.slugs().is_empty(), "no harvest on cancel");
}
/// Lot C5 — explicit OR signal: an `idea_reply` from the emitting agent flips it back
/// to `Idle` (its FIFO can advance), independently of any prompt-ready pattern. Before
/// the reply the agent is `Busy` on the delegated turn; after, it is `Idle`.
@ -1639,9 +1834,6 @@ async fn ask_different_targets_run_in_parallel() {
#[derive(Clone, Default)]
struct CapturingFs(Arc<Mutex<Vec<(String, String)>>>);
impl CapturingFs {
fn writes(&self) -> Vec<(String, String)> {
self.0.lock().unwrap().clone()
}
/// Contenu écrit dont le chemin se termine par `suffix` (ex. `.mcp.json`).
fn content_ending_with(&self, suffix: &str) -> Option<String> {
self.0
@ -2733,6 +2925,9 @@ fn ask_fixture_with_record(
bus,
pty,
mediator,
// Harvest is intentionally not wired here: these fixtures exercise the
// RecordTurn checkpoint in isolation (proving it stays unchanged).
memories: Arc::new(HarvestMemories::default()),
}
}