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

@ -959,6 +959,7 @@ impl ChangeAgentProfile {
// endpoint is re-driven through the app-tauri launch path.
mcp_runtime: None,
allow_structured_alongside_pty: false,
require_structured: false,
})
.await?;
Ok(Some(output.session))
@ -1112,6 +1113,12 @@ pub struct LaunchAgentInput {
///
/// Ordinary UI launches keep this `false` and retain the singleton/rebind guard.
pub allow_structured_alongside_pty: bool,
/// UI routing intent for the custom chat CLI: when `true`, a profile carrying a
/// `structured_adapter` must be launched as an [`AgentSession`] even on the
/// human-facing launcher, instead of taking the native TUI/PTY fallback.
///
/// Historical callers leave this `false`, preserving the PTY launch contract.
pub require_structured: bool,
}
/// OS/runtime facts injected by the composition root (`app-tauri`) to materialise
@ -1813,6 +1820,25 @@ impl LaunchAgent {
let agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
let mut profile = self
.profiles
.list()
.await?
.into_iter()
.find(|p| p.id == agent.profile_id)
.ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
if input.require_structured && profile.structured_adapter.is_none() {
return Err(AppError::Invalid(format!(
"agent {}: le lancement structuré a été demandé, mais le profil {} \
ne déclare pas d'adaptateur structured/headless",
input.agent_id, profile.id
)));
}
let wants_structured = input.require_structured
|| matches!(
self.structured_routing_mode,
StructuredRoutingMode::RequireStructured
);
// 1b. Enforce the "one live session per agent" invariant (decision: an
// agent is a singleton that runs in a single cell at a time). This
@ -1847,12 +1873,16 @@ impl LaunchAgent {
let host_node = self
.sessions
.node_for_agent_in_project(input.project.id, &input.agent_id);
if input.allow_structured_alongside_pty && input.node_id.is_none() {
if input.allow_structured_alongside_pty && input.node_id.is_none() && wants_structured {
crate::diag!(
"[launch] existing PTY kept while structured launch proceeds: agent={} \
pty_session={existing_id}",
input.agent_id
);
} else if input.require_structured && profile.structured_adapter.is_some() {
if let Some(handle) = self.sessions.remove(&existing_id) {
self.pty.kill(&handle).await?;
}
} else {
match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref())
{
@ -1947,13 +1977,6 @@ impl LaunchAgent {
.contexts
.read_context(&input.project, &agent.id)
.await?;
let mut profile = self
.profiles
.list()
.await?
.into_iter()
.find(|p| p.id == agent.profile_id)
.ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
if input.allow_structured_alongside_pty && profile.structured_adapter.is_none() {
return Err(AppError::Invalid(format!(
"agent {}: le lancement headless structuré a été demandé, mais le profil {} \
@ -2109,10 +2132,10 @@ impl LaunchAgent {
profile.structured_adapter.is_some(),
self.session_factory.as_ref(),
self.structured.as_ref(),
self.structured_routing_mode,
wants_structured,
) {
(false, _, _, _) => {}
(true, Some(factory), Some(structured), _) => {
(true, Some(factory), Some(structured), true) => {
// ── 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é
@ -2152,10 +2175,10 @@ impl LaunchAgent {
)
.await;
}
(true, _, _, StructuredRoutingMode::HumanPtyFallback) => {
(true, _, _, false) => {
// Fallback humain intentionnel : cellule interactive native en PTY.
}
(true, _, _, StructuredRoutingMode::RequireStructured) => {
(true, _, _, true) => {
return Err(AppError::Process(
"structured profile requires structured session factory".to_owned(),
));