fix(agent): rendre explicite le routage structuré vs PTY dans LaunchAgent (#33)
Un profil structuré rencontrant une factory de session non câblée retombait
silencieusement sur pty.spawn (fallthrough du `if let`), masquant une erreur de
configuration au lieu de la signaler.
Remplace ce fallthrough par une intention explicite :
- nouvel enum StructuredRoutingMode { HumanPtyFallback, RequireStructured } sur
LaunchAgent, posé via le builder with_structured_routing_mode ;
- le `if let` devient un `match` explicite (agent/lifecycle.rs) ; en mode
RequireStructured, un profil structuré sans factory câblée retourne
AppError::Process("structured profile requires structured session factory")
au lieu de tomber sur pty.spawn.
Composition root (app-tauri/src/state.rs) : launcher humain = HumanPtyFallback,
launcher orchestrateur = RequireStructured, wake background rebranché sur
orchestrator_launch_agent.
Tests : agent_lifecycle.rs (4 branches de routage) + non-régression
agent_wake/structured_launch_d3. Crate application verte.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -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]
|
||||
async fn launch_injects_shared_project_context_from_ideai_context_md() {
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(
|
||||
|
||||
Reference in New Issue
Block a user