Merge feature/ticket33-structured-routing-explicit into develop (#33)

Routage structuré vs PTY rendu explicite dans LaunchAgent
(StructuredRoutingMode) : erreur explicite au lieu du fallthrough silencieux
quand un profil structuré rencontre une factory non câblée. QA vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:08:32 +02:00
5 changed files with 268 additions and 56 deletions

View File

@ -36,10 +36,10 @@ use application::{
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog,
SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout,
SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent,
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
}; };
use async_trait::async_trait; use async_trait::async_trait;
@ -1568,6 +1568,7 @@ impl AppState {
Arc::clone(&memory_recall_port), Arc::clone(&memory_recall_port),
Some(Arc::clone(&check_embedder_suggestion)), Some(Arc::clone(&check_embedder_suggestion)),
) )
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
.with_permission_store(Arc::clone(&permission_store_port)) .with_permission_store(Arc::clone(&permission_store_port))
// Reprise conversationnelle (lot P7) : à chaque (re)lancement, si la cellule // Reprise conversationnelle (lot P7) : à chaque (re)lancement, si la cellule
// porte une conversation et qu'un handoff existe (`<root>/.ideai/conversations/`), // porte une conversation et qu'un handoff existe (`<root>/.ideai/conversations/`),
@ -1613,6 +1614,7 @@ impl AppState {
Arc::clone(&memory_recall_port), Arc::clone(&memory_recall_port),
Some(Arc::clone(&check_embedder_suggestion)), Some(Arc::clone(&check_embedder_suggestion)),
) )
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
.with_permission_store(Arc::clone(&permission_store_port)) .with_permission_store(Arc::clone(&permission_store_port))
.with_handoff_provider( .with_handoff_provider(
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider> Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>
@ -1910,7 +1912,7 @@ impl AppState {
Arc::clone(&mailbox), Arc::clone(&mailbox),
Arc::clone(&background_tasks_port), Arc::clone(&background_tasks_port),
Arc::new(AppWakeSessionProvider { Arc::new(AppWakeSessionProvider {
launch_agent: Arc::clone(&launch_agent), launch_agent: Arc::clone(&orchestrator_launch_agent),
structured_sessions: Arc::clone(&structured_sessions), structured_sessions: Arc::clone(&structured_sessions),
}), }),
Some(Arc::clone(&events_port)), Some(Arc::clone(&events_port)),

View File

@ -998,6 +998,15 @@ pub struct LaunchAgentOutput {
pub profile: Option<AgentProfile>, pub profile: Option<AgentProfile>,
} }
/// 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 /// Registry mapping each [`ProjectorKey`] to its concrete
/// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the /// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the
/// optional builder [`LaunchAgent::with_permission_projectors`]; the concrete /// 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 /// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime
/// when `OpenCodeConfig.localModelServerId` is set. /// when `OpenCodeConfig.localModelServerId` is set.
local_model_server: Option<Arc<EnsureLocalModelServer>>, local_model_server: Option<Arc<EnsureLocalModelServer>>,
/// Explicit intent for structured profiles when structured ports are absent.
structured_routing_mode: StructuredRoutingMode,
} }
impl LaunchAgent { impl LaunchAgent {
@ -1180,9 +1191,17 @@ impl LaunchAgent {
projectors: None, projectors: None,
live_state_lean: None, live_state_lean: None,
local_model_server: 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. /// Injects the local model-server ensure use case used by OpenCode profiles.
#[must_use] #[must_use]
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self { pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
@ -1712,51 +1731,60 @@ impl LaunchAgent {
); );
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ── // 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le // L'intention est explicite sur le launcher : les cellules humaines peuvent
// run dir (étape 5) ; la CLI structurée le lira à chaque tour // garder une CLI/TUI native en PTY, les launchers headless/orchestrés doivent
// (incarnation « un run par tour »). Si le profil porte un // échouer fort si le routage structuré est mal câblé.
// `structured_adapter` ET que la fabrique structurée est câblée, on match (
// démarre une `AgentSession` via le port — **pas** de `pty.spawn` — et on profile.structured_adapter.is_some(),
// l'enregistre dans `StructuredSessions`. Sinon : chemin PTY inchangé.
if let (Some(factory), Some(structured), true) = (
self.session_factory.as_ref(), self.session_factory.as_ref(),
self.structured.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, (false, _, _, _) => {}
// lot P8a) ── (true, Some(factory), Some(structured), _) => {
// - cellule porteuse d'un `conversation_id` (resume, ou lancement délégué // ── Clé **logique** de la cellule = id de paire IdeA (ARCHITECTURE §19.7,
// qui a déjà injecté l'id de paire A↔B) ⇒ c'est **déjà** l'id de paire, // lot P8a) ──
// on le conserve tel quel ; // - cellule porteuse d'un `conversation_id` (resume, ou lancement délégué
// - cellule structurée **neuve** (lancement direct utilisateur, aucun // qui a déjà injecté l'id de paire A↔B) ⇒ c'est **déjà** l'id de paire,
// requester) ⇒ on **dérive** `pair(User, agent)` via la fonction domaine // on le conserve tel quel ;
// pure partagée avec `resolve_conversation` (aucun couplage à // - cellule structurée **neuve** (lancement direct utilisateur, aucun
// l'orchestrateur). C'est cet id — et **non** l'id de session moteur — // requester) ⇒ on **dérive** `pair(User, agent)` via la fonction domaine
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap. // pure partagée avec `resolve_conversation` (aucun couplage à
let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| { // l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
ConversationId::for_pair( // qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
ConversationParty::User, let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| {
ConversationParty::agent(agent.id), ConversationId::for_pair(
) ConversationParty::User,
.to_string() ConversationParty::agent(agent.id),
}); )
return self .to_string()
.launch_structured( });
factory.as_ref(), return self
structured, .launch_structured(
&agent, factory.as_ref(),
&profile, structured,
&prepared, &agent,
&run_dir, &profile,
&session_plan, &prepared,
pair_conversation_id, &run_dir,
&input.project.root, &session_plan,
input.node_id, pair_conversation_id,
size, &input.project.root,
&spec.env, input.node_id,
spec.sandbox.as_ref(), size,
) &spec.env,
.await; 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. // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.

View File

@ -34,8 +34,8 @@ pub use lifecycle::{
InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContext,
AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX,
}; };
pub use resume::{ pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,

View File

@ -52,8 +52,9 @@ pub use agent::{
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext, PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService, ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext,
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
}; };
pub use background::{ pub use background::{
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput, BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,

View File

@ -28,10 +28,11 @@ use domain::permission::{
ProjectionContext, ProjectorKey, ProjectionContext, ProjectorKey,
}; };
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PermissionStore,
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyStream,
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
}; };
use domain::profile::{ use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig, AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
@ -48,7 +49,8 @@ use uuid::Uuid;
use application::{ use application::{
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext, 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<String>,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
self.conversation_id.clone()
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
Ok(Box::new(std::iter::empty()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[derive(Clone)]
struct FakeStructuredFactory {
trace: Trace,
starts: Arc<Mutex<Vec<ProfileId>>>,
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<ProfileId> {
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<Arc<dyn AgentSession>, 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 // 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<dyn AgentSessionFactory> = 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] #[tokio::test]
async fn launch_injects_shared_project_context_from_ideai_context_md() { async fn launch_injects_shared_project_context_from_ideai_context_md() {
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture( let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(