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