test(persistence): P8a — déterminisme registre + cohérence de clé end-to-end
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 <noreply@anthropic.com>
This commit is contained in:
@ -2100,7 +2100,7 @@ async fn launch_degraded_mode_without_assign_flag_first_launch_is_none() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
use application::HandoffProvider;
|
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
|
/// In-memory [`HandoffStore`] for the P7 launch tests: holds at most one handoff
|
||||||
/// per [`ConversationId`]. `load` returns `Ok(None)` for an unknown conversation
|
/// 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}"
|
"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<dyn HandoffProvider>;
|
||||||
|
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<dyn HandoffProvider>;
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -194,4 +194,94 @@ mod tests {
|
|||||||
let reg = InMemoryConversationRegistry::new();
|
let reg = InMemoryConversationRegistry::new();
|
||||||
assert!(reg.get(ConversationId::new_random()).is_none());
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user