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

@ -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");
}