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:
@ -23,15 +23,17 @@ use application::{
|
|||||||
LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||||
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
|
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
|
||||||
RecallMemory, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveMemoryLinks,
|
RecallMemory, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveMemoryLinks,
|
||||||
SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, SuggestedThisSession,
|
SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
|
||||||
|
SuggestedThisSession,
|
||||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
|
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
|
||||||
UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal,
|
UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal,
|
||||||
AGENT_MEMORY_RECALL_BUDGET,
|
AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore,
|
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
||||||
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, MemoryRecall, MemoryStore,
|
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||||
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore,
|
MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore,
|
||||||
|
TemplateStore,
|
||||||
};
|
};
|
||||||
use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
|
use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
|
||||||
|
|
||||||
@ -40,7 +42,8 @@ use infrastructure::{
|
|||||||
EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore,
|
EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore,
|
||||||
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
|
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
|
||||||
Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall,
|
Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall,
|
||||||
OrchestratorWatchHandle, PortablePtyAdapter, SystemClock, TokioBroadcastEventBus,
|
OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock,
|
||||||
|
TokioBroadcastEventBus,
|
||||||
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
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 pty_port = Arc::clone(&pty) as Arc<dyn PtyPort>;
|
||||||
let terminal_sessions = Arc::new(TerminalSessions::new());
|
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(
|
let open_terminal = Arc::new(OpenTerminal::new(
|
||||||
Arc::clone(&pty_port),
|
Arc::clone(&pty_port),
|
||||||
Arc::clone(&terminal_sessions),
|
Arc::clone(&terminal_sessions),
|
||||||
@ -507,7 +518,8 @@ impl AppState {
|
|||||||
));
|
));
|
||||||
// LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
|
// LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
|
||||||
// use cases — indispensable for the PtyBridge to work correctly.
|
// 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(&contexts_port),
|
||||||
Arc::clone(&profile_store_port),
|
Arc::clone(&profile_store_port),
|
||||||
Arc::clone(&runtime_port),
|
Arc::clone(&runtime_port),
|
||||||
@ -519,12 +531,21 @@ impl AppState {
|
|||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
Arc::clone(&memory_recall_port),
|
Arc::clone(&memory_recall_port),
|
||||||
Some(Arc::clone(&check_embedder_suggestion)),
|
Some(Arc::clone(&check_embedder_suggestion)),
|
||||||
));
|
)
|
||||||
|
// 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/
|
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
|
||||||
// profile/project/fs stores, the live-session registry and PTY port, and
|
// profile/project/fs stores, the live-session registry and PTY port, and
|
||||||
// *composes* the launcher above for the in-place relaunch (no duplication).
|
// *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(&contexts_port),
|
||||||
Arc::clone(&profile_store_port),
|
Arc::clone(&profile_store_port),
|
||||||
Arc::clone(&store_port),
|
Arc::clone(&store_port),
|
||||||
@ -533,7 +554,9 @@ impl AppState {
|
|||||||
Arc::clone(&pty_port),
|
Arc::clone(&pty_port),
|
||||||
Arc::clone(&launch_agent),
|
Arc::clone(&launch_agent),
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
));
|
)
|
||||||
|
.with_structured(Arc::clone(&structured_sessions)),
|
||||||
|
);
|
||||||
|
|
||||||
// Read-only inventory of resumable agent cells (§15.2). Reuses the shared
|
// Read-only inventory of resumable agent cells (§15.2). Reuses the shared
|
||||||
// project/fs/context/profile stores already injected above — no new port.
|
// project/fs/context/profile stores already injected above — no new port.
|
||||||
|
|||||||
@ -14,20 +14,20 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError,
|
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
|
||||||
IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, ProjectStore, PtyPort,
|
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore,
|
||||||
RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
|
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
|
||||||
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project,
|
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::error::AppError;
|
||||||
use crate::layout::{persist_doc, resolve_doc};
|
use crate::layout::{persist_doc, resolve_doc};
|
||||||
use crate::project::project_context_path;
|
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.
|
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
||||||
const AGENTS_SUBDIR: &str = "agents";
|
const AGENTS_SUBDIR: &str = "agents";
|
||||||
@ -321,6 +321,11 @@ pub struct ChangeAgentProfile {
|
|||||||
pty: Arc<dyn PtyPort>,
|
pty: Arc<dyn PtyPort>,
|
||||||
launch: Arc<LaunchAgent>,
|
launch: Arc<LaunchAgent>,
|
||||||
events: Arc<dyn EventBus>,
|
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 {
|
impl ChangeAgentProfile {
|
||||||
@ -346,9 +351,20 @@ impl ChangeAgentProfile {
|
|||||||
pty,
|
pty,
|
||||||
launch,
|
launch,
|
||||||
events,
|
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.
|
/// Executes the hot-swap, following the 7-step algorithm of §15.1.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -476,18 +492,14 @@ impl ChangeAgentProfile {
|
|||||||
&self,
|
&self,
|
||||||
input: &ChangeAgentProfileInput,
|
input: &ChangeAgentProfileInput,
|
||||||
) -> Result<Option<TerminalSession>, AppError> {
|
) -> 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);
|
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
|
let output = self
|
||||||
.launch
|
.launch
|
||||||
@ -504,6 +516,44 @@ impl ChangeAgentProfile {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(Some(output.session))
|
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>,
|
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`].
|
/// Output of [`LaunchAgent::execute`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct LaunchAgentOutput {
|
pub struct LaunchAgentOutput {
|
||||||
/// The created agent terminal session.
|
/// 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,
|
pub session: TerminalSession,
|
||||||
/// The conversation id **assigned** by this launch, when the profile supports
|
/// 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
|
/// 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
|
/// (resume of an existing id, degraded mode, or a profile without a session
|
||||||
/// block) — the caller has nothing to persist.
|
/// block) — the caller has nothing to persist.
|
||||||
pub assigned_conversation_id: Option<String>,
|
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
|
/// 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
|
/// reads the project memory. `None` keeps the launcher independent of it (legacy
|
||||||
/// wiring / tests). A failure here never affects the launch.
|
/// wiring / tests). A failure here never affects the launch.
|
||||||
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
|
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 {
|
impl LaunchAgent {
|
||||||
@ -661,9 +749,30 @@ impl LaunchAgent {
|
|||||||
ids,
|
ids,
|
||||||
recall,
|
recall,
|
||||||
embedder_suggestion,
|
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
|
/// Resolves the Markdown bodies of an agent's assigned skills, in the
|
||||||
/// **manifest order** (deterministic). A skill that no longer exists in its
|
/// **manifest order** (deterministic). A skill that no longer exists in its
|
||||||
/// store (deleted out from under the assignment) is silently skipped — a
|
/// 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:
|
// - without a requested node → idempotent background/no-op launch:
|
||||||
// return the existing session without respawning.
|
// return the existing session without respawning.
|
||||||
// The resume path (agent dead ⇒ no live session) is unaffected.
|
// 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(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
|
||||||
if let Some(node_id) = input.node_id {
|
if let Some(node_id) = input.node_id {
|
||||||
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) {
|
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) {
|
||||||
return Ok(LaunchAgentOutput {
|
return Ok(LaunchAgentOutput {
|
||||||
session,
|
session,
|
||||||
assigned_conversation_id: None,
|
assigned_conversation_id: None,
|
||||||
|
structured: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -775,6 +890,31 @@ impl LaunchAgent {
|
|||||||
return Ok(LaunchAgentOutput {
|
return Ok(LaunchAgentOutput {
|
||||||
session,
|
session,
|
||||||
assigned_conversation_id: None,
|
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?;
|
.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.
|
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
||||||
let handle = self.pty.spawn(spec.clone(), size).await?;
|
let handle = self.pty.spawn(spec.clone(), size).await?;
|
||||||
let session_id = handle.session_id;
|
let session_id = handle.session_id;
|
||||||
@ -889,6 +1057,67 @@ impl LaunchAgent {
|
|||||||
Ok(LaunchAgentOutput {
|
Ok(LaunchAgentOutput {
|
||||||
session,
|
session,
|
||||||
assigned_conversation_id,
|
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,
|
profile: &AgentProfile,
|
||||||
cell_conversation_id: Option<String>,
|
cell_conversation_id: Option<String>,
|
||||||
) -> (SessionPlan, 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 {
|
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);
|
return (SessionPlan::None, None);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1053,6 +1291,34 @@ pub(crate) fn agent_run_dir(
|
|||||||
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
|
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
|
/// Builds the Claude Code permission seed (`.claude/settings.local.json`) written
|
||||||
/// into an agent's run dir: full project autonomy (`bypassPermissions` + broad
|
/// into an agent's run dir: full project autonomy (`bypassPermissions` + broad
|
||||||
/// Read/Edit/Write/Bash) with the project root granted as an additional working
|
/// Read/Edit/Write/Bash) with the project root granted as an additional working
|
||||||
|
|||||||
@ -161,13 +161,21 @@ impl ListResumableAgents {
|
|||||||
};
|
};
|
||||||
let name = entry.name.clone();
|
let name = entry.name.clone();
|
||||||
|
|
||||||
// `resume_supported` : le profil de l'agent porte-t-il une
|
// `resume_supported` : le profil de l'agent sait-il reprendre une
|
||||||
// SessionStrategy exploitable (resume_flag présent) ?
|
// 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
|
let resume_supported = entry
|
||||||
.to_agent()
|
.to_agent()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|agent| profiles.iter().find(|p| p.id == agent.profile_id))
|
.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 {
|
resumable.push(ResumableAgent {
|
||||||
agent_id,
|
agent_id,
|
||||||
|
|||||||
1077
crates/application/tests/structured_launch_d3.rs
Normal file
1077
crates/application/tests/structured_launch_d3.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -142,13 +142,18 @@ impl CodexExecSession {
|
|||||||
/// Compose la ligne de commande d'un tour.
|
/// Compose la ligne de commande d'un tour.
|
||||||
///
|
///
|
||||||
/// Format RÉEL vérifié 2026-06-09 :
|
/// 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
|
/// - 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
|
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
|
||||||
/// lancement devra ajouter `--sandbox workspace-write` + une politique
|
/// écrire dans son workspace et `--ask-for-approval never` évite tout blocage sur
|
||||||
/// d'approbation non bloquante. Ce n'est pas le rôle de cette composition.
|
/// 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 {
|
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
||||||
let mut args = vec!["exec".to_owned()];
|
let mut args = vec!["exec".to_owned()];
|
||||||
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
|
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("--json".to_owned());
|
||||||
args.push("--skip-git-repo-check".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());
|
args.push(prompt.to_owned());
|
||||||
SpawnLine {
|
SpawnLine {
|
||||||
command: self.command.clone(),
|
command: self.command.clone(),
|
||||||
|
|||||||
@ -1127,4 +1127,93 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&cmd);
|
let _ = std::fs::remove_file(&cmd);
|
||||||
let _ = std::fs::remove_file(&argv);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user