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:
@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user