Files
IdeA/crates/application/src/agent/lifecycle.rs
Blomios 6236cd727b feat(permissions): LP4-4 — enforcement Landlock sur le chemin structuré
Étend l'enforcement OS au chemin structuré (sessions Claude/Codex mode JSON),
jusqu'ici seulement advisory. Approche validée par l'Architecte : transposer la
technique du PTY plutôt qu'un pre_exec (rejeté — landlock alloue, deadlock malloc
post-fork en process multithreadé).

Mécanique (cfg(target_os=linux)) : run_turn_sandboxed/drain_sandboxed exécutent
enforce(plan) sur un thread jetable AVANT le spawn std, puis std::process::spawn
depuis ce thread ; l'enfant hérite le domaine Landlock via les credentials de la
tâche (garanti à travers fork/clone/execve, y compris posix_spawn — pas de pre_exec
nécessaire, forbid(unsafe_code) préservé). Fail-closed sur Err d'enforce (aucun
child). Timeout sous sandbox : oneshot killer + tokio::time::timeout → kill → EOF →
reap (pas de zombie/thread bloqué). Chemin non-sandboxé (plan None / pas d'enforcer /
non-Linux) = drain async tokio inchangé.

Contrat : SpawnLine.sandbox ; AgentSessionFactory::start(.., sandbox) ;
StructuredSessionFactory::with_sandbox_enforcer (jumeau du PTY) ; plan calculé en
step 5d de lifecycle relayé à launch_structured ; default_enforcer() injecté au
composition root.

Tests : 7 invariants e2e (parité, companion négatif, fail-closed, no-op natif,
confinement de l'irréversibilité entre tours, timeout, resume préservé) — zéro
token (sh/FakeCli). 80 suites vertes, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:47:33 +02:00

3201 lines
140 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Agent lifecycle use cases (ARCHITECTURE §6, L6).
//!
//! These own the *project-agent* side (distinct from the profile side in
//! [`super::usecases`]): creating agents and their `.md` contexts under
//! `.ideai/`, listing/reading/updating them, and — the centrepiece —
//! [`LaunchAgent`], which resolves the agent's profile + context, applies the
//! profile's context-injection strategy, opens a PTY cell at the right `cwd` and
//! spawns the CLI.
//!
//! Every use case talks **only to ports** ([`AgentContextStore`], [`ProfileStore`],
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
//! a concrete adapter or Tauri.
use std::collections::HashMap;
use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
ProfileStore, ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::profile::{McpConfigStrategy, StructuredAdapter};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry,
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId,
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
};
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc};
use crate::project::project_context_path;
use crate::terminal::{StructuredSessions, TerminalSessions};
/// Directory (relative to `.ideai/`) under which agent contexts are written.
const AGENTS_SUBDIR: &str = "agents";
/// Token budget of the project-memory recall injected into the convention file at
/// agent activation (ARCHITECTURE §14.5.4). Bounds the number of index entries
/// (étage 1) handed to the agent. Internal and intentionally **not yet exposed in
/// config**: it may later become a per-project setting without changing the
/// 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>>;
}
/// Fournit le [`ProviderSessionStore`] **lié au project root** du lancement en cours
/// (lot P8b). Jumeau stateless de [`HandoffProvider`] : même tension (instance
/// [`LaunchAgent`] partagée vs adapter `Fs*` à racine fixée à la construction),
/// même réponse (matérialiser le store ciblant le **bon** dossier à chaque appel).
///
/// Sert à ranger, après un lancement structuré, l'id de session **moteur**
/// (resumable du provider) sous la clé de paire IdeA dans `providers.json`. `None`
/// ⇒ aucune écriture (best-effort absente) : zéro régression pour les call
/// sites/tests qui ne le branchent pas. Implémenté dans `app-tauri`.
pub trait ProviderSessionProvider: Send + Sync {
/// Construit le [`ProviderSessionStore`] dont la persistance cible `root`. Appelé
/// une fois par lancement best-effort ; `None` ⇒ on saute silencieusement
/// l'écriture du resumable.
fn provider_session_store_for(
&self,
root: &ProjectPath,
) -> Option<Arc<dyn ProviderSessionStore>>;
}
// ---------------------------------------------------------------------------
// CreateAgentFromScratch
// ---------------------------------------------------------------------------
/// Input for [`CreateAgentFromScratch::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentInput {
/// The project that owns the agent.
pub project: Project,
/// Display name of the agent.
pub name: String,
/// Runtime profile the agent launches with.
pub profile_id: ProfileId,
/// Initial `.md` content (empty when `None`).
pub initial_content: Option<String>,
}
/// Output of [`CreateAgentFromScratch::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentOutput {
/// The freshly-created agent.
pub agent: Agent,
}
/// Creates a project agent from scratch: mints an id, derives a unique `.md`
/// path, records the manifest entry, then writes the (possibly empty) context.
pub struct CreateAgentFromScratch {
contexts: Arc<dyn AgentContextStore>,
ids: Arc<dyn domain::ports::IdGenerator>,
events: Arc<dyn EventBus>,
}
impl CreateAgentFromScratch {
/// Builds the use case from its injected ports.
#[must_use]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
ids: Arc<dyn domain::ports::IdGenerator>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
contexts,
ids,
events,
}
}
/// Executes creation.
///
/// Ordering matters: the manifest entry is persisted **before** the context
/// is written, because [`AgentContextStore::write_context`] resolves the
/// on-disk path from the manifest.
///
/// # Errors
/// - [`AppError::Invalid`] if the name is empty or the manifest would become
/// inconsistent,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: CreateAgentInput) -> Result<CreateAgentOutput, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let id = AgentId::from_uuid(self.ids.new_uuid());
let md_path = unique_md_path(&input.name, &manifest);
let agent = Agent::new(
id,
input.name,
md_path,
input.profile_id,
AgentOrigin::Scratch,
false,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
// Append the entry and re-validate the whole manifest (unique md_paths).
let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
// Now the path resolves: write the initial context.
let md = MarkdownDoc::new(input.initial_content.unwrap_or_default());
self.contexts
.write_context(&input.project, &agent.id, &md)
.await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id,
});
Ok(CreateAgentOutput { agent })
}
}
// ---------------------------------------------------------------------------
// ListAgents
// ---------------------------------------------------------------------------
/// Input for [`ListAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListAgentsInput {
/// The project whose agents to list.
pub project: Project,
}
/// Output of [`ListAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListAgentsOutput {
/// The project's agents (reconstructed from the manifest).
pub agents: Vec<Agent>,
}
/// Lists a project's agents by reconstructing them from the manifest entries.
pub struct ListAgents {
contexts: Arc<dyn AgentContextStore>,
}
impl ListAgents {
/// Builds the use case from the [`AgentContextStore`] port.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Loads the manifest and folds each entry back into an [`Agent`].
///
/// # Errors
/// - [`AppError::Store`] on persistence failure,
/// - [`AppError::Invalid`] if a persisted entry violates an agent invariant.
pub async fn execute(&self, input: ListAgentsInput) -> Result<ListAgentsOutput, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let agents = manifest
.entries
.iter()
.map(|e| {
e.to_agent()
.map_err(|err| AppError::Invalid(err.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(ListAgentsOutput { agents })
}
}
// ---------------------------------------------------------------------------
// ReadAgentContext / UpdateAgentContext
// ---------------------------------------------------------------------------
/// Input for [`ReadAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadAgentContextInput {
/// The owning project.
pub project: Project,
/// The agent whose `.md` to read.
pub agent_id: AgentId,
}
/// Output of [`ReadAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadAgentContextOutput {
/// The agent's Markdown context.
pub content: MarkdownDoc,
}
/// Reads an agent's `.md` context.
pub struct ReadAgentContext {
contexts: Arc<dyn AgentContextStore>,
}
impl ReadAgentContext {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Reads the context.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent (or its `.md`) is unknown,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: ReadAgentContextInput,
) -> Result<ReadAgentContextOutput, AppError> {
let content = self
.contexts
.read_context(&input.project, &input.agent_id)
.await?;
Ok(ReadAgentContextOutput { content })
}
}
/// Input for [`UpdateAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentContextInput {
/// The owning project.
pub project: Project,
/// The agent whose `.md` to overwrite.
pub agent_id: AgentId,
/// New Markdown content.
pub content: String,
}
/// Overwrites an agent's `.md` context.
pub struct UpdateAgentContext {
contexts: Arc<dyn AgentContextStore>,
}
impl UpdateAgentContext {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Writes the new context.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: UpdateAgentContextInput) -> Result<(), AppError> {
let md = MarkdownDoc::new(input.content);
self.contexts
.write_context(&input.project, &input.agent_id, &md)
.await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// ChangeAgentProfile
// ---------------------------------------------------------------------------
/// Input for [`ChangeAgentProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeAgentProfileInput {
/// The owning project.
pub project: Project,
/// The agent whose runtime profile to hot-swap.
pub agent_id: AgentId,
/// The new runtime profile.
pub profile_id: ProfileId,
/// Terminal height in rows for a possible hot relaunch.
pub rows: u16,
/// Terminal width in columns for a possible hot relaunch.
pub cols: u16,
}
/// Output of [`ChangeAgentProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeAgentProfileOutput {
/// The mutated agent (now carrying the new profile).
pub agent: Agent,
/// The freshly relaunched session, when a live session was hot-swapped.
pub relaunched: Option<TerminalSession>,
}
/// Hot-swaps an existing agent's runtime profile (ARCHITECTURE §15.1).
///
/// **Single Responsibility**: mutate the profile in the manifest, clear the now
/// foreign conversation id on every persisted layout cell hosting the agent, and
/// — if the agent is live — kill its PTY and re-sequence the session in the same
/// cell with the new engine. The relaunch is **composed**, not duplicated: this
/// use case *calls* [`LaunchAgent::execute`] rather than re-implementing the spawn.
///
/// Ports consumed (ISP — only what is needed): [`AgentContextStore`] (manifest),
/// [`ProfileStore`] (validate the target profile), [`ProjectStore`] +
/// [`FileSystem`] (clean the conversation id on persisted layouts, exactly like
/// [`crate::layout::SnapshotRunningAgents`]), [`TerminalSessions`] + [`PtyPort`]
/// (detect/kill a live session), an [`Arc<LaunchAgent>`] for the hot relaunch, and
/// [`EventBus`] to publish.
pub struct ChangeAgentProfile {
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
sessions: Arc<TerminalSessions>,
pty: Arc<dyn PtyPort>,
launch: Arc<LaunchAgent>,
events: Arc<dyn EventBus>,
/// Registre des sessions **structurées** (§17.5), pour un « kill » polymorphe
/// (§17.4) : si l'agent a une session structurée vivante, on la `shutdown()` au
/// lieu de tuer un PTY. Injecté au câblage via [`Self::with_structured`] ; `None`
/// ⇒ seul le registre PTY est consulté (mode legacy / tests existants).
structured: Option<Arc<StructuredSessions>>,
/// Registre des **projecteurs de permissions** (lot LP3-4), pour nettoyer au swap
/// les fichiers `Replace` orphelins possédés par l'ANCIEN profil que le nouveau ne
/// réécrira pas. Injecté via [`Self::with_permission_projectors`] (le câblage passe
/// le **même** `Arc` que celui donné au `LaunchAgent` interne). `None` ⇒ aucun
/// nettoyage (comportement legacy ; la re-projection du nouveau profil, elle, reste
/// assurée par le `LaunchAgent` composé).
projectors: Option<Arc<PermissionProjectorRegistry>>,
}
impl ChangeAgentProfile {
/// Builds the use case from its injected ports (and the composed launcher).
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
sessions: Arc<TerminalSessions>,
pty: Arc<dyn PtyPort>,
launch: Arc<LaunchAgent>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
contexts,
profiles,
projects,
fs,
sessions,
pty,
launch,
events,
structured: None,
projectors: None,
}
}
/// Branche le registre des sessions **structurées** (§17.4) pour un kill
/// polymorphe au hot-swap. Builder additif : signature de [`Self::new`]
/// **inchangée** (les tests A existants restent verts), le câblage fait
/// `ChangeAgentProfile::new(...).with_structured(registry)`.
#[must_use]
pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
self.structured = Some(structured);
self
}
/// Branche le **registre de projecteurs de permissions** (lot LP3-4) pour le
/// nettoyage des fichiers orphelins au swap cross-profile. Le câblage passe le
/// **même** `Arc<PermissionProjectorRegistry>` que celui injecté au `LaunchAgent`
/// interne (source unique de vérité). Builder additif : signature de [`Self::new`]
/// inchangée ; `None` ⇒ pas de nettoyage (legacy).
#[must_use]
pub fn with_permission_projectors(
mut self,
projectors: Arc<PermissionProjectorRegistry>,
) -> Self {
self.projectors = Some(projectors);
self
}
/// Executes the hot-swap, following the 7-step algorithm of §15.1.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent or the target profile is unknown,
/// - [`AppError::Invalid`] on a manifest/layout invariant violation,
/// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
/// the respective port failures (manifest, layouts, PTY kill, relaunch).
pub async fn execute(
&self,
input: ChangeAgentProfileInput,
) -> Result<ChangeAgentProfileOutput, AppError> {
// 1. Load the manifest and resolve the agent's entry (NotFound otherwise).
let manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
// Capture the PREVIOUS profile id BEFORE mutation (lot LP3-4): it identifies
// the projector whose now-orphan `Replace` files must be cleaned up at the swap.
let previous_profile_id = entry.profile_id;
// 2. Same profile ⇒ no-op: return the agent unchanged, no kill/relaunch,
// no event.
if entry.profile_id == input.profile_id {
let agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
return Ok(ChangeAgentProfileOutput {
agent,
relaunched: None,
});
}
// 3. Validate that the target profile is a known one (ProfileStore.list).
// Capture both the NEW profile (validation) and the PREVIOUS profile (for
// the LP3-4 cleanup; absent if it was deleted ⇒ cleanup is skipped).
let profiles = self.profiles.list().await?;
let Some(new_profile) = profiles.iter().find(|p| p.id == input.profile_id).cloned() else {
return Err(AppError::NotFound(format!("profile {}", input.profile_id)));
};
let previous_profile = profiles
.iter()
.find(|p| p.id == previous_profile_id)
.cloned();
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
// and persist.
let mut entries = manifest.entries;
let mut mutated_agent = None;
for e in &mut entries {
if e.agent_id == input.agent_id {
e.profile_id = input.profile_id;
let agent = e
.to_agent()
.map_err(|err| AppError::Invalid(err.to_string()))?;
mutated_agent = Some(agent);
}
}
let agent =
mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
// 5. Invalidate the engine link on every persisted layout: for each leaf
// hosting this agent, drop the (now foreign) engine resumable cache and
// reset the running flag, while **preserving** the stable IdeA pair id.
// Mirrors `SnapshotRunningAgents`: resolve_doc → walk agent_leaves →
// mutate → persist_doc (if changed). Returns the preserved pair id.
let pair_id = self
.invalidate_engine_link(&input.project, &input.agent_id)
.await?;
// 5b. (lot LP3-4) Clean up the PREVIOUS profile's now-orphan permission files
// in the agent's (stable) run dir, BEFORE the relaunch re-projects the new
// profile — so we never delete a file the new profile is about to write.
// Only `Replace`-owned paths of the old projector that the new projector
// does NOT also own are removed (best-effort). `MergeToml` files are never
// touched. No-op without a projector registry.
self.cleanup_swapped_out_files(
&input.project.root,
&input.agent_id,
previous_profile.as_ref(),
&new_profile,
)
.await;
// 6. A live session? Kill its PTY then relaunch in the same cell with the
// new profile, carrying the **preserved** pair id (so the handoff is
// re-injected and resume routes via providers.json[new provider]).
let relaunched = self.relaunch_if_live(&input, pair_id).await?;
// 7. Publish the profile change and return.
self.events.publish(DomainEvent::AgentProfileChanged {
agent_id: input.agent_id,
profile_id: input.profile_id,
});
Ok(ChangeAgentProfileOutput { agent, relaunched })
}
/// Invalidates the **engine link** on every persisted layout leaf hosting
/// `agent_id` (step 5), while **preserving** the IdeA pair conversation id.
///
/// Post-P8a, `LeafCell::conversation_id` is a stable, provider-independent
/// **pair id** (User↔agent) that retrieves the work log + handoff and must
/// survive a profile swap. Only the engine-side cache
/// (`LeafCell::engine_session_id`, a foreign resumable for the new engine) is
/// cleared; the source of truth for resume routing is `providers.json`. The
/// running flag is reset too.
///
/// Returns the **preserved pair id** of the agent (the first non-`None` leaf;
/// every leaf of this agent carries the same User↔agent pair id), or `None`
/// when no hosting leaf was found. Persists only when something actually
/// changed (a project with no such leaf is a no-op write).
async fn invalidate_engine_link(
&self,
project: &Project,
agent_id: &AgentId,
) -> Result<Option<String>, AppError> {
let project = self.projects.load_project(project.id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
let mut changed = false;
let mut pair_id: Option<String> = None;
for named in &mut doc.layouts {
for (leaf_id, leaf_agent) in named.tree.agent_leaves() {
if &leaf_agent != agent_id {
continue;
}
// Capture the preserved pair id (stable across all leaves of this
// agent) — used to relaunch with handoff re-injection (P7).
if pair_id.is_none() {
if let Some(cid) = named
.tree
.leaf(leaf_id)
.and_then(|l| l.conversation_id.clone())
{
pair_id = Some(cid);
}
}
// Pure ops — only NodeNotFound is possible, which cannot happen
// since `leaf_id` came from this very tree.
//
// Preserve `conversation_id` (stable pair id); only drop the
// engine-side resumable cache (foreign to the new engine).
named.tree = named
.tree
.set_cell_engine_session(leaf_id, None)
.map_err(|e| AppError::Invalid(e.to_string()))?;
named.tree = named
.tree
.set_agent_running(leaf_id, false)
.map_err(|e| AppError::Invalid(e.to_string()))?;
changed = true;
}
}
if changed {
persist_doc(self.fs.as_ref(), &project, &doc).await?;
}
Ok(pair_id)
}
/// Removes the **previous** profile's now-orphan permission files at the swap
/// (lot LP3-4), best-effort. Because the run dir is **stable per agent id**
/// (`agent_run_dir`), the old CLI's config (e.g. `.claude/settings.local.json`)
/// survives a swap in the very same directory; left in place it is stale.
///
/// Cleanup rule (Architect-validated): delete exactly
/// `owned_replace_paths(old) owned_replace_paths(new)` — only the
/// **`Replace`-owned** files of the old projector that the new projector will not
/// re-own (and thus re-write). `MergeToml` (co-owned, e.g. `.codex/config.toml`)
/// files are **never** deleted. Examples: Claude→Codex removes
/// `.claude/settings.local.json`; Claude→Claude removes nothing (empty diff);
/// Codex→Claude removes nothing on the Replace side (Codex owns no Replace file).
///
/// No-op when: no registry is wired, the previous profile is unknown (deleted),
/// or it maps to no projector. Every delete is best-effort (`remove_file` is
/// idempotent), so a missing file never fails the swap. This does **not** touch
/// the stable pair id or the handoff (P8d) — it only removes engine config files.
async fn cleanup_swapped_out_files(
&self,
project_root: &ProjectPath,
agent_id: &AgentId,
previous_profile: Option<&AgentProfile>,
new_profile: &AgentProfile,
) {
let Some(registry) = &self.projectors else {
return;
};
let Some(previous_profile) = previous_profile else {
return;
};
let Some(old_key) = select_projector_key(previous_profile) else {
return;
};
let Some(old_projector) = registry.get(old_key) else {
return;
};
let old_owned = old_projector.owned_replace_paths();
if old_owned.is_empty() {
return;
}
// Paths the NEW projector will re-own (and re-write) ⇒ never delete these.
let kept: Vec<String> = select_projector_key(new_profile)
.and_then(|key| registry.get(key))
.map(|p| p.owned_replace_paths())
.unwrap_or_default();
let run_dir = match agent_run_dir(project_root, agent_id) {
Ok(dir) => dir,
Err(_) => return,
};
for rel in old_owned {
if kept.iter().any(|k| k == &rel) {
continue;
}
let path = RemotePath::new(join(&run_dir, &rel));
// Best-effort: a missing file / delete failure must never fail the swap.
let _ = self.fs.remove_file(&path).await;
}
}
/// Kills the agent's live PTY (if any) and relaunches it in the same cell with
/// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the
/// relaunched session, or `None` when the agent had no live session.
///
/// `pair_id` is the **preserved** IdeA pair id (User↔agent) captured at step 5;
/// it is threaded into the relaunch so the handoff (P7) is re-injected into the
/// new engine and resume (P8c) is routed via `providers.json[new provider]` —
/// the old engine's resumable is **never** replayed (providers.json for the new
/// provider is empty ⇒ `SessionPlan::None`, fidelity carried by the handoff).
/// When step 5 found no hosting leaf (e.g. a background session without a cell),
/// the id is **derived** deterministically via `for_pair(User, agent)`.
async fn relaunch_if_live(
&self,
input: &ChangeAgentProfileInput,
pair_id: Option<String>,
) -> Result<Option<TerminalSession>, AppError> {
// Résolution **polymorphe** de la session vivante sur les deux registres
// (§17.4) : structuré d'abord, puis PTY. Un agent ne vit que dans un seul des
// deux à la fois (invariant « 1 session/agent »).
let killed = self.kill_live_session(&input.agent_id).await?;
let Some(node_id) = killed else {
// Aucune session vivante (ni structurée, ni PTY) ⇒ rien à relancer.
return Ok(None);
};
// Pair id préservé (step 5) ; en repli (session de fond sans cellule), on
// dérive l'id de paire déterministe User↔agent — les cellules sont toujours
// User↔agent, donc cette dérivation coïncide avec ce qu'eût porté la feuille.
let conversation_id = Some(pair_id.unwrap_or_else(|| {
ConversationId::for_pair(
ConversationParty::User,
ConversationParty::agent(input.agent_id),
)
.to_string()
}));
let output = self
.launch
.execute(LaunchAgentInput {
project: input.project.clone(),
agent_id: input.agent_id,
rows: input.rows,
cols: input.cols,
node_id,
// Pair id **préservé** (id de paire IdeA stable) : le handoff (P7) est
// réinjecté dans le nouveau moteur, et le resume (P8c) est routé via
// providers.json[nouveau provider] — l'ancien resumable n'est jamais
// repassé (providers.json du nouveau provider vide ⇒ moteur neuf).
conversation_id,
// Internal relaunch (no app-tauri composition root in scope) ⇒ the
// MCP runtime is not injected here; `apply_mcp_config` falls back to
// the minimal declaration. A profile-hot-swap that needs the real
// endpoint is re-driven through the app-tauri launch path.
mcp_runtime: None,
})
.await?;
Ok(Some(output.session))
}
/// Tue la session vivante de `agent_id` de façon **polymorphe** (§17.4) :
/// `shutdown()` si elle est structurée, kill PTY sinon. Retire la session de son
/// registre **avant** l'arrêt, pour que la garde d'unicité du relance (sur les
/// deux registres) ne voie plus de session vivante.
///
/// Retourne `Some(node_id_hôte)` quand une session a été tuée (le node où
/// relancer), `None` si l'agent n'avait aucune session vivante. La cellule hôte
/// peut elle-même être absente (session de fond) ⇒ `Some(None)` est replié sur un
/// node neuf côté relance via `LaunchAgentInput.node_id = None`.
async fn kill_live_session(
&self,
agent_id: &AgentId,
) -> Result<Option<Option<NodeId>>, AppError> {
// 1. Session structurée vivante ? ⇒ shutdown polymorphe.
if let Some(structured) = &self.structured {
if let Some(session_id) = structured.session_id_for_agent(agent_id) {
let node_id = structured.node_for_agent(agent_id);
if let Some(session) = structured.remove(&session_id) {
session
.shutdown()
.await
.map_err(|e| AppError::Process(e.to_string()))?;
}
return Ok(Some(node_id));
}
}
// 2. Sinon, session PTY vivante ? ⇒ kill PTY (chemin historique).
let Some(session_id) = self.sessions.session_for_agent(agent_id) else {
return Ok(None);
};
let node_id = self.sessions.node_for_agent(agent_id);
if let Some(handle) = self.sessions.remove(&session_id) {
self.pty.kill(&handle).await?;
}
Ok(Some(node_id))
}
}
// ---------------------------------------------------------------------------
// DeleteAgent
// ---------------------------------------------------------------------------
/// Input for [`DeleteAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteAgentInput {
/// The owning project.
pub project: Project,
/// The agent to remove.
pub agent_id: AgentId,
}
/// Removes an agent from the project manifest.
///
/// The orphaned `.md` file is left on disk: the [`FileSystem`] port exposes no
/// delete, and keeping the file is the safe default (the user may want to recover
/// the context). Re-creating an agent with the same name reuses a fresh path.
pub struct DeleteAgent {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl DeleteAgent {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Drops the manifest entry for the agent.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is not in the manifest,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let before = manifest.entries.len();
let entries: Vec<ManifestEntry> = manifest
.entries
.into_iter()
.filter(|e| e.agent_id != input.agent_id)
.collect();
if entries.len() == before {
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
}
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id,
});
Ok(())
}
}
// ---------------------------------------------------------------------------
// LaunchAgent
// ---------------------------------------------------------------------------
/// Input for [`LaunchAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchAgentInput {
/// The owning project.
pub project: Project,
/// The agent to launch.
pub agent_id: AgentId,
/// Initial terminal height in rows.
pub rows: u16,
/// Initial terminal width in columns.
pub cols: u16,
/// The layout leaf hosting the session (a fresh node when `None`).
pub node_id: Option<NodeId>,
/// The persistent CLI conversation id currently recorded on the hosting cell,
/// if any. `Some` means a previous conversation exists and the launch should
/// **resume** it; `None` means a fresh cell (the launch may *assign* a new id
/// when the profile supports it). The caller (which owns the layout) reads this
/// from the leaf's [`domain::layout::LeafCell::conversation_id`].
pub conversation_id: Option<String>,
/// Runtime facts needed to write the **real** IdeA MCP server declaration
/// (M5d). These are **OS/runtime data** (the IdeA executable path, the
/// project's loopback endpoint) that live in `app-tauri` — they are *injected
/// as data* from the composition root, never computed in `application` (which
/// must not depend on `app-tauri`, cadrage v5 §0.3 / §7).
///
/// `None` ⇒ no runtime injected (launches issued from inside `application`:
/// the orchestrator's `spawn_agent`/`ask_agent`, a profile hot-swap relaunch,
/// or tests). In that case [`apply_mcp_config`](LaunchAgent::apply_mcp_config)
/// still honours the profile's `McpConfigStrategy`, but falls back to a
/// **coherent minimal** declaration (the `idea mcp-server` command without the
/// endpoint/project/requester args) rather than a project-bound one — see
/// [`mcp_server_declaration`].
pub mcp_runtime: Option<McpRuntime>,
}
/// OS/runtime facts injected by the composition root (`app-tauri`) to materialise
/// the **real** IdeA MCP server declaration in an agent's `.mcp.json` (cadrage v5
/// §2). Carrying these as **plain data** on [`LaunchAgentInput`] keeps
/// `application` free of any `app-tauri` / `current_exe` / `mcp_endpoint`
/// dependency: the endpoint stays computed by the *single source of truth*
/// (`app-tauri::mcp_endpoint`) and only its **string** crosses the layer boundary.
///
/// All four fields are the exact strings the spawned bridge expects on its command
/// line (`<exe> mcp-server --endpoint <endpoint> --project <project_id> --requester
/// <requester>`); the `project_id` must already be in the **hyphen-free 32-hex
/// `simple` form** consumed by the M5c handshake guard (`serve_peer`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpRuntime {
/// Absolute path to the IdeA executable (`std::env::current_exe()`), used as the
/// declaration's `command` — the CLI spawns *this* binary in its `mcp-server`
/// bridge mode.
pub exe: String,
/// The project's loopback endpoint string (`mcp_endpoint(project).as_cli_arg()`),
/// passed as `--endpoint`. **Same source of truth** as the listener bound by
/// `ensure_mcp_server` (cadrage v5 §2 coherence invariant).
pub endpoint: String,
/// The project id in the **hyphen-free 32-hex `simple` form** consumed by the
/// M5c handshake guard, passed as `--project`.
pub project_id: String,
/// The launching agent's id, passed as `--requester` so the server tags
/// `OrchestratorRequestProcessed.requester_id` with the real agent (cadrage v5
/// §1.4) instead of the frozen `"mcp"` placeholder.
pub requester: String,
}
/// Descripteur d'une session **structurée** (IA, cellule chat) démarrée par
/// [`LaunchAgent`] (ARCHITECTURE §17.4). Renvoyé en plus du snapshot
/// [`TerminalSession`] quand le profil porte un `structured_adapter` : il identifie
/// la session structurée vivante (registre [`StructuredSessions`]) sans faire fuiter
/// l'`Arc<dyn AgentSession>` à travers la frontière de sortie du use case.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructuredSessionDescriptor {
/// L'id de session IdeA de la session structurée (clé du registre).
pub session_id: SessionId,
/// L'agent IA pilotant cette session.
pub agent_id: AgentId,
/// La cellule (feuille de layout) qui héberge la vue chat.
pub node_id: NodeId,
/// L'id de conversation du moteur, s'il a déjà été attribué.
pub conversation_id: Option<String>,
}
/// Output of [`LaunchAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchAgentOutput {
/// The created agent terminal session.
///
/// **Toujours présent**, y compris pour un agent IA structuré (cellule chat) :
/// dans ce cas c'est un snapshot `kind = Agent` portant l'id de la session
/// structurée (cf. [`Self::structured`]), conformément à §17.4. Garde la sortie
/// **non cassante** pour A/B et le câblage existant.
pub session: TerminalSession,
/// L'**id de paire IdeA** (pivot **logique** de la conversation, ARCHITECTURE
/// §19.7) assigné par ce lancement, quand la cellule n'en portait pas encore. Le
/// caller le persiste sur la feuille hôte via `set_cell_conversation` : c'est la
/// clé qui retrouve **log + handoff** au (re)lancement (P7) et survit au swap de
/// profil. **Ce n'est plus l'id de session moteur** (qui part désormais dans
/// [`Self::engine_session_id`]). `None` quand rien de neuf n'a été assigné (reprise
/// d'un id existant, mode dégradé, ou profil sans session) — rien à persister.
pub assigned_conversation_id: Option<String>,
/// L'**id de session moteur** (resumable du provider : id Claude `--resume`, ou
/// l'UUID minté pour `--session-id`) exposé/attribué par ce lancement, **distinct**
/// de l'id de paire (ARCHITECTURE §19.7, lot P8a). Le caller le range dans le
/// **cache** `LeafCell::engine_session_id` (via `set_cell_engine_session`) — jamais
/// sur `conversation_id`. La **source de vérité** du resumable reste `providers.json`
/// (lot P8b, hors P8a). `None` pour le chemin PTY/legacy ou quand le moteur n'expose
/// aucun id : rien à cacher.
pub engine_session_id: Option<String>,
/// Descripteur de la session **structurée**, présent **uniquement** quand ce
/// lancement a routé vers un agent IA structuré (`profile.structured_adapter =
/// Some(_)`, ARCHITECTURE §17.4). `None` pour le chemin PTY/terminal brut
/// historique. Champ **additionnel et optionnel** : la sortie reste compatible
/// avec les use cases A/B et le câblage qui ne lisent que `session` /
/// `assigned_conversation_id`.
pub structured: Option<StructuredSessionDescriptor>,
}
/// Registry mapping each [`ProjectorKey`] to its concrete
/// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the
/// optional builder [`LaunchAgent::with_permission_projectors`]; the concrete
/// projectors (Claude / Codex) live in `infrastructure` and are inserted at the
/// composition root (lot LP3-5). Absent registry ⇒ no projection (legacy
/// behaviour preserved).
#[derive(Default, Clone)]
pub struct PermissionProjectorRegistry {
by_key: HashMap<ProjectorKey, Arc<dyn PermissionProjector>>,
}
impl PermissionProjectorRegistry {
/// An empty registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Registers a projector under its own [`PermissionProjector::key`] and returns
/// `self` (builder style), so a registry can be assembled fluently.
#[must_use]
pub fn with(mut self, projector: Arc<dyn PermissionProjector>) -> Self {
self.insert(projector);
self
}
/// Registers (or replaces) a projector under its own key.
pub fn insert(&mut self, projector: Arc<dyn PermissionProjector>) {
self.by_key.insert(projector.key(), projector);
}
/// Returns the projector registered for `key`, if any.
#[must_use]
pub fn get(&self, key: ProjectorKey) -> Option<&Arc<dyn PermissionProjector>> {
self.by_key.get(&key)
}
}
/// Selects the [`ProjectorKey`] for a launched agent's profile (lot LP3-3).
///
/// Primary source: the profile's explicit `projector` field. **Migration
/// fallback** for legacy profiles (e.g. an old `profiles.json` written before the
/// field existed): the historical heuristic — a `CLAUDE.md` convention file ⇒
/// Claude; a `StructuredAdapter::Codex` or a `TomlConfigHome` MCP strategy ⇒
/// Codex. Anything else ⇒ `None` (no projection).
fn select_projector_key(profile: &AgentProfile) -> Option<ProjectorKey> {
if let Some(key) = profile.projector {
return Some(key);
}
// Legacy fallback: Claude is recognised by its `CLAUDE.md` convention file.
let is_claude = matches!(
&profile.context_injection,
ContextInjection::ConventionFile { target }
if target
.rsplit(['/', '\\'])
.next()
.unwrap_or(target)
.eq_ignore_ascii_case("CLAUDE.md")
);
if is_claude {
return Some(ProjectorKey::Claude);
}
// Legacy fallback: Codex is recognised by its structured adapter or its
// CODEX_HOME-isolated TOML MCP strategy.
if matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
return Some(ProjectorKey::Codex);
}
if let Some(mcp) = &profile.mcp {
if matches!(mcp.config, McpConfigStrategy::TomlConfigHome { .. }) {
return Some(ProjectorKey::Codex);
}
}
None
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
///
/// This is the orchestrating use case of L6 and therefore consumes several ports
/// — each only for the slice it needs (Interface Segregation): the context store
/// (agent `.md` + manifest), the profile store (resolve the runtime), the runtime
/// (build the [`SpawnSpec`]), the filesystem (materialise a `conventionFile`
/// context), and the PTY (spawn + optional stdin injection).
pub struct LaunchAgent {
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
skills: Arc<dyn SkillStore>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
ids: Arc<dyn IdGenerator>,
/// Bounded recall of the project's memory index, injected into the convention
/// file at activation (ARCHITECTURE §14.5.4). Best-effort by contract: an absent
/// or empty memory yields an empty list, never blocking a launch.
recall: Arc<dyn MemoryRecall>,
/// Optional contextual embedder-suggestion check (LOT C3, §14.5.5), run
/// best-effort right after the memory recall at activation — the moment an agent
/// reads the project memory. `None` keeps the launcher independent of it (legacy
/// wiring / tests). A failure here never affects the launch.
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
/// Optional permissions store (LP1). When absent, the launcher keeps its
/// historical hardcoded CLI permission seeds. When present and a project or
/// agent policy is configured, the resolved policy is projected to the CLI.
permissions: Option<Arc<dyn PermissionStore>>,
/// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage
/// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4
/// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy /
/// tests existants, qui restent verts sans changement de signature).
session_factory: Option<Arc<dyn AgentSessionFactory>>,
/// Registre des sessions structurées vivantes (§17.5), jumeau de
/// [`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>>,
/// Provider du [`ProviderSessionStore`] **par project root** (lot P8b), pour ranger
/// **best-effort** l'id de session moteur (resumable du provider) sous la clé de
/// paire IdeA dans `providers.json` après un lancement structuré. Injecté au câblage
/// via [`Self::with_provider_session_provider`] ; `None` ⇒ aucune écriture (zéro
/// régression pour les call sites/tests legacy). Une écriture en échec ⇒ lancement
/// normal, jamais d'échec.
provider_sessions: Option<Arc<dyn ProviderSessionProvider>>,
/// Registre des **projecteurs de permissions** par CLI (lot LP3-3). Injecté au
/// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection
/// (comportement historique préservé pour les call sites/tests legacy).
projectors: Option<Arc<PermissionProjectorRegistry>>,
}
impl LaunchAgent {
/// Builds the use case from its injected ports.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
skills: Arc<dyn SkillStore>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
ids: Arc<dyn IdGenerator>,
recall: Arc<dyn MemoryRecall>,
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
) -> Self {
Self {
contexts,
profiles,
runtime,
fs,
pty,
skills,
sessions,
events,
ids,
recall,
embedder_suggestion,
permissions: None,
session_factory: None,
structured: None,
handoffs: None,
provider_sessions: None,
projectors: None,
}
}
/// Injects the per-CLI permission **projector registry** (lot LP3-3) used to
/// translate the resolved [`EffectivePermissions`] into the launched CLI's
/// concrete permission config. Without this call (legacy call sites / tests),
/// no projection happens — signature of [`Self::new`] unchanged.
#[must_use]
pub fn with_permission_projectors(
mut self,
projectors: Arc<PermissionProjectorRegistry>,
) -> Self {
self.projectors = Some(projectors);
self
}
/// Injects the project permission store used to resolve effective agent
/// permissions at launch time.
#[must_use]
pub fn with_permission_store(mut self, permissions: Arc<dyn PermissionStore>) -> Self {
self.permissions = Some(permissions);
self
}
/// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher :
/// après un lancement structuré exposant un id de session moteur, range
/// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json`
/// du projet. Sans cet appel (cas legacy / tests existants), aucune écriture —
/// 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_provider_session_provider(provider)`.
#[must_use]
pub fn with_provider_session_provider(
mut self,
provider_sessions: Arc<dyn ProviderSessionProvider>,
) -> Self {
self.provider_sessions = Some(provider_sessions);
self
}
/// 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`
/// route **tout** vers le PTY — signature de [`Self::new`] **inchangée**, donc
/// aucun appelant existant ne casse.
///
/// Builder (consomme `self`, retourne `Self`) pour rester additif : le câblage
/// fait `LaunchAgent::new(...).with_structured(factory, registry)`.
#[must_use]
pub fn with_structured(
mut self,
session_factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>,
) -> Self {
self.session_factory = Some(session_factory);
self.structured = Some(structured);
self
}
/// Resolves the Markdown bodies of an agent's assigned skills, in the
/// **manifest order** (deterministic). A skill that no longer exists in its
/// store (deleted out from under the assignment) is silently skipped — a
/// dangling [`domain::SkillRef`] must not block a launch.
///
/// # Errors
/// [`AppError::Store`] on any store failure other than a missing skill.
async fn resolve_skills(
&self,
agent: &Agent,
root: &ProjectPath,
) -> Result<Vec<Skill>, AppError> {
let mut out = Vec::with_capacity(agent.skills.len());
for skill_ref in &agent.skills {
match self
.skills
.get(skill_ref.scope, root, skill_ref.skill_id)
.await
{
Ok(skill) => out.push(skill),
Err(StoreError::NotFound) => {}
Err(e) => return Err(e.into()),
}
}
Ok(out)
}
/// Resolves the project's memory recall (index/hooks) to inject into the
/// convention file at activation (ARCHITECTURE §14.5.4), mirroring
/// [`Self::resolve_skills`]. The query text is the agent's persona `.md`
/// (irrelevant to the naïve adapter, but already the right query for the future
/// semantic recall — zero refactor at étage 2), bounded by
/// [`AGENT_MEMORY_RECALL_BUDGET`].
///
/// **Best-effort, never blocking**: an absent or empty memory yields an empty
/// list by the [`MemoryRecall`] contract, and any unexpected error degrades to
/// an empty list rather than failing the launch (exactly like a dangling skill).
async fn resolve_memory(&self, root: &ProjectPath, persona: &str) -> Vec<MemoryIndexEntry> {
let query = MemoryQuery {
text: persona.to_owned(),
token_budget: AGENT_MEMORY_RECALL_BUDGET,
};
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
/// project-context section from the generated model context.
async fn resolve_project_context(&self, project: &Project) -> Result<String, AppError> {
match self.fs.read(&project_context_path(project)).await {
Ok(bytes) => String::from_utf8(bytes)
.map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}"))),
Err(FsError::NotFound(_)) => Ok(String::new()),
Err(e) => Err(AppError::FileSystem(e.to_string())),
}
}
/// Executes the launch.
///
/// Step order is contractually significant (and unit-tested): resolve the
/// agent + context, **`prepare_invocation`**, **apply the injection plan**
/// (write a `conventionFile` / set an env var), then **`pty.spawn`** at the
/// resolved `cwd`, and finally pipe the context on stdin for the `Stdin`
/// strategy.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent or its profile is unknown,
/// - [`AppError::Invalid`] for a zero-sized terminal,
/// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
/// the respective port failures.
pub async fn execute(&self, input: LaunchAgentInput) -> Result<LaunchAgentOutput, AppError> {
let size =
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
// 1. Resolve the agent from the manifest (name + profile + md_path).
let manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
// 1b. Enforce the "one live session per agent" invariant (decision: an
// agent is a singleton that runs in a single cell at a time). This
// runs AFTER the NotFound resolution above (so an unknown agent still
// errors NotFound) but BEFORE any I/O (run dir, seed, spawn). If the
// agent already owns a live session:
// - with a requested node → rebind the live session to that cell and
// return it without respawning;
// - without a requested node → idempotent background/no-op launch:
// return the existing session without respawning.
// The resume path (agent dead ⇒ no live session) is unaffected.
//
// Invariant « 1 session vivante/agent » **généralisé aux deux registres**
// (§17.4) : un agent est vivant s'il a une session PTY *ou* structurée.
// On consulte d'abord le registre PTY (chemin historique), puis le
// registre structuré (le cas échéant).
//
// R0a — discrimination réattache-de-vue vs second lancement (cadrage v5
// §3.2, Trou A). Un agent est un **singleton** : une seule session vivante.
// Quand l'agent est déjà vivant, on distingue (cf. [`Self::reattach_decision`]) :
// - **réattache de vue** (rebind sans respawn) : le `node_id` demandé est
// le node hôte vivant, OU la cellule porte une `conversation_id`
// (réattache explicite — la cellule sait que l'agent tournait) ;
// - **lancement background/idempotent** : ni node, ni conversation ⇒ no-op
// (rend la session existante, pas de respawn) ;
// - **second lancement neuf** : on vise un **autre** node, sans signal de
// réattache ⇒ refus [`AppError::AgentAlreadyRunning`] (node hôte rapporté).
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
let host_node = self.sessions.node_for_agent(&input.agent_id);
match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref()) {
ReattachDecision::Rebind { node_id } => {
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id)
{
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
});
}
}
ReattachDecision::Idempotent => {}
ReattachDecision::Refuse { node_id } => {
return Err(AppError::AgentAlreadyRunning {
agent_id: input.agent_id,
node_id,
});
}
}
// Idempotent (or a rebind that found no entry to move) — hand back the
// already-registered session, no respawn, nothing new to persist.
if let Some(session) = self.sessions.session(&existing_id) {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
});
}
}
// Garde structurée (§17.4) : même sémantique côté registre IA. R0a appliqué de
// façon identique — rebind de la cellule-vue pour une réattache légitime,
// idempotence sans node/conversation, refus d'un second lancement neuf ailleurs.
if let Some(structured) = &self.structured {
if let Some(existing) = structured.session_for_agent(&input.agent_id) {
let host_node = structured.node_for_agent(&input.agent_id);
let node_id = match reattach_decision(
input.node_id,
host_node,
input.conversation_id.as_deref(),
) {
ReattachDecision::Rebind { node_id } => {
let _ = structured.rebind_agent_node(&input.agent_id, node_id);
node_id
}
// Idempotent — garder le node hôte courant, sinon un node neuf.
ReattachDecision::Idempotent => host_node.unwrap_or_else(NodeId::new_random),
ReattachDecision::Refuse { node_id } => {
return Err(AppError::AgentAlreadyRunning {
agent_id: input.agent_id,
node_id,
});
}
};
return Ok(LaunchAgentOutput {
session: structured_snapshot(&existing, input.agent_id, node_id, size),
assigned_conversation_id: None,
// Rebind de vue : aucune (ré)assignation ⇒ rien de neuf à cacher.
engine_session_id: None,
structured: Some(StructuredSessionDescriptor {
session_id: existing.id(),
agent_id: input.agent_id,
node_id,
conversation_id: existing.conversation_id(),
}),
});
}
}
// 2. Read its context and resolve its profile.
let content = self
.contexts
.read_context(&input.project, &agent.id)
.await?;
let profile = self
.profiles
.list()
.await?
.into_iter()
.find(|p| p.id == agent.profile_id)
.ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
// 3. Compute and create the agent's isolated run directory
// `<root>/.ideai/run/<agent-id>/` (ARCHITECTURE §14.1). The PTY cwd is
// *never* the project root: each agent gets its own directory so that N
// instances of the same profile never collide on a single conventional
// file (CLAUDE.md, …). This is the only I/O in the cwd resolution; the
// runtime's `prepare_invocation` stays pure.
let run_dir = agent_run_dir(&input.project.root, &agent.id)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.fs
.create_dir_all(&RemotePath::new(run_dir.as_str().to_owned()))
.await?;
let effective_permissions = self
.resolve_effective_permissions(&input.project, agent.id)
.await?;
// 3b. (Permission projection moved to step 5c, after the convention file and
// the MCP config, so both the structured and PTY paths inherit it — see
// `apply_permission_projection`.)
// 4. Prepare the invocation (pure): command + args + injection plan + cwd.
// The run dir is passed as the cwd base; the profile's `{agentRunDir}`
// placeholder resolves against it.
let prepared = PreparedContext {
content: content.clone(),
relative_path: agent.context_path.clone(),
};
// 4a. Resolve the session intention (T4). The conversation id is a property
// of the *cell*, not the PTY: the caller (which owns the layout) passes
// the cell's current `conversation_id`. Any id this launch *assigns* is
// returned in the output so the caller persists it on the leaf.
let (session_plan, assigned_conversation_id) = self
.resolve_session_plan(&profile, input.conversation_id.clone(), &input.project.root)
.await;
let mut spec =
self.runtime
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
// the injection plan side effects *before* spawning.
let skills = self.resolve_skills(&agent, &input.project.root).await?;
let project_context = self.resolve_project_context(&input.project).await?;
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 —
// an error or absence of the check never affects activation.
if let Some(check) = &self.embedder_suggestion {
let _ = check
.execute(crate::embedder::CheckEmbedderSuggestionInput {
project_id: input.project.id,
project_root: input.project.root.clone(),
})
.await;
}
self.apply_injection(
&input.project,
&agent.context_path,
&content,
&project_context,
&skills,
&memory,
handoff.as_ref(),
profile.mcp.is_some(),
&mut spec,
)
.await?;
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
// IdeA matérialise SA config MCP au format de CETTE CLI, dans le **même**
// run dir isolé que le convention file et le seed de permissions. `None`
// ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression).
self.apply_mcp_config(
&profile,
&run_dir,
&input.project.root,
input.mcp_runtime.as_ref(),
&mut spec,
)
.await;
// 5c. ── PROJECTION DES PERMISSIONS (lot LP3-3) ──
// Strictement APRÈS le convention file (5) ET la conf MCP (5a), donc
// AVANT le split structuré/PTY (5b) et le spawn : les DEUX chemins
// héritent de la projection (fichiers du plan écrits dans le run dir,
// `args`/`env` foldés dans `spec`). `None` registre / profil non
// projetable / `eff == None` ⇒ no-op (prompting natif conservé).
self.apply_permission_projection(
&profile,
&run_dir,
&input.project.root,
effective_permissions.as_ref(),
&mut spec,
)
.await?;
// 5d. ── PLAN DE SANDBOX OS (lot LP4-3) ──
// Strictement APRÈS la résolution des permissions (3) et la projection
// advisory (5c), AVANT le split structuré/PTY : `compile_sandbox_plan`
// est **pur** (domaine) — le launch path ne fait qu'orchestrer
// `EffectivePermissions` (déjà lue du store en 3) → `SandboxPlan` et
// remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan,
// comportement natif conservé (invariant produit). L'enforcer concret
// (Landlock/Noop) est injecté côté PTY au composition root.
spec.sandbox = compile_sandbox_plan(
effective_permissions.as_ref(),
&SandboxContext {
project_root: input.project.root.as_str(),
run_dir: run_dir.as_str(),
},
);
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
// run dir (étape 5) ; la CLI structurée le lira à chaque tour
// (incarnation « un run par tour »). Si le profil porte un
// `structured_adapter` ET que la fabrique structurée est câblée, on
// démarre une `AgentSession` via le port — **pas** de `pty.spawn` — et on
// l'enregistre dans `StructuredSessions`. Sinon : chemin PTY inchangé.
if let (Some(factory), Some(structured), true) = (
self.session_factory.as_ref(),
self.structured.as_ref(),
profile.structured_adapter.is_some(),
) {
// ── Clé **logique** de la cellule = id de paire IdeA (ARCHITECTURE §19.7,
// lot P8a) ──
// - cellule porteuse d'un `conversation_id` (resume, ou lancement délégué
// qui a déjà injecté l'id de paire A↔B) ⇒ c'est **déjà** l'id de paire,
// on le conserve tel quel ;
// - cellule structurée **neuve** (lancement direct utilisateur, aucun
// requester) ⇒ on **dérive** `pair(User, agent)` via la fonction domaine
// pure partagée avec `resolve_conversation` (aucun couplage à
// l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| {
ConversationId::for_pair(
ConversationParty::User,
ConversationParty::agent(agent.id),
)
.to_string()
});
return self
.launch_structured(
factory.as_ref(),
structured,
&agent,
&profile,
&prepared,
&run_dir,
&session_plan,
pair_conversation_id,
&input.project.root,
input.node_id,
size,
spec.sandbox.as_ref(),
)
.await;
}
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
let handle = self.pty.spawn(spec.clone(), size).await?;
let session_id = handle.session_id;
// 7. For the Stdin strategy, pipe the context once the PTY is live.
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
self.pty.write(&handle, content.as_str().as_bytes())?;
}
let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
let mut session = TerminalSession::starting(
session_id,
node_id,
spec.cwd.clone(),
SessionKind::Agent { agent_id: agent.id },
size,
);
session.status = SessionStatus::Running;
self.sessions.insert(handle, session.clone());
self.events.publish(DomainEvent::AgentLaunched {
agent_id: agent.id,
session_id,
});
Ok(LaunchAgentOutput {
session,
assigned_conversation_id,
// Chemin PTY/terminal brut : pas de session moteur structurée à cacher.
engine_session_id: None,
structured: None,
})
}
/// Démarre un agent IA **structuré** (§17.4) : crée une [`AgentSession`] via la
/// fabrique, l'enregistre dans [`StructuredSessions`] avec son `agent_id`/`node_id`
/// et publie [`DomainEvent::AgentLaunched`]. **Aucun `pty.spawn`** — la cellule est
/// de type chat.
///
/// L'`assigned_conversation_id` calculé en amont (`resolve_session_plan`) est
/// préservé, mais l'id réellement attribué par le moteur (s'il diffère ou s'il
/// apparaît seulement après le démarrage) prime quand il est disponible : c'est
/// `session.conversation_id()` qui fait foi pour la persistance sur la cellule.
#[allow(clippy::too_many_arguments)]
async fn launch_structured(
&self,
factory: &dyn AgentSessionFactory,
structured: &Arc<StructuredSessions>,
agent: &Agent,
profile: &AgentProfile,
prepared: &PreparedContext,
run_dir: &ProjectPath,
session_plan: &SessionPlan,
pair_conversation_id: String,
root: &ProjectPath,
node_id: Option<NodeId>,
size: PtySize,
sandbox: Option<&SandboxPlan>,
) -> Result<LaunchAgentOutput, AppError> {
// Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`,
// déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée.
let session = factory
.start(profile, prepared, run_dir, session_plan, sandbox)
.await
.map_err(|e| AppError::Process(e.to_string()))?;
let session_id = session.id();
let node_id = node_id.unwrap_or_else(NodeId::new_random);
// Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en
// amont sur les deux registres).
structured.insert(Arc::clone(&session), agent.id, node_id);
// ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ──
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur
// `LeafCell.conversation_id`. C'est lui qui retrouve log + handoff (P7) et
// survit au swap. Stable, indépendant du provider.
// - **id de session moteur** (`session.conversation_id()`) : resumable du
// provider (Claude/Codex). Rangé **séparément** dans le cache
// `LeafCell.engine_session_id` (via `set_cell_engine_session`) — **jamais**
// sur `conversation_id`. `None` si le moteur n'expose encore aucun id.
let engine_session_id = session.conversation_id();
// ── P8b — range le resumable moteur par provider dans `providers.json`
// (best-effort, jamais bloquant) ──
// On persiste `(pair_conversation_id, provider_key) → engine_session_id`
// **uniquement** quand le moteur expose un id ET que le provider est câblé.
// La clé provider est dérivée de la **famille de moteur** (`structured_adapter`),
// pas de l'uuid d'instance du profil : un swap vers la même famille réutilise la
// même entrée. Toute défaillance (provider absent, pair_conversation_id non-UUID,
// erreur du store) dégrade silencieusement — un lancement n'est **jamais** cassé
// par cette persistance (lot P8c lira ce store pour `--resume`).
if let Some(engine_id) = engine_session_id.as_deref() {
self.persist_provider_session(root, profile, &pair_conversation_id, engine_id)
.await;
}
let snapshot = structured_snapshot(&session, agent.id, node_id, size);
self.events.publish(DomainEvent::AgentLaunched {
agent_id: agent.id,
session_id,
});
Ok(LaunchAgentOutput {
session: snapshot,
// Cellule ⇐ id de **paire** (pivot logique de reprise §15.2 / §19.7).
assigned_conversation_id: Some(pair_conversation_id),
// Cache moteur ⇐ id resumable du provider (distinct de la paire).
engine_session_id: engine_session_id.clone(),
structured: Some(StructuredSessionDescriptor {
session_id,
agent_id: agent.id,
node_id,
conversation_id: engine_session_id,
}),
})
}
/// Range **best-effort** (lot P8b) le resumable moteur `engine_id` sous la clé
/// `(pair_conversation_id, provider_key)` dans `providers.json` du projet.
///
/// `provider_key` est dérivée de la **famille de moteur** du profil
/// ([`StructuredAdapter::provider_key`]) ; un profil sans `structured_adapter` (ne
/// devrait pas arriver sur ce chemin structuré) ⇒ skip. `pair_conversation_id` est
/// une chaîne : un id non-UUID (donnée legacy/corrompue) ⇒ skip. Le provider absent
/// ([`Self::with_provider_session_provider`] non appelé) ⇒ skip. Toute erreur du
/// store est tracée et avalée : **jamais** d'échec ni de panique — le lancement a
/// déjà réussi à ce stade.
async fn persist_provider_session(
&self,
root: &ProjectPath,
profile: &AgentProfile,
pair_conversation_id: &str,
engine_id: &str,
) {
let Some(provider) = self.provider_sessions.as_ref() else {
return;
};
let Some(adapter) = profile.structured_adapter else {
return;
};
// Le `conversation_id` d'une cellule est un id de paire (UUID, §C3).
let Ok(uuid) = uuid::Uuid::parse_str(pair_conversation_id) else {
return;
};
let conversation = ConversationId::from_uuid(uuid);
let Some(store) = provider.provider_session_store_for(root) else {
return;
};
// Best-effort : toute erreur du store est avalée — le lancement a déjà réussi,
// la persistance du resumable ne doit jamais le casser (lot P8c lira ce store).
let _ = store
.set(conversation, adapter.provider_key(), engine_id)
.await;
}
/// Resolves the [`SessionPlan`] for a launch from the profile's session
/// strategy and the cell's current `conversation_id` (T4).
///
/// Returns the plan *and* — when this launch mints a fresh id — that id, so the
/// caller can persist it on the hosting leaf. The id is only generated for an
/// `Assign` (profile has a `session` block with an `assign_flag`, and the cell
/// had no id yet); every other branch returns `None` (nothing to persist).
///
/// Branches:
/// - cell already has an id ⇒ [`SessionPlan::Resume`] (reopen) — no new id;
/// - no id, profile has `session.assign_flag` ⇒ mint a UUID, [`SessionPlan::Assign`];
/// - no id, profile has `session` but no `assign_flag` (degraded) ⇒
/// [`SessionPlan::None`] (nothing to resume on a first launch; the adapter
/// uses the bare resume flag only on later reopens);
/// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy).
async fn resolve_session_plan(
&self,
profile: &AgentProfile,
cell_conversation_id: Option<String>,
root: &ProjectPath,
) -> (SessionPlan, Option<String>) {
// Profil **structuré** (§17, lot P8c) : le `conversation_id` de la cellule est
// un **id de paire IdeA**, jamais le resumable du moteur. Le passer en `--resume`
// serait invalide (Claude/Codex attendent *leur* id). On route donc le resume
// moteur via `providers.json`, keyé par `(id de paire, provider_key)` (écrit en
// P8b). Rien à pré-attribuer ici : l'id de paire est géré en aval (P8a), le
// resumable moteur est capté/rapporté par P8b au fil de l'exécution.
if let Some(adapter) = profile.structured_adapter {
// Store câblé : on lit le resumable moteur rangé pour cette paire+provider.
if let Some(provider) = self.provider_sessions.as_ref() {
let engine = match &cell_conversation_id {
// L'id de paire (UUID) → resumable moteur via providers.json.
Some(raw) => match uuid::Uuid::parse_str(raw) {
Ok(uuid) => match provider.provider_session_store_for(root) {
Some(store) => store
.get(ConversationId::from_uuid(uuid), adapter.provider_key())
.await
.ok()
.flatten(),
None => None,
},
Err(_) => None,
},
None => None,
};
return match engine {
// Vrai resumable moteur connu ⇒ resume propre du moteur.
Some(engine_id) => (
SessionPlan::Resume {
conversation_id: engine_id,
},
None,
),
// Aucun resumable (None / parse KO / store absent / pas d'id de
// cellule) ⇒ premier lancement propre. Le moteur attribuera/rapportera
// son id (capté par P8b) ; la continuité du travail est assurée par le
// handoff (P7). On ne passe **jamais** l'id de paire en `--resume`.
None => (SessionPlan::None, None),
};
}
// Store **non câblé** (tests/legacy) : repli gracieux sur l'ancien
// comportement — une conversation sur la cellule ⇒ `Resume`, sinon `None`.
// Garantit zéro régression des tests qui ne câblent pas le store.
return match cell_conversation_id {
Some(conversation_id) => (SessionPlan::Resume { conversation_id }, None),
None => (SessionPlan::None, None),
};
}
// Profils **non structurés** (PTY/TUI) : la cellule porte toujours l'id moteur.
// Comportement **inchangé** depuis T4.
let Some(session) = &profile.session else {
return (SessionPlan::None, None);
};
// The cell already carries a conversation: resume it (no new id minted).
if let Some(conversation_id) = cell_conversation_id {
return (SessionPlan::Resume { conversation_id }, None);
}
// Fresh cell. Only mint+assign an id when the profile can assign one;
// otherwise (degraded mode) the first launch has nothing to resume.
if session.assign_flag.is_some() {
let conversation_id = self.ids.new_uuid().to_string();
(
SessionPlan::Assign {
conversation_id: conversation_id.clone(),
},
Some(conversation_id),
)
} else {
(SessionPlan::None, None)
}
}
/// Projects the resolved [`EffectivePermissions`] into the launched CLI's
/// concrete permission config (lot LP3-3), replacing the historical per-CLI
/// seeds. Selects the profile's [`PermissionProjector`] (cf.
/// [`select_projector_key`]), asks it for a pure [`domain::permission::PermissionProjection`]
/// (a plan), then **applies** that plan: materialises its files in the run dir
/// and folds its `args`/`env` into `spec`.
///
/// File ownership drives the write regime:
/// - [`ProjectedFile::Replace`] ⇒ **clobber** (always overwrite). Unlike the old
/// non-clobbering seed, this lets a re-projection (e.g. after a profile swap)
/// refresh an IdeA-owned file.
/// - [`ProjectedFile::MergeToml`] ⇒ merge only the **managed** tables/keys into
/// any existing file (via the shared TOML helpers), preserving everything else.
///
/// No-op when: no registry is wired, the profile has no matching projector, or
/// the resolved permissions are `None` (the projector returns an empty
/// projection — native prompting preserved).
///
/// # Errors
/// [`AppError::FileSystem`] if a plan file cannot be written.
async fn apply_permission_projection(
&self,
profile: &AgentProfile,
run_dir: &ProjectPath,
project_root: &ProjectPath,
permissions: Option<&EffectivePermissions>,
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
let Some(registry) = &self.projectors else {
return Ok(());
};
let Some(key) = select_projector_key(profile) else {
return Ok(());
};
let Some(projector) = registry.get(key) else {
return Ok(());
};
let ctx = ProjectionContext {
project_root: project_root.as_str(),
run_dir: run_dir.as_str(),
};
let projection = projector.project(permissions, &ctx);
for file in &projection.files {
match file {
ProjectedFile::Replace { rel_path, contents } => {
self.ensure_run_dir_parent(run_dir, rel_path).await?;
let path = RemotePath::new(join(run_dir, rel_path));
// Clobber: IdeA owns this file; overwriting refreshes it on re-projection.
self.fs.write(&path, contents.as_bytes()).await?;
}
ProjectedFile::MergeToml {
rel_path,
managed_tables,
managed_keys,
contents,
} => {
self.ensure_run_dir_parent(run_dir, rel_path).await?;
let path = RemotePath::new(join(run_dir, rel_path));
let existing = match self.fs.read(&path).await {
Ok(bytes) => String::from_utf8(bytes).unwrap_or_default(),
Err(_) => String::new(),
};
let merged =
merge_managed_toml(&existing, managed_tables, managed_keys, contents);
self.fs.write(&path, merged.as_bytes()).await?;
}
}
}
// Fold the plan's launch args/env into the spec, before the structured/PTY
// split so both inherit them.
spec.args.extend(projection.args.iter().cloned());
spec.env.extend(projection.env.iter().cloned());
Ok(())
}
/// Ensures the parent directory of `<run_dir>/<rel_path>` exists (e.g. the
/// `.claude/` or `.codex/` subdir), so a projected file write never fails on a
/// missing directory. A `rel_path` with no separator needs no extra dir.
async fn ensure_run_dir_parent(
&self,
run_dir: &ProjectPath,
rel_path: &str,
) -> Result<(), AppError> {
if let Some((parent, _)) = rel_path.rsplit_once(['/', '\\']) {
if !parent.is_empty() {
self.fs
.create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str())))
.await?;
}
}
Ok(())
}
async fn resolve_effective_permissions(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<Option<EffectivePermissions>, AppError> {
let Some(store) = &self.permissions else {
return Ok(None);
};
let doc = store.load_permissions(project).await?;
Ok(doc.resolve_for(agent_id))
}
/// Applies the context-injection plan that must happen *before* spawn:
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
/// or attaching the on-disk context path to an environment variable. `Args` is
/// already folded into the spec by the runtime; `Stdin` is handled post-spawn.
#[allow(clippy::too_many_arguments)]
async fn apply_injection(
&self,
project: &Project,
context_rel_path: &str,
content: &MarkdownDoc,
project_context: &str,
skills: &[Skill],
memory: &[MemoryIndexEntry],
handoff: Option<&Handoff>,
mcp_enabled: bool,
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
match spec.context_plan.clone() {
Some(ContextInjectionPlan::File { target }) => {
// conventionFile (ARCHITECTURE §14.1): IdeA *generates* the
// conventional file (e.g. CLAUDE.md) inside the agent's isolated
// run directory — `spec.cwd` is that run dir, never the project
// root, so there is zero collision between agents. The document is
// composed: an absolute project-root header (so the agent knows
// where to operate, since its cwd is *not* the root), the agent's
// persona `.md`, then the bodies of its assigned skills (§14.2).
let document = compose_convention_file(
project.root.as_str(),
project_context,
content.as_str(),
skills,
memory,
handoff,
mcp_enabled,
);
let path = RemotePath::new(join(&spec.cwd, &target));
self.fs.write(&path, document.as_bytes()).await?;
}
Some(ContextInjectionPlan::Env { var }) => {
// Hand the CLI the absolute path of the agent's `.md` (which lives at
// `<root>/.ideai/<context_rel_path>`) via the environment variable.
let abspath = join(&project.root, &format!(".ideai/{context_rel_path}"));
spec.env.push((var, abspath));
}
// Args were folded into spec.args by prepare_invocation; Stdin is
// applied after the PTY is live.
Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {}
}
Ok(())
}
/// Materialises the IdeA MCP server config for **this** CLI when the profile
/// carries an [`McpCapability`] (cadrage v3, Décision 3). Runs **after** the
/// convention file (`apply_injection`) and **before** the spawn / `factory.start`,
/// in the **same** isolated run dir as the convention file and the permission seed.
///
/// Dispatch by [`McpConfigStrategy`]:
/// - `ConfigFile { target }` → write `<run_dir>/<target>` (e.g. `.mcp.json`) with
/// the IdeA MCP server declaration. **Best-effort** (any write/exists failure is
/// swallowed so it **never** fails the launch), with two clobber regimes:
/// * with an injected [`McpRuntime`] (real app-tauri launch) the file is
/// **regenerated and clobbered on every (re)launch**, exactly like the
/// convention file. The declaration's `command` carries the **stable** IdeA
/// exe path (`$APPIMAGE`) and the project's live endpoint — both drift between
/// runs (the AppImage mount path changes at each remount/reboot), so a stale
/// `.mcp.json` would point the bridge at a dead binary/socket. `.mcp.json` is
/// IdeA-managed (not user-edited), so clobbering is safe;
/// * without a runtime (orchestrator / hot-swap / tests) only a degraded minimal
/// declaration is available, so the write stays **non-clobbering** (mirrors
/// [`Self::seed_cli_permissions`]) — never overwriting a real declaration.
/// - `Flag { flag }` → append the flag + run-dir config path to [`SpawnSpec::args`].
/// - `Env { var }` → append the variable (run-dir config path) to [`SpawnSpec::env`].
///
/// `profile.mcp == None` ⇒ no-op: no write, no flag, no env (current path, zero
/// regression).
///
/// MCP runtime (M5d): when the composition root injects a [`McpRuntime`], the
/// declaration written for a `ConfigFile` strategy is the **real** one — it points
/// the spawned `idea mcp-server` bridge at the project's exact loopback endpoint
/// (same source of truth as `ensure_mcp_server`, cadrage v5 §2). When no runtime is
/// injected (launches issued from inside `application`), a coherent **minimal**
/// declaration is written instead (see [`mcp_server_declaration`]). `Flag`/`Env`
/// are unaffected by the runtime: they keep passing the run-dir config path.
async fn apply_mcp_config(
&self,
profile: &AgentProfile,
run_dir: &ProjectPath,
project_root: &ProjectPath,
runtime: Option<&McpRuntime>,
spec: &mut SpawnSpec,
) {
let Some(mcp) = &profile.mcp else {
return;
};
match &mcp.config {
domain::profile::McpConfigStrategy::ConfigFile { target } => {
let path = RemotePath::new(join(run_dir, target));
let declaration = mcp_server_declaration(mcp.transport, runtime);
match runtime {
// Real launch (app-tauri injected the runtime): the declaration
// carries the **stable** IdeA executable path (`$APPIMAGE`, resolved
// by `idea_exe_path`) and the project's live loopback endpoint. Both
// can drift between runs — the AppImage internal mount path changes
// at every remount/reboot — so the file MUST be regenerated and
// **clobbered** on every (re)launch, exactly like the convention file
// (`apply_injection`). `.mcp.json` is IdeA-managed, not user-edited,
// so there is nothing to preserve. Best-effort: a write failure must
// never fail the launch.
Some(_) => {
let _ = self.fs.write(&path, declaration.as_bytes()).await;
}
// Internal launch (orchestrator / hot-swap / tests): we only have a
// degraded **minimal** declaration (no endpoint/project/requester).
// Stay non-clobbering — mirrors `seed_cli_permissions` — so we never
// overwrite a real declaration previously written by an app-tauri
// launch with the minimal one.
None => match self.fs.exists(&path).await {
Ok(true) => {}
Ok(false) => {
let _ = self.fs.write(&path, declaration.as_bytes()).await;
}
Err(_) => {}
},
}
}
domain::profile::McpConfigStrategy::TomlConfigHome { target, home_env } => {
// Pendant Codex de `ConfigFile` : on écrit un `config.toml` (table
// `[mcp_servers.idea]`, via l'encodeur TOML partagé D2) au chemin
// `<run_dir>/<target>`, et on pousse `home_env` (ex. `CODEX_HOME`) vers
// le **dossier parent** de ce fichier. Codex lit alors ses serveurs MCP
// dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global.
let path = RemotePath::new(join(run_dir, target));
let declaration = mcp_server_wiring(mcp.transport, runtime).to_config_toml();
// Même régime clobber/non-clobber que `.mcp.json` : avec un runtime réel
// (lancement app-tauri) on régénère/clobber à chaque (re)lancement (l'exe
// `$APPIMAGE` et l'endpoint dérivent entre runs) ; sans runtime
// (orchestrateur / hot-swap / tests) on reste non-clobbering pour ne pas
// écraser une déclaration réelle par la minimale.
match runtime {
Some(_) => {
let existing = match self.fs.read(&path).await {
Ok(bytes) => String::from_utf8(bytes).ok(),
Err(_) => None,
};
let rendered = codex_config_toml(
existing.as_deref(),
&declaration,
run_dir.as_str(),
project_root.as_str(),
);
let _ = self.fs.write(&path, rendered.as_bytes()).await;
}
None => match self.fs.exists(&path).await {
Ok(true) => {}
Ok(false) => {
let rendered = codex_config_toml(
None,
&declaration,
run_dir.as_str(),
project_root.as_str(),
);
let _ = self.fs.write(&path, rendered.as_bytes()).await;
}
Err(_) => {}
},
}
// Permission projection (sandbox_mode/approval_policy + --sandbox/
// --ask-for-approval) is NO LONGER done here — it is decoupled into
// `apply_permission_projection` (lot LP3-3), so a Codex profile gets its
// sandbox even without an MCP capability. `apply_mcp_config` is now
// MCP-only (mcp_servers.idea table + projects trust).
// `home_env` pointe sur le DOSSIER PARENT de `target` (ex.
// `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`.
let home_dir = parent_dir(run_dir, target);
spec.env.push((home_env.clone(), home_dir));
}
domain::profile::McpConfigStrategy::Flag { flag } => {
// Pass the server via a launch flag (e.g. `--mcp-config {path}`). The
// config path is the run dir itself (the CLI's cwd), where the server
// declaration / connection is anchored.
spec.args.push(flag.clone());
spec.args.push(run_dir.as_str().to_owned());
}
domain::profile::McpConfigStrategy::Env { var } => {
spec.env.push((var.clone(), run_dir.as_str().to_owned()));
}
}
}
}
/// Outcome of the R0a discrimination between a legitimate **view reattach** and a
/// **fresh second launch** of an already-live agent (cadrage v5 §3.2, Trou A).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReattachDecision {
/// Rebind the live session's view to `node_id` without respawning (the request
/// targets the live host node, or carries an explicit reattach signal).
Rebind { node_id: NodeId },
/// Background / idempotent no-op: hand back the existing session unchanged
/// (neither a node nor a conversation id was supplied).
Idempotent,
/// Refuse the launch — a genuine second launch of a singleton agent already
/// live on `node_id` (the host node, reported in `AgentAlreadyRunning`).
Refuse { node_id: NodeId },
}
impl ReattachDecision {
/// Decides reattach vs refuse for an already-live agent. See [`reattach_decision`].
#[must_use]
pub(crate) fn resolve(
requested_node: Option<NodeId>,
host_node: Option<NodeId>,
conversation_id: Option<&str>,
) -> Self {
reattach_decision(requested_node, host_node, conversation_id)
}
}
/// Decides whether a launch hitting an already-live agent is a legitimate view
/// **reattach** (rebind), a **background/idempotent** no-op, or a **refused** second
/// launch (cadrage v5 §3.2, Trou A). Pure (no I/O), unit-testable.
///
/// Signals (both already present in the launch flow):
/// - `requested_node` — the hosting leaf the caller targets (`None` ⇒ no cell, e.g.
/// a background launch);
/// - `host_node` — the node currently hosting the agent's live session, if known;
/// - `conversation_id` — the conversation id recorded on the hosting cell. Its
/// presence means the **cell knows the agent was running** ⇒ it is a reattach of a
/// view onto an in-progress session, not a brand-new launch.
///
/// Rules:
/// - requested node **is** the host node ⇒ `Rebind` (re-open of the same cell);
/// - a `conversation_id` is present ⇒ `Rebind` (explicit reattach, even on another
/// node — the cell is a rebindable view, §17.6);
/// - no node **and** no conversation ⇒ `Idempotent` (background/no-op relaunch);
/// - a different node, no reattach signal ⇒ `Refuse` (genuine second launch).
fn reattach_decision(
requested_node: Option<NodeId>,
host_node: Option<NodeId>,
conversation_id: Option<&str>,
) -> ReattachDecision {
// Explicit reattach: the cell carries a conversation id, so it is re-binding a
// view onto an already-running session. Rebind to the requested node, or keep the
// current host node when none was supplied.
if conversation_id.is_some() {
if let Some(node_id) = requested_node.or(host_node) {
return ReattachDecision::Rebind { node_id };
}
return ReattachDecision::Idempotent;
}
match requested_node {
// Same cell re-opened ⇒ idempotent rebind (no respawn).
Some(node) if host_node == Some(node) => ReattachDecision::Rebind { node_id: node },
// A different cell with no reattach signal ⇒ genuine second launch: refuse,
// reporting the live host node (falling back to the requested node only if the
// host is somehow unknown, so the error always carries a meaningful cell).
Some(node) => ReattachDecision::Refuse {
node_id: host_node.unwrap_or(node),
},
// No node and no reattach signal ⇒ background/idempotent no-op.
None => ReattachDecision::Idempotent,
}
}
/// Builds the IdeA MCP server declaration written into a CLI's `ConfigFile` MCP
/// config (cadrage v3 D3 ; cadrage v5 §2). Kept pure (no I/O) so it is unit-testable.
///
/// Two shapes, by whether the composition root injected a [`McpRuntime`]:
///
/// - **`Some(runtime)` — the real declaration (M5d, end of the placeholder)**:
/// `command = runtime.exe` (the IdeA executable), and `args` carry the
/// `mcp-server` subcommand plus the project's exact loopback `--endpoint`, its
/// `--project` (hyphen-free 32-hex form, consumed by the M5c guard) and the
/// launching agent as `--requester`. The endpoint string comes verbatim from the
/// **single source of truth** (`app-tauri::mcp_endpoint`), so the bridge connects
/// to exactly what `ensure_mcp_server` listens on.
///
/// - **`None` — coherent minimal declaration**: launches issued from inside
/// `application` (orchestrator/hot-swap/tests) have no OS/runtime facts to inject;
/// rather than fabricate a project-bound endpoint, we write the `idea mcp-server`
/// command without the endpoint/project/requester args. It stays a valid,
/// self-consistent `mcpServers/idea` entry (the bridge would resolve the endpoint
/// from its own project context), surfacing `transport` for the future socket path.
#[must_use]
fn mcp_server_declaration(
transport: domain::profile::McpTransport,
runtime: Option<&McpRuntime>,
) -> String {
mcp_server_wiring(transport, runtime).to_mcp_json()
}
/// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by
/// every materialisation format (cadrage Codex D2). It is the **single source of
/// truth** for *what* the bridge is launched as; the concrete bytes (`.mcp.json`
/// JSON for Claude, `config.toml` TOML for Codex) are produced by the domain
/// encoders [`domain::McpServerWiring::to_mcp_json`] /
/// [`domain::McpServerWiring::to_config_toml`], so the two sites can never drift.
///
/// `Some(runtime)` ⇒ the **real** wiring (this IdeA exe + the project's loopback
/// endpoint/project/requester); `None` ⇒ the coherent **minimal** `idea mcp-server`
/// fallback (orchestrator / hot-swap / tests).
#[must_use]
fn mcp_server_wiring(
transport: domain::profile::McpTransport,
runtime: Option<&McpRuntime>,
) -> domain::McpServerWiring {
let (command, args) = match runtime {
Some(rt) => (
rt.exe.clone(),
vec![
"mcp-server".to_owned(),
"--endpoint".to_owned(),
rt.endpoint.clone(),
"--project".to_owned(),
rt.project_id.clone(),
"--requester".to_owned(),
rt.requester.clone(),
],
),
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
};
domain::McpServerWiring::new(command, args, transport)
}
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
/// segment using a POSIX separator.
fn join(base: &ProjectPath, rel: &str) -> String {
let b = base.as_str().trim_end_matches(['/', '\\']);
format!("{b}/{rel}")
}
/// Resolves the **parent directory** (absolute) of `<base>/<rel>` — used to point a
/// CLI's `home_env` (e.g. `CODEX_HOME`) at the directory *containing* the materialised
/// config file (`config.toml`), not the file itself. When `rel` has no separator
/// (file directly in the run dir), the parent is the run dir.
fn parent_dir(base: &ProjectPath, rel: &str) -> String {
let full = join(base, rel);
match full.rsplit_once(['/', '\\']) {
Some((parent, _)) if !parent.is_empty() => parent.to_owned(),
_ => base.as_str().trim_end_matches(['/', '\\']).to_owned(),
}
}
/// Computes an agent's isolated run directory `<root>/.ideai/run/<agent-id>/`
/// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project
/// root — guaranteeing that two distinct agents on the same project root get two
/// distinct cwd (the anti-collision contract).
///
/// # Errors
/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is
/// not a valid [`ProjectPath`] (should not happen for an absolute project root).
pub(crate) fn agent_run_dir(
root: &ProjectPath,
agent_id: &AgentId,
) -> Result<ProjectPath, domain::error::DomainError> {
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
}
/// Construit le snapshot [`TerminalSession`] qui représente une session
/// **structurée** (cellule chat) dans la sortie de [`LaunchAgent`] (§17.4).
///
/// Le modèle de sortie reste le même que pour le PTY (non cassant pour A/B) : un
/// snapshot `kind = Agent` portant l'id de la session structurée. Le `cwd` n'a pas
/// de sens process ici (la cellule chat n'a pas de PTY) ; on réutilise le run dir
/// au câblage si besoin, mais le snapshot ne porte qu'un placeholder cohérent.
fn structured_snapshot(
session: &Arc<dyn domain::ports::AgentSession>,
agent_id: AgentId,
node_id: NodeId,
size: PtySize,
) -> TerminalSession {
// La cellule chat n'a pas de cwd PTY ; un chemin racine neutre suffit au snapshot
// (le run dir réel reste connu de l'adapter). `ProjectPath::new("/")` ne peut pas
// échouer (chemin absolu trivial).
let cwd = ProjectPath::new("/").expect("/ est un ProjectPath absolu valide");
let mut snapshot = TerminalSession::starting(
session.id(),
node_id,
cwd,
SessionKind::Agent { agent_id },
size,
);
snapshot.status = SessionStatus::Running;
snapshot
}
/// Minimal JSON string escaper used by [`toml_string`] (and historically by the
/// Claude seed, now extracted to the infra projector). Handles the characters that
/// actually occur in paths: backslash, quote, control chars.
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
fn toml_string(s: &str) -> String {
format!("\"{}\"", json_escape(s))
}
/// Renders Codex's `config.toml` **MCP part only** (lot LP3-3 decoupling): merges
/// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust
/// entries. The permission part (`sandbox_mode` / `approval_policy` + the
/// `--sandbox` / `--ask-for-approval` args) now lives in the Codex permission
/// projector (`apply_permission_projection`), so this function is MCP-only and a
/// Codex profile receives its sandbox even without an MCP capability.
fn codex_config_toml(
existing: Option<&str>,
mcp_declaration: &str,
run_dir: &str,
project_root: &str,
) -> String {
let mut text = existing.unwrap_or_default().to_owned();
text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
text = ensure_codex_trust(&text, run_dir);
text = ensure_codex_trust(&text, project_root);
if !text.ends_with('\n') {
text.push('\n');
}
text
}
/// Merges a projector's **managed** TOML fragment into an existing `config.toml`
/// (lot LP3-3, [`ProjectedFile::MergeToml`]). Only the `managed_tables` and
/// `managed_keys` are touched; every other line of `existing` is preserved.
///
/// - each managed table is replaced wholesale by its block from `fragment`
/// ([`replace_toml_table`]);
/// - each managed top-level key takes the value found for it in `fragment`
/// ([`set_top_level_toml_line`]).
///
/// A managed table/key absent from `fragment` leaves `existing` untouched for it.
fn merge_managed_toml(
existing: &str,
managed_tables: &[String],
managed_keys: &[String],
fragment: &str,
) -> String {
let mut text = existing.to_owned();
for table in managed_tables {
if let Some(block) = extract_toml_table(fragment, table) {
text = replace_toml_table(&text, table, block.trim_end());
}
}
for key in managed_keys {
if let Some(line) = extract_top_level_toml_line(fragment, key) {
text = set_top_level_toml_line(&text, key, line);
}
}
if !text.ends_with('\n') {
text.push('\n');
}
text
}
/// Returns the full `key = …` line for `key` found at top level in `fragment`
/// (before any `[table]` header), or `None` if absent.
fn extract_top_level_toml_line<'a>(fragment: &'a str, key: &str) -> Option<&'a str> {
let needle = format!("{key} =");
for line in fragment.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
break;
}
if trimmed.starts_with(&needle) {
return Some(line);
}
}
None
}
/// Returns the `[table]` block (header + body up to the next header / EOF) for
/// `table` in `fragment`, or `None` if the table is absent.
fn extract_toml_table(fragment: &str, table: &str) -> Option<String> {
let header = format!("[{table}]");
let mut block: Vec<&str> = Vec::new();
let mut capturing = false;
for line in fragment.lines() {
let trimmed = line.trim();
if trimmed == header {
capturing = true;
block.push(line);
continue;
}
if capturing {
if trimmed.starts_with('[') {
break;
}
block.push(line);
}
}
if capturing {
Some(block.join("\n"))
} else {
None
}
}
fn ensure_codex_trust(input: &str, path: &str) -> String {
let header = format!("[projects.{}]", toml_string(path));
if input.lines().any(|line| line.trim() == header) {
return input.to_owned();
}
append_block(input, &format!("{header}\ntrust_level = \"trusted\""))
}
fn replace_toml_table(input: &str, table: &str, block: &str) -> String {
let header = format!("[{table}]");
let mut out = Vec::new();
let mut skipping = false;
for line in input.lines() {
let trimmed = line.trim();
if trimmed == header {
skipping = true;
continue;
}
if skipping && trimmed.starts_with('[') {
skipping = false;
}
if !skipping {
out.push(line.to_owned());
}
}
append_block(&out.join("\n"), block)
}
/// Upserts a top-level `key` with a pre-formatted `replacement` line (already
/// `key = <toml value>`): replaces the existing top-level occurrence if present,
/// otherwise prepends it. The line-level core shared by [`merge_managed_toml`].
fn set_top_level_toml_line(input: &str, key: &str, replacement: &str) -> String {
let mut out = Vec::new();
let mut replaced = false;
let mut in_top_level = true;
for line in input.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
in_top_level = false;
}
if in_top_level && !replaced && trimmed.starts_with(&format!("{key} =")) {
out.push(replacement.to_owned());
replaced = true;
} else {
out.push(line.to_owned());
}
}
let text = out.join("\n");
if replaced {
text
} else {
prepend_line(&text, replacement)
}
}
fn prepend_line(input: &str, line: &str) -> String {
if input.trim().is_empty() {
format!("{line}\n")
} else {
format!("{line}\n{}", input.trim_start_matches('\n'))
}
}
fn append_block(input: &str, block: &str) -> String {
let trimmed = input.trim_end();
if trimmed.is_empty() {
format!("{}\n", block.trim_end())
} else {
format!("{trimmed}\n\n{}\n", block.trim_end())
}
}
/// Composes the convention file IdeA writes into an agent's run directory: an
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
/// so it must be told where to work), the IdeA orchestration contract, the
/// agent's persona `.md`, then the bodies of its assigned `skills` under a
/// `# Skills` section (ARCHITECTURE §14.2).
///
/// Skills are emitted in the order given (the caller passes them in manifest
/// order, making the output deterministic); each is introduced by a `##` header
/// carrying its name. When `skills` is empty the section is omitted entirely, so
/// an agent with no skills gets exactly the previous document.
///
/// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as
/// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per
/// 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
/// 3): when `mcp_enabled` is `true` (`profile.mcp.is_some()`), the agent sees the
/// native `idea_*` tools, so the prose points to those instead of the file
/// protocol — while keeping the **same** ban on the provider's native subagents.
/// When `false` (`mcp == None`) the prose is the **current `.ideai/requests`
/// wording, unchanged** (zero regression).
#[must_use]
pub(crate) fn compose_convention_file(
project_root: &str,
project_context: &str,
agent_md: &str,
skills: &[Skill],
memory: &[MemoryIndexEntry],
handoff: Option<&Handoff>,
mcp_enabled: bool,
) -> String {
let mut out = String::new();
out.push_str("# Project root\n\n");
out.push_str(project_root);
out.push_str("\n\nTous tes travaux portent sur ce project root (chemin absolu ci-dessus). ");
out.push_str(
"Ton répertoire courant est un dossier d'exécution isolé (`.ideai/run/<agent>/`) ; \
opère sur le project root, pas sur ce dossier.\n\n",
);
out.push_str("---\n\n");
out.push_str("# Orchestration IdeA\n\n");
if mcp_enabled {
// Surface MCP (cadrage v3, D3) : l'alternative native aux subagents est
// exposée comme outils typés `idea_*`. L'interdiction des subagents natifs
// du fournisseur est **conservée** ; seule la voie de délégation change.
out.push_str(
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
natifs du fournisseur IA. Utilise les outils IdeA natifs : \
`idea_ask_agent` (déléguer une tâche et recevoir la réponse), \
`idea_launch_agent` (lancer/réattacher un agent) et `idea_list_agents` \
(lister les agents du projet). IdeA lancera ou réattachera l'agent cible \
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
);
// Protocole de délégation (Option 1, B-5) : côté agent SOLLICITÉ. Une tâche
// déléguée arrive dans ton terminal préfixée `[IdeA · tâche de … · ticket …]`.
// Tu DOIS y répondre via l'outil `idea_reply`, jamais en texte libre — sinon
// l'agent qui t'a sollicité reste bloqué (sa réponse ne lui parviendra pas).
out.push_str(
"Quand tu reçois une tâche déléguée par IdeA (un message préfixé \
`[IdeA · tâche de … · ticket …]`), traite-la puis appelle \
**impérativement** l'outil `idea_reply(result=…)` pour rendre ton \
résultat. Ne réponds **jamais** uniquement en texte : seul `idea_reply` \
débloque l'agent qui t'a sollicité.\n\n",
);
} else {
out.push_str(
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \
`.ideai/requests/<ton-agent>/` ; IdeA lancera ou réattachera l'agent cible \
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
);
}
out.push_str("---\n\n");
if !project_context.trim().is_empty() {
out.push_str("# Contexte projet\n\n");
out.push_str(project_context.trim());
out.push_str("\n\n---\n\n");
}
out.push_str(agent_md);
if !skills.is_empty() {
out.push_str("\n\n---\n\n# Skills\n");
for skill in skills {
out.push_str("\n## ");
out.push_str(&skill.name);
out.push_str("\n\n");
out.push_str(skill.content_md.as_str());
out.push('\n');
}
}
if !memory.is_empty() {
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
for entry in memory {
out.push_str("- [");
out.push_str(&entry.title);
out.push_str("](");
out.push_str(".ideai/memory/");
out.push_str(entry.slug.as_str());
out.push_str(".md) — ");
out.push_str(&entry.hook);
out.push_str(" (");
out.push_str(memory_type_label(entry.r#type));
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
}
/// Renders a [`MemoryType`] as its stable lowercase label for the convention-file
/// memory section (`user`/`feedback`/`project`/`reference`).
#[must_use]
fn memory_type_label(kind: MemoryType) -> &'static str {
match kind {
MemoryType::User => "user",
MemoryType::Feedback => "feedback",
MemoryType::Project => "project",
MemoryType::Reference => "reference",
}
}
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
/// agent, disambiguating against the manifest's existing paths with a numeric
/// suffix when needed. Shared with the template-driven agent creation (L7).
pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String {
let slug = slugify(name);
let base = if slug.is_empty() {
"agent".to_owned()
} else {
slug
};
let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md");
let mut n = 2;
while manifest.entries.iter().any(|e| e.md_path == candidate) {
candidate = format!("{AGENTS_SUBDIR}/{base}-{n}.md");
n += 1;
}
candidate
}
/// Lowercases and slugifies a display name into a safe file stem
/// (`[a-z0-9-]`), collapsing runs of separators.
fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut prev_dash = false;
for ch in name.trim().chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() {
let root = ProjectPath::new("/home/me/proj").unwrap();
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
let b = AgentId::from_uuid(uuid::Uuid::from_u128(2));
let dir_a = agent_run_dir(&root, &a).unwrap();
let dir_b = agent_run_dir(&root, &b).unwrap();
assert_eq!(dir_a.as_str(), format!("/home/me/proj/.ideai/run/{a}"));
assert_ne!(dir_a, dir_b, "distinct agents → distinct run dirs");
// Never the project root.
assert_ne!(dir_a.as_str(), "/home/me/proj");
}
#[test]
fn compose_convention_file_carries_root_then_persona() {
let doc = compose_convention_file(
"/abs/project/root",
"",
"# Persona\n\nDo things.",
&[],
&[],
None,
false,
);
// Absolute project root present.
assert!(doc.contains("/abs/project/root"));
// Persona present.
assert!(doc.contains("# Persona"));
assert!(doc.contains("Do things."));
// Root header precedes the persona body (ordering of the composition).
let root_at = doc.find("/abs/project/root").unwrap();
let persona_at = doc.find("# Persona").unwrap();
assert!(root_at < persona_at, "root header must precede the persona");
// No skills ⇒ no Skills section.
assert!(!doc.contains("# Skills"));
}
#[test]
fn compose_convention_file_includes_project_context_before_persona() {
let doc = compose_convention_file(
"/root",
"# Shared project context\n\nUse pnpm.",
"# Persona\n\nDo X.",
&[],
&[],
None,
false,
);
assert!(doc.contains("# Contexte projet"));
assert!(doc.contains("Use pnpm."));
let project_context_at = doc.find("# Contexte projet").unwrap();
let persona_at = doc.find("# Persona").unwrap();
assert!(
project_context_at < persona_at,
"shared project context must precede agent persona"
);
}
#[test]
fn compose_convention_file_appends_assigned_skills_in_order() {
let s = |n: u128, name: &str, body: &str| {
Skill::new(
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
name,
MarkdownDoc::new(body),
domain::SkillScope::Global,
)
.unwrap()
};
let doc = compose_convention_file(
"/root",
"",
"# Persona",
&[
s(1, "refactor", "REFAC_BODY"),
s(2, "review", "REVIEW_BODY"),
],
&[],
None,
false,
);
// Both skill bodies present, after the persona.
assert!(doc.contains("REFAC_BODY"));
assert!(doc.contains("REVIEW_BODY"));
let persona_at = doc.find("# Persona").unwrap();
let refac_at = doc.find("REFAC_BODY").unwrap();
let review_at = doc.find("REVIEW_BODY").unwrap();
assert!(persona_at < refac_at, "skills come after the persona");
// Deterministic order: first assigned skill precedes the second.
assert!(refac_at < review_at, "skills emitted in the given order");
// Skill names surface as sub-headers.
assert!(doc.contains("## refactor"));
assert!(doc.contains("## review"));
}
/// Builds a memory index entry for the convention-file composition tests.
fn mem(slug_str: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry {
MemoryIndexEntry {
slug: domain::MemorySlug::new(slug_str).unwrap(),
title: title.to_owned(),
hook: hook.to_owned(),
r#type: kind,
}
}
#[test]
fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
// 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.", &[], &[], None, false);
assert!(
!with_empty.contains("# Mémoire projet"),
"no memory ⇒ no memory section"
);
// Same document whether or not we thread an empty slice (it already is the
// 4-arg call; this pins the omission contract explicitly).
assert_eq!(
with_empty,
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false)
);
}
#[test]
fn compose_convention_file_appends_memory_entries_in_order() {
let doc = compose_convention_file(
"/root",
"",
"# Persona",
&[],
&[
mem("alpha-note", "Alpha", "the first hook", MemoryType::User),
mem(
"beta-note",
"Beta",
"the second hook",
MemoryType::Reference,
),
],
None,
false,
);
// Section present, after the persona.
assert!(doc.contains("# Mémoire projet"));
let persona_at = doc.find("# Persona").unwrap();
let section_at = doc.find("# Mémoire projet").unwrap();
assert!(persona_at < section_at, "memory comes after the persona");
// Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`.
assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)"));
assert!(doc.contains("- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"));
// Deterministic order: first entry precedes the second.
let alpha_at = doc.find("[Alpha]").unwrap();
let beta_at = doc.find("[Beta]").unwrap();
assert!(
alpha_at < beta_at,
"memory entries emitted in the given order"
);
}
#[test]
fn compose_convention_file_memory_and_skills_coexist() {
let skill = Skill::new(
domain::SkillId::from_uuid(uuid::Uuid::from_u128(1)),
"refactor",
MarkdownDoc::new("REFAC_BODY"),
domain::SkillScope::Global,
)
.unwrap();
let doc = compose_convention_file(
"/root",
"",
"# Persona",
std::slice::from_ref(&skill),
&[mem("note", "Note", "a hook", MemoryType::Project)],
None,
false,
);
// Both sections present.
assert!(doc.contains("# Skills"));
assert!(doc.contains("REFAC_BODY"));
assert!(doc.contains("# Mémoire projet"));
assert!(doc.contains("- [Note](.ideai/memory/note.md) — a hook (project)"));
// Skills section precedes the memory section (persona → skills → memory).
let skills_at = doc.find("# Skills").unwrap();
let memory_at = doc.find("# Mémoire projet").unwrap();
assert!(skills_at < memory_at, "skills come before memory");
}
#[test]
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", &[], &[], None, true);
// Native IdeA orchestration tools surfaced.
assert!(
doc.contains("idea_ask_agent"),
"MCP prose must mention idea_ask_agent"
);
assert!(doc.contains("idea_launch_agent"));
assert!(doc.contains("idea_list_agents"));
// Native-subagent ban preserved.
assert!(
doc.contains("n'utilise jamais les subagents"),
"MCP prose must keep the native-subagent ban"
);
// It does NOT fall back to the file protocol wording.
assert!(
!doc.contains(".ideai/requests"),
"MCP prose must not point to the file protocol"
);
}
#[test]
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", &[], &[], None, true);
assert!(
doc.contains("idea_reply"),
"MCP prose must instruct answering via idea_reply"
);
assert!(
doc.contains("[IdeA · tâche"),
"MCP prose must describe the delegated-task prefix it answers to"
);
// 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", &[], &[], None, false);
assert!(!file_doc.contains("idea_reply"));
}
#[test]
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", &[], &[], None, false);
assert!(
doc.contains(".ideai/requests"),
"non-MCP prose must point to the file protocol"
);
assert!(
doc.contains("n'utilise jamais les subagents"),
"non-MCP prose keeps the native-subagent ban"
);
// No native MCP tools leaked into the file-protocol prose.
assert!(
!doc.contains("idea_ask_agent"),
"non-MCP prose must not mention the idea_* tools"
);
}
#[test]
fn mcp_declaration_with_runtime_points_the_bridge_at_the_real_endpoint() {
// B-0 — a PTY-launched MCP-capable CLI (Claude/Codex REPL) auto-discovers the
// `.mcp.json` in its cwd (the run dir). When the composition root injects the
// real `McpRuntime`, the declaration written there must spawn *this* IdeA exe
// in `mcp-server` mode and dial the project's exact loopback endpoint — the
// same source of truth `ensure_mcp_server` binds — so the CLI can call
// `idea_list_agents` and get a reply end-to-end.
let rt = McpRuntime {
exe: "/opt/idea/idea".to_owned(),
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
requester: "11112222-3333-4444-5555-666677778888".to_owned(),
};
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, Some(&rt));
// It is valid JSON with the IdeA server under `mcpServers/idea`.
let parsed: serde_json::Value =
serde_json::from_str(&decl).expect("declaration is valid JSON");
let idea = &parsed["mcpServers"]["idea"];
assert_eq!(idea["command"], "/opt/idea/idea");
let args = idea["args"].as_array().expect("args is an array");
let args: Vec<&str> = args.iter().filter_map(serde_json::Value::as_str).collect();
assert_eq!(args[0], "mcp-server");
// The real endpoint / project / requester are all threaded through.
assert!(args.contains(&"--endpoint"));
assert!(args.contains(&"/run/user/1000/idea-mcp/proj.sock"));
assert!(args.contains(&"--project"));
assert!(args.contains(&"0123456789abcdef0123456789abcdef"));
assert!(args.contains(&"--requester"));
assert_eq!(idea["transport"], "stdio");
}
#[test]
fn mcp_declaration_without_runtime_is_a_coherent_minimal_fallback() {
// Launches issued from inside `application` (orchestrator/hot-swap/tests) have
// no OS/runtime facts: the declaration falls back to the bare `idea mcp-server`
// command — still a valid, self-consistent `mcpServers/idea` entry.
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, None);
let parsed: serde_json::Value =
serde_json::from_str(&decl).expect("minimal declaration is valid JSON");
let idea = &parsed["mcpServers"]["idea"];
assert_eq!(idea["command"], "idea");
let args: Vec<&str> = idea["args"]
.as_array()
.expect("args array")
.iter()
.filter_map(serde_json::Value::as_str)
.collect();
assert_eq!(args, vec!["mcp-server"]);
// No endpoint/project/requester when no runtime was injected.
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}"
);
}
// NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed —
// that translation now lives in `infrastructure::permission::ClaudePermissionProjector`
// and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval
// derivation moved to `CodexPermissionProjector`; `codex_config_toml` here is now
// **MCP-only**, so the Codex tests below assert only the MCP/trust merge.
#[test]
fn codex_config_trusts_run_and_project_and_replaces_only_idea_mcp() {
let existing = r#"[projects."/home/me/proj"]
trust_level = "trusted"
approval_policy = "nested"
[mcp_servers.idea]
command = "old"
[mcp_servers.other]
command = "other"
"#;
// No `permissions` argument anymore: the permission projection is decoupled
// (lot LP3-3) into `CodexPermissionProjector`. This function only merges MCP.
let rendered = codex_config_toml(
Some(existing),
"[mcp_servers.idea]\ncommand = \"new\"",
"/home/me/proj/.ideai/run/a",
"/home/me/proj",
);
// MCP table + trust entries are the only things this function touches.
assert!(rendered.contains("[projects.\"/home/me/proj\"]"));
assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]"));
assert!(rendered.contains("approval_policy = \"nested\""));
assert!(rendered.contains("[mcp_servers.idea]\ncommand = \"new\""));
assert!(rendered.contains("[mcp_servers.other]\ncommand = \"other\""));
assert!(!rendered.contains("command = \"old\""));
assert_eq!(rendered.matches("[mcp_servers.idea]").count(), 1);
assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1);
// Permission keys are NOT added by this MCP-only function.
assert!(!rendered.contains("sandbox_mode ="));
}
#[test]
fn codex_config_is_mcp_only_and_adds_no_permission_keys() {
let rendered = codex_config_toml(
None,
"[mcp_servers.idea]\ncommand = \"idea-mcp\"",
"/home/me/proj/.ideai/run/a",
"/home/me/proj",
);
assert!(!rendered.contains("approval_policy ="));
assert!(!rendered.contains("sandbox_mode ="));
assert!(rendered.contains("[projects.\"/home/me/proj\"]"));
assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]"));
}
#[test]
fn merge_managed_toml_upserts_only_managed_keys() {
// Existing config (e.g. written by `apply_mcp_config`): MCP + trust + a user key.
let existing = r#"user_key = "keep-me"
[mcp_servers.idea]
command = "idea-mcp"
"#;
// Codex projector fragment: the two managed permission keys.
let fragment = "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n";
let merged = merge_managed_toml(
existing,
&[],
&["sandbox_mode".to_owned(), "approval_policy".to_owned()],
fragment,
);
// Managed keys are spliced in…
assert!(merged.contains("sandbox_mode = \"workspace-write\""));
assert!(merged.contains("approval_policy = \"never\""));
// …without disturbing the rest of the file.
assert!(merged.contains("user_key = \"keep-me\""));
assert!(merged.contains("[mcp_servers.idea]\ncommand = \"idea-mcp\""));
}
}