From 09cc8f0902fb976e1758c7fdd3d53c74c36201a4 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 12 Jun 2026 18:27:09 +0200 Subject: [PATCH] =?UTF-8?q?test(persistence):=20P8a=20=E2=80=94=20d=C3=A9t?= =?UTF-8?q?erminisme=20registre=20+=20coh=C3=A9rence=20de=20cl=C3=A9=20end?= =?UTF-8?q?-to-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scelle la cohérence corrigée en P8a : - infra (5) : resolve stable à travers 2 instances de registre (restart), resolve.id == for_pair == uuid agent, commutativité, distinction des paires - application (2) : round-trip réel « handoff sauvé sous la clé resolve ↔ rechargé au relancement de la cellule portant cette clé » + contre-épreuve (handoff d'une autre paire ⇒ pas de reprise) Clarifie : une cellule neuve (conversation_id=None) ne charge pas de handoff ; la reprise opère à la réouverture. domain/infra/application verts, zéro régression. Code de prod non modifié. Co-Authored-By: Claude Opus 4.8 --- crates/application/tests/agent_lifecycle.rs | 97 ++++++++++++++++++- crates/infrastructure/src/conversation/mod.rs | 90 +++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 12880f6..f7463ee 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -2100,7 +2100,7 @@ async fn launch_degraded_mode_without_assign_flag_first_launch_is_none() { // --------------------------------------------------------------------------- use application::HandoffProvider; -use domain::{ConversationId, Handoff, HandoffStore, TurnId}; +use domain::{ConversationId, ConversationParty, Handoff, HandoffStore, TurnId}; /// In-memory [`HandoffStore`] for the P7 launch tests: holds at most one handoff /// per [`ConversationId`]. `load` returns `Ok(None)` for an unknown conversation @@ -2312,3 +2312,98 @@ async fn launch_with_store_without_handoff_omits_resume_section() { "store without handoff ⇒ no resume section: {doc}" ); } + +// --------------------------------------------------------------------------- +// Bloc 2 — cohérence end-to-end de la clé de conversation (LE test de vérité). +// +// Round-trip réel « sauve sous resolve / charge au lancement » : +// 1. SAUVEGARDE — côté orchestrateur, le handoff est rangé sous la clé que +// `resolve_conversation(None, agent)` renvoie ; avec un registre câblé c'est +// `reg.resolve(User, agent).id`, qui (scellé Bloc 1 : +// `resolve_id_equals_for_pair_user_agent`) vaut `ConversationId::for_pair(User, +// agent)`. On dérive donc la clé de SAUVEGARDE via ce **même** `for_pair` — +// sans tirer `infrastructure` comme dépendance de la couche application (règle +// hexagonale ; l'égalité registre↔for_pair est prouvée côté infra). +// 2. PERSISTANCE (P8a) — au 1er lancement d'une cellule neuve, IdeA **persiste** +// `for_pair(User, agent)` sur `LeafCell.conversation_id`. Donc à la réouverture +// la cellule **porte** cette clé. +// 3. CHARGEMENT (P7) — `resolve_handoff` charge le handoff sous +// `LaunchAgentInput.conversation_id` (la clé persistée que la cellule porte). +// +// Ce test joue (2)+(3) fidèlement : la cellule porte la clé que P8a a persistée +// (`for_pair`), et on prouve qu'elle retrouve le handoff sauvé sous la clé de (1). +// C'est l'invariant que Main a rattrapé : si l'une des trois clés dérivait, la +// section de reprise serait absente et ce test échouerait. +// --------------------------------------------------------------------------- +#[tokio::test] +async fn reopened_cell_loads_handoff_saved_under_resolve_key_end_to_end() { + let agent_party = ConversationParty::agent(aid(1)); // l'agent du harnais. + // (1) Clé de SAUVEGARDE = resolve_conversation(None, agent) avec registre câblé + // == for_pair(User, agent) (égalité scellée Bloc 1, côté infrastructure). + let save_key = ConversationId::for_pair(ConversationParty::User, agent_party); + // (2) Clé que P8a persiste sur la cellule neuve, donc portée à la réouverture. + let persisted_on_leaf = ConversationId::for_pair(ConversationParty::User, agent_party); + assert_eq!( + save_key, persisted_on_leaf, + "préalable end-to-end : clé sauvegarde (resolve) == clé persistée (P8a)" + ); + + let store = FakeHandoffStore::with( + save_key, + handoff_for("État au dernier tour : lot P8a câblé.", Some("Sceller §19")), + ); + let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc; + let (launch, agent, fs) = launch_with_handoff(Some(provider)); + + // (3) Réouverture : la cellule porte la clé que P8a avait persistée. + let mut input = launch_input(agent.id); + input.conversation_id = Some(persisted_on_leaf.to_string()); + + launch.execute(input).await.expect("launch succeeds"); + + let doc = convention_doc(&fs); + assert!( + doc.contains("# Reprise de la conversation"), + "clé de chargement (P7, clé persistée par P8a) == clé de sauvegarde (resolve) : \ + le handoff doit être retrouvé. doc: {doc}" + ); + assert!( + doc.contains("**Objectif :** Sceller §19"), + "objectif du handoff sauvé sous la clé de résolution injecté: {doc}" + ); + assert!( + doc.contains("État au dernier tour : lot P8a câblé."), + "résumé du handoff sauvé sous la clé de résolution injecté: {doc}" + ); +} + +/// Contre-épreuve : un handoff rangé sous une **autre** clé (un agent différent) ne +/// doit PAS être chargé — garantit que la réussite du test ci-dessus tient à +/// l'**égalité** des clés, pas à un chargement inconditionnel. +#[tokio::test] +async fn reopened_cell_does_not_load_handoff_saved_under_a_different_pair_key() { + let agent_party = ConversationParty::agent(aid(1)); // agent du harnais. + // La cellule porte SA clé de paire (celle que P8a aurait persistée)… + let leaf_key = ConversationId::for_pair(ConversationParty::User, agent_party); + // …mais le handoff est rangé sous la clé d'une AUTRE paire (autre agent). + let other_key = ConversationId::for_pair( + ConversationParty::User, + ConversationParty::agent(aid(2)), + ); + assert_ne!(leaf_key, other_key, "préalable : deux clés de paire distinctes"); + + let store = FakeHandoffStore::with(other_key, handoff_for("ne doit pas fuiter", Some("X"))); + let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc; + let (launch, agent, fs) = launch_with_handoff(Some(provider)); + + let mut input = launch_input(agent.id); + input.conversation_id = Some(leaf_key.to_string()); + + launch.execute(input).await.expect("launch succeeds"); + + let doc = convention_doc(&fs); + assert!( + !doc.contains("# Reprise de la conversation"), + "handoff d'une autre paire ⇒ aucune reprise (pas de fuite de clé): {doc}" + ); +} diff --git a/crates/infrastructure/src/conversation/mod.rs b/crates/infrastructure/src/conversation/mod.rs index fc2981b..0e5f48a 100644 --- a/crates/infrastructure/src/conversation/mod.rs +++ b/crates/infrastructure/src/conversation/mod.rs @@ -194,4 +194,94 @@ mod tests { let reg = InMemoryConversationRegistry::new(); assert!(reg.get(ConversationId::new_random()).is_none()); } + + // --------------------------------------------------------------------------- + // Bloc 1 — déterminisme du registre (§19 : la clé de persistance = id de paire, + // déterministe et **stable au redémarrage**). Scelle l'invariant que Main a + // rattrapé : deux instances neuves du registre (= deux démarrages de l'IDE) + // doivent dériver **le même** id pour la même paire, et cet id doit être + // **exactement** `ConversationId::for_pair`, sinon la clé sous laquelle le + // handoff/log est rangé dériverait au redémarrage. + // --------------------------------------------------------------------------- + + #[test] + fn resolve_is_stable_across_registry_restart() { + // Deux instances neuves (by_pair/by_id vides) simulent deux démarrages de + // l'IDE : la même paire User↔Agent doit produire le **même** id. + let a = agent(7); + let first = InMemoryConversationRegistry::new() + .resolve(ConversationParty::User, a) + .id; + let second = InMemoryConversationRegistry::new() + .resolve(ConversationParty::User, a) + .id; + assert_eq!( + first, second, + "id de paire stable au redémarrage (registre neuf ⇒ même id)" + ); + } + + #[test] + fn resolve_is_stable_across_registry_restart_agent_agent() { + // Même garantie pour une paire Agent↔Agent (dérivation XOR commutative). + let x = agent(11); + let y = agent(13); + let first = InMemoryConversationRegistry::new().resolve(x, y).id; + let second = InMemoryConversationRegistry::new().resolve(y, x).id; + assert_eq!( + first, second, + "id de paire Agent↔Agent stable au redémarrage, insensible à l'ordre" + ); + } + + #[test] + fn resolve_id_equals_for_pair_user_agent() { + // Alignement de clé : l'id que le registre matérialise == le repli pur + // `for_pair` == `from_uuid(agent)` (la clé que P8a/`resolve_conversation` + // dérivent pour la paire canonique User↔Agent). + let agent_id = AgentId::from_uuid(uuid::Uuid::from_u128(42)); + let party = ConversationParty::agent(agent_id); + let resolved = InMemoryConversationRegistry::new() + .resolve(ConversationParty::User, party) + .id; + assert_eq!( + resolved, + ConversationId::for_pair(ConversationParty::User, party), + "resolve == for_pair (alignement de clé)" + ); + assert_eq!( + resolved, + ConversationId::from_uuid(agent_id.as_uuid()), + "User↔Agent ⇒ id == uuid de l'agent (repli resolve_conversation)" + ); + } + + #[test] + fn resolve_id_equals_for_pair_agent_agent_commutative() { + // Commutativité et alignement sur `for_pair` pour Agent↔Agent. + let x = agent(101); + let y = agent(202); + let reg = InMemoryConversationRegistry::new(); + let id_xy = reg.resolve(x, y).id; + let id_yx = reg.resolve(y, x).id; + assert_eq!(id_xy, id_yx, "resolve(a,b) == resolve(b,a)"); + assert_eq!( + id_xy, + ConversationId::for_pair(x, y), + "resolve == for_pair (Agent↔Agent)" + ); + assert_eq!(reg.len(), 1, "une seule conversation pour la paire {{a,b}}"); + } + + #[test] + fn distinct_pairs_yield_distinct_ids_across_kinds() { + // Deux paires distinctes ⇒ deux ids distincts (pas de collision de clé). + let reg = InMemoryConversationRegistry::new(); + let user_a = reg.resolve(ConversationParty::User, agent(1)).id; + let user_b = reg.resolve(ConversationParty::User, agent(2)).id; + let a_b = reg.resolve(agent(1), agent(2)).id; + assert_ne!(user_a, user_b, "User↔A ≠ User↔B"); + assert_ne!(user_a, a_b, "User↔A ≠ A↔B"); + assert_ne!(user_b, a_b, "User↔B ≠ A↔B"); + } }