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

@ -49,7 +49,7 @@ mod summarizer;
pub use handoff::FsHandoffStore;
pub use providers::FsProviderSessionStore;
pub use summarizer::{HeuristicHandoffSummarizer, WINDOW};
pub use summarizer::{HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW};
/// Dossier `.ideai/` à la racine d'un project root.
pub(crate) const IDEAI_DIR: &str = ".ideai";

View File

@ -51,6 +51,14 @@ pub const WINDOW: usize = 20;
/// Longueur maximale (en caractères) de l'objectif extrait d'un premier prompt.
const OBJECTIVE_MAX_CHARS: usize = 200;
/// Longueur maximale (en caractères) du **texte aplati** d'un tour rendu sur une ligne
/// (lot LS5). C'est le correctif central du trou de perf : sans borne, un gros
/// `Response` produit une ligne géante qui gonfle le `summary_md` (et donc le contexte
/// injecté à chaque lancement). Au-delà, le texte est tronqué avec une élision `…` ; le
/// préfixe `- **role:**` reste **intact** (la ligne reste reparsable). Borne **publique**
/// pour que l'agent Test la vérifie sans la deviner.
pub const TURN_LINE_MAX_CHARS: usize = 240;
/// Préfixe de la ligne d'objectif dans le `summary_md` (sert au rendu **et** au reparse).
const OBJECTIVE_PREFIX: &str = "**Objectif :** ";
@ -80,7 +88,9 @@ fn render_turn(turn: &ConversationTurn) -> String {
TurnRole::Response => "Response",
TurnRole::ToolActivity => "Tool",
};
let flat = flatten(&turn.text);
// Borne LS5 : le texte aplati est tronqué à `TURN_LINE_MAX_CHARS` (élision `…`) pour
// éviter qu'un gros Response ne produise une ligne géante. Le préfixe reste intact.
let flat = elide(&flatten(&turn.text), TURN_LINE_MAX_CHARS);
format!("- **{label}:** {flat}")
}
@ -89,6 +99,17 @@ fn flatten(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Tronque `text` à `max_chars` caractères (char-boundary safe), en ajoutant une
/// élision `…` quand il a effectivement été coupé. En deçà, renvoie le texte tel quel.
fn elide(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
text.to_owned()
} else {
let kept: String = text.chars().take(max_chars).collect();
format!("{kept}")
}
}
/// Reparse les lignes de tours déjà rendues dans un `summary_md` précédent.
///
/// Ne récupère que les lignes-tours (préfixe `- **`), en ignorant la ligne d'objectif et

View File

@ -39,7 +39,8 @@ pub mod timeparse;
pub use clock::SystemClock;
pub use conversation::InMemoryConversationRegistry;
pub use conversation_log::{
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, WINDOW,
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
TURN_LINE_MAX_CHARS, WINDOW,
};
pub use eventbus::TokioBroadcastEventBus;
pub use fileguard::RwFileGuard;

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