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:
242
crates/application/tests/memory_harvest.rs
Normal file
242
crates/application/tests/memory_harvest.rs
Normal 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());
|
||||
}
|
||||
@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user