diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index b665989..d16be76 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -36,10 +36,10 @@ use application::{ ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, - StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, - UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, - UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, - UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, + StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, + TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, + UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, + UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; @@ -1568,6 +1568,7 @@ impl AppState { Arc::clone(&memory_recall_port), Some(Arc::clone(&check_embedder_suggestion)), ) + .with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback) .with_permission_store(Arc::clone(&permission_store_port)) // Reprise conversationnelle (lot P7) : à chaque (re)lancement, si la cellule // porte une conversation et qu'un handoff existe (`/.ideai/conversations/`), @@ -1613,6 +1614,7 @@ impl AppState { Arc::clone(&memory_recall_port), Some(Arc::clone(&check_embedder_suggestion)), ) + .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) .with_permission_store(Arc::clone(&permission_store_port)) .with_handoff_provider( Arc::new(AppHandoffProvider) as Arc @@ -1910,7 +1912,7 @@ impl AppState { Arc::clone(&mailbox), Arc::clone(&background_tasks_port), Arc::new(AppWakeSessionProvider { - launch_agent: Arc::clone(&launch_agent), + launch_agent: Arc::clone(&orchestrator_launch_agent), structured_sessions: Arc::clone(&structured_sessions), }), Some(Arc::clone(&events_port)), diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index c30c908..35f6003 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -998,6 +998,15 @@ pub struct LaunchAgentOutput { pub profile: Option, } +/// Explicit routing intent for profiles that declare a structured adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StructuredRoutingMode { + /// Human-facing cells intentionally keep the native CLI/TUI in a PTY. + HumanPtyFallback, + /// Headless/orchestrated launchers must route structured profiles via AgentSession. + RequireStructured, +} + /// Registry mapping each [`ProjectorKey`] to its concrete /// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the /// optional builder [`LaunchAgent::with_permission_projectors`]; the concrete @@ -1141,6 +1150,8 @@ pub struct LaunchAgent { /// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime /// when `OpenCodeConfig.localModelServerId` is set. local_model_server: Option>, + /// Explicit intent for structured profiles when structured ports are absent. + structured_routing_mode: StructuredRoutingMode, } impl LaunchAgent { @@ -1180,9 +1191,17 @@ impl LaunchAgent { projectors: None, live_state_lean: None, local_model_server: None, + structured_routing_mode: StructuredRoutingMode::HumanPtyFallback, } } + /// Sets the explicit routing intent for profiles carrying a structured adapter. + #[must_use] + pub const fn with_structured_routing_mode(mut self, mode: StructuredRoutingMode) -> Self { + self.structured_routing_mode = mode; + self + } + /// Injects the local model-server ensure use case used by OpenCode profiles. #[must_use] pub fn with_local_model_server(mut self, ensure: Arc) -> Self { @@ -1712,51 +1731,60 @@ impl LaunchAgent { ); // 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) = ( + // L'intention est explicite sur le launcher : les cellules humaines peuvent + // garder une CLI/TUI native en PTY, les launchers headless/orchestrés doivent + // échouer fort si le routage structuré est mal câblé. + match ( + profile.structured_adapter.is_some(), self.session_factory.as_ref(), self.structured.as_ref(), - profile.structured_adapter.is_some(), + self.structured_routing_mode, ) { - // ── 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.env, - spec.sandbox.as_ref(), - ) - .await; + (false, _, _, _) => {} + (true, Some(factory), Some(structured), _) => { + // ── 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.env, + spec.sandbox.as_ref(), + ) + .await; + } + (true, _, _, StructuredRoutingMode::HumanPtyFallback) => { + // Fallback humain intentionnel : cellule interactive native en PTY. + } + (true, _, _, StructuredRoutingMode::RequireStructured) => { + return Err(AppError::Process( + "structured profile requires structured session factory".to_owned(), + )); + } } // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere. diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 2da7679..8c7a5aa 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -34,8 +34,8 @@ pub use lifecycle::{ InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, - StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, - AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX, + StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContext, + UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX, }; pub use resume::{ ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 18ead57..7d069de 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -52,8 +52,9 @@ pub use agent::{ PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService, - StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput, - AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, + StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, + UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, + LIVE_STATE_INJECT_MAX, RESUME_PROMPT, }; pub use background::{ BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput, diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 9474242..a5b1234 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -28,10 +28,11 @@ use domain::permission::{ ProjectionContext, ProjectorKey, }; use domain::ports::{ - AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, - ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, - OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, - RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, + AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory, + ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, + IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PermissionStore, + PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyStream, + RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::profile::{ AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig, @@ -48,7 +49,8 @@ use uuid::Uuid; use application::{ CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext, - ReadAgentContextInput, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, + ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions, + UpdateAgentContext, UpdateAgentContextInput, }; // --------------------------------------------------------------------------- @@ -557,6 +559,83 @@ impl PtyPort for FakePty { } } +// --------------------------------------------------------------------------- +// FakeStructuredFactory (AgentSessionFactory) — records structured starts +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct FakeSession { + id: SessionId, + conversation_id: Option, +} + +#[async_trait] +impl AgentSession for FakeSession { + fn id(&self) -> SessionId { + self.id + } + + fn conversation_id(&self) -> Option { + self.conversation_id.clone() + } + + async fn send(&self, _prompt: &str) -> Result { + Ok(Box::new(std::iter::empty())) + } + + async fn shutdown(&self) -> Result<(), AgentSessionError> { + Ok(()) + } +} + +#[derive(Clone)] +struct FakeStructuredFactory { + trace: Trace, + starts: Arc>>, + next_session: SessionId, +} + +impl FakeStructuredFactory { + fn new(trace: Trace, next_session: SessionId) -> Self { + Self { + trace, + starts: Arc::new(Mutex::new(Vec::new())), + next_session, + } + } + + fn starts(&self) -> Vec { + self.starts.lock().unwrap().clone() + } +} + +#[async_trait] +impl AgentSessionFactory for FakeStructuredFactory { + fn supports(&self, profile: &AgentProfile) -> bool { + profile.structured_adapter.is_some() + } + + async fn start( + &self, + profile: &AgentProfile, + _ctx: &PreparedContext, + _cwd: &ProjectPath, + _session: &SessionPlan, + _env: &[(String, String)], + _sandbox: Option<&domain::sandbox::SandboxPlan>, + ) -> Result, AgentSessionError> { + self.trace + .lock() + .unwrap() + .push("structured.start".to_owned()); + self.starts.lock().unwrap().push(profile.id); + Ok(Arc::new(FakeSession { + id: self.next_session, + conversation_id: Some("engine-session-1".to_owned()), + })) + } +} + // --------------------------------------------------------------------------- // SpyBus + SeqIds // --------------------------------------------------------------------------- @@ -941,6 +1020,108 @@ async fn launch_orders_prepare_then_injection_then_spawn() { ); } +#[tokio::test] +async fn structured_profile_with_factory_routes_to_structured_session_without_pty_spawn() { + let profile = profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ) + .with_structured_adapter(StructuredAdapter::Claude); + let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888)); + let factory_port: Arc = Arc::new(factory.clone()); + let structured = Arc::new(StructuredSessions::new()); + let launch = launch + .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) + .with_structured(factory_port, Arc::clone(&structured)); + + let out = launch.execute(launch_input(agent.id)).await.unwrap(); + + assert_eq!(factory.starts(), vec![pid(9)]); + assert!( + pty.spawns().is_empty(), + "structured route must not spawn PTY" + ); + assert!(out.structured.is_some()); + assert!(structured.session(&sid(888)).is_some()); + assert_eq!( + *tr.lock().unwrap(), + vec![ + "prepare".to_owned(), + "fs.write".to_owned(), + "structured.start".to_owned() + ] + ); +} + +#[tokio::test] +async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() { + let profile = profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ) + .with_structured_adapter(StructuredAdapter::Claude); + let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + let launch = launch.with_structured_routing_mode(StructuredRoutingMode::RequireStructured); + + let err = launch.execute(launch_input(agent.id)).await.unwrap_err(); + + assert!(err + .to_string() + .contains("structured profile requires structured session factory")); + assert!( + pty.spawns().is_empty(), + "miswired structured route must not spawn PTY" + ); +} + +#[tokio::test] +async fn structured_profile_without_factory_human_fallback_spawns_pty() { + let profile = profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ) + .with_structured_adapter(StructuredAdapter::Claude); + let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + let launch = launch.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback); + + let out = launch.execute(launch_input(agent.id)).await.unwrap(); + + assert!(out.structured.is_none()); + assert_eq!(pty.spawns().len(), 1, "human fallback keeps native CLI PTY"); +} + +#[tokio::test] +async fn non_structured_profile_uses_pty_even_when_structured_is_required() { + let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture( + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + let launch = launch.with_structured_routing_mode(StructuredRoutingMode::RequireStructured); + + let out = launch.execute(launch_input(agent.id)).await.unwrap(); + + assert!(out.structured.is_none()); + assert_eq!(pty.spawns().len(), 1); +} + #[tokio::test] async fn launch_injects_shared_project_context_from_ideai_context_md() { let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(