feat(persistence): P7 — reprise par injection du handoff au lancement

Au (re)lancement, l'agent retrouve sur quoi il travaillait : si la cellule a une
conversation_id avec un handoff, son objectif + résumé sont injectés en dernière
section du convention file (# Reprise de la conversation), best-effort et
provider-indépendant.

- application : port HandoffProvider (root par appel, comme MemoryRecall) ;
  LaunchAgent.with_handoff_provider + resolve_handoff (parse uuid + load,
  jamais bloquant) ; section rendue dans compose_convention_file après la
  mémoire projet
- app-tauri : AppHandoffProvider (FsHandoffStore sur le root du projet) câblé
- tests : 4 purs (objectif présent/None/blanc/absent) + 5 intégration
  (happy path, provider absent, conversation_id None, uuid invalide, sans
  handoff ⇒ lancement OK sans section) ; application 311 + app-tauri 204 verts

Volet --resume/providers.json = P8 (swap cross-profile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:42:19 +02:00
parent 0bf7a5c43c
commit c1411d3b69
5 changed files with 484 additions and 23 deletions

View File

@ -2094,3 +2094,221 @@ async fn launch_degraded_mode_without_assign_flag_first_launch_is_none() {
);
assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None));
}
// ---------------------------------------------------------------------------
// LaunchAgent — handoff resume injection (lot P7)
// ---------------------------------------------------------------------------
use application::HandoffProvider;
use domain::{ConversationId, Handoff, HandoffStore, TurnId};
/// In-memory [`HandoffStore`] for the P7 launch tests: holds at most one handoff
/// per [`ConversationId`]. `load` returns `Ok(None)` for an unknown conversation
/// (the store-without-handoff case), so the best-effort `resolve_handoff` degrades
/// to "no section".
#[derive(Clone, Default)]
struct FakeHandoffStore(Arc<Mutex<HashMap<ConversationId, Handoff>>>);
impl FakeHandoffStore {
fn with(conv: ConversationId, handoff: Handoff) -> Self {
let me = Self::default();
me.0.lock().unwrap().insert(conv, handoff);
me
}
}
#[async_trait]
impl HandoffStore for FakeHandoffStore {
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
Ok(self.0.lock().unwrap().get(&conversation).cloned())
}
async fn save(
&self,
conversation: ConversationId,
handoff: Handoff,
) -> Result<(), StoreError> {
self.0.lock().unwrap().insert(conversation, handoff);
Ok(())
}
}
/// [`HandoffProvider`] that hands back the same store for any root (the launch
/// tests use a single project root).
#[derive(Clone)]
struct FakeHandoffProvider(Arc<dyn HandoffStore>);
impl HandoffProvider for FakeHandoffProvider {
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn HandoffStore>> {
Some(Arc::clone(&self.0))
}
}
/// Builds a `conventionFile` LaunchAgent wired with an **optional** handoff
/// provider, returning the launcher and the recording fs (the only port the P7
/// resume assertions read). When `provider` is `None`, no handoff is wired —
/// exactly the legacy launch path.
fn launch_with_handoff(provider: Option<Arc<dyn HandoffProvider>>) -> (LaunchAgent, Agent, FakeFs) {
let injection = ContextInjection::convention_file("CLAUDE.md").unwrap();
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
let tr = trace();
let fs = FakeFs::new(Arc::clone(&tr));
let pty = FakePty::new(Arc::clone(&tr), sid(777));
let mut launch = LaunchAgent::new(
Arc::new(contexts),
Arc::new(profiles),
Arc::new(FakeRuntime::new(
Arc::clone(&tr),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
)),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::new(FakeSkills::default()),
Arc::new(TerminalSessions::new()),
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()),
None,
);
if let Some(p) = provider {
launch = launch.with_handoff_provider(p);
}
(launch, agent, fs)
}
fn conv_id(n: u128) -> ConversationId {
ConversationId::from_uuid(Uuid::from_u128(n))
}
fn handoff_for(summary: &str, objective: Option<&str>) -> Handoff {
Handoff::new(
summary,
TurnId::from_uuid(Uuid::from_u128(7)),
objective.map(ToOwned::to_owned),
)
}
/// Reads the (single) convention file written by the launch.
fn convention_doc(fs: &FakeFs) -> String {
let writes = fs.context_writes();
assert_eq!(writes.len(), 1, "exactly one convention file");
String::from_utf8(writes[0].1.clone()).unwrap()
}
#[tokio::test]
async fn launch_injects_handoff_resume_when_provider_and_conversation_match() {
// Happy path: a wired provider, a cell conversation_id that parses to a known
// ConversationId with a stored handoff ⇒ the convention file carries the
// `# Reprise de la conversation` section (objective + summary).
let conv = conv_id(123);
let store = FakeHandoffStore::with(
conv,
handoff_for("On a terminé l'étape A.", Some("Finir le lot P7")),
);
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
let (launch, agent, fs) = launch_with_handoff(Some(provider));
let mut input = launch_input(agent.id);
// The cell carries the conversation id of the known pair (UUID string form).
input.conversation_id = Some(Uuid::from_u128(123).to_string());
launch.execute(input).await.expect("launch succeeds");
let doc = convention_doc(&fs);
assert!(
doc.contains("# Reprise de la conversation"),
"resume section materialised in the run dir: {doc}"
);
assert!(
doc.contains("**Objectif :** Finir le lot P7"),
"objective injected: {doc}"
);
assert!(
doc.contains("On a terminé l'étape A."),
"summary injected: {doc}"
);
}
#[tokio::test]
async fn launch_without_handoff_provider_omits_resume_section() {
// No provider wired (legacy path) ⇒ no resume section, even with a cell
// conversation_id present.
let (launch, agent, fs) = launch_with_handoff(None);
let mut input = launch_input(agent.id);
input.conversation_id = Some(Uuid::from_u128(123).to_string());
launch.execute(input).await.expect("launch succeeds");
let doc = convention_doc(&fs);
assert!(
!doc.contains("# Reprise de la conversation"),
"no provider ⇒ no resume section: {doc}"
);
}
#[tokio::test]
async fn launch_with_no_conversation_id_omits_resume_section() {
// Provider wired and a handoff stored, but the cell has NO conversation_id ⇒
// nothing to resolve ⇒ no section, launch unaffected.
let store = FakeHandoffStore::with(conv_id(123), handoff_for("x", Some("y")));
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
let (launch, agent, fs) = launch_with_handoff(Some(provider));
// launch_input leaves conversation_id = None.
launch
.execute(launch_input(agent.id))
.await
.expect("launch succeeds");
let doc = convention_doc(&fs);
assert!(
!doc.contains("# Reprise de la conversation"),
"no conversation_id ⇒ no resume section: {doc}"
);
}
#[tokio::test]
async fn launch_with_invalid_uuid_conversation_id_omits_resume_section() {
// A non-UUID conversation_id (legacy CLI id / corrupt data) must NOT crash the
// launch and must inject no resume section (parse failure ⇒ best-effort None).
let store = FakeHandoffStore::with(conv_id(123), handoff_for("x", Some("y")));
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
let (launch, agent, fs) = launch_with_handoff(Some(provider));
let mut input = launch_input(agent.id);
input.conversation_id = Some("not-a-uuid".to_owned());
launch
.execute(input)
.await
.expect("invalid uuid must not fail the launch");
let doc = convention_doc(&fs);
assert!(
!doc.contains("# Reprise de la conversation"),
"invalid uuid ⇒ no resume section: {doc}"
);
}
#[tokio::test]
async fn launch_with_store_without_handoff_omits_resume_section() {
// Provider wired, valid conversation_id, but the store holds NO handoff for it
// (load ⇒ Ok(None)) ⇒ no section, launch succeeds.
let store = FakeHandoffStore::default(); // empty
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
let (launch, agent, fs) = launch_with_handoff(Some(provider));
let mut input = launch_input(agent.id);
input.conversation_id = Some(Uuid::from_u128(999).to_string());
launch.execute(input).await.expect("launch succeeds");
let doc = convention_doc(&fs);
assert!(
!doc.contains("# Reprise de la conversation"),
"store without handoff ⇒ no resume section: {doc}"
);
}