|
|
|
|
@ -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
|
|
|
|
|
|