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

@ -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**
/// (`<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<Arc<dyn domain::HandoffStore>> {
Some(Arc::new(FsHandoffStore::new(root)) as Arc<dyn domain::HandoffStore>)
}
}
/// Everything the IPC layer needs at runtime, managed by Tauri. /// Everything the IPC layer needs at runtime, managed by Tauri.
/// ///
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete /// 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 // 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). // `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 _session_factory = session_factory; // décâblé en B-2 (nettoyage B-6)
let launch_agent = Arc::new(LaunchAgent::new( let launch_agent = Arc::new(
Arc::clone(&contexts_port), LaunchAgent::new(
Arc::clone(&profile_store_port), Arc::clone(&contexts_port),
Arc::clone(&runtime_port), Arc::clone(&profile_store_port),
Arc::clone(&fs_port), Arc::clone(&runtime_port),
Arc::clone(&pty_port), Arc::clone(&fs_port),
Arc::clone(&skill_store_port), Arc::clone(&pty_port),
Arc::clone(&terminal_sessions), Arc::clone(&skill_store_port),
Arc::clone(&events_port), Arc::clone(&terminal_sessions),
Arc::clone(&ids) as Arc<dyn IdGenerator>, Arc::clone(&events_port),
Arc::clone(&memory_recall_port), Arc::clone(&ids) as Arc<dyn IdGenerator>,
Some(Arc::clone(&check_embedder_suggestion)), 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 (`<root>/.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<dyn application::HandoffProvider>),
);
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ // 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 // profile/project/fs stores, the live-session registry and PTY port, and

View File

@ -19,9 +19,10 @@ use domain::ports::{
ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError, ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
}; };
use domain::{ use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, DomainEvent, Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType,
ProjectPath, PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession, NodeId, ProfileId, Project, ProjectPath, PtySize, SessionId, SessionKind, SessionStatus, Skill,
TerminalSession,
}; };
use crate::error::AppError; use crate::error::AppError;
@ -39,6 +40,27 @@ const AGENTS_SUBDIR: &str = "agents";
/// contract. /// contract.
pub const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048; 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**
/// (`<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<Arc<dyn HandoffStore>>;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CreateAgentFromScratch // CreateAgentFromScratch
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -767,6 +789,13 @@ pub struct LaunchAgent {
/// [`TerminalSessions`]. Peuplé quand un agent IA structuré est lancé. `None` en /// [`TerminalSessions`]. Peuplé quand un agent IA structuré est lancé. `None` en
/// même temps que [`Self::session_factory`]. /// même temps que [`Self::session_factory`].
structured: Option<Arc<StructuredSessions>>, structured: Option<Arc<StructuredSessions>>,
/// 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<Arc<dyn HandoffProvider>>,
} }
impl LaunchAgent { impl LaunchAgent {
@ -800,9 +829,25 @@ impl LaunchAgent {
embedder_suggestion, embedder_suggestion,
session_factory: None, session_factory: None,
structured: 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<dyn HandoffProvider>) -> Self {
self.handoffs = Some(handoffs);
self
}
/// Branche le **routage structuré (§17.4)** sur ce launcher : fournit la fabrique /// Branche le **routage structuré (§17.4)** sur ce launcher : fournit la fabrique
/// [`AgentSessionFactory`] et le registre [`StructuredSessions`] injectés au /// [`AgentSessionFactory`] et le registre [`StructuredSessions`] injectés au
/// composition root. Sans cet appel (cas legacy / tests existants), `execute` /// 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() 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<Handoff> {
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`. /// Reads the shared project context from `.ideai/CONTEXT.md`.
/// ///
/// A missing file is normal for existing projects and simply omits the /// A missing file is normal for existing projects and simply omits the
@ -1061,6 +1131,12 @@ impl LaunchAgent {
let memory = self let memory = self
.resolve_memory(&input.project.root, content.as_str()) .resolve_memory(&input.project.root, content.as_str())
.await; .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 // 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 // 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 — // semantic embedder would now help. Fully isolated from the launch outcome —
@ -1080,6 +1156,7 @@ impl LaunchAgent {
&project_context, &project_context,
&skills, &skills,
&memory, &memory,
handoff.as_ref(),
profile.mcp.is_some(), profile.mcp.is_some(),
&mut spec, &mut spec,
) )
@ -1328,6 +1405,7 @@ impl LaunchAgent {
project_context: &str, project_context: &str,
skills: &[Skill], skills: &[Skill],
memory: &[MemoryIndexEntry], memory: &[MemoryIndexEntry],
handoff: Option<&Handoff>,
mcp_enabled: bool, mcp_enabled: bool,
spec: &mut SpawnSpec, spec: &mut SpawnSpec,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
@ -1346,6 +1424,7 @@ impl LaunchAgent {
content.as_str(), content.as_str(),
skills, skills,
memory, memory,
handoff,
mcp_enabled, mcp_enabled,
); );
let path = RemotePath::new(join(&spec.cwd, &target)); 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 /// 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. /// 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. /// 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 /// 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, agent_md: &str,
skills: &[Skill], skills: &[Skill],
memory: &[MemoryIndexEntry], memory: &[MemoryIndexEntry],
handoff: Option<&Handoff>,
mcp_enabled: bool, mcp_enabled: bool,
) -> String { ) -> String {
let mut out = String::new(); let mut out = String::new();
@ -1805,6 +1891,22 @@ pub(crate) fn compose_convention_file(
out.push_str(")\n"); 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 out
} }
@ -1883,6 +1985,7 @@ mod tests {
"# Persona\n\nDo things.", "# Persona\n\nDo things.",
&[], &[],
&[], &[],
None,
false, false,
); );
@ -1907,6 +2010,7 @@ mod tests {
"# Persona\n\nDo X.", "# Persona\n\nDo X.",
&[], &[],
&[], &[],
None,
false, false,
); );
@ -1940,6 +2044,7 @@ mod tests {
s(2, "review", "REVIEW_BODY"), s(2, "review", "REVIEW_BODY"),
], ],
&[], &[],
None,
false, false,
); );
@ -1972,7 +2077,7 @@ mod tests {
// An empty `memory` must yield exactly the previous document: no section, // An empty `memory` must yield exactly the previous document: no section,
// byte-for-byte identical to the no-skills/no-memory composition. // byte-for-byte identical to the no-skills/no-memory composition.
let with_empty = let with_empty =
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], false); compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false);
assert!( assert!(
!with_empty.contains("# Mémoire projet"), !with_empty.contains("# Mémoire projet"),
"no memory ⇒ no memory section" "no memory ⇒ no memory section"
@ -1981,7 +2086,7 @@ mod tests {
// 4-arg call; this pins the omission contract explicitly). // 4-arg call; this pins the omission contract explicitly).
assert_eq!( assert_eq!(
with_empty, 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, MemoryType::Reference,
), ),
], ],
None,
false, false,
); );
@ -2042,6 +2148,7 @@ mod tests {
"# Persona", "# Persona",
std::slice::from_ref(&skill), std::slice::from_ref(&skill),
&[mem("note", "Note", "a hook", MemoryType::Project)], &[mem("note", "Note", "a hook", MemoryType::Project)],
None,
false, false,
); );
@ -2061,7 +2168,7 @@ mod tests {
fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() { 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 // mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping
// the ban on the provider's native subagents (cadrage v3, Décision 3). // 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. // Native IdeA orchestration tools surfaced.
assert!( assert!(
@ -2086,7 +2193,7 @@ mod tests {
fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() { 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 // 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. // `[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!( assert!(
doc.contains("idea_reply"), doc.contains("idea_reply"),
"MCP prose must instruct answering via 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 // Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply
// instruction (zero regression on the file path). // 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")); assert!(!file_doc.contains("idea_reply"));
} }
@ -2105,7 +2212,7 @@ mod tests {
fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() { fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
// mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged, // mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged,
// and crucially WITHOUT the `idea_*` tools (zero regression). // 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!( assert!(
doc.contains(".ideai/requests"), doc.contains(".ideai/requests"),
@ -2176,6 +2283,114 @@ mod tests {
assert!(!args.contains(&"--endpoint")); 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] #[test]
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() { fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
let json = claude_settings_seed("/home/me/proj"); let json = claude_settings_seed("/home/me/proj");

View File

@ -25,7 +25,8 @@ pub use resume::{
}; };
pub use lifecycle::{ pub use lifecycle::{
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, LaunchAgent, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
LaunchAgent,
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
UpdateAgentContext, UpdateAgentContext,

View File

@ -33,6 +33,7 @@ pub use agent::{
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
HandoffProvider,
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent, InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime, ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime,

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)); 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}"
);
}