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:
@ -23,11 +23,12 @@ use domain::ports::{
|
|||||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
|
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
||||||
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry,
|
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
||||||
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId, Project,
|
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId,
|
||||||
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
|
PermissionProjector, ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext,
|
||||||
SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill,
|
||||||
|
TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
||||||
};
|
};
|
||||||
|
|
||||||
use domain::live_state::WorkStatus;
|
use domain::live_state::WorkStatus;
|
||||||
@ -1318,7 +1319,15 @@ impl LaunchAgent {
|
|||||||
let conversation = ConversationId::from_uuid(uuid::Uuid::parse_str(raw).ok()?);
|
let conversation = ConversationId::from_uuid(uuid::Uuid::parse_str(raw).ok()?);
|
||||||
let store = provider.handoff_store_for(root)?;
|
let store = provider.handoff_store_for(root)?;
|
||||||
// Toute erreur de lecture/désérialisation ⇒ pas d'injection (best-effort).
|
// Toute erreur de lecture/désérialisation ⇒ pas d'injection (best-effort).
|
||||||
store.load(conversation).await.ok().flatten()
|
// Borne défensive LS5 : un `handoff.md` legacy non borné (écrit avant LS5) est
|
||||||
|
// ramené sous `HANDOFF_SUMMARY_MAX_CHARS` **avant** d'entrer dans le contexte
|
||||||
|
// composé — sans migration de données, idempotent et best-effort.
|
||||||
|
store
|
||||||
|
.load(conversation)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|h| bound_handoff_summary(h, HANDOFF_SUMMARY_MAX_CHARS))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Résout **best-effort** l'aperçu live-state des **autres** agents (lot LS4) à
|
/// Résout **best-effort** l'aperçu live-state des **autres** agents (lot LS4) à
|
||||||
@ -3738,6 +3747,56 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_with_normal_handoff_is_byte_identical_pre_and_post_ls5_bound() {
|
||||||
|
// ZÉRO RÉGRESSION (LS5) : pour un handoff sous le plafond, le seul changement
|
||||||
|
// LS5 du chemin d'injection — `bound_handoff_summary` dans `resolve_handoff` —
|
||||||
|
// est l'identité (cf. domaine `bound_under_limit_is_unchanged`). Le document
|
||||||
|
// composé est donc OCTET-IDENTIQUE avant/après LS5. Assertion stricte.
|
||||||
|
let memory = [mem(
|
||||||
|
"git-optional",
|
||||||
|
"Git optionnel",
|
||||||
|
"git reste un simple tool",
|
||||||
|
MemoryType::Project,
|
||||||
|
)];
|
||||||
|
let normal = handoff(
|
||||||
|
"**Objectif :** livrer LS5\n\n- **Prompt:** a\n- **Response:** b",
|
||||||
|
Some("livrer LS5"),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
normal.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
|
||||||
|
"pré-condition : handoff sous le plafond"
|
||||||
|
);
|
||||||
|
|
||||||
|
// « Avant LS5 » : le composeur reçoit le handoff tel quel (aucune borne).
|
||||||
|
let pre = compose_convention_file(
|
||||||
|
"/root",
|
||||||
|
"",
|
||||||
|
"# Persona",
|
||||||
|
&[],
|
||||||
|
&memory,
|
||||||
|
Some(&normal),
|
||||||
|
&[],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// « Après LS5 » : le handoff passe d'abord par la borne universelle.
|
||||||
|
let bounded = bound_handoff_summary(normal.clone(), HANDOFF_SUMMARY_MAX_CHARS);
|
||||||
|
let post = compose_convention_file(
|
||||||
|
"/root",
|
||||||
|
"",
|
||||||
|
"# Persona",
|
||||||
|
&[],
|
||||||
|
&memory,
|
||||||
|
Some(&bounded),
|
||||||
|
&[],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pre, post,
|
||||||
|
"handoff normal ⇒ document composé octet-identique"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// État du projet (lot LS4) — live-state preview section in the composer.
|
// État du projet (lot LS4) — live-state preview section in the composer.
|
||||||
// The composer is pure: it renders the already-distilled `InjectedLiveRow`s
|
// The composer is pure: it renders the already-distilled `InjectedLiveRow`s
|
||||||
|
|||||||
@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::{ConversationId, ConversationLog, ConversationTurn, HandoffStore, HandoffSummarizer};
|
use domain::{
|
||||||
|
bound_handoff_summary, ConversationId, ConversationLog, ConversationTurn, HandoffStore,
|
||||||
|
HandoffSummarizer, HANDOFF_SUMMARY_MAX_CHARS,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@ -63,6 +66,9 @@ impl RecordTurn {
|
|||||||
/// 1. **append** the turn to the canonical log (source of truth) ;
|
/// 1. **append** the turn to the canonical log (source of truth) ;
|
||||||
/// 2. **load** the previous handoff (`None` on a first checkpoint) ;
|
/// 2. **load** the previous handoff (`None` on a first checkpoint) ;
|
||||||
/// 3. **fold** `prev` with the single new turn (incremental, best-effort) ;
|
/// 3. **fold** `prev` with the single new turn (incremental, best-effort) ;
|
||||||
|
/// 3b. **bound** the folded handoff's summary to [`HANDOFF_SUMMARY_MAX_CHARS`] — a
|
||||||
|
/// **universal cap independent of the summarizer** (heuristic *or* a future LLM):
|
||||||
|
/// idempotent, never rejecting, so it never blocks the checkpoint (LS5) ;
|
||||||
/// 4. **save** the advanced handoff.
|
/// 4. **save** the advanced handoff.
|
||||||
///
|
///
|
||||||
/// The append happens **first** so the durable source of truth is never behind
|
/// The append happens **first** so the durable source of truth is never behind
|
||||||
@ -92,6 +98,13 @@ impl RecordTurn {
|
|||||||
.fold(prev, std::slice::from_ref(&turn))
|
.fold(prev, std::slice::from_ref(&turn))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// 3b. **Universal bound** (LS5): clamp the resume summary to
|
||||||
|
// `HANDOFF_SUMMARY_MAX_CHARS`, *independently of the summarizer*. Whatever
|
||||||
|
// produced the handoff — the heuristic or a future LLM summarizer — the
|
||||||
|
// persisted (and later injected) summary is size-bounded. Idempotent and
|
||||||
|
// never rejecting, so it can never block the checkpoint.
|
||||||
|
let handoff = bound_handoff_summary(handoff, HANDOFF_SUMMARY_MAX_CHARS);
|
||||||
|
|
||||||
// 4. Persist the advanced handoff (overwrites the previous resume point).
|
// 4. Persist the advanced handoff (overwrites the previous resume point).
|
||||||
self.handoffs.save(conversation, handoff).await?;
|
self.handoffs.save(conversation, handoff).await?;
|
||||||
|
|
||||||
|
|||||||
@ -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é).
|
// Bloc 2 — cohérence end-to-end de la clé de conversation (LE test de vérité).
|
||||||
//
|
//
|
||||||
|
|||||||
@ -20,7 +20,7 @@ use async_trait::async_trait;
|
|||||||
use domain::ports::StoreError;
|
use domain::ports::StoreError;
|
||||||
use domain::{
|
use domain::{
|
||||||
ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||||
InputSource, TurnId, TurnRole,
|
InputSource, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
|
||||||
};
|
};
|
||||||
|
|
||||||
use application::{AppError, RecordTurn};
|
use application::{AppError, RecordTurn};
|
||||||
@ -373,3 +373,109 @@ async fn records_are_isolated_per_conversation() {
|
|||||||
assert_eq!(hb.up_to, tb.id);
|
assert_eq!(hb.up_to, tb.id);
|
||||||
assert!(hb.summary_md.contains("beta") && !hb.summary_md.contains("alpha"));
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@ -206,6 +206,141 @@ impl Handoff {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Borne dure (en **caractères**) du `summary_md` d'un [`Handoff`] (lot LS5).
|
||||||
|
///
|
||||||
|
/// Plafond universel **indépendant du résumeur** : qu'il soit produit par
|
||||||
|
/// l'heuristique zéro-dépendance ou par un futur résumeur LLM, le résumé injecté dans
|
||||||
|
/// le contexte d'un agent (et persisté) ne dépasse jamais cette taille. `4096`
|
||||||
|
/// caractères tiennent largement un objectif + une fenêtre de tours déjà bornés
|
||||||
|
/// individuellement, tout en gardant le contexte de reprise petit (chemin chaud).
|
||||||
|
pub const HANDOFF_SUMMARY_MAX_CHARS: usize = 4096;
|
||||||
|
|
||||||
|
/// Préfixe d'une **ligne de tour** dans un `summary_md` rendu (cf. le résumeur
|
||||||
|
/// heuristique d'infra) : une ligne `- **<role>:** <texte>`. Sert ici à séparer le
|
||||||
|
/// **préambule d'objectif** des **lignes de tours** pour la troncature distillée.
|
||||||
|
const TURN_LINE_PREFIX: &str = "- **";
|
||||||
|
|
||||||
|
/// Borne le `summary_md` d'un [`Handoff`] à `max_chars` caractères, **best-effort et
|
||||||
|
/// idempotent** (lot LS5).
|
||||||
|
///
|
||||||
|
/// - Sous la borne (`summary_md.chars().count() <= max_chars`) ⇒ handoff **inchangé**
|
||||||
|
/// (coût nul, cas chaud nominal).
|
||||||
|
/// - Au-dessus ⇒ **troncature distillée** : on garde le **préambule d'objectif** (le
|
||||||
|
/// 1er bloc, avant la première ligne de tour) PUIS le **maximum de lignes de tours
|
||||||
|
/// les plus récentes** (fin de fenêtre) tenant sous `max_chars`, en **droppant les
|
||||||
|
/// plus anciennes d'abord**. Si la ligne conservée la plus récente dépasse à elle
|
||||||
|
/// seule le budget, sa **fin** est tronquée en gardant le préfixe `- **role:**`
|
||||||
|
/// intact (le résultat reste **toujours reparsable** comme un `summary_md`).
|
||||||
|
///
|
||||||
|
/// [`Handoff::up_to`] et [`Handoff::objective`] ne sont **jamais** modifiés. La fonction
|
||||||
|
/// ne **re-fold jamais**, ne **rejette jamais** (elle ne doit pas pouvoir bloquer un
|
||||||
|
/// append/checkpoint) et garantit `bound(bound(x)) == bound(x)`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn bound_handoff_summary(handoff: Handoff, max_chars: usize) -> Handoff {
|
||||||
|
if handoff.summary_md.chars().count() <= max_chars {
|
||||||
|
return handoff;
|
||||||
|
}
|
||||||
|
let summary_md = distill_summary(&handoff.summary_md, max_chars);
|
||||||
|
Handoff {
|
||||||
|
summary_md,
|
||||||
|
up_to: handoff.up_to,
|
||||||
|
objective: handoff.objective,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Troncature distillée d'un `summary_md` au-dessus de la borne (cf.
|
||||||
|
/// [`bound_handoff_summary`]). Sépare le préambule d'objectif des lignes de tours, garde
|
||||||
|
/// le préambule puis le suffixe le plus récent de tours tenant sous `max_chars`.
|
||||||
|
fn distill_summary(summary: &str, max_chars: usize) -> String {
|
||||||
|
// Préambule d'objectif = lignes de tête jusqu'à la 1re ligne de tour ; lignes de
|
||||||
|
// tours = celles préfixées `- **` (les seules reparsées par le résumeur). Toute
|
||||||
|
// ligne non-tour APRÈS la 1re ligne de tour est du bruit inter-tours, droppée.
|
||||||
|
let mut objective_block: Vec<&str> = Vec::new();
|
||||||
|
let mut turn_lines: Vec<&str> = Vec::new();
|
||||||
|
let mut seen_turn = false;
|
||||||
|
for line in summary.lines() {
|
||||||
|
if line.starts_with(TURN_LINE_PREFIX) {
|
||||||
|
seen_turn = true;
|
||||||
|
turn_lines.push(line);
|
||||||
|
} else if !seen_turn {
|
||||||
|
objective_block.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while objective_block.last().is_some_and(|l| l.trim().is_empty()) {
|
||||||
|
objective_block.pop();
|
||||||
|
}
|
||||||
|
let objective_md = objective_block.join("\n");
|
||||||
|
let objective_len = objective_md.chars().count();
|
||||||
|
// Séparateur `\n\n` entre l'objectif et la fenêtre, seulement si les deux existent.
|
||||||
|
let separator_len = if objective_md.is_empty() { 0 } else { 2 };
|
||||||
|
let budget_for_turns = max_chars.saturating_sub(objective_len + separator_len);
|
||||||
|
|
||||||
|
// Garder les lignes de tours les plus RÉCENTES (fin) tenant sous le budget ; dropper
|
||||||
|
// les plus anciennes d'abord. On parcourt depuis la fin et on s'arrête au 1er dépassement.
|
||||||
|
let mut kept_rev: Vec<String> = Vec::new();
|
||||||
|
let mut used = 0usize;
|
||||||
|
for line in turn_lines.iter().rev() {
|
||||||
|
let join_cost = usize::from(!kept_rev.is_empty()); // le `\n` de jointure
|
||||||
|
let line_len = line.chars().count();
|
||||||
|
if used + join_cost + line_len <= budget_for_turns {
|
||||||
|
used += join_cost + line_len;
|
||||||
|
kept_rev.push((*line).to_owned());
|
||||||
|
} else {
|
||||||
|
// La ligne la plus récente ne tient pas même seule ⇒ hard-truncate sa fin
|
||||||
|
// en gardant le préfixe `- **role:**` intact (reparse toujours valide).
|
||||||
|
if kept_rev.is_empty() {
|
||||||
|
let truncated = truncate_turn_line_keeping_prefix(line, budget_for_turns);
|
||||||
|
if !truncated.is_empty() {
|
||||||
|
kept_rev.push(truncated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kept_rev.reverse();
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
if !objective_md.is_empty() {
|
||||||
|
out.push_str(&objective_md);
|
||||||
|
if !kept_rev.is_empty() {
|
||||||
|
out.push_str("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !kept_rev.is_empty() {
|
||||||
|
out.push_str(&kept_rev.join("\n"));
|
||||||
|
}
|
||||||
|
// Clamp final de sûreté : garantit `out.chars().count() <= max_chars` (donc
|
||||||
|
// l'idempotence via la garde d'entrée) même sur une entrée pathologique (objectif
|
||||||
|
// gigantesque, aucune ligne de tour). Char-boundary safe.
|
||||||
|
if out.chars().count() > max_chars {
|
||||||
|
out = out.chars().take(max_chars).collect();
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tronque la **fin** d'une ligne de tour à `budget` caractères en gardant intact le
|
||||||
|
/// préfixe `- **<role>:** ` pour que la ligne reste reparsable comme un tour.
|
||||||
|
fn truncate_turn_line_keeping_prefix(line: &str, budget: usize) -> String {
|
||||||
|
if line.chars().count() <= budget {
|
||||||
|
return line.to_owned();
|
||||||
|
}
|
||||||
|
// Préfixe = `- **Role:** ` (inclus l'espace) quand présent, sinon a minima `- **`.
|
||||||
|
let prefix: &str = match line.find(":** ") {
|
||||||
|
Some(pos) => &line[..pos + 4],
|
||||||
|
None => TURN_LINE_PREFIX,
|
||||||
|
};
|
||||||
|
let prefix_len = prefix.chars().count();
|
||||||
|
if budget <= prefix_len {
|
||||||
|
// Budget trop petit pour le moindre texte : on garde juste le préfixe reparsable.
|
||||||
|
return prefix.to_owned();
|
||||||
|
}
|
||||||
|
let tail: String = line[prefix.len()..]
|
||||||
|
.chars()
|
||||||
|
.take(budget - prefix_len)
|
||||||
|
.collect();
|
||||||
|
format!("{prefix}{tail}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Le store du résumé de reprise (handoff) d'une conversation (port driven, lot P3).
|
/// Le store du résumé de reprise (handoff) d'une conversation (port driven, lot P3).
|
||||||
///
|
///
|
||||||
/// Distinct du [`ConversationLog`] append-only : ici on garde **un seul** [`Handoff`]
|
/// Distinct du [`ConversationLog`] append-only : ici on garde **un seul** [`Handoff`]
|
||||||
@ -483,6 +618,95 @@ mod tests {
|
|||||||
// Port contract — via the in-memory double
|
// Port contract — via the in-memory double
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// bound_handoff_summary (lot LS5) — borne universelle du summary_md
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn handoff(summary: &str) -> Handoff {
|
||||||
|
Handoff::new(summary, turn_id(1), Some("garder l'objectif".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bound_under_limit_is_unchanged() {
|
||||||
|
let h = handoff("**Objectif :** x\n\n- **Prompt:** a\n- **Response:** b");
|
||||||
|
let bounded = bound_handoff_summary(h.clone(), HANDOFF_SUMMARY_MAX_CHARS);
|
||||||
|
assert_eq!(bounded, h, "sous la borne ⇒ inchangé (coût nul)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bound_preserves_objective_and_cursor_and_drops_oldest_turns() {
|
||||||
|
// 10 lignes de tours ; borne minuscule ⇒ on garde l'objectif + le suffixe récent.
|
||||||
|
let mut s = String::from("**Objectif :** garder ceci");
|
||||||
|
for i in 0..10 {
|
||||||
|
s.push_str(&format!("\n\n"));
|
||||||
|
s.push_str(&format!("- **Prompt:** tour-{i}"));
|
||||||
|
}
|
||||||
|
// Reconstruire au bon format (objectif + lignes jointes par \n).
|
||||||
|
let summary = {
|
||||||
|
let mut out = String::from("**Objectif :** garder ceci\n\n");
|
||||||
|
let lines: Vec<String> = (0..10).map(|i| format!("- **Prompt:** tour-{i}")).collect();
|
||||||
|
out.push_str(&lines.join("\n"));
|
||||||
|
out
|
||||||
|
};
|
||||||
|
let h = Handoff::new(summary, turn_id(7), Some("garder ceci".to_owned()));
|
||||||
|
let bounded = bound_handoff_summary(h.clone(), 60);
|
||||||
|
|
||||||
|
assert!(bounded.summary_md.chars().count() <= 60);
|
||||||
|
// Objectif + curseur jamais modifiés.
|
||||||
|
assert_eq!(bounded.objective, h.objective);
|
||||||
|
assert_eq!(bounded.up_to, h.up_to);
|
||||||
|
assert!(bounded.summary_md.contains("**Objectif :** garder ceci"));
|
||||||
|
// Les plus récents survivent, les plus anciens sont droppés.
|
||||||
|
assert!(
|
||||||
|
bounded.summary_md.contains("tour-9"),
|
||||||
|
"le plus récent reste"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!bounded.summary_md.contains("tour-0"),
|
||||||
|
"le plus ancien droppé"
|
||||||
|
);
|
||||||
|
// Chaque ligne de tour conservée reste reparsable.
|
||||||
|
for line in bounded.summary_md.lines().filter(|l| l.starts_with("- **")) {
|
||||||
|
assert!(line.starts_with("- **"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bound_hard_truncates_a_single_oversize_line_keeping_prefix() {
|
||||||
|
let long = "x".repeat(500);
|
||||||
|
let summary = format!("- **Response:** {long}");
|
||||||
|
let h = Handoff::new(summary, turn_id(3), None);
|
||||||
|
let bounded = bound_handoff_summary(h, 60);
|
||||||
|
assert!(bounded.summary_md.chars().count() <= 60);
|
||||||
|
assert!(
|
||||||
|
bounded.summary_md.starts_with("- **Response:** "),
|
||||||
|
"préfixe de tour intact ⇒ reparse valide : {}",
|
||||||
|
bounded.summary_md
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bound_is_idempotent() {
|
||||||
|
let summary = {
|
||||||
|
let mut out = String::from("**Objectif :** garder ceci\n\n");
|
||||||
|
let lines: Vec<String> = (0..30)
|
||||||
|
.map(|i| {
|
||||||
|
format!(
|
||||||
|
"- **Prompt:** un tour relativement long numero {i} {}",
|
||||||
|
"y".repeat(50)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.push_str(&lines.join("\n"));
|
||||||
|
out
|
||||||
|
};
|
||||||
|
let h = Handoff::new(summary, turn_id(9), Some("garder ceci".to_owned()));
|
||||||
|
let once = bound_handoff_summary(h, 200);
|
||||||
|
let twice = bound_handoff_summary(once.clone(), 200);
|
||||||
|
assert_eq!(once, twice, "bound(bound(x)) == bound(x)");
|
||||||
|
assert!(once.summary_md.chars().count() <= 200);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn append_then_read_all_preserves_insertion_order() {
|
async fn append_then_read_all_preserves_insertion_order() {
|
||||||
let log = InMemoryConversationLog::default();
|
let log = InMemoryConversationLog::default();
|
||||||
|
|||||||
@ -103,8 +103,8 @@ pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
|||||||
pub use session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
|
pub use session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
|
||||||
|
|
||||||
pub use conversation_log::{
|
pub use conversation_log::{
|
||||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
bound_handoff_summary, ConversationLog, ConversationTurn, Handoff, HandoffStore,
|
||||||
ProviderSessionStore, TurnId, TurnRole,
|
HandoffSummarizer, ProviderSessionStore, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use fileguard::{
|
pub use fileguard::{
|
||||||
|
|||||||
@ -49,7 +49,7 @@ mod summarizer;
|
|||||||
|
|
||||||
pub use handoff::FsHandoffStore;
|
pub use handoff::FsHandoffStore;
|
||||||
pub use providers::FsProviderSessionStore;
|
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.
|
/// Dossier `.ideai/` à la racine d'un project root.
|
||||||
pub(crate) const IDEAI_DIR: &str = ".ideai";
|
pub(crate) const IDEAI_DIR: &str = ".ideai";
|
||||||
|
|||||||
@ -51,6 +51,14 @@ pub const WINDOW: usize = 20;
|
|||||||
/// Longueur maximale (en caractères) de l'objectif extrait d'un premier prompt.
|
/// Longueur maximale (en caractères) de l'objectif extrait d'un premier prompt.
|
||||||
const OBJECTIVE_MAX_CHARS: usize = 200;
|
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).
|
/// Préfixe de la ligne d'objectif dans le `summary_md` (sert au rendu **et** au reparse).
|
||||||
const OBJECTIVE_PREFIX: &str = "**Objectif :** ";
|
const OBJECTIVE_PREFIX: &str = "**Objectif :** ";
|
||||||
|
|
||||||
@ -80,7 +88,9 @@ fn render_turn(turn: &ConversationTurn) -> String {
|
|||||||
TurnRole::Response => "Response",
|
TurnRole::Response => "Response",
|
||||||
TurnRole::ToolActivity => "Tool",
|
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}")
|
format!("- **{label}:** {flat}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,6 +99,17 @@ fn flatten(text: &str) -> String {
|
|||||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
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.
|
/// 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
|
/// Ne récupère que les lignes-tours (préfixe `- **`), en ignorant la ligne d'objectif et
|
||||||
|
|||||||
@ -39,7 +39,8 @@ pub mod timeparse;
|
|||||||
pub use clock::SystemClock;
|
pub use clock::SystemClock;
|
||||||
pub use conversation::InMemoryConversationRegistry;
|
pub use conversation::InMemoryConversationRegistry;
|
||||||
pub use conversation_log::{
|
pub use conversation_log::{
|
||||||
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, WINDOW,
|
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
|
||||||
|
TURN_LINE_MAX_CHARS, WINDOW,
|
||||||
};
|
};
|
||||||
pub use eventbus::TokioBroadcastEventBus;
|
pub use eventbus::TokioBroadcastEventBus;
|
||||||
pub use fileguard::RwFileGuard;
|
pub use fileguard::RwFileGuard;
|
||||||
|
|||||||
@ -18,14 +18,15 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use domain::conversation::ConversationId;
|
use domain::conversation::ConversationId;
|
||||||
use domain::conversation_log::{
|
use domain::conversation_log::{
|
||||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
bound_handoff_summary, ConversationLog, ConversationTurn, Handoff, HandoffStore,
|
||||||
ProviderSessionStore, TurnId, TurnRole,
|
HandoffSummarizer, ProviderSessionStore, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
|
||||||
};
|
};
|
||||||
use domain::input::InputSource;
|
use domain::input::InputSource;
|
||||||
use domain::ports::StoreError;
|
use domain::ports::StoreError;
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, WINDOW,
|
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
|
||||||
|
TURN_LINE_MAX_CHARS, WINDOW,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
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));
|
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
|
// P5 — FsProviderSessionStore (providers.json): le `resumable_id` rangé par
|
||||||
// (conversation, provider). Mêmes helpers L2 que P2/P3/P4 — `TempDir` maison
|
// (conversation, provider). Mêmes helpers L2 que P2/P3/P4 — `TempDir` maison
|
||||||
|
|||||||
Reference in New Issue
Block a user