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

@ -18,14 +18,15 @@ use std::path::PathBuf;
use domain::conversation::ConversationId;
use domain::conversation_log::{
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
ProviderSessionStore, TurnId, TurnRole,
bound_handoff_summary, ConversationLog, ConversationTurn, Handoff, HandoffStore,
HandoffSummarizer, ProviderSessionStore, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
};
use domain::input::InputSource;
use domain::ports::StoreError;
use domain::project::ProjectPath;
use infrastructure::{
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, WINDOW,
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
TURN_LINE_MAX_CHARS, WINDOW,
};
use uuid::Uuid;
@ -900,6 +901,156 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
assert_eq!(h2.up_to, turn_id(3));
}
// ---------------------------------------------------------------------------
// LS5 — borne par-ligne (`render_turn`/`elide`) + boundedness sur 100 replis +
// round-trip Fs réel avec `summary_md` borné. Le préfixe de ligne de tour, mesuré
// une fois pour les assertions de borne structurelle.
// ---------------------------------------------------------------------------
/// Le préfixe `- **Response:** ` (en caractères), partagé par les bornes structurelles.
const RESPONSE_PREFIX: &str = "- **Response:** ";
/// LS5 — un tour `Response` énorme : sa ligne rendue est **élidée** (le texte aplati est
/// borné à [`TURN_LINE_MAX_CHARS`] + le marqueur `…`), le préfixe `- **role:**` reste
/// intact, et la ligne reste reparsable. Testé via `fold` (`render_turn` est privé).
#[tokio::test]
async fn fold_elides_a_huge_response_line_keeping_prefix() {
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
let huge = "z".repeat(10_000);
let h = s
.fold(None, &[turn(c, turn_id(1), TurnRole::Response, &huge)])
.await;
let line = turn_lines(&h.summary_md)[0];
// Préfixe intact ⇒ ligne reparsable.
assert!(
line.starts_with(RESPONSE_PREFIX),
"préfixe de tour intact : {line}"
);
// Élidée : marqueur `…` final, et la ligne n'a pas conservé les 10 000 caractères.
assert!(line.ends_with('…'), "marqueur d'élision présent : {line}");
// Borne structurelle : préfixe + TURN_LINE_MAX_CHARS (texte borné) + 1 pour `…`.
let max_line = RESPONSE_PREFIX.chars().count() + TURN_LINE_MAX_CHARS + 1;
assert!(
line.chars().count() <= max_line,
"ligne bornée à {max_line} car., obtenu {} : {line}",
line.chars().count()
);
// Le texte aplati lui-même (hors préfixe, hors `…`) est borné à TURN_LINE_MAX_CHARS.
let text_chars = line.chars().count() - RESPONSE_PREFIX.chars().count() - 1;
assert_eq!(
text_chars, TURN_LINE_MAX_CHARS,
"texte aplati borné à TURN_LINE_MAX_CHARS"
);
}
/// LS5 — 100 replis incrémentaux : la `WINDOW` borne le nombre de lignes (la croissance
/// ne s'accumule pas), l'objectif extrait du 1er prompt est **figé**, la fenêtre reparse
/// proprement, et — pour des tours de taille réaliste — le `summary_md` reste sous le
/// plafond universel [`HANDOFF_SUMMARY_MAX_CHARS`]. (Le plafond *dur*, valable même pour
/// un résumeur pathologique/LLM, est garanti séparément par `bound_handoff_summary` —
/// cf. les tests domaine/application.)
#[tokio::test]
async fn fold_a_hundred_increments_stays_bounded_and_window_reparses() {
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
// 1er tour = Prompt énonçant l'objectif ; replis incrémentaux ensuite.
let mut handoff = s
.fold(
None,
&[turn(
c,
turn_id(1),
TurnRole::Prompt,
"objectif initial figé",
)],
)
.await;
for n in 2..=100u128 {
let text = format!("tour numero {n} {}", "détail ".repeat(15)); // ~120 car.
handoff = s
.fold(
Some(handoff),
&[turn(c, turn_id(n), TurnRole::Response, &text)],
)
.await;
}
// WINDOW respectée : jamais plus de WINDOW lignes-tours, malgré 100 replis.
let lines = turn_lines(&handoff.summary_md);
assert_eq!(lines.len(), WINDOW, "WINDOW borne le nombre de lignes");
// Reparse stable : chaque ligne conservée est une ligne de tour bien formée.
for line in &lines {
assert!(line.starts_with("- **"), "ligne reparsable : {line}");
}
// Objectif figé depuis le 1er prompt.
assert_eq!(handoff.objective.as_deref(), Some("objectif initial figé"));
assert!(handoff
.summary_md
.contains("**Objectif :** objectif initial figé"));
// Curseur = dernier tour intégré.
assert_eq!(handoff.up_to, turn_id(100));
// Borné sous le plafond universel (tours réalistes ⇒ la WINDOW suffit).
assert!(
handoff.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
"summary borné à HANDOFF_SUMMARY_MAX_CHARS, obtenu {}",
handoff.summary_md.chars().count()
);
}
/// LS5 — round-trip `FsHandoffStore` **réel** : après un fold multi-tours (texte volumineux
/// ⇒ summary potentiellement > plafond) PUIS `bound_handoff_summary`, le `handoff.md` écrit
/// sur disque relit un `summary_md` ≤ [`HANDOFF_SUMMARY_MAX_CHARS`], objectif/curseur
/// préservés, et reste reparsable.
#[tokio::test]
async fn fs_handoff_round_trip_persists_bounded_summary() {
let tmp = TempDir::new();
let store = FsHandoffStore::new(&tmp.project_path());
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
// Fold de tours VOLUMINEUX : chaque ligne est élidée à ~257 car., 20 lignes ⇒ le
// summary heuristique dépasse le plafond universel (≈ 5 000 > 4096).
let mut handoff = s
.fold(None, &[turn(c, turn_id(1), TurnRole::Prompt, "objectif")])
.await;
for n in 2..=60u128 {
let big = "m".repeat(3_000);
handoff = s
.fold(
Some(handoff),
&[turn(c, turn_id(n), TurnRole::Response, &big)],
)
.await;
}
assert!(
handoff.summary_md.chars().count() > HANDOFF_SUMMARY_MAX_CHARS,
"pré-condition : le fold heuristique seul dépasse le plafond ({})",
handoff.summary_md.chars().count()
);
// Pipeline réel : borne universelle AVANT persistance.
let bounded = bound_handoff_summary(handoff.clone(), HANDOFF_SUMMARY_MAX_CHARS);
store.save(c, bounded.clone()).await.expect("save ok");
let loaded = store.load(c).await.expect("load ok").expect("present");
assert!(
loaded.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
"handoff.md sur disque borné, obtenu {}",
loaded.summary_md.chars().count()
);
assert_eq!(loaded.objective, bounded.objective, "objectif préservé");
assert_eq!(loaded.up_to, bounded.up_to, "curseur préservé");
// Reparsable : au moins une ligne de tour, toutes bien préfixées.
let kept = turn_lines(&loaded.summary_md);
assert!(!kept.is_empty(), "au moins une ligne de tour conservée");
for line in kept {
assert!(line.starts_with("- **"), "ligne reparsable : {line}");
}
}
// ===========================================================================
// P5 — FsProviderSessionStore (providers.json): le `resumable_id` rangé par
// (conversation, provider). Mêmes helpers L2 que P2/P3/P4 — `TempDir` maison