feat(chat): propage l'intention chat → require_structured dans le pipeline de lancement

- Ajoute le champ cell_kind dans LaunchAgentRequestDto pour distinguer les demandes chat (custom CLI) des lancements PTY historiques
- Convertit cellKind:Chat en require_structured:true dans commands.rs et web-server/src/lib.rs
- Implémente la logique de routing structuré dans LaunchAgent::execute :
  - valide que require_structured implique un profil avec structured_adapter
  - remplace une session PTY existante quand require_structured est vrai
  - route vers structured seulement quand wants_structured est vrai
- Met à jour tous les appels historiques avec require_structured:false pour préserver le contrat PTY
- Ajoute les tests unitaires human_launcher_with_chat_intent_routes_structured_profile_to_chat_session et chat_intent_replaces_existing_pty_instead_of_returning_pty_session
- Wire les ports structured dans BackendCore pour le launcher humain

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 23:39:35 +02:00
parent 7071c53bb2
commit c43f29b2f2
9 changed files with 163 additions and 21 deletions

View File

@ -516,6 +516,7 @@ struct FakePty {
next_id: SessionId,
spawns: Arc<Mutex<Vec<SpawnSpec>>>,
writes: WriteLog<SessionId>,
kills: Arc<Mutex<Vec<SessionId>>>,
}
impl FakePty {
@ -525,6 +526,7 @@ impl FakePty {
next_id,
spawns: Arc::new(Mutex::new(Vec::new())),
writes: Arc::new(Mutex::new(Vec::new())),
kills: Arc::new(Mutex::new(Vec::new())),
}
}
fn spawns(&self) -> Vec<SpawnSpec> {
@ -533,6 +535,9 @@ impl FakePty {
fn writes(&self) -> Vec<(SessionId, Vec<u8>)> {
self.writes.lock().unwrap().clone()
}
fn kills(&self) -> Vec<SessionId> {
self.kills.lock().unwrap().clone()
}
}
#[async_trait]
@ -566,7 +571,8 @@ impl PtyPort for FakePty {
fn try_wait(&self, _handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
Ok(Some(ExitStatus { code: Some(0) }))
}
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
self.kills.lock().unwrap().push(handle.session_id);
Ok(ExitStatus { code: Some(0) })
}
}
@ -1130,6 +1136,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
conversation_id: None,
mcp_runtime: None,
allow_structured_alongside_pty: false,
require_structured: false,
}
}
@ -1257,6 +1264,86 @@ async fn structured_profile_with_factory_routes_to_structured_session_without_pt
);
}
#[tokio::test]
async fn human_launcher_with_chat_intent_routes_structured_profile_to_chat_session() {
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 structured = Arc::new(StructuredSessions::new());
let launch = launch
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
let mut input = launch_input(agent.id);
input.node_id = Some(nid(77));
input.require_structured = true;
let out = launch.execute(input).await.unwrap();
assert_eq!(factory.starts(), vec![pid(9)]);
assert!(pty.spawns().is_empty(), "custom chat must not spawn PTY");
assert!(
out.structured.is_some(),
"custom chat receives a descriptor"
);
assert_eq!(
structured.node_for_agent_in_project(project().id, &agent.id),
Some(nid(77))
);
}
#[tokio::test]
async fn chat_intent_replaces_existing_pty_instead_of_returning_pty_session() {
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(),
}),
);
seed_live_agent_session(&sessions, agent.id, nid(7), sid(42));
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
let structured = Arc::new(StructuredSessions::new());
let launch = launch
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
let mut input = launch_input(agent.id);
input.node_id = Some(nid(7));
input.require_structured = true;
let out = launch.execute(input).await.unwrap();
assert_eq!(pty.kills(), vec![sid(42)], "old PTY is closed");
assert!(
pty.spawns().is_empty(),
"replacement uses structured, not PTY"
);
assert!(
sessions
.session_for_agent_in_project(project().id, &agent.id)
.is_none(),
"old PTY registration is removed"
);
assert_eq!(factory.starts(), vec![pid(9)]);
assert_eq!(out.session.id, sid(888));
assert!(
out.structured.is_some(),
"backend returns cellKind=chat via DTO"
);
}
#[tokio::test]
async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() {
let profile = profile(