From c1411d3b69e245ce3cb74811485a768847619102 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 12 Jun 2026 13:42:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(persistence):=20P7=20=E2=80=94=20reprise?= =?UTF-8?q?=20par=20injection=20du=20handoff=20au=20lancement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/app-tauri/src/state.rs | 52 +++-- crates/application/src/agent/lifecycle.rs | 233 +++++++++++++++++++- crates/application/src/agent/mod.rs | 3 +- crates/application/src/lib.rs | 1 + crates/application/tests/agent_lifecycle.rs | 218 ++++++++++++++++++ 5 files changed, 484 insertions(+), 23 deletions(-) diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 4618f88..110a109 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -82,6 +82,25 @@ impl RecordTurnProvider for AppRecordTurnProvider { } } +/// Implémente [`HandoffProvider`](application::HandoffProvider) (lot P7) en +/// matérialisant un [`FsHandoffStore`] ciblant le **project root** du lancement en +/// cours. +/// +/// Même raison d'être que [`AppRecordTurnProvider`] : [`LaunchAgent`] est unique pour +/// tous les projets, alors que le handoff est **par project root** +/// (`/.ideai/conversations/`). On construit donc un store frais par lancement, +/// ciblant le bon dossier. Sans état (zéro champ), partagé via un simple `Arc`. +struct AppHandoffProvider; + +impl application::HandoffProvider for AppHandoffProvider { + fn handoff_store_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(Arc::new(FsHandoffStore::new(root)) as Arc) + } +} + /// Everything the IPC layer needs at runtime, managed by Tauri. /// /// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete @@ -590,19 +609,26 @@ impl AppState { // PTH : tout profil (Claude/Codex inclus) ouvre une cellule terminal. Le code // `launch_structured` reste en place (mort-code retiré au lot de nettoyage B-6). let _session_factory = session_factory; // décâblé en B-2 (nettoyage B-6) - let launch_agent = Arc::new(LaunchAgent::new( - Arc::clone(&contexts_port), - Arc::clone(&profile_store_port), - Arc::clone(&runtime_port), - Arc::clone(&fs_port), - Arc::clone(&pty_port), - Arc::clone(&skill_store_port), - Arc::clone(&terminal_sessions), - Arc::clone(&events_port), - Arc::clone(&ids) as Arc, - Arc::clone(&memory_recall_port), - Some(Arc::clone(&check_embedder_suggestion)), - )); + let launch_agent = Arc::new( + LaunchAgent::new( + Arc::clone(&contexts_port), + Arc::clone(&profile_store_port), + Arc::clone(&runtime_port), + Arc::clone(&fs_port), + Arc::clone(&pty_port), + Arc::clone(&skill_store_port), + Arc::clone(&terminal_sessions), + Arc::clone(&events_port), + Arc::clone(&ids) as Arc, + Arc::clone(&memory_recall_port), + Some(Arc::clone(&check_embedder_suggestion)), + ) + // Reprise conversationnelle (lot P7) : à chaque (re)lancement, si la cellule + // porte une conversation et qu'un handoff existe (`/.ideai/conversations/`), + // son résumé est réinjecté dans le convention file. Best-effort, additif : + // un handoff absent/illisible ⇒ lancement normal sans section. + .with_handoff_provider(Arc::new(AppHandoffProvider) as Arc), + ); // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ // profile/project/fs stores, the live-session registry and PTY port, and diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index fec5d08..a2799a9 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -19,9 +19,10 @@ use domain::ports::{ ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::{ - Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent, - ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, - ProjectPath, PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession, + Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId, + DomainEvent, Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, + NodeId, ProfileId, Project, ProjectPath, PtySize, SessionId, SessionKind, SessionStatus, Skill, + TerminalSession, }; use crate::error::AppError; @@ -39,6 +40,27 @@ const AGENTS_SUBDIR: &str = "agents"; /// contract. pub const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048; +/// Fournit le [`HandoffStore`] **lié au project root** du lancement en cours (lot P7). +/// +/// [`LaunchAgent`] est une **instance unique partagée par tous les projets** (le +/// project root arrive *par lancement* via [`LaunchAgentInput::project`]), alors que +/// le handoff conversationnel est **par project root** +/// (`/.ideai/conversations/`, comme la mémoire et le log). Les adapters `Fs*` +/// fixent leur racine **à la construction** et ne portent pas le root par appel : on +/// ne peut donc pas figer un `HandoffStore` global. Ce **port** lève la tension — +/// calqué sur [`crate::RecordTurnProvider`] — en matérialisant un store ciblant le +/// **bon** dossier à chaque résolution (les adapters `Fs*` ne font que des jointures +/// de chemin, leur construction est triviale). +/// +/// `None` ⇒ aucune injection de reprise (best-effort absente) : zéro régression pour +/// les call sites/tests qui ne le branchent pas. Implémenté dans `app-tauri` (seul +/// détenteur des adapters `Fs*`). +pub trait HandoffProvider: Send + Sync { + /// Construit le [`HandoffStore`] dont la persistance cible `root`. Appelé une fois + /// par lancement best-effort ; `None` ⇒ on saute silencieusement l'injection. + fn handoff_store_for(&self, root: &ProjectPath) -> Option>; +} + // --------------------------------------------------------------------------- // CreateAgentFromScratch // --------------------------------------------------------------------------- @@ -767,6 +789,13 @@ pub struct LaunchAgent { /// [`TerminalSessions`]. Peuplé quand un agent IA structuré est lancé. `None` en /// même temps que [`Self::session_factory`]. structured: Option>, + /// Provider du [`HandoffStore`] **par project root** (lot P7), pour réinjecter + /// **best-effort** le résumé de reprise (`summary_md` + objectif) de la + /// conversation de la cellule dans le convention file au (re)lancement. Injecté au + /// câblage via [`Self::with_handoff_provider`] ; `None` ⇒ aucune injection de + /// reprise (zéro régression pour les call sites/tests legacy). Un handoff + /// absent/illisible ⇒ lancement normal, jamais d'échec. + handoffs: Option>, } impl LaunchAgent { @@ -800,9 +829,25 @@ impl LaunchAgent { embedder_suggestion, session_factory: None, structured: None, + handoffs: None, } } + /// Branche le provider de **handoff de reprise (lot P7)** sur ce launcher : au + /// (re)lancement, si la cellule porte une `conversation_id` et qu'un [`Handoff`] + /// existe pour elle, son `summary_md` (et son objectif) sont injectés comme section + /// de contexte dans le convention file, à côté de la mémoire projet. Sans cet appel + /// (cas legacy / tests existants), aucune section de reprise n'est injectée — + /// signature de [`Self::new`] **inchangée**, donc aucun appelant existant ne casse. + /// + /// Builder additif (consomme `self`, retourne `Self`) : le câblage fait + /// `LaunchAgent::new(...).with_handoff_provider(provider)`. + #[must_use] + pub fn with_handoff_provider(mut self, handoffs: Arc) -> Self { + self.handoffs = Some(handoffs); + self + } + /// Branche le **routage structuré (§17.4)** sur ce launcher : fournit la fabrique /// [`AgentSessionFactory`] et le registre [`StructuredSessions`] injectés au /// composition root. Sans cet appel (cas legacy / tests existants), `execute` @@ -867,6 +912,31 @@ impl LaunchAgent { self.recall.recall(root, &query).await.unwrap_or_default() } + /// Résout **best-effort** le handoff de reprise de la cellule (lot P7), calqué sur + /// [`Self::resolve_memory`]. L'injection n'a lieu que si : + /// - la cellule porte une `conversation_id` ([`LaunchAgentInput::conversation_id`]), + /// - le provider de handoff est câblé ([`Self::with_handoff_provider`]), + /// - cette chaîne se parse en [`ConversationId`] (UUID), et + /// - un [`Handoff`] existe effectivement pour cette conversation. + /// + /// **Jamais bloquant** : un parse en échec, l'absence de provider/handoff, ou + /// toute erreur du store dégradent vers `None` (pas de section de reprise), exactly + /// comme une mémoire absente — un lancement n'est jamais cassé par la reprise. + async fn resolve_handoff( + &self, + root: &ProjectPath, + cell_conversation_id: Option<&str>, + ) -> Option { + let provider = self.handoffs.as_ref()?; + let raw = cell_conversation_id?; + // Le `conversation_id` d'une cellule est un identifiant de paire (UUID, §C3). + // Un id non-UUID (ancien id CLI, donnée corrompue) ⇒ pas d'injection. + let conversation = ConversationId::from_uuid(uuid::Uuid::parse_str(raw).ok()?); + let store = provider.handoff_store_for(root)?; + // Toute erreur de lecture/désérialisation ⇒ pas d'injection (best-effort). + store.load(conversation).await.ok().flatten() + } + /// Reads the shared project context from `.ideai/CONTEXT.md`. /// /// A missing file is normal for existing projects and simply omits the @@ -1061,6 +1131,12 @@ impl LaunchAgent { let memory = self .resolve_memory(&input.project.root, content.as_str()) .await; + // Reprise conversationnelle (lot P7) : best-effort, additif. Si la cellule a une + // conversation et qu'un handoff existe, son résumé est injecté dans le convention + // file (à côté de la mémoire projet). Indépendant du provider/resumable id. + let handoff = self + .resolve_handoff(&input.project.root, input.conversation_id.as_deref()) + .await; // Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent // has just read the project memory, so this is the moment to check whether a // semantic embedder would now help. Fully isolated from the launch outcome — @@ -1080,6 +1156,7 @@ impl LaunchAgent { &project_context, &skills, &memory, + handoff.as_ref(), profile.mcp.is_some(), &mut spec, ) @@ -1328,6 +1405,7 @@ impl LaunchAgent { project_context: &str, skills: &[Skill], memory: &[MemoryIndexEntry], + handoff: Option<&Handoff>, mcp_enabled: bool, spec: &mut SpawnSpec, ) -> Result<(), AppError> { @@ -1346,6 +1424,7 @@ impl LaunchAgent { content.as_str(), skills, memory, + handoff, mcp_enabled, ); let path = RemotePath::new(join(&spec.cwd, &target)); @@ -1711,6 +1790,12 @@ fn json_escape(s: &str) -> String { /// 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. /// +/// The conversation `handoff` (lot P7, ARCHITECTURE §19) is appended **last**, as a +/// `# Reprise de la conversation` section carrying the optional objective and the +/// cumulative `summary_md`. Placed after the project memory (the most situational, +/// "where we left off" context an agent reads), it is omitted entirely when `handoff` +/// is `None`, so an agent with no handoff gets exactly the previous document. +/// /// Kept as a **pure** function (no I/O) so it is unit-testable in isolation. /// /// The orchestration prose adapts to the agent's **surface** (cadrage v3, Décision @@ -1726,6 +1811,7 @@ pub(crate) fn compose_convention_file( agent_md: &str, skills: &[Skill], memory: &[MemoryIndexEntry], + handoff: Option<&Handoff>, mcp_enabled: bool, ) -> String { let mut out = String::new(); @@ -1805,6 +1891,22 @@ pub(crate) fn compose_convention_file( out.push_str(")\n"); } } + + // Reprise conversationnelle (lot P7) : section finale, la plus situationnelle + // (« où on en était »), placée après la mémoire projet. Omise sans handoff. + if let Some(handoff) = handoff { + out.push_str("\n\n---\n\n# Reprise de la conversation\n\n"); + if let Some(objective) = &handoff.objective { + if !objective.trim().is_empty() { + out.push_str("**Objectif :** "); + out.push_str(objective.trim()); + out.push_str("\n\n"); + } + } + out.push_str(handoff.summary_md.as_str()); + out.push('\n'); + } + out } @@ -1883,6 +1985,7 @@ mod tests { "# Persona\n\nDo things.", &[], &[], + None, false, ); @@ -1907,6 +2010,7 @@ mod tests { "# Persona\n\nDo X.", &[], &[], + None, false, ); @@ -1940,6 +2044,7 @@ mod tests { s(2, "review", "REVIEW_BODY"), ], &[], + None, false, ); @@ -1972,7 +2077,7 @@ mod tests { // 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.", &[], &[], false); + compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false); assert!( !with_empty.contains("# Mémoire projet"), "no memory ⇒ no memory section" @@ -1981,7 +2086,7 @@ mod tests { // 4-arg call; this pins the omission contract explicitly). assert_eq!( with_empty, - compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], false) + compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false) ); } @@ -2001,6 +2106,7 @@ mod tests { MemoryType::Reference, ), ], + None, false, ); @@ -2042,6 +2148,7 @@ mod tests { "# Persona", std::slice::from_ref(&skill), &[mem("note", "Note", "a hook", MemoryType::Project)], + None, false, ); @@ -2061,7 +2168,7 @@ mod tests { fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() { // mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping // the ban on the provider's native subagents (cadrage v3, Décision 3). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); // Native IdeA orchestration tools surfaced. assert!( @@ -2086,7 +2193,7 @@ mod tests { fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() { // B-5 — the solicited-agent side of the protocol: a delegated task arrives as // `[IdeA · tâche …]` and MUST be answered via `idea_reply`, never plain text. - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); assert!( doc.contains("idea_reply"), "MCP prose must instruct answering via idea_reply" @@ -2097,7 +2204,7 @@ mod tests { ); // Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply // instruction (zero regression on the file path). - let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], false); + let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); assert!(!file_doc.contains("idea_reply")); } @@ -2105,7 +2212,7 @@ mod tests { fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() { // mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged, // and crucially WITHOUT the `idea_*` tools (zero regression). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], false); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); assert!( doc.contains(".ideai/requests"), @@ -2176,6 +2283,114 @@ mod tests { assert!(!args.contains(&"--endpoint")); } + /// Builds a [`Handoff`] for the pure-section tests (lot P7). + fn handoff(summary: &str, objective: Option<&str>) -> Handoff { + Handoff::new( + summary, + domain::TurnId::from_uuid(uuid::Uuid::from_u128(1)), + objective.map(ToOwned::to_owned), + ) + } + + #[test] + fn compose_convention_file_appends_handoff_section_after_memory_with_objective() { + // lot P7 — a handoff with an objective ⇒ a final `# Reprise de la conversation` + // section carrying the `**Objectif :**` line and the cumulative summary, placed + // AFTER the `# Mémoire projet` section. + let memory = [mem( + "git-optional", + "Git optionnel", + "git reste un simple tool", + MemoryType::Project, + )]; + let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7")); + let doc = compose_convention_file( + "/root", + "", + "# Persona", + &[], + &memory, + Some(&h), + false, + ); + + assert!( + doc.contains("# Reprise de la conversation"), + "resume section present: {doc}" + ); + assert!( + doc.contains("**Objectif :** Livrer le lot P7"), + "objective line present: {doc}" + ); + assert!( + doc.contains("Résumé : on a fini l'étape 2."), + "summary present: {doc}" + ); + + // Ordering: the resume section comes AFTER the project memory. + let memory_at = doc.find("# Mémoire projet").unwrap(); + let resume_at = doc.find("# Reprise de la conversation").unwrap(); + assert!( + memory_at < resume_at, + "resume must follow the project memory: {doc}" + ); + // And the objective line precedes the summary inside the section. + let objective_at = doc.find("**Objectif :**").unwrap(); + let summary_at = doc.find("Résumé : on a fini").unwrap(); + assert!( + resume_at < objective_at && objective_at < summary_at, + "objective then summary inside the section: {doc}" + ); + } + + #[test] + fn compose_convention_file_handoff_without_objective_omits_objective_line() { + // objective = None ⇒ the section and the summary are present, but NO + // `**Objectif :**` line. + let h = handoff("Juste le résumé.", None); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false); + + assert!( + doc.contains("# Reprise de la conversation"), + "section present even without objective: {doc}" + ); + assert!(doc.contains("Juste le résumé."), "summary present: {doc}"); + assert!( + !doc.contains("**Objectif :**"), + "no objective line when objective is None: {doc}" + ); + } + + #[test] + fn compose_convention_file_handoff_blank_objective_omits_objective_line() { + // objective = Some(" ") (whitespace only) ⇒ trimmed to empty ⇒ no objective + // line, but the section + summary still render. + let h = handoff("Le résumé.", Some(" ")); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false); + + assert!(doc.contains("# Reprise de la conversation")); + assert!(doc.contains("Le résumé.")); + assert!( + !doc.contains("**Objectif :**"), + "blank objective is omitted: {doc}" + ); + } + + #[test] + fn compose_convention_file_without_handoff_omits_resume_section() { + // handoff = None ⇒ no `# Reprise de la conversation` section at all + // (zero regression for launches with no resume). + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + assert!( + !doc.contains("# Reprise de la conversation"), + "no handoff ⇒ no resume section: {doc}" + ); + assert!( + !doc.contains("**Objectif :**"), + "no handoff ⇒ no objective line: {doc}" + ); + } + #[test] fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() { let json = claude_settings_seed("/home/me/proj"); diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index beb8419..97b361e 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -25,7 +25,8 @@ pub use resume::{ }; pub use lifecycle::{ ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, - CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, LaunchAgent, + CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider, + LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor, UpdateAgentContext, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 9cb380d..fa0f1dd 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -33,6 +33,7 @@ pub use agent::{ ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, + HandoffProvider, InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime, diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index cdf2514..12880f6 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -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>>); + +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, 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); + +impl HandoffProvider for FakeHandoffProvider { + fn handoff_store_for(&self, _root: &ProjectPath) -> Option> { + 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>) -> (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; + 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; + 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; + 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; + 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}" + ); +}