diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 6f1983d..24eac65 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -1175,8 +1175,9 @@ impl LaunchAgent { // of the *cell*, not the PTY: the caller (which owns the layout) passes // the cell's current `conversation_id`. Any id this launch *assigns* is // returned in the output so the caller persists it on the leaf. - let (session_plan, assigned_conversation_id) = - self.resolve_session_plan(&profile, input.conversation_id.clone()); + let (session_plan, assigned_conversation_id) = self + .resolve_session_plan(&profile, input.conversation_id.clone(), &input.project.root) + .await; let mut spec = self.runtime .prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?; @@ -1444,22 +1445,63 @@ impl LaunchAgent { /// [`SessionPlan::None`] (nothing to resume on a first launch; the adapter /// uses the bare resume flag only on later reopens); /// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy). - fn resolve_session_plan( + async fn resolve_session_plan( &self, profile: &AgentProfile, cell_conversation_id: Option, + root: &ProjectPath, ) -> (SessionPlan, Option) { - // No session strategy at all: behave exactly as before — **sauf** pour un - // profil **structuré** (§17), dont la reprise ne dépend pas d'un bloc - // `session` mais de l'adapter (Claude `--resume` / Codex `exec resume`). Pour - // un tel profil, une conversation présente sur la cellule ⇒ `Resume`, l'id - // étant capté/attribué par le moteur au premier tour (rien à pré-attribuer). - let Some(session) = &profile.session else { - if profile.structured_adapter.is_some() { - if let Some(conversation_id) = cell_conversation_id { - return (SessionPlan::Resume { conversation_id }, None); - } + // Profil **structuré** (§17, lot P8c) : le `conversation_id` de la cellule est + // un **id de paire IdeA**, jamais le resumable du moteur. Le passer en `--resume` + // serait invalide (Claude/Codex attendent *leur* id). On route donc le resume + // moteur via `providers.json`, keyé par `(id de paire, provider_key)` (écrit en + // P8b). Rien à pré-attribuer ici : l'id de paire est géré en aval (P8a), le + // resumable moteur est capté/rapporté par P8b au fil de l'exécution. + if let Some(adapter) = profile.structured_adapter { + // Store câblé : on lit le resumable moteur rangé pour cette paire+provider. + if let Some(provider) = self.provider_sessions.as_ref() { + let engine = match &cell_conversation_id { + // L'id de paire (UUID) → resumable moteur via providers.json. + Some(raw) => match uuid::Uuid::parse_str(raw) { + Ok(uuid) => match provider.provider_session_store_for(root) { + Some(store) => store + .get(ConversationId::from_uuid(uuid), adapter.provider_key()) + .await + .ok() + .flatten(), + None => None, + }, + Err(_) => None, + }, + None => None, + }; + return match engine { + // Vrai resumable moteur connu ⇒ resume propre du moteur. + Some(engine_id) => ( + SessionPlan::Resume { + conversation_id: engine_id, + }, + None, + ), + // Aucun resumable (None / parse KO / store absent / pas d'id de + // cellule) ⇒ premier lancement propre. Le moteur attribuera/rapportera + // son id (capté par P8b) ; la continuité du travail est assurée par le + // handoff (P7). On ne passe **jamais** l'id de paire en `--resume`. + None => (SessionPlan::None, None), + }; } + // Store **non câblé** (tests/legacy) : repli gracieux sur l'ancien + // comportement — une conversation sur la cellule ⇒ `Resume`, sinon `None`. + // Garantit zéro régression des tests qui ne câblent pas le store. + return match cell_conversation_id { + Some(conversation_id) => (SessionPlan::Resume { conversation_id }, None), + None => (SessionPlan::None, None), + }; + } + + // Profils **non structurés** (PTY/TUI) : la cellule porte toujours l'id moteur. + // Comportement **inchangé** depuis T4. + let Some(session) = &profile.session else { return (SessionPlan::None, None); }; diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index e17d0e5..549955f 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -566,6 +566,15 @@ impl FakeProviderSessionStore { fail_set: true, } } + /// Pre-seeds a `(conversation, provider) → resumable_id` mapping synchronously, + /// so a P8c launch reading via [`ProviderSessionStore::get`] resolves the engine + /// resumable for that pair (mirrors what a previous P8b launch would have stored). + fn seed_sync(&self, conversation: ConversationId, provider: &str, resumable_id: &str) { + self.entries.lock().unwrap().insert( + (conversation, provider.to_owned()), + resumable_id.to_owned(), + ); + } /// Observed resumable id for a `(conversation, provider)` couple, if any. fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option { self.entries @@ -1452,3 +1461,207 @@ async fn p8b_store_set_error_does_not_fail_launch() { // Le store n'a (logiquement) rien persisté. assert_eq!(store.len(), 0, "failing set wrote nothing"); } + +// =========================================================================== +// LOT P8c — routage du `--resume` moteur via providers.json +// +// Prouve `resolve_session_plan` (privée) **par son contrat observable** : le +// `SessionPlan` que `LaunchAgent::execute` transmet à `factory.start` (capturé par +// la `FakeFactory`). Pour un profil **structuré** avec un store provider câblé, le +// resume moteur est lu dans `providers.json` keyé par (id de paire, provider_key) — +// **jamais** l'id de paire lui-même n'est passé en `--resume`. +// +// Le fallback (provider non câblé) et le chemin non structuré sont déjà attestés par +// `structured_profile_with_cell_conversation_resolves_to_resume`, +// `structured_profile_fresh_cell_resolves_to_none` (cas 3 structuré non câblé) et les +// suites existantes ; on ajoute ici le **cœur P8c** : la lecture du store. +// =========================================================================== + +/// Variante de [`launch_fixture_p8b`] qui **rend aussi la factory** (clone partageant +/// `starts` via `Arc`), pour observer le `SessionPlan` transmis à `start`. +fn launch_fixture_p8c( + profile: AgentProfile, + factory: FakeFactory, + store: Option>, +) -> (Arc, Agent, FakeFactory) { + let observed = factory.clone(); + let (launch, agent) = launch_fixture_p8b(profile, factory, store); + (launch, agent, observed) +} + +/// **Cas 1 — Claude câblé, store contient l'engine id** : la cellule porte l'id de +/// paire (UUID) ; le store mappe `(pair, "claude") → "engine-x"`. Le lancement doit +/// transmettre `SessionPlan::Resume{ conversation_id: "engine-x" }` à la factory — +/// **et surtout PAS** l'id de paire. +#[tokio::test] +async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() { + let store = Arc::new(FakeProviderSessionStore::new()); + // Le moteur n'attribue pas d'id neuf (None) : le resume vient du store, pas du run. + let factory = FakeFactory::new(500, None); + let (launch, agent, observed) = + launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + + // Pré-remplit le store : (pair, "claude") → "engine-x". + let pair = ConversationId::from_uuid(Uuid::from_u128(123)); + store.seed_sync(pair, "claude", "engine-x"); + + // La cellule porte l'id de **paire** (UUID), jamais l'id moteur. + let mut input = launch_input(agent.id); + input.conversation_id = Some(pair.to_string()); + + launch.execute(input).await.expect("structured launch resumes"); + + // Le SessionPlan transmis à factory.start est Resume avec l'**engine id du store**. + let starts = observed.starts(); + assert_eq!(starts.len(), 1, "factory.start called once"); + assert_eq!( + starts[0].1, + SessionPlan::Resume { + conversation_id: "engine-x".to_owned() + }, + "resume must carry the engine resumable id from providers.json" + ); + // GARDE-FOU EXPLICITE : ce n'est PAS l'id de paire qui part en --resume. + match &starts[0].1 { + SessionPlan::Resume { conversation_id } => { + assert_ne!( + conversation_id, + &pair.to_string(), + "the pair id must NEVER be passed as the engine --resume" + ); + } + other => panic!("expected Resume, got {other:?}"), + } +} + +/// **Cas 1bis — Codex câblé, store contient l'engine id sous la clé `"codex"`** : +/// même contrat, clé provider `"codex"`. +#[tokio::test] +async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() { + let store = Arc::new(FakeProviderSessionStore::new()); + let factory = FakeFactory::new(500, None); + let (launch, agent, observed) = launch_fixture_p8c( + structured_codex_profile(pid(9)), + factory, + Some(Arc::clone(&store)), + ); + + let pair = ConversationId::from_uuid(Uuid::from_u128(456)); + store.seed_sync(pair, "codex", "engine-codex-x"); + + let mut input = launch_input(agent.id); + input.conversation_id = Some(pair.to_string()); + + launch.execute(input).await.expect("structured codex launch"); + + let starts = observed.starts(); + assert_eq!(starts.len(), 1); + assert_eq!( + starts[0].1, + SessionPlan::Resume { + conversation_id: "engine-codex-x".to_owned() + }, + "codex resume reads its own provider key" + ); +} + +/// **Cas 1ter — la clé provider discrimine** : le store ne contient l'engine id que +/// sous `"codex"`, mais le profil est **Claude** ⇒ le lookup sous `"claude"` ne trouve +/// rien ⇒ `SessionPlan::None` (premier lancement propre, jamais l'id de paire). +#[tokio::test] +async fn p8c_provider_key_mismatch_falls_back_to_none() { + let store = Arc::new(FakeProviderSessionStore::new()); + let factory = FakeFactory::new(500, None); + let (launch, agent, observed) = + launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + + let pair = ConversationId::from_uuid(Uuid::from_u128(123)); + // Engine id rangé sous une AUTRE clé provider. + store.seed_sync(pair, "codex", "engine-codex"); + + let mut input = launch_input(agent.id); + input.conversation_id = Some(pair.to_string()); + + launch.execute(input).await.expect("structured launch"); + + let starts = observed.starts(); + assert_eq!(starts.len(), 1); + assert_eq!( + starts[0].1, + SessionPlan::None, + "a claude profile must not pick up a codex-keyed resumable" + ); +} + +/// **Cas 2 — store câblé mais vide** (`get` → `None`) : la cellule porte un id de +/// paire valide mais aucun resumable n'a été rangé ⇒ `SessionPlan::None` (premier +/// lancement propre). On vérifie aussi que l'id de paire n'est jamais transmis. +#[tokio::test] +async fn p8c_structured_store_empty_resolves_to_none() { + let store = Arc::new(FakeProviderSessionStore::new()); + let factory = FakeFactory::new(500, None); + let (launch, agent, observed) = + launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + + let pair = ConversationId::from_uuid(Uuid::from_u128(123)); + let mut input = launch_input(agent.id); + input.conversation_id = Some(pair.to_string()); + + launch.execute(input).await.expect("structured launch"); + + let starts = observed.starts(); + assert_eq!(starts.len(), 1); + assert_eq!( + starts[0].1, + SessionPlan::None, + "empty store ⇒ clean first launch, never the pair id" + ); +} + +/// **Cas 2bis — store câblé, cellule neuve (pas d'id de paire)** : aucun id à +/// chercher ⇒ `SessionPlan::None`. +#[tokio::test] +async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() { + let store = Arc::new(FakeProviderSessionStore::new()); + let factory = FakeFactory::new(500, None); + let (launch, agent, observed) = + launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + + // Cellule neuve : conversation_id = None. + launch + .execute(launch_input(agent.id)) + .await + .expect("fresh structured launch"); + + let starts = observed.starts(); + assert_eq!(starts.len(), 1); + assert_eq!( + starts[0].1, + SessionPlan::None, + "no cell id ⇒ nothing to look up ⇒ None" + ); +} + +/// **Robustesse — id de paire non-UUID + store câblé** : un `conversation_id` qui ne +/// parse pas en UUID (donnée legacy/corrompue) ⇒ pas de lookup possible ⇒ +/// `SessionPlan::None`, jamais l'id de paire passé tel quel en `--resume`. +#[tokio::test] +async fn p8c_non_uuid_pair_id_with_store_resolves_to_none() { + let store = Arc::new(FakeProviderSessionStore::new()); + let factory = FakeFactory::new(500, None); + let (launch, agent, observed) = + launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + + let mut input = launch_input(agent.id); + input.conversation_id = Some("not-a-uuid".to_owned()); + + launch.execute(input).await.expect("structured launch"); + + let starts = observed.starts(); + assert_eq!(starts.len(), 1); + assert_eq!( + starts[0].1, + SessionPlan::None, + "a non-UUID pair id must degrade to None, never resume the raw string" + ); +}