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

@ -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**
/// (`<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
// ---------------------------------------------------------------------------
@ -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<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 {
@ -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<dyn HandoffProvider>) -> 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<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`.
///
/// 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");

View File

@ -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,

View File

@ -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,