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:
@ -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,7 +609,8 @@ 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(
|
||||||
|
LaunchAgent::new(
|
||||||
Arc::clone(&contexts_port),
|
Arc::clone(&contexts_port),
|
||||||
Arc::clone(&profile_store_port),
|
Arc::clone(&profile_store_port),
|
||||||
Arc::clone(&runtime_port),
|
Arc::clone(&runtime_port),
|
||||||
@ -602,7 +622,13 @@ impl AppState {
|
|||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
Arc::clone(&memory_recall_port),
|
Arc::clone(&memory_recall_port),
|
||||||
Some(Arc::clone(&check_embedder_suggestion)),
|
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
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user