feat(domain,infra,app): borne summary_md du handoff + durcissement seam LLM (LS5)

Clôt le must-have perf du handoff :
- domain : HANDOFF_SUMMARY_MAX_CHARS + bound_handoff_summary (helpers privés), re-export lib.
- infrastructure : TURN_LINE_MAX_CHARS + élision dans render_turn (summarizer), re-exports.
- application : borne entre fold et save (conversation/record), borne défensive
  dans resolve_handoff (agent/lifecycle) + module test.
- seam LLM prêt mais NON activé (aucun LLM câblé).
- tests (QA, verts) : conversation_log, conversation_record, agent_lifecycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 12:29:44 +02:00
parent 533a9c57f3
commit 13a953cb05
10 changed files with 673 additions and 16 deletions

View File

@ -2448,6 +2448,88 @@ async fn launch_with_store_without_handoff_omits_resume_section() {
);
}
/// LS5 — un `handoff.md` **legacy surdimensionné** (écrit avant LS5, non borné) chargé
/// au lancement ⇒ la borne défensive de `resolve_handoff` ramène le `summary_md` injecté
/// sous [`domain::HANDOFF_SUMMARY_MAX_CHARS`], sans migration de données ni crash. On
/// vérifie sur la section `# Reprise de la conversation` réellement écrite dans le run dir.
#[tokio::test]
async fn launch_bounds_oversize_legacy_handoff_resume_section() {
// Handoff legacy géant : 500 lignes-tours bien formées, objectif absent (⇒ la section
// injectée = le seul summary, comparable directement au plafond).
let mut summary = String::new();
for i in 0..500u32 {
if i > 0 {
summary.push('\n');
}
summary.push_str(&format!(
"- **Response:** tour numero {i} {}",
"x".repeat(30)
));
}
assert!(
summary.chars().count() > domain::HANDOFF_SUMMARY_MAX_CHARS,
"pré-condition : le handoff legacy dépasse le plafond ({})",
summary.chars().count()
);
let conv = conv_id(123);
let store = FakeHandoffStore::with(conv, handoff_for(&summary, None));
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(Uuid::from_u128(123).to_string());
launch.execute(input).await.expect("launch succeeds");
let doc = convention_doc(&fs);
// La section de reprise est la DERNIÈRE du document (ordre composeur). Son contenu
// (après l'entête) = le summary injecté, qui doit être borné.
let section = doc
.split("# Reprise de la conversation\n\n")
.nth(1)
.expect("resume section present")
.trim_end();
assert!(
section.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS,
"summary legacy injecté borné, obtenu {}",
section.chars().count()
);
// Distillation : les lignes les plus récentes survivent, les plus anciennes droppées.
assert!(section.contains("tour numero 499"), "le plus récent reste");
assert!(
!section.contains("tour numero 0 "),
"le plus ancien droppé : {section}"
);
// Reparsable : chaque ligne conservée garde le préfixe de tour.
for line in section.lines().filter(|l| !l.is_empty()) {
assert!(line.starts_with("- **"), "ligne reparsable : {line}");
}
}
/// LS5 — zéro régression : un handoff **normal** (sous le plafond) est injecté
/// **verbatim** (la borne est l'identité), objectif et summary intacts.
#[tokio::test]
async fn launch_normal_handoff_injects_summary_verbatim() {
let summary = "**Objectif :** livrer LS5\n\n- **Prompt:** a\n- **Response:** b";
assert!(summary.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS);
let conv = conv_id(123);
let store = FakeHandoffStore::with(conv, handoff_for(summary, Some("livrer LS5")));
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(Uuid::from_u128(123).to_string());
launch.execute(input).await.expect("launch succeeds");
let doc = convention_doc(&fs);
// Le summary normal est injecté tel quel (la borne ne l'a pas touché).
assert!(
doc.contains(summary),
"handoff normal injecté verbatim (borne = identité) : {doc}"
);
assert!(doc.contains("**Objectif :** livrer LS5"));
}
// ---------------------------------------------------------------------------
// Bloc 2 — cohérence end-to-end de la clé de conversation (LE test de vérité).
//

View File

@ -20,7 +20,7 @@ use async_trait::async_trait;
use domain::ports::StoreError;
use domain::{
ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
InputSource, TurnId, TurnRole,
InputSource, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
};
use application::{AppError, RecordTurn};
@ -373,3 +373,109 @@ async fn records_are_isolated_per_conversation() {
assert_eq!(hb.up_to, tb.id);
assert!(hb.summary_md.contains("beta") && !hb.summary_md.contains("alpha"));
}
// ---------------------------------------------------------------------------
// LS5 — la borne universelle s'applique côté `record`, indépendamment du résumeur.
// ---------------------------------------------------------------------------
/// Un résumeur **adversarial** qui renvoie toujours un `summary_md` largement
/// surdimensionné (au-delà du plafond), au format reparsable, pour prouver que la
/// garantie de borne vient de `record` (via `bound_handoff_summary`), PAS du résumeur.
#[derive(Default)]
struct OversizeSummarizer;
#[async_trait]
impl HandoffSummarizer for OversizeSummarizer {
async fn fold(&self, _prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
// Beaucoup de lignes de tours bien formées ⇒ summary_md >> plafond.
let mut s = String::from("**Objectif :** objectif preserve");
for i in 0..1_000 {
s.push_str(&format!(
"\n- **Response:** ligne surdimensionnee numero {i}"
));
}
let up_to = new_turns
.last()
.map(|t| t.id)
.expect("fold receives at least one turn");
Handoff::new(s, up_to, Some("objectif preserve".to_owned()))
}
}
/// Avec un résumeur qui produit un summary géant, le handoff **persisté** est borné à
/// `HANDOFF_SUMMARY_MAX_CHARS` (garantie universelle), tout en gardant l'ordre `append`-
/// d'abord, le fold incrémental (un appel par tour), objectif et curseur préservés.
#[tokio::test]
async fn record_bounds_persisted_handoff_even_with_oversize_summarizer() {
let log = Arc::new(InMemoryConversationLog::default());
let handoffs = Arc::new(InMemoryHandoffStore::default());
let summarizer = Arc::new(OversizeSummarizer);
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
let conv = conv_id(1);
let t1 = turn(conv, turn_id(10), "premier tour");
uc.record(conv, t1.clone()).await.expect("record ok");
// append-d'abord : le log canonique porte le tour.
assert_eq!(log.thread(conv), vec![t1.clone()]);
// Le handoff persisté est BORNÉ malgré le résumeur surdimensionné.
let h = handoffs.loaded(conv).expect("handoff saved");
assert!(
h.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
"summary persisté borné, obtenu {}",
h.summary_md.chars().count()
);
// Objectif + curseur jamais modifiés par la borne.
assert_eq!(h.objective.as_deref(), Some("objectif preserve"));
assert_eq!(h.up_to, t1.id);
// Reparsable : le préambule d'objectif survit, les lignes de tours sont préfixées.
assert!(h.summary_md.contains("**Objectif :** objectif preserve"));
for line in h.summary_md.lines().filter(|l| l.starts_with("- **")) {
assert!(line.starts_with("- **"));
}
}
/// La borne au `record` reste incrémentale sur plusieurs tours : un `fold` par tour
/// (jamais de relecture complète), et chaque checkpoint persiste un summary borné.
#[tokio::test]
async fn record_stays_incremental_and_bounded_across_turns() {
use std::sync::Mutex;
/// Compte les appels `fold` pour prouver l'incrémentalité (un par tour).
#[derive(Default)]
struct CountingOversize {
calls: Mutex<usize>,
}
#[async_trait]
impl HandoffSummarizer for CountingOversize {
async fn fold(&self, _prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
*self.calls.lock().unwrap() += 1;
// L'incrément ne porte qu'un seul tour à chaque checkpoint.
assert_eq!(new_turns.len(), 1, "fold incrémental : un tour à la fois");
let big = "g".repeat(HANDOFF_SUMMARY_MAX_CHARS * 3);
let up_to = new_turns[0].id;
Handoff::new(format!("- **Response:** {big}"), up_to, None)
}
}
let log = Arc::new(InMemoryConversationLog::default());
let handoffs = Arc::new(InMemoryHandoffStore::default());
let summarizer = Arc::new(CountingOversize::default());
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
let conv = conv_id(1);
for n in 1..=5u128 {
uc.record(conv, turn(conv, turn_id(n), &format!("tour {n}")))
.await
.expect("record ok");
let h = handoffs.loaded(conv).expect("handoff saved");
assert!(
h.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
"checkpoint {n} borné, obtenu {}",
h.summary_md.chars().count()
);
}
assert_eq!(*summarizer.calls.lock().unwrap(), 5, "un fold par tour");
}