feat(persistence): couche conversationnelle — cadrage §18/§19 + briques P1→P4

Resync ARCHITECTURE.md (état livré + cadrage persistance/handoff) et premières
briques de la couche de persistance conversationnelle (log canonique par paire
+ handoff incrémental), indépendante du provider — prépare reprise fiable et
handoff cross-profile Claude↔Codex.

ARCHITECTURE.md
- §14.3.2/§17 : M5 marqué livré, verrou « ouvert » périmé, §17 réconcilié
  (vue = terminal de sortie, pas d'UI chat) ; §18 état livré 2026-06-12 ;
  §19 cadrage persistance/handoff (log par paire + handoff, 10 lots P1→P10)

Domaine (conversation_log.rs, pur)
- P1 : ConversationTurn / TurnId / TurnRole + port ConversationLog
- P3 : Handoff + port HandoffStore
- P4 : port HandoffSummarizer (async, seam OCP pour adapter LLM futur)

Infrastructure (conversation_log/)
- P2 : FsConversationLog — JSONL append-only par paire, sync_all (durabilité
  crash), skip ligne corrompue, fichier absent ⇒ vide
- P3 : FsHandoffStore — handoff.md front-matter, write atomique tmp+rename
- P4 : HeuristicHandoffSummarizer — incrémental, zéro modèle/I/O, fenêtre WINDOW

Tests : domaine 12 + infra 24 (conversation_log) verts, suites complètes sans
régression. Cycle dev/test : le binôme a débusqué et corrigé un bug de
durabilité (append sans flush) au passage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:09:35 +02:00
parent eca2ba95c4
commit 75e4f57a71
8 changed files with 2032 additions and 1 deletions

View File

@ -0,0 +1,217 @@
//! [`FsHandoffStore`] — l'adapter `tokio::fs` du port [`HandoffStore`]
//! (cadrage « persistance conversationnelle », lot P3).
//!
//! Le **point de reprise** d'une conversation (ARCHITECTURE §19.2/§19.3) est un
//! [`Handoff`] : un résumé cumulatif Markdown borné par un curseur [`TurnId`]. On en
//! garde **un seul** par conversation (le dernier), dans un fichier lisible à l'œil :
//!
//! ```text
//! <project_root>/.ideai/conversations/
//! └── <conversationId>/
//! ├── log.jsonl # le log append-only (lot P2)
//! └── handoff.md # le dernier point de reprise (ce module)
//! ```
//!
//! ## Format `handoff.md`
//!
//! Un **front-matter** YAML délimité par `---`, suivi du corps `summary_md` tel quel :
//!
//! ```text
//! ---
//! upTo: 00000000-0000-0000-0000-00000000002a
//! objective: livrer le lot P3
//! ---
//! # Résumé
//! …le summary_md, octet pour octet…
//! ```
//!
//! Le front-matter porte le curseur `upTo` (toujours) et `objective` (seulement s'il
//! est `Some`). Le corps après le second `---\n` est le `summary_md` **exact** : le
//! round-trip (`save` puis `load`) redonne le même [`Handoff`]. `objective` étant
//! sérialisé sur une seule ligne, un objectif est interdit de retour-chariot ici ; en
//! pratique c'est une phrase courte (un titre de but), jamais du multi-ligne.
//!
//! ## Robustesse
//!
//! - **Écriture atomique** : on écrit dans `handoff.md.tmp` puis on `rename` — jamais
//! de fichier à moitié écrit visible.
//! - **Fichier absent** ⇒ `Ok(None)` (jamais une erreur) : une conversation sans
//! reprise est l'état normal au premier tour.
//! - **Fichier présent mais illisible** (front-matter absent/incomplet, `upTo`
//! manquant ou non-UUID) ⇒ [`StoreError::Serialization`]. Contrairement au log
//! JSONL (où une ligne corrompue est sautée), un handoff corrompu est une vraie
//! erreur : il n'y a qu'un enregistrement, on ne peut pas « sauter ».
use std::path::PathBuf;
use async_trait::async_trait;
use domain::conversation::ConversationId;
use domain::conversation_log::{Handoff, HandoffStore, TurnId};
use domain::ports::StoreError;
use domain::project::ProjectPath;
use super::{CONVERSATIONS_DIR, IDEAI_DIR};
/// Nom du fichier de handoff, par conversation.
const HANDOFF_FILE: &str = "handoff.md";
/// Nom du fichier temporaire d'écriture atomique (renommé sur `handoff.md`).
const HANDOFF_TMP_FILE: &str = "handoff.md.tmp";
/// Délimiteur de front-matter (en tête et fin de l'entête).
const FRONT_MATTER_FENCE: &str = "---";
/// Adapter `tokio::fs` du store de handoff, un `handoff.md` par conversation.
///
/// Même convention de construction que [`super::FsConversationLog`] : le **project
/// root** est fourni au constructeur, la base `<root>/.ideai/conversations` en dérive,
/// et chaque conversation a son sous-dossier `<conversationId>/`.
pub struct FsHandoffStore {
/// Racine `<project_root>/.ideai/conversations`.
base: PathBuf,
}
impl FsHandoffStore {
/// Construit l'adapter à partir du **project root**.
///
/// La base `<root>/.ideai/conversations` en est dérivée ; le dossier de
/// conversation est créé paresseusement au premier `save`.
#[must_use]
pub fn new(root: &ProjectPath) -> Self {
let base = PathBuf::from(root.as_str())
.join(IDEAI_DIR)
.join(CONVERSATIONS_DIR);
Self { base }
}
/// `<base>/<conversationId>` — le dossier d'une conversation.
fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
self.base.join(conversation.to_string())
}
/// `<base>/<conversationId>/handoff.md` — le fichier de handoff d'une conversation.
fn handoff_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation).join(HANDOFF_FILE)
}
/// `<base>/<conversationId>/handoff.md.tmp` — le fichier temporaire d'écriture.
fn handoff_tmp_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation).join(HANDOFF_TMP_FILE)
}
}
/// Sérialise un [`Handoff`] au format `handoff.md` (front-matter + corps exact).
fn serialize(handoff: &Handoff) -> String {
let mut out = String::new();
out.push_str(FRONT_MATTER_FENCE);
out.push('\n');
out.push_str(&format!("upTo: {}\n", handoff.up_to));
if let Some(objective) = &handoff.objective {
out.push_str(&format!("objective: {objective}\n"));
}
out.push_str(FRONT_MATTER_FENCE);
out.push('\n');
// Corps : le summary_md tel quel, octet pour octet.
out.push_str(&handoff.summary_md);
out
}
/// Parse le contenu d'un `handoff.md` en [`Handoff`].
///
/// Format attendu : `---\n<clé: valeur>*\n---\n<summary_md>`. Le corps après le second
/// `---\n` est rendu **exact**. Toute déviation (front-matter absent/incomplet, `upTo`
/// manquant ou non-UUID) ⇒ `Err(StoreError::Serialization)`.
fn deserialize(content: &str) -> Result<Handoff, StoreError> {
let bad = |msg: &str| StoreError::Serialization(format!("handoff.md: {msg}"));
// Entête : `---\n` exactement en tête.
let after_open = content
.strip_prefix(FRONT_MATTER_FENCE)
.and_then(|rest| rest.strip_prefix('\n'))
.ok_or_else(|| bad("front-matter ouvrant `---` absent"))?;
// Fin de l'entête : la première ligne `---\n` (ou `---` en toute fin).
// On sépare les lignes du front-matter du corps.
let mut up_to: Option<TurnId> = None;
let mut objective: Option<String> = None;
// Trouver le `---` de fermeture, ligne par ligne.
let mut rest = after_open;
loop {
// Découpe la prochaine ligne (jusqu'au `\n` inclus si présent).
let (line, tail) = match rest.find('\n') {
Some(idx) => (&rest[..idx], &rest[idx + 1..]),
None => (rest, ""),
};
if line == FRONT_MATTER_FENCE {
// Fermeture trouvée : `tail` est le corps exact.
let up_to = up_to.ok_or_else(|| bad("clé `upTo` absente du front-matter"))?;
return Ok(Handoff {
summary_md: tail.to_string(),
up_to,
objective,
});
}
// Une ligne de clé: valeur dans le front-matter.
let (key, value) = line
.split_once(':')
.ok_or_else(|| bad("ligne de front-matter sans `:`"))?;
let value = value.trim();
match key.trim() {
"upTo" => {
let uuid = uuid::Uuid::parse_str(value)
.map_err(|_| bad("`upTo` n'est pas un UUID valide"))?;
up_to = Some(TurnId::from_uuid(uuid));
}
"objective" => objective = Some(value.to_string()),
// Clé inconnue : tolérée (extensible), ignorée.
_ => {}
}
if tail.is_empty() {
// Plus de lignes et toujours pas de `---` fermant.
return Err(bad("front-matter fermant `---` absent"));
}
rest = tail;
}
}
#[async_trait]
impl HandoffStore for FsHandoffStore {
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
let content = match tokio::fs::read_to_string(self.handoff_path(conversation)).await {
Ok(content) => content,
// Absent ⇒ pas de reprise (jamais une erreur).
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(StoreError::Io(e.to_string())),
};
deserialize(&content).map(Some)
}
async fn save(
&self,
conversation: ConversationId,
handoff: Handoff,
) -> Result<(), StoreError> {
let body = serialize(&handoff);
let dir = self.conversation_dir(conversation);
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
// Écriture atomique : écrire le tmp puis `rename` sur la cible. Un lecteur ne
// voit jamais de fichier à moitié écrit (le rename est atomique sur le FS).
let tmp = self.handoff_tmp_path(conversation);
tokio::fs::write(&tmp, body.as_bytes())
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
tokio::fs::rename(&tmp, self.handoff_path(conversation))
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
Ok(())
}
}

View File

@ -0,0 +1,209 @@
//! [`FsConversationLog`] — l'adapter `tokio::fs` du port [`ConversationLog`]
//! (cadrage « persistance conversationnelle », lot P2).
//!
//! La **source de vérité durable** d'une conversation (ARCHITECTURE §19, D19-1a)
//! est un log **append-only**, **un fichier JSONL par conversation (paire)** sous
//! le project root :
//!
//! ```text
//! <project_root>/.ideai/conversations/
//! └── <conversationId>/
//! └── log.jsonl # un ConversationTurn JSON par ligne, dans l'ordre d'ajout
//! ```
//!
//! Chaque ligne est un [`ConversationTurn`] sérialisé en JSON (`serde_json`), suivi
//! d'un `\n`. Deux conversations sont **disjointes** : chacune a son propre dossier.
//!
//! ## Robustesse (survivre à un crash)
//!
//! Le log doit survivre à une **ligne tronquée** par un crash en plein milieu d'une
//! écriture : à la relecture, une ligne **illisible/corrompue est silencieusement
//! ignorée** (jamais de panic, jamais d'erreur dure). Un fichier **absent** est une
//! conversation vide (un `Vec` vide, pas une erreur). Seules les vraies erreurs d'I/O
//! (hors « absent », hors « ligne corrompue ») remontent en [`StoreError::Io`].
//!
//! ## Concurrence
//!
//! L'écriture est **sérialisée par conversation** par un `Mutex` async dédié à
//! chaque fichier (registre `paths → Arc<tokio::sync::Mutex<()>>`), tenu le temps
//! de l'`append`. Deux `append` sur des conversations **différentes** n'entrent
//! jamais en contention sur la donnée (verrous distincts) ; ils ne se croisent que
//! brièvement sur le registre. (P9 ajoutera un `FileGuard` plus large si un besoin
//! réel d'arbitrage lecture/écriture émerge — ici on ne sur-conçoit pas.)
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use tokio::io::AsyncWriteExt;
use domain::conversation::ConversationId;
use domain::conversation_log::{ConversationLog, ConversationTurn, TurnId};
use domain::ports::StoreError;
use domain::project::ProjectPath;
mod handoff;
mod summarizer;
pub use handoff::FsHandoffStore;
pub use summarizer::{HeuristicHandoffSummarizer, WINDOW};
/// Dossier `.ideai/` à la racine d'un project root.
pub(crate) const IDEAI_DIR: &str = ".ideai";
/// Sous-dossier des logs de conversation dans `.ideai/`.
pub(crate) const CONVERSATIONS_DIR: &str = "conversations";
/// Nom du fichier log JSONL, par conversation.
const LOG_FILE: &str = "log.jsonl";
/// Adapter `tokio::fs` du log canonique append-only, un `log.jsonl` par conversation.
///
/// Le **project root** est fourni au constructeur (comme [`crate::FsProjectStore`] et
/// l'orchestrateur fichier reçoivent leur racine) : une instance sert toutes les
/// conversations d'un même projet. Tous les chemins en dérivent.
pub struct FsConversationLog {
/// Racine `<project_root>/.ideai/conversations`.
base: PathBuf,
/// Verrous d'écriture, un par fichier de conversation (sérialise les `append`).
write_locks: Mutex<HashMap<ConversationId, Arc<tokio::sync::Mutex<()>>>>,
}
impl FsConversationLog {
/// Construit l'adapter à partir du **project root**.
///
/// La base `<root>/.ideai/conversations` en est dérivée ; les dossiers de
/// conversation sont créés paresseusement au premier `append`.
#[must_use]
pub fn new(root: &ProjectPath) -> Self {
let base = PathBuf::from(root.as_str())
.join(IDEAI_DIR)
.join(CONVERSATIONS_DIR);
Self {
base,
write_locks: Mutex::new(HashMap::new()),
}
}
/// `<base>/<conversationId>` — le dossier d'une conversation.
fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
self.base.join(conversation.to_string())
}
/// `<base>/<conversationId>/log.jsonl` — le fichier log d'une conversation.
fn log_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation).join(LOG_FILE)
}
/// Renvoie (en le créant au besoin) le verrou d'écriture de `conversation`.
fn write_lock(&self, conversation: ConversationId) -> Arc<tokio::sync::Mutex<()>> {
self.write_locks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(conversation)
.or_default()
.clone()
}
/// Lit et parse tout le fil de `conversation`, dans l'ordre d'ajout.
///
/// Fichier absent ⇒ `Vec` vide. Une ligne illisible (UTF-8 invalide ou JSON
/// corrompu) est **silencieusement ignorée** : le log survit à une ligne tronquée
/// par un crash. Seule une vraie erreur d'I/O remonte.
async fn read_all(
&self,
conversation: ConversationId,
) -> Result<Vec<ConversationTurn>, StoreError> {
let bytes = match tokio::fs::read(self.log_path(conversation)).await {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(StoreError::Io(e.to_string())),
};
// UTF-8 partiel (ex. fichier tronqué) : on décode en lossy plutôt que d'échouer ;
// une ligne devenue invalide ne parsera simplement pas en JSON et sera ignorée.
let text = String::from_utf8_lossy(&bytes);
let turns = text
.lines()
.filter(|line| !line.trim().is_empty())
// Ligne corrompue/illisible ⇒ skip silencieux (jamais d'erreur dure).
.filter_map(|line| serde_json::from_str::<ConversationTurn>(line).ok())
.collect();
Ok(turns)
}
}
#[async_trait]
impl ConversationLog for FsConversationLog {
async fn append(
&self,
conversation: ConversationId,
turn: ConversationTurn,
) -> Result<(), StoreError> {
// Sérialiser **avant** d'ouvrir le fichier : une erreur de sérialisation ne doit
// pas laisser le fichier ouvert ni écrire de ligne partielle.
let mut line =
serde_json::to_string(&turn).map_err(|e| StoreError::Serialization(e.to_string()))?;
line.push('\n');
// Écriture sérialisée par conversation : le verrou est tenu le temps de
// create_dir_all + open(append) + write, donc deux `append` concurrents sur la
// même conversation s'ordonnent (pas d'entrelacement de lignes).
let lock = self.write_lock(conversation);
let _guard = lock.lock().await;
let dir = self.conversation_dir(conversation);
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.log_path(conversation))
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
file.write_all(line.as_bytes())
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
// Le `File` async de tokio met l'écriture en file vers une tâche blocante ;
// droppé sans flush, l'écriture en vol du dernier `append` peut être jetée.
// `sync_all` force le drainage **et** la durabilité crash promise par l'en-tête
// du module (survivre à un crash en plein milieu d'une écriture).
file.sync_all()
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
Ok(())
}
async fn read(
&self,
conversation: ConversationId,
since: Option<TurnId>,
) -> Result<Vec<ConversationTurn>, StoreError> {
let all = self.read_all(conversation).await?;
let out = match since {
None => all,
// Curseur **exclusif** : tout ce qui suit strictement le tour `cursor`.
// Curseur introuvable ⇒ rien (cohérent avec le double in-memory du port).
Some(cursor) => match all.iter().position(|t| t.id == cursor) {
Some(idx) => all[idx + 1..].to_vec(),
None => Vec::new(),
},
};
Ok(out)
}
async fn last(
&self,
conversation: ConversationId,
n: usize,
) -> Result<Vec<ConversationTurn>, StoreError> {
if n == 0 {
return Ok(Vec::new());
}
let all = self.read_all(conversation).await?;
let start = all.len().saturating_sub(n);
Ok(all[start..].to_vec())
}
}

View File

@ -0,0 +1,166 @@
//! [`HeuristicHandoffSummarizer`] — l'adapter zéro-dépendance du port
//! [`HandoffSummarizer`] (cadrage « persistance conversationnelle », lot P4).
//!
//! Replie un [`Handoff`] de façon **incrémentale**, **déterministe**, **sans I/O** et
//! **sans modèle** (ARCHITECTURE §19.2/§19.3, ligne P4 du §19.6). C'est le repli
//! universel zéro-dépendance ; un `LlmHandoffSummarizer` plus riche viendra le substituer
//! en P10 (OCP — le port async est figé pour ça, cf. [`HandoffSummarizer`]).
//!
//! ## Stratégie heuristique
//!
//! Le résumé garde deux choses, en ne lisant que **l'incrément** (`new_turns`) — jamais
//! tout le fil depuis zéro :
//!
//! 1. **Un objectif courant** ([`Handoff::objective`]) : repris **tel quel** de `prev`.
//! Si `prev` n'en a pas (ou est `None`), on en **extrait** un depuis le **premier
//! tour `Prompt`** de l'incrément (sa première ligne non vide, tronquée) — règle simple
//! et déterministe : le premier prompt d'un fil énonce typiquement la tâche. Une fois
//! fixé, l'objectif ne change plus (on ne le réécrit pas à chaque tour).
//!
//! 2. **Une fenêtre des [`WINDOW`] derniers tours** rendue en Markdown. Comme on ne
//! dispose pas de tout le fil dans `fold`, on **reconstitue** cette fenêtre à partir des
//! tours déjà rendus dans `prev.summary_md` (reparsés) **concaténés** à `new_turns`,
//! puis on **tronque** aux [`WINDOW`] derniers. La borne est donc toujours respectée,
//! même après de nombreux replis successifs.
//!
//! Le `summary_md` résultant = (ligne d'objectif si présent) + les [`WINDOW`] derniers
//! tours formatés. Format d'un tour stable et reparsable (cf. [`render_turn`]).
//!
//! ## Curseur (`up_to`)
//!
//! - `new_turns` non vide ⇒ l'id du **dernier** tour de l'incrément.
//! - `new_turns` vide ⇒ on renvoie `prev` **inchangé** (rien de neuf à intégrer).
//! - `prev = None` et `new_turns` vide ⇒ handoff vide, curseur = **nil UUID**
//! ([`TurnId::from_uuid(Uuid::nil())`]) : sentinelle sûre « aucun tour couvert ».
//!
//! ## Déterminisme
//!
//! Mêmes entrées ⇒ même sortie (aucune horloge, aucun aléa, aucune I/O), donc trivialement
//! testable.
use async_trait::async_trait;
use domain::conversation_log::{ConversationTurn, Handoff, HandoffSummarizer, TurnId, TurnRole};
/// Nombre maximum de tours conservés dans la fenêtre Markdown du résumé.
///
/// Borne **publique** (et donc lisible par l'agent Test pour vérifier la troncature sans
/// la deviner) : au-delà de `WINDOW` tours, seuls les `WINDOW` derniers sont rendus.
pub const WINDOW: usize = 20;
/// Longueur maximale (en caractères) de l'objectif extrait d'un premier prompt.
const OBJECTIVE_MAX_CHARS: usize = 200;
/// Préfixe de la ligne d'objectif dans le `summary_md` (sert au rendu **et** au reparse).
const OBJECTIVE_PREFIX: &str = "**Objectif :** ";
/// Adapter heuristique zéro-dépendance du résumeur de handoff.
///
/// Sans état (aucun champ) : une seule instance sert toutes les conversations. Construit
/// via [`HeuristicHandoffSummarizer::new`] ou [`Default`].
#[derive(Debug, Default, Clone, Copy)]
pub struct HeuristicHandoffSummarizer;
impl HeuristicHandoffSummarizer {
/// Construit le résumeur heuristique (sans état).
#[must_use]
pub const fn new() -> Self {
Self
}
}
/// Rend un tour en une ligne Markdown stable et **reparsable** (cf. [`parse_rendered`]).
///
/// Format : `- **<role>:** <texte sur une ligne>`. Le texte est aplati (sauts de ligne →
/// espaces) pour garder une ligne par tour, ce qui rend la fenêtre reconstituable depuis
/// `prev.summary_md`.
fn render_turn(turn: &ConversationTurn) -> String {
let label = match turn.role {
TurnRole::Prompt => "Prompt",
TurnRole::Response => "Response",
TurnRole::ToolActivity => "Tool",
};
let flat = flatten(&turn.text);
format!("- **{label}:** {flat}")
}
/// Aplati un texte multi-lignes en une seule ligne (sauts de ligne → espace, trim).
fn flatten(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// 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
/// les blancs. On garde la **ligne brute** (déjà au bon format) : pas besoin de
/// reconstruire un `ConversationTurn` complet, on ne manipule que du Markdown.
fn parse_rendered(summary_md: &str) -> Vec<String> {
summary_md
.lines()
.filter(|l| l.starts_with("- **"))
.map(str::to_owned)
.collect()
}
/// Extrait un objectif candidat du premier tour `Prompt` de l'incrément, le cas échéant.
///
/// Première ligne non vide du premier prompt, aplatie et tronquée à [`OBJECTIVE_MAX_CHARS`].
fn extract_objective(new_turns: &[ConversationTurn]) -> Option<String> {
let first_prompt = new_turns.iter().find(|t| t.role == TurnRole::Prompt)?;
let flat = flatten(&first_prompt.text);
if flat.is_empty() {
return None;
}
let truncated: String = flat.chars().take(OBJECTIVE_MAX_CHARS).collect();
Some(truncated)
}
/// Assemble le `summary_md` final = ligne d'objectif (si présent) + fenêtre de tours.
fn render_summary(objective: Option<&str>, window: &[String]) -> String {
let mut blocks: Vec<String> = Vec::new();
if let Some(obj) = objective {
blocks.push(format!("{OBJECTIVE_PREFIX}{obj}"));
}
if !window.is_empty() {
blocks.push(window.join("\n"));
}
blocks.join("\n\n")
}
#[async_trait]
impl HandoffSummarizer for HeuristicHandoffSummarizer {
async fn fold(&self, prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
// Rien de neuf : on rend `prev` inchangé (ou un handoff vide cohérent si None).
if new_turns.is_empty() {
return prev.unwrap_or_else(|| {
Handoff::new(String::new(), TurnId::from_uuid(uuid::Uuid::nil()), None)
});
}
// Objectif : repris de `prev` s'il existe, sinon extrait du premier prompt de
// l'incrément (règle simple et déterministe ; figé une fois fixé).
let objective = prev
.as_ref()
.and_then(|h| h.objective.clone())
.or_else(|| extract_objective(new_turns));
// Fenêtre : (tours déjà rendus dans prev) ++ (incrément rendu), tronquée aux
// WINDOW derniers. On ne relit jamais tout le log : seul l'incrément est nouveau.
let mut window: Vec<String> = prev
.as_ref()
.map(|h| parse_rendered(&h.summary_md))
.unwrap_or_default();
window.extend(new_turns.iter().map(render_turn));
let start = window.len().saturating_sub(WINDOW);
let window = &window[start..];
// Curseur : l'id du dernier tour de l'incrément (new_turns non vide ici).
let up_to = new_turns
.last()
.map(|t| t.id)
.unwrap_or_else(|| TurnId::from_uuid(uuid::Uuid::nil()));
let summary_md = render_summary(objective.as_deref(), window);
Handoff::new(summary_md, up_to, objective)
}
}

View File

@ -14,6 +14,7 @@
pub mod clock;
pub mod conversation;
pub mod conversation_log;
pub mod eventbus;
pub mod fileguard;
pub mod fs;
@ -32,6 +33,7 @@ pub mod store;
pub use clock::SystemClock;
pub use conversation::InMemoryConversationRegistry;
pub use conversation_log::{FsConversationLog, FsHandoffStore, HeuristicHandoffSummarizer, WINDOW};
pub use eventbus::TokioBroadcastEventBus;
pub use fileguard::RwFileGuard;
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};

View File

@ -0,0 +1,763 @@
//! L2 integration tests for [`FsConversationLog`] against a **real** temp directory
//! (cadrage « persistance conversationnelle », lot P2, critères §19.6).
//!
//! These lock the *durable* behaviour the in-memory double of P1 cannot prove:
//! - one JSONL line per appended turn, each line valid JSON;
//! - persistence survives a "restart" (a fresh instance on the same root relits all);
//! - two conversations land in two disjoint `log.jsonl` files (no leak);
//! - a corrupted/truncated line is silently skipped (no panic, no hard error);
//! - missing file/conversation => empty (never an error);
//! - the cursor/`last` contract (exclusive `since`, `last(0)`, `n>len`, `n<len`);
//! - two concurrent appends on the same conversation => 2 lines, no corruption.
//!
//! Convention: a hand-rolled [`TempDir`] over the OS temp dir (calqué sur
//! `project_store.rs`) — it yields an **absolute** path, as `ProjectPath::new`
//! requires, and is cleaned up on drop. No extra dependency is pulled in.
use std::path::PathBuf;
use domain::conversation::ConversationId;
use domain::conversation_log::{
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, TurnId, TurnRole,
};
use domain::input::InputSource;
use domain::ports::StoreError;
use domain::project::ProjectPath;
use infrastructure::{FsConversationLog, FsHandoffStore, HeuristicHandoffSummarizer, WINDOW};
use uuid::Uuid;
// ---------------------------------------------------------------------------
// Test scaffolding
// ---------------------------------------------------------------------------
/// A unique scratch directory under the OS temp dir, cleaned up on drop. Its path
/// is absolute, so `ProjectPath::new` accepts it directly as a project root.
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-l2-convlog-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
/// The project root as a [`ProjectPath`] (absolute, as the composition root passes).
fn project_path(&self) -> ProjectPath {
ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap()
}
/// `<root>/.ideai/conversations/<conversationId>/log.jsonl` — the raw log file.
fn log_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation).join("log.jsonl")
}
/// `<root>/.ideai/conversations/<conversationId>/` — a conversation's dir.
fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
self.0
.join(".ideai")
.join("conversations")
.join(conversation.to_string())
}
/// `<root>/.ideai/conversations/<conversationId>/handoff.md` — the handoff file (P3).
fn handoff_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation).join("handoff.md")
}
/// `<root>/.ideai/conversations/<conversationId>/handoff.md.tmp` — the atomic-write tmp.
fn handoff_tmp_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation).join("handoff.md.tmp")
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
// Deterministic constructors (calqués sur les tests P1 de conversation_log.rs).
fn conv_id(n: u128) -> ConversationId {
ConversationId::from_uuid(Uuid::from_u128(n))
}
fn turn_id(n: u128) -> TurnId {
TurnId::from_uuid(Uuid::from_u128(n))
}
fn turn(conv: ConversationId, id: TurnId, role: TurnRole, text: &str) -> ConversationTurn {
ConversationTurn::new(id, conv, 1_000, InputSource::Human, role, text)
}
fn texts(turns: &[ConversationTurn]) -> Vec<String> {
turns.iter().map(|t| t.text.clone()).collect()
}
// ---------------------------------------------------------------------------
// append persistence — one JSONL line per turn, each line valid JSON
// ---------------------------------------------------------------------------
#[tokio::test]
async fn append_writes_one_valid_json_line_per_turn() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "a"))
.await
.unwrap();
log.append(c, turn(c, turn_id(2), TurnRole::Response, "b"))
.await
.unwrap();
log.append(c, turn(c, turn_id(3), TurnRole::Prompt, "c"))
.await
.unwrap();
// Inspect the raw file: nb of non-empty lines == nb of appends, each valid JSON.
let raw = std::fs::read_to_string(tmp.log_path(c)).unwrap();
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 3, "one line per append, got: {raw:?}");
for line in &lines {
let parsed: ConversationTurn =
serde_json::from_str(line).unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"));
// camelCase shape leaks through to the file (sanity on the persisted format).
assert!(line.contains("\"atMs\""), "expected camelCase atMs in {line:?}");
let _ = parsed;
}
}
// ---------------------------------------------------------------------------
// survives a "restart" — a fresh instance on the same root relits everything
// ---------------------------------------------------------------------------
#[tokio::test]
async fn persists_across_a_fresh_instance_restart() {
let tmp = TempDir::new();
let c = conv_id(7);
// First instance appends three turns, then is dropped.
{
let log = FsConversationLog::new(&tmp.project_path());
for (id, role, txt) in [
(1, TurnRole::Prompt, "a"),
(2, TurnRole::Response, "b"),
(3, TurnRole::Prompt, "c"),
] {
log.append(c, turn(c, turn_id(id), role, txt)).await.unwrap();
}
}
// A brand-new instance on the same root reads it all back (no in-memory cache).
let reborn = FsConversationLog::new(&tmp.project_path());
let all = reborn.read(c, None).await.unwrap();
assert_eq!(texts(&all), vec!["a", "b", "c"]);
assert_eq!(texts(&reborn.last(c, 2).await.unwrap()), vec!["b", "c"]);
}
// ---------------------------------------------------------------------------
// disjoint conversations => disjoint files, no leak
// ---------------------------------------------------------------------------
#[tokio::test]
async fn conversations_land_in_disjoint_files() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c1 = conv_id(1);
let c2 = conv_id(2);
log.append(c1, turn(c1, turn_id(1), TurnRole::Prompt, "c1-a"))
.await
.unwrap();
log.append(c2, turn(c2, turn_id(2), TurnRole::Prompt, "c2-a"))
.await
.unwrap();
log.append(c1, turn(c1, turn_id(3), TurnRole::Response, "c1-b"))
.await
.unwrap();
// Two distinct files exist on disk.
let p1 = tmp.log_path(c1);
let p2 = tmp.log_path(c2);
assert!(p1.exists(), "c1 log file must exist");
assert!(p2.exists(), "c2 log file must exist");
assert_ne!(p1, p2, "the two conversations must use distinct files");
// No cross-leak through the API, and the c2 file holds only c2's single line.
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
let c2_raw = std::fs::read_to_string(&p2).unwrap();
assert_eq!(
c2_raw.lines().filter(|l| !l.trim().is_empty()).count(),
1,
"c2 file must not contain c1's turns"
);
assert!(!c2_raw.contains("c1-"), "no c1 content leaked into c2's file");
}
// ---------------------------------------------------------------------------
// robustness — a corrupted/truncated line is silently skipped
// ---------------------------------------------------------------------------
#[tokio::test]
async fn corrupted_line_is_skipped_without_panic() {
let tmp = TempDir::new();
let c = conv_id(5);
// Append two real turns first (creates the dir + file).
{
let log = FsConversationLog::new(&tmp.project_path());
log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "good-1"))
.await
.unwrap();
log.append(c, turn(c, turn_id(2), TurnRole::Response, "good-2"))
.await
.unwrap();
}
// Hand-write a garbage (truncated) line directly into the JSONL, as a crash mid-write would.
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(tmp.log_path(c))
.unwrap();
writeln!(f, "{{tronqué").unwrap();
}
// A fresh instance must skip the junk line and return only the two valid turns.
let log = FsConversationLog::new(&tmp.project_path());
let all = log.read(c, None).await.unwrap();
assert_eq!(texts(&all), vec!["good-1", "good-2"], "garbage line skipped");
// `last` must be just as tolerant.
assert_eq!(texts(&log.last(c, 5).await.unwrap()), vec!["good-1", "good-2"]);
// And the cursor still works across the corrupted tail.
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["good-2"]);
}
#[tokio::test]
async fn good_line_appended_after_corruption_is_still_read() {
let tmp = TempDir::new();
let c = conv_id(6);
let log = FsConversationLog::new(&tmp.project_path());
log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "before"))
.await
.unwrap();
// Inject a junk line between two good appends.
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(tmp.log_path(c))
.unwrap();
writeln!(f, "not json at all }}}}").unwrap();
}
log.append(c, turn(c, turn_id(2), TurnRole::Response, "after"))
.await
.unwrap();
let all = log.read(c, None).await.unwrap();
assert_eq!(texts(&all), vec!["before", "after"], "junk in the middle is skipped, both reals survive");
}
// ---------------------------------------------------------------------------
// missing file / conversation => empty (never an error)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn missing_conversation_reads_empty() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
// Nothing was ever appended: no .ideai dir at all.
assert!(log.read(conv_id(99), None).await.unwrap().is_empty());
assert!(log.read(conv_id(99), Some(turn_id(1))).await.unwrap().is_empty());
assert!(log.last(conv_id(99), 5).await.unwrap().is_empty());
}
// ---------------------------------------------------------------------------
// cursor / last contract (mirrors the P1 port contract, now over the Fs adapter)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn read_cursor_is_strictly_exclusive() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
for (id, txt) in [(1, "a"), (2, "b"), (3, "c")] {
log.append(c, turn(c, turn_id(id), TurnRole::Prompt, txt))
.await
.unwrap();
}
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["b", "c"]);
assert_eq!(texts(&log.read(c, Some(turn_id(2))).await.unwrap()), vec!["c"]);
// Cursor on the last id => nothing after.
assert!(log.read(c, Some(turn_id(3))).await.unwrap().is_empty());
// Unknown cursor => empty (cohérent avec le double in-memory de P1).
assert!(log.read(c, Some(turn_id(999))).await.unwrap().is_empty());
}
#[tokio::test]
async fn last_contract_zero_and_bounds() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
for (id, txt) in [(1, "a"), (2, "b"), (3, "c"), (4, "d")] {
log.append(c, turn(c, turn_id(id), TurnRole::Prompt, txt))
.await
.unwrap();
}
// last(0) => empty.
assert!(log.last(c, 0).await.unwrap().is_empty());
// last(n < len) => the n last, in insertion order.
assert_eq!(texts(&log.last(c, 2).await.unwrap()), vec!["c", "d"]);
// last(n > len) => everything.
assert_eq!(texts(&log.last(c, 10).await.unwrap()), vec!["a", "b", "c", "d"]);
// last(n == len) => everything.
assert_eq!(texts(&log.last(c, 4).await.unwrap()), vec!["a", "b", "c", "d"]);
}
// ---------------------------------------------------------------------------
// concurrency — two concurrent appends => 2 intact lines, no corruption
// ---------------------------------------------------------------------------
#[tokio::test]
async fn concurrent_appends_on_same_conversation_keep_both_lines_intact() {
use std::sync::Arc;
let tmp = TempDir::new();
let log = Arc::new(FsConversationLog::new(&tmp.project_path()));
let c = conv_id(42);
let l1 = Arc::clone(&log);
let l2 = Arc::clone(&log);
let h1 = tokio::spawn(async move {
l1.append(c, turn(c, turn_id(1), TurnRole::Prompt, "first"))
.await
.unwrap();
});
let h2 = tokio::spawn(async move {
l2.append(c, turn(c, turn_id(2), TurnRole::Response, "second"))
.await
.unwrap();
});
h1.await.unwrap();
h2.await.unwrap();
// Exactly two non-empty lines, each a fully-parseable turn (no interleaving).
let raw = std::fs::read_to_string(tmp.log_path(c)).unwrap();
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 2, "two concurrent appends => two lines, got: {raw:?}");
for line in &lines {
serde_json::from_str::<ConversationTurn>(line)
.unwrap_or_else(|e| panic!("interleaved/corrupted line {line:?}: {e}"));
}
// Both turns are present (order between the two is unspecified under a race).
let mut got = texts(&log.read(c, None).await.unwrap());
got.sort();
assert_eq!(got, vec!["first".to_string(), "second".to_string()]);
}
// ===========================================================================
// P3 — FsHandoffStore (handoff.md): le point de reprise, un par conversation.
//
// Choix d'emplacement : ces cas vivent dans CE fichier (et non un frère) car ils
// réutilisent à l'identique le scaffolding L2 de P2 — `TempDir` maison (chemin
// absolu pour `ProjectPath::new`), constructeurs déterministes `conv_id`/`turn_id`,
// et la même convention de dossier `<root>/.ideai/conversations/<id>/`. Tout regrouper
// garde la cartographie « persistance conversationnelle » au même endroit.
// ===========================================================================
/// Écrit un `handoff.md` brut (corruption volontaire), créant le dossier au besoin.
fn write_raw_handoff(tmp: &TempDir, c: ConversationId, content: &str) {
std::fs::create_dir_all(tmp.conversation_dir(c)).unwrap();
std::fs::write(tmp.handoff_path(c), content).unwrap();
}
// ---------------------------------------------------------------------------
// round-trip exact — summary_md multi-ligne + objective: Some(...)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn handoff_round_trip_multiline_summary_with_objective() {
let tmp = TempDir::new();
let store = FsHandoffStore::new(&tmp.project_path());
let c = conv_id(1);
// summary_md délibérément multi-ligne, avec un `---` interne et des espaces de fin
// pour éprouver la fidélité octet-pour-octet du corps.
let summary = "# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n";
let handoff = Handoff::new(summary, turn_id(42), Some("livrer le lot P3".to_string()));
store.save(c, handoff.clone()).await.unwrap();
let loaded = store.load(c).await.unwrap();
assert_eq!(loaded, Some(handoff.clone()), "round-trip doit redonner le Handoff exact");
let loaded = loaded.unwrap();
assert_eq!(loaded.summary_md, summary, "summary_md conservé octet pour octet");
assert_eq!(loaded.up_to, turn_id(42), "up_to conservé à l'identique");
assert_eq!(loaded.objective.as_deref(), Some("livrer le lot P3"));
}
// ---------------------------------------------------------------------------
// round-trip exact — objective: None
// ---------------------------------------------------------------------------
#[tokio::test]
async fn handoff_round_trip_without_objective() {
let tmp = TempDir::new();
let store = FsHandoffStore::new(&tmp.project_path());
let c = conv_id(2);
let summary = "résumé simple\nsur deux lignes\n";
let handoff = Handoff::new(summary, turn_id(7), None);
store.save(c, handoff.clone()).await.unwrap();
let loaded = store.load(c).await.unwrap();
assert_eq!(loaded, Some(handoff), "round-trip exact sans objective");
let loaded = store.load(c).await.unwrap().unwrap();
assert_eq!(loaded.objective, None, "objective reste None");
assert_eq!(loaded.summary_md, summary);
assert_eq!(loaded.up_to, turn_id(7));
// Sanity sur le format brut : pas de ligne `objective:` quand None.
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
assert!(!raw.contains("objective:"), "aucune clé objective ne doit être écrite, got: {raw:?}");
assert!(raw.contains("upTo: "), "upTo toujours présent, got: {raw:?}");
}
// ---------------------------------------------------------------------------
// absent ⇒ Ok(None)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn handoff_absent_loads_none() {
let tmp = TempDir::new();
let store = FsHandoffStore::new(&tmp.project_path());
// Aucune conversation jamais écrite : pas de dossier .ideai du tout.
assert_eq!(store.load(conv_id(123)).await.unwrap(), None);
}
// ---------------------------------------------------------------------------
// écriture atomique — pas de .tmp résiduel, fichier final complet
// ---------------------------------------------------------------------------
#[tokio::test]
async fn handoff_save_is_atomic_no_tmp_left_behind() {
let tmp = TempDir::new();
let store = FsHandoffStore::new(&tmp.project_path());
let c = conv_id(3);
let handoff = Handoff::new("corps complet\n", turn_id(9), Some("but".to_string()));
store.save(c, handoff.clone()).await.unwrap();
// Aucun handoff.md.tmp résiduel après save.
assert!(
!tmp.handoff_tmp_path(c).exists(),
"le fichier temporaire handoff.md.tmp ne doit pas subsister après save"
);
// Le fichier final existe et est complet/relisible.
assert!(tmp.handoff_path(c).exists(), "handoff.md final doit exister");
assert_eq!(store.load(c).await.unwrap(), Some(handoff), "contenu final lisible et complet");
}
// ---------------------------------------------------------------------------
// overwrite — deux save successifs => load = le dernier (pas d'accumulation)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn handoff_overwrite_keeps_only_the_last() {
let tmp = TempDir::new();
let store = FsHandoffStore::new(&tmp.project_path());
let c = conv_id(4);
let first = Handoff::new("première version\n", turn_id(1), Some("obj-1".to_string()));
let second = Handoff::new("deuxième version\nplus longue\n", turn_id(2), None);
store.save(c, first).await.unwrap();
store.save(c, second.clone()).await.unwrap();
// load renvoie le dernier, aucune trace du premier.
assert_eq!(store.load(c).await.unwrap(), Some(second), "load doit renvoyer le dernier save");
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
assert!(!raw.contains("première version"), "le contenu du premier save ne doit pas subsister");
assert!(!raw.contains("obj-1"), "l'objective du premier save ne doit pas subsister");
}
// ---------------------------------------------------------------------------
// isolation par conversationId — deux convs => deux handoff distincts
// ---------------------------------------------------------------------------
#[tokio::test]
async fn handoff_is_isolated_per_conversation() {
let tmp = TempDir::new();
let store = FsHandoffStore::new(&tmp.project_path());
let c1 = conv_id(10);
let c2 = conv_id(20);
let h1 = Handoff::new("résumé c1\n", turn_id(11), Some("obj-c1".to_string()));
let h2 = Handoff::new("résumé c2\n", turn_id(22), None);
store.save(c1, h1.clone()).await.unwrap();
store.save(c2, h2.clone()).await.unwrap();
// Chacune relit le sien, sans fuite.
assert_eq!(store.load(c1).await.unwrap(), Some(h1));
assert_eq!(store.load(c2).await.unwrap(), Some(h2));
// Deux fichiers distincts sur disque.
let p1 = tmp.handoff_path(c1);
let p2 = tmp.handoff_path(c2);
assert_ne!(p1, p2, "deux conversations => deux fichiers handoff distincts");
assert!(p1.exists() && p2.exists());
let raw2 = std::fs::read_to_string(&p2).unwrap();
assert!(!raw2.contains("résumé c1"), "aucune fuite de c1 dans le handoff de c2");
assert!(!raw2.contains("obj-c1"), "aucune fuite de l'objective de c1 dans c2");
}
// ---------------------------------------------------------------------------
// corruption ⇒ Err(Serialization), aucun panic — les 5 cas fournis
// ---------------------------------------------------------------------------
#[tokio::test]
async fn handoff_corruption_yields_serialization_error_no_panic() {
let tmp = TempDir::new();
let store = FsHandoffStore::new(&tmp.project_path());
// (1) pas de `---` ouvrant.
// (2) `---` fermant absent.
// (3) clé `upTo` absente.
// (4) `upTo` non-UUID.
// (5) ligne de front-matter sans `:`.
let cases: [(u128, &str, &str); 5] = [
(101, "pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n", "fence ouvrant absent"),
(102, "---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n", "fence fermant absent"),
(103, "---\nobjective: but\n---\ncorps\n", "upTo absent"),
(104, "---\nupTo: pas-un-uuid\n---\ncorps\n", "upTo non-UUID"),
(105, "---\nligne sans deux-points\n---\ncorps\n", "ligne front-matter sans `:`"),
];
for (n, content, label) in cases {
let c = conv_id(n);
write_raw_handoff(&tmp, c, content);
let result = store.load(c).await;
match result {
Err(StoreError::Serialization(msg)) => {
assert!(
msg.starts_with("handoff.md:"),
"cas «{label}» : message préfixé `handoff.md:` attendu, got: {msg:?}"
);
}
other => panic!("cas «{label}» : Err(Serialization) attendu, got: {other:?}"),
}
}
}
// ===========================================================================
// P4 — HeuristicHandoffSummarizer (port HandoffSummarizer, §19.6)
//
// Tests du repli incrémental, déterministe, sans I/O. Réutilise les helpers
// `conv_id` / `turn_id` / `turn` ci-dessus. Placés ici (cible infra) car l'impl
// `HeuristicHandoffSummarizer` est infra-side ; même module de test que P2/P3.
//
// Format reparsable verrouillé (cf. `summarizer.rs`) :
// - ligne d'objectif : `**Objectif :** <obj>`
// - une ligne par tour : `- **<Prompt|Response|Tool>:** <texte aplati>`
// ===========================================================================
/// Compte les lignes-tours (`- **…`) dans un `summary_md` rendu.
fn turn_lines(summary_md: &str) -> Vec<&str> {
summary_md.lines().filter(|l| l.starts_with("- **")).collect()
}
/// `fold(None, turns)` = calcul de base : tours rendus, curseur = dernier id,
/// objectif extrait du 1er Prompt.
#[tokio::test]
async fn fold_none_renders_base_window_objective_and_cursor() {
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
let turns = vec![
turn(c, turn_id(1), TurnRole::Prompt, "Implémente la feature X"),
turn(c, turn_id(2), TurnRole::ToolActivity, "ran grep"),
turn(c, turn_id(3), TurnRole::Response, "fait"),
];
let h = s.fold(None, &turns).await;
// Objectif extrait du 1er Prompt.
assert_eq!(h.objective.as_deref(), Some("Implémente la feature X"));
assert!(
h.summary_md.contains("**Objectif :** Implémente la feature X"),
"ligne d'objectif attendue, got:\n{}",
h.summary_md
);
// Curseur = dernier id de l'incrément.
assert_eq!(h.up_to, turn_id(3));
// Les 3 tours rendus, un par ligne, avec le bon label.
let lines = turn_lines(&h.summary_md);
assert_eq!(lines.len(), 3, "3 lignes-tours attendues, got:\n{}", h.summary_md);
assert_eq!(lines[0], "- **Prompt:** Implémente la feature X");
assert_eq!(lines[1], "- **Tool:** ran grep");
assert_eq!(lines[2], "- **Response:** fait");
}
/// Incrément seulement : `h2 = fold(Some(h1), [t6])` étend h1 (t1..t5) sans
/// re-fournir t1..t5 ; curseur avance à t6.
#[tokio::test]
async fn fold_is_incremental_does_not_re_pass_old_turns() {
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
let first = vec![
turn(c, turn_id(1), TurnRole::Prompt, "obj un"),
turn(c, turn_id(2), TurnRole::Response, "r2"),
turn(c, turn_id(3), TurnRole::Response, "r3"),
turn(c, turn_id(4), TurnRole::Response, "r4"),
turn(c, turn_id(5), TurnRole::Response, "r5"),
];
let h1 = s.fold(None, &first).await;
// Le 2e appel ne re-fournit QUE t6.
let h2 = s
.fold(Some(h1.clone()), &[turn(c, turn_id(6), TurnRole::Response, "r6")])
.await;
let lines = turn_lines(&h2.summary_md);
assert_eq!(lines.len(), 6, "t1..t6 reconstitués depuis prev + incrément");
assert_eq!(lines[0], "- **Prompt:** obj un");
assert_eq!(lines[5], "- **Response:** r6");
assert_eq!(h2.up_to, turn_id(6), "curseur = dernier de l'incrément");
// Aucune duplication de t5 (la borne de prev) — exactement une occurrence.
assert_eq!(h2.summary_md.matches("- **Response:** r5").count(), 1);
}
/// Borne WINDOW : folder > WINDOW tours ⇒ exactement WINDOW lignes, et ce sont
/// les DERNIERS (les plus anciens évincés).
#[tokio::test]
async fn fold_truncates_to_window_keeping_the_last() {
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
let total = WINDOW + 5; // 25 si WINDOW=20.
let mut turns = Vec::new();
// 1er tour = Prompt (pour avoir un objectif) ; le reste = Response numérotés.
turns.push(turn(c, turn_id(1), TurnRole::Prompt, "objectif global"));
for n in 2..=(total as u128) {
turns.push(turn(c, turn_id(n), TurnRole::Response, &format!("r{n}")));
}
let h = s.fold(None, &turns).await;
let lines = turn_lines(&h.summary_md);
assert_eq!(lines.len(), WINDOW, "exactement WINDOW lignes-tours");
// Les plus anciens (Prompt + premiers Response) sont évincés.
assert!(
!h.summary_md.contains("- **Prompt:** objectif global"),
"le 1er tour doit être évincé de la fenêtre"
);
assert!(!h.summary_md.contains("- **Response:** r2 "), "r2 évincé");
// Le tout dernier reste, en dernière ligne.
let last_n = total as u128;
assert_eq!(lines[WINDOW - 1], format!("- **Response:** r{last_n}"));
// La 1re ligne conservée = total - WINDOW + 1 (numérotation des tours).
let first_kept = last_n - WINDOW as u128 + 1;
assert_eq!(lines[0], format!("- **Response:** r{first_kept}"));
// L'objectif extrait du 1er Prompt survit même si ce Prompt sort de la fenêtre.
assert_eq!(h.objective.as_deref(), Some("objectif global"));
assert_eq!(h.up_to, turn_id(last_n));
}
/// Objectif figé : un `prev` avec objectif le conserve même si l'incrément a un
/// autre 1er Prompt.
#[tokio::test]
async fn fold_keeps_existing_objective_over_new_prompt() {
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
let prev = Handoff::new(
"**Objectif :** but initial".to_string(),
turn_id(1),
Some("but initial".to_string()),
);
let h = s
.fold(Some(prev), &[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")])
.await;
assert_eq!(h.objective.as_deref(), Some("but initial"), "objectif figé");
assert!(
h.summary_md.contains("**Objectif :** but initial")
&& !h.summary_md.contains("**Objectif :** un autre but"),
"l'objectif ne doit pas être réécrit, got:\n{}",
h.summary_md
);
}
/// `fold(None, [])` ⇒ handoff vide cohérent : summary vide, curseur nil, objectif None.
#[tokio::test]
async fn fold_none_empty_yields_empty_handoff() {
let s = HeuristicHandoffSummarizer::new();
let h = s.fold(None, &[]).await;
assert_eq!(h.summary_md, "");
assert_eq!(h.up_to, TurnId::from_uuid(Uuid::nil()));
assert_eq!(h.objective, None);
}
/// `fold(Some(h), [])` ⇒ `h` strictement inchangé.
#[tokio::test]
async fn fold_some_empty_returns_prev_unchanged() {
let s = HeuristicHandoffSummarizer::new();
let prev = Handoff::new(
"**Objectif :** but\n\n- **Prompt:** but".to_string(),
turn_id(7),
Some("but".to_string()),
);
let h = s.fold(Some(prev.clone()), &[]).await;
assert_eq!(h, prev, "rien de neuf ⇒ prev rendu tel quel");
}
/// Déterminisme : deux `fold` identiques ⇒ sorties strictement égales.
#[tokio::test]
async fn fold_is_deterministic() {
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
let turns = vec![
turn(c, turn_id(1), TurnRole::Prompt, "tâche"),
turn(c, turn_id(2), TurnRole::Response, "ok"),
];
let a = s.fold(None, &turns).await;
let b = s.fold(None, &turns).await;
assert_eq!(a, b);
}
/// Robustesse format : un tour dont le texte contient des sauts de ligne et la
/// séquence `- **` reste UNE seule ligne (collapse) et ne casse pas le reparse de
/// la fenêtre au repli suivant.
#[tokio::test]
async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
// Texte piégeux : retours à la ligne + une séquence ressemblant à un marqueur.
let nasty = "ligne une\n- **Prompt:** faux marqueur\nligne trois";
let turns = vec![
turn(c, turn_id(1), TurnRole::Prompt, "vrai objectif"),
turn(c, turn_id(2), TurnRole::Response, nasty),
];
let h1 = s.fold(None, &turns).await;
// Le tour piégeux est aplati en une seule ligne (whitespace collapsé).
let lines = turn_lines(&h1.summary_md);
assert_eq!(lines.len(), 2, "2 lignes-tours seulement, pas de ligne fantôme");
assert_eq!(
lines[1],
"- **Response:** ligne une - **Prompt:** faux marqueur ligne trois",
"texte multi-lignes + marqueur aplati en une ligne"
);
// Sonde de cohérence de fenêtre : un repli incrémental reparse proprement.
// NOTE de fragilité (rapportée à Main) : la ligne aplatie contient toujours la
// sous-chaîne `- **Prompt:**`, MAIS comme elle ne COMMENCE pas par `- **`
// (préfixée par `- **Response:** ligne une `), le reparse par `starts_with("- **")`
// la compte comme UNE seule ligne — la fenêtre reste cohérente.
let h2 = s
.fold(Some(h1.clone()), &[turn(c, turn_id(3), TurnRole::Response, "suite")])
.await;
let lines2 = turn_lines(&h2.summary_md);
assert_eq!(lines2.len(), 3, "fenêtre cohérente après repli (pas de split parasite)");
assert_eq!(lines2[2], "- **Response:** suite");
assert_eq!(h2.up_to, turn_id(3));
}