feat(agent): routage LaunchAgent structuré vs PTY + réconciliation A/B (D3) — §17

- LaunchAgent route sur profile.structured_adapter : Some ⇒ AgentSession via
  factory + enregistrement StructuredSessions (aucun pty.spawn) ; None ⇒ PTY
  inchangé. Dépendances structurées injectées par builders additifs
  with_structured (signatures publiques inchangées ⇒ A/B legacy verts).
- LaunchAgentOutput étendu d'un champ optionnel structured (non cassant).
- A (ChangeAgentProfile) : kill polymorphe — session structurée ⇒ shutdown(),
  PTY ⇒ kill ; détection sur les deux registres.
- B : resume_supported vrai pour profil structuré ; resolve_session_plan ⇒
  Resume{conversation_id} (Claude --resume / Codex exec resume).
- Codex : build_spawn_line porte --sandbox workspace-write --ask-for-approval
  never (autonomie d'écriture ; à terme piloté par les permissions).

Tests : structured_launch 8 + codex flags 2 ; application/infrastructure 0 échec.

Reste D4 : ChatBridge (Channel) + commandes agent_send/reattach + StructuredSessions
dans AppState + DTO cellKind/ReplyChunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 18:48:25 +02:00
parent f104682477
commit 56913b9053
6 changed files with 1524 additions and 52 deletions

View File

@ -23,15 +23,17 @@ use application::{
LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
RecallMemory, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveMemoryLinks,
SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, SuggestedThisSession,
SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
};
use domain::ports::{
AgentContextStore, AgentRuntime, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore,
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, MemoryRecall, MemoryStore,
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore,
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore,
TemplateStore,
};
use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
@ -40,7 +42,8 @@ use infrastructure::{
EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore,
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, SystemClock, TokioBroadcastEventBus,
OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock,
TokioBroadcastEventBus,
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};
@ -291,6 +294,14 @@ impl AppState {
let pty_port = Arc::clone(&pty) as Arc<dyn PtyPort>;
let terminal_sessions = Arc::new(TerminalSessions::new());
// --- Sessions structurées (IA / cellules chat, §17) ---
// Registre jumeau de TerminalSessions + fabrique infra routée par
// `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
// ChangeAgentProfile (shutdown polymorphe au hot-swap).
let structured_sessions = Arc::new(StructuredSessions::new());
let session_factory =
Arc::new(StructuredSessionFactory::new()) as Arc<dyn AgentSessionFactory>;
let open_terminal = Arc::new(OpenTerminal::new(
Arc::clone(&pty_port),
Arc::clone(&terminal_sessions),
@ -507,7 +518,8 @@ impl AppState {
));
// LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
// use cases — indispensable for the PtyBridge to work correctly.
let launch_agent = Arc::new(LaunchAgent::new(
let launch_agent = Arc::new(
LaunchAgent::new(
Arc::clone(&contexts_port),
Arc::clone(&profile_store_port),
Arc::clone(&runtime_port),
@ -519,12 +531,21 @@ impl AppState {
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&memory_recall_port),
Some(Arc::clone(&check_embedder_suggestion)),
));
)
// Routage structuré §17.4 : un profil avec `structured_adapter` lance une
// AgentSession (cellule chat) au lieu d'un PTY ; sinon chemin PTY inchangé.
.with_structured(
Arc::clone(&session_factory),
Arc::clone(&structured_sessions),
),
);
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
// profile/project/fs stores, the live-session registry and PTY port, and
// *composes* the launcher above for the in-place relaunch (no duplication).
let change_agent_profile = Arc::new(ChangeAgentProfile::new(
// Voit aussi le registre structuré pour un « kill » polymorphe (§17.4).
let change_agent_profile = Arc::new(
ChangeAgentProfile::new(
Arc::clone(&contexts_port),
Arc::clone(&profile_store_port),
Arc::clone(&store_port),
@ -533,7 +554,9 @@ impl AppState {
Arc::clone(&pty_port),
Arc::clone(&launch_agent),
Arc::clone(&events_port),
));
)
.with_structured(Arc::clone(&structured_sessions)),
);
// Read-only inventory of resumable agent cells (§15.2). Reuses the shared
// project/fs/context/profile stores already injected above — no new port.

View File

@ -14,20 +14,20 @@
use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError,
IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, ProjectStore, PtyPort,
RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore,
ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project,
ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
ProjectPath, PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
};
use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc};
use crate::project::project_context_path;
use crate::terminal::TerminalSessions;
use crate::terminal::{StructuredSessions, TerminalSessions};
/// Directory (relative to `.ideai/`) under which agent contexts are written.
const AGENTS_SUBDIR: &str = "agents";
@ -321,6 +321,11 @@ pub struct ChangeAgentProfile {
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>>,
}
impl ChangeAgentProfile {
@ -346,9 +351,20 @@ impl ChangeAgentProfile {
pty,
launch,
events,
structured: 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
}
/// Executes the hot-swap, following the 7-step algorithm of §15.1.
///
/// # Errors
@ -476,18 +492,14 @@ impl ChangeAgentProfile {
&self,
input: &ChangeAgentProfileInput,
) -> Result<Option<TerminalSession>, AppError> {
let Some(session_id) = self.sessions.session_for_agent(&input.agent_id) else {
// 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);
};
// The hosting cell of the live session — the relaunch reopens here.
let node_id = self.sessions.node_for_agent(&input.agent_id);
// Kill the PTY: remove from the registry first (so the relaunch's
// one-live-session-per-agent guard sees no live session), then kill the
// process.
if let Some(handle) = self.sessions.remove(&session_id) {
self.pty.kill(&handle).await?;
}
let output = self
.launch
@ -504,6 +516,44 @@ impl ChangeAgentProfile {
.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))
}
}
// ---------------------------------------------------------------------------
@ -589,10 +639,32 @@ pub struct LaunchAgentInput {
pub conversation_id: Option<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,
/// The conversation id **assigned** by this launch, when the profile supports
/// session assignment and the cell had none yet. The caller persists it on the
@ -601,6 +673,13 @@ pub struct LaunchAgentOutput {
/// (resume of an existing id, degraded mode, or a profile without a session
/// block) — the caller has nothing to persist.
pub assigned_conversation_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>,
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
@ -630,6 +709,15 @@ pub struct LaunchAgent {
/// 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>>,
/// 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>>,
}
impl LaunchAgent {
@ -661,9 +749,30 @@ impl LaunchAgent {
ids,
recall,
embedder_suggestion,
session_factory: None,
structured: None,
}
}
/// 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
@ -760,12 +869,18 @@ impl LaunchAgent {
// - 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).
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
if let Some(node_id) = input.node_id {
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
structured: None,
});
}
}
@ -775,6 +890,31 @@ impl LaunchAgent {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
structured: None,
});
}
}
// Garde structurée (§17.4) : même sémantique côté registre IA. Rebind de la
// cellule-vue si un node est demandé, sinon idempotence (pas de redémarrage).
if let Some(structured) = &self.structured {
if let Some(existing) = structured.session_for_agent(&input.agent_id) {
// Cellule-vue : le node demandé, sinon le node hôte courant, sinon un
// node neuf (rebind sans redémarrer le process, « la cellule est une
// vue »).
let node_id = input.node_id.or_else(|| structured.node_for_agent(&input.agent_id));
if let Some(node_id) = node_id {
let _ = structured.rebind_agent_node(&input.agent_id, node_id);
}
let node_id = node_id.unwrap_or_else(NodeId::new_random);
return Ok(LaunchAgentOutput {
session: structured_snapshot(&existing, input.agent_id, node_id, size),
assigned_conversation_id: None,
structured: Some(StructuredSessionDescriptor {
session_id: existing.id(),
agent_id: input.agent_id,
node_id,
conversation_id: existing.conversation_id(),
}),
});
}
}
@ -861,6 +1001,34 @@ impl LaunchAgent {
)
.await?;
// 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(),
) {
return self
.launch_structured(
factory.as_ref(),
structured,
&agent,
&profile,
&prepared,
&run_dir,
&session_plan,
assigned_conversation_id,
input.node_id,
size,
)
.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;
@ -889,6 +1057,67 @@ impl LaunchAgent {
Ok(LaunchAgentOutput {
session,
assigned_conversation_id,
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,
assigned_conversation_id: Option<String>,
node_id: Option<NodeId>,
size: PtySize,
) -> Result<LaunchAgentOutput, AppError> {
let session = factory
.start(profile, prepared, run_dir, session_plan)
.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);
// L'id de conversation à persister : celui que le moteur expose s'il en a un,
// sinon l'id pré-attribué par le plan de session (§15).
let engine_conversation_id = session.conversation_id();
let conversation_id = engine_conversation_id.clone().or(assigned_conversation_id);
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,
// Le caller persiste cet id sur la cellule (pivot de reprise §15.2).
assigned_conversation_id: conversation_id.clone(),
structured: Some(StructuredSessionDescriptor {
session_id,
agent_id: agent.id,
node_id,
conversation_id: engine_conversation_id,
}),
})
}
@ -912,8 +1141,17 @@ impl LaunchAgent {
profile: &AgentProfile,
cell_conversation_id: Option<String>,
) -> (SessionPlan, Option<String>) {
// No session strategy at all: behave exactly as before.
// No session strategy at all: behave exactly as before — **sauf** pour un
// profil **structuré** (§17), dont la reprise ne dépend pas d'un bloc
// `session` mais de l'adapter (Claude `--resume` / Codex `exec resume`). Pour
// un tel profil, une conversation présente sur la cellule ⇒ `Resume`, l'id
// étant capté/attribué par le moteur au premier tour (rien à pré-attribuer).
let Some(session) = &profile.session else {
if profile.structured_adapter.is_some() {
if let Some(conversation_id) = cell_conversation_id {
return (SessionPlan::Resume { conversation_id }, None);
}
}
return (SessionPlan::None, None);
};
@ -1053,6 +1291,34 @@ pub(crate) fn agent_run_dir(
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
}
/// Builds the Claude Code permission seed (`.claude/settings.local.json`) written
/// into an agent's run dir: full project autonomy (`bypassPermissions` + broad
/// Read/Edit/Write/Bash) with the project root granted as an additional working

View File

@ -161,13 +161,21 @@ impl ListResumableAgents {
};
let name = entry.name.clone();
// `resume_supported` : le profil de l'agent porte-t-il une
// SessionStrategy exploitable (resume_flag présent) ?
// `resume_supported` : le profil de l'agent sait-il reprendre une
// conversation ? Vrai si :
// - il porte une `SessionStrategy` (TUI/PTY avec `resume_flag`,
// sémantique §15), **ou**
// - il est **structuré** (`structured_adapter`, §17) : l'adapter
// Claude (`--resume`) / Codex (`exec resume`) passe le flag de
// reprise du moteur via `SessionPlan::Resume`, donc la reprise est
// intrinsèquement supportée.
let resume_supported = entry
.to_agent()
.ok()
.and_then(|agent| profiles.iter().find(|p| p.id == agent.profile_id))
.is_some_and(|profile| profile.session.is_some());
.is_some_and(|profile| {
profile.session.is_some() || profile.structured_adapter.is_some()
});
resumable.push(ResumableAgent {
agent_id,

File diff suppressed because it is too large Load Diff

View File

@ -142,13 +142,18 @@ impl CodexExecSession {
/// Compose la ligne de commande d'un tour.
///
/// Format RÉEL vérifié 2026-06-09 :
/// - Conversation neuve : `codex exec --json --skip-git-repo-check <prompt>`.
/// - Conversation neuve : `codex exec --json --skip-git-repo-check
/// --sandbox workspace-write --ask-for-approval never <prompt>`.
/// - Reprise (id connu) : `codex exec resume <thread_id> --json
/// --skip-git-repo-check <prompt>`.
/// --skip-git-repo-check --sandbox workspace-write --ask-for-approval never
/// <prompt>`.
///
/// NOTE intégration (D3, hors parsing) : pour qu'un agent puisse **écrire**, le
/// lancement devra ajouter `--sandbox workspace-write` + une politique
/// d'approbation non bloquante. Ce n'est pas le rôle de cette composition.
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
/// écrire dans son workspace et `--ask-for-approval never` évite tout blocage sur
/// une demande d'approbation interactive (un tour structuré non-interactif ne peut
/// répondre à un prompt). Défaut raisonnable, aligné sur l'autonomie projet
/// (CLAUDE.md §12) ; à terme **piloté par les permissions de l'agent**
/// (`.ideai/permissions.json` + sandbox OS) — non implémenté ici.
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
let mut args = vec!["exec".to_owned()];
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
@ -157,6 +162,10 @@ impl CodexExecSession {
}
args.push("--json".to_owned());
args.push("--skip-git-repo-check".to_owned());
args.push("--sandbox".to_owned());
args.push("workspace-write".to_owned());
args.push("--ask-for-approval".to_owned());
args.push("never".to_owned());
args.push(prompt.to_owned());
SpawnLine {
command: self.command.clone(),

View File

@ -1127,4 +1127,93 @@ mod tests {
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
// =====================================================================
// DURCISSEMENT QA (lot D3, §17.9 D3) — autonomie d'écriture Codex :
// la commande générée porte `--sandbox workspace-write` ET
// `--ask-for-approval never`, conversation NEUVE comme en REPRISE.
// Prouvé via le sidecar argv du fake enregistreur (jamais le vrai codex).
// =====================================================================
/// Conversation NEUVE : `codex exec --json --skip-git-repo-check
/// --sandbox workspace-write --ask-for-approval never <prompt>`. On prouve la
/// présence des deux flags d'autonomie ET leur appariement valeur (sandbox=
/// workspace-write, ask-for-approval=never), sans sous-commande `resume`.
#[tokio::test]
async fn codex_new_conversation_command_carries_autonomy_flags() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
// Conversation neuve : pas de sous-commande resume.
assert!(
!args.contains(&"resume"),
"1er tour NEUF ne doit PAS porter resume, vu: {args:?}"
);
// Flags d'autonomie (D3), avec leur valeur appariée.
assert!(args.contains(&"--sandbox"), "vu: {args:?}");
assert!(args.contains(&"workspace-write"), "vu: {args:?}");
assert!(args.contains(&"--ask-for-approval"), "vu: {args:?}");
assert!(args.contains(&"never"), "vu: {args:?}");
// `--sandbox` est immédiatement suivi de `workspace-write`.
let sb = args.iter().position(|a| *a == "--sandbox").unwrap();
assert_eq!(
args.get(sb + 1),
Some(&"workspace-write"),
"--sandbox doit être suivi de workspace-write, vu: {args:?}"
);
// `--ask-for-approval` est immédiatement suivi de `never`.
let af = args.iter().position(|a| *a == "--ask-for-approval").unwrap();
assert_eq!(
args.get(af + 1),
Some(&"never"),
"--ask-for-approval doit être suivi de never, vu: {args:?}"
);
// La base + le prompt restent présents.
assert!(args.contains(&"--json") && args.contains(&"--skip-git-repo-check"));
assert!(args.contains(&"salut"), "vu: {args:?}");
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
/// REPRISE (seed d'id) : `codex exec resume <id> --json --skip-git-repo-check
/// --sandbox workspace-write --ask-for-approval never <prompt>`. Les flags
/// d'autonomie sont AUSSI présents lors d'une reprise.
#[tokio::test]
async fn codex_resume_command_carries_autonomy_flags() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new(
SessionId::new_random(),
cmd.clone(),
"/",
Some("cx-id".to_owned()),
);
let _ = session.send("vas-y").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
// Reprise : sous-commande resume + id.
assert!(args.contains(&"resume"), "vu: {args:?}");
assert!(args.contains(&"cx-id"), "vu: {args:?}");
// Flags d'autonomie présents même en reprise.
let sb = args
.iter()
.position(|a| *a == "--sandbox")
.expect("--sandbox présent en reprise");
assert_eq!(args.get(sb + 1), Some(&"workspace-write"), "vu: {args:?}");
let af = args
.iter()
.position(|a| *a == "--ask-for-approval")
.expect("--ask-for-approval présent en reprise");
assert_eq!(args.get(af + 1), Some(&"never"), "vu: {args:?}");
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
}