Files
IdeA/crates/infrastructure/tests/conversation_log.rs
Blomios 40ca3e522f feat(domain,infra,app): rotation/rétention log.jsonl + lecture paginée (LS6)
Backend uniquement (UI React repoussée à LS7) :
- domain : port ConversationArchive + structs SegmentStats/PageCursor/PageDirection/
  TurnSlice/RotationDecision/RotationThresholds, fn pures rotation_plan/clamp_page_limit
  + consts ; re-exports lib.
- infrastructure : impl ConversationArchive pour FsConversationLog (stats/rotate/page
  + helpers), archive segmentée hors chemin chaud.
- application : ReadConversationPage + DTO + ConversationArchiveProvider (conversation/paginate),
  RotateConversationLog (conversation/rotate), exports mod/lib.
- app-tauri : AppConversationArchiveProvider + wiring (state), rotation détachée dans
  launch_agent + commande read_conversation_page (commands), DTOs (dto),
  commande enregistrée (generate_handler!).
- tests (QA, verts) : conversation_log, conversation_rotate_paginate (nouveau),
  dto (module test). Pivots INV-LS6 et cohérence fold-après-rotation verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:58:40 +02:00

1851 lines
68 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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::{
bound_handoff_summary, ConversationArchive, ConversationLog, ConversationTurn, Handoff,
HandoffStore, HandoffSummarizer, PageCursor, PageDirection, ProviderSessionStore, TurnId,
TurnRole, HANDOFF_SUMMARY_MAX_CHARS, MAX_ARCHIVE_SEGMENTS, ROTATE_AFTER_BYTES,
};
use domain::input::InputSource;
use domain::ports::StoreError;
use domain::project::ProjectPath;
use infrastructure::{
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
TURN_LINE_MAX_CHARS, 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")
}
/// `<root>/.ideai/conversations/<conversationId>/providers.json` — the per-provider sessions (P5).
fn providers_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation).join("providers.json")
}
/// `<root>/.ideai/conversations/<conversationId>/providers.json.tmp` — the atomic-write tmp (P5).
fn providers_tmp_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation)
.join("providers.json.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));
}
// ---------------------------------------------------------------------------
// LS5 — borne par-ligne (`render_turn`/`elide`) + boundedness sur 100 replis +
// round-trip Fs réel avec `summary_md` borné. Le préfixe de ligne de tour, mesuré
// une fois pour les assertions de borne structurelle.
// ---------------------------------------------------------------------------
/// Le préfixe `- **Response:** ` (en caractères), partagé par les bornes structurelles.
const RESPONSE_PREFIX: &str = "- **Response:** ";
/// LS5 — un tour `Response` énorme : sa ligne rendue est **élidée** (le texte aplati est
/// borné à [`TURN_LINE_MAX_CHARS`] + le marqueur `…`), le préfixe `- **role:**` reste
/// intact, et la ligne reste reparsable. Testé via `fold` (`render_turn` est privé).
#[tokio::test]
async fn fold_elides_a_huge_response_line_keeping_prefix() {
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
let huge = "z".repeat(10_000);
let h = s
.fold(None, &[turn(c, turn_id(1), TurnRole::Response, &huge)])
.await;
let line = turn_lines(&h.summary_md)[0];
// Préfixe intact ⇒ ligne reparsable.
assert!(
line.starts_with(RESPONSE_PREFIX),
"préfixe de tour intact : {line}"
);
// Élidée : marqueur `…` final, et la ligne n'a pas conservé les 10 000 caractères.
assert!(line.ends_with('…'), "marqueur d'élision présent : {line}");
// Borne structurelle : préfixe + TURN_LINE_MAX_CHARS (texte borné) + 1 pour `…`.
let max_line = RESPONSE_PREFIX.chars().count() + TURN_LINE_MAX_CHARS + 1;
assert!(
line.chars().count() <= max_line,
"ligne bornée à {max_line} car., obtenu {} : {line}",
line.chars().count()
);
// Le texte aplati lui-même (hors préfixe, hors `…`) est borné à TURN_LINE_MAX_CHARS.
let text_chars = line.chars().count() - RESPONSE_PREFIX.chars().count() - 1;
assert_eq!(
text_chars, TURN_LINE_MAX_CHARS,
"texte aplati borné à TURN_LINE_MAX_CHARS"
);
}
/// LS5 — 100 replis incrémentaux : la `WINDOW` borne le nombre de lignes (la croissance
/// ne s'accumule pas), l'objectif extrait du 1er prompt est **figé**, la fenêtre reparse
/// proprement, et — pour des tours de taille réaliste — le `summary_md` reste sous le
/// plafond universel [`HANDOFF_SUMMARY_MAX_CHARS`]. (Le plafond *dur*, valable même pour
/// un résumeur pathologique/LLM, est garanti séparément par `bound_handoff_summary` —
/// cf. les tests domaine/application.)
#[tokio::test]
async fn fold_a_hundred_increments_stays_bounded_and_window_reparses() {
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
// 1er tour = Prompt énonçant l'objectif ; replis incrémentaux ensuite.
let mut handoff = s
.fold(
None,
&[turn(
c,
turn_id(1),
TurnRole::Prompt,
"objectif initial figé",
)],
)
.await;
for n in 2..=100u128 {
let text = format!("tour numero {n} {}", "détail ".repeat(15)); // ~120 car.
handoff = s
.fold(
Some(handoff),
&[turn(c, turn_id(n), TurnRole::Response, &text)],
)
.await;
}
// WINDOW respectée : jamais plus de WINDOW lignes-tours, malgré 100 replis.
let lines = turn_lines(&handoff.summary_md);
assert_eq!(lines.len(), WINDOW, "WINDOW borne le nombre de lignes");
// Reparse stable : chaque ligne conservée est une ligne de tour bien formée.
for line in &lines {
assert!(line.starts_with("- **"), "ligne reparsable : {line}");
}
// Objectif figé depuis le 1er prompt.
assert_eq!(handoff.objective.as_deref(), Some("objectif initial figé"));
assert!(handoff
.summary_md
.contains("**Objectif :** objectif initial figé"));
// Curseur = dernier tour intégré.
assert_eq!(handoff.up_to, turn_id(100));
// Borné sous le plafond universel (tours réalistes ⇒ la WINDOW suffit).
assert!(
handoff.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
"summary borné à HANDOFF_SUMMARY_MAX_CHARS, obtenu {}",
handoff.summary_md.chars().count()
);
}
/// LS5 — round-trip `FsHandoffStore` **réel** : après un fold multi-tours (texte volumineux
/// ⇒ summary potentiellement > plafond) PUIS `bound_handoff_summary`, le `handoff.md` écrit
/// sur disque relit un `summary_md` ≤ [`HANDOFF_SUMMARY_MAX_CHARS`], objectif/curseur
/// préservés, et reste reparsable.
#[tokio::test]
async fn fs_handoff_round_trip_persists_bounded_summary() {
let tmp = TempDir::new();
let store = FsHandoffStore::new(&tmp.project_path());
let s = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
// Fold de tours VOLUMINEUX : chaque ligne est élidée à ~257 car., 20 lignes ⇒ le
// summary heuristique dépasse le plafond universel (≈ 5 000 > 4096).
let mut handoff = s
.fold(None, &[turn(c, turn_id(1), TurnRole::Prompt, "objectif")])
.await;
for n in 2..=60u128 {
let big = "m".repeat(3_000);
handoff = s
.fold(
Some(handoff),
&[turn(c, turn_id(n), TurnRole::Response, &big)],
)
.await;
}
assert!(
handoff.summary_md.chars().count() > HANDOFF_SUMMARY_MAX_CHARS,
"pré-condition : le fold heuristique seul dépasse le plafond ({})",
handoff.summary_md.chars().count()
);
// Pipeline réel : borne universelle AVANT persistance.
let bounded = bound_handoff_summary(handoff.clone(), HANDOFF_SUMMARY_MAX_CHARS);
store.save(c, bounded.clone()).await.expect("save ok");
let loaded = store.load(c).await.expect("load ok").expect("present");
assert!(
loaded.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
"handoff.md sur disque borné, obtenu {}",
loaded.summary_md.chars().count()
);
assert_eq!(loaded.objective, bounded.objective, "objectif préservé");
assert_eq!(loaded.up_to, bounded.up_to, "curseur préservé");
// Reparsable : au moins une ligne de tour, toutes bien préfixées.
let kept = turn_lines(&loaded.summary_md);
assert!(!kept.is_empty(), "au moins une ligne de tour conservée");
for line in kept {
assert!(line.starts_with("- **"), "ligne reparsable : {line}");
}
}
// ===========================================================================
// P5 — FsProviderSessionStore (providers.json): le `resumable_id` rangé par
// (conversation, provider). Mêmes helpers L2 que P2/P3/P4 — `TempDir` maison
// (chemin absolu pour `ProjectPath::new`), constructeurs déterministes
// `conv_id`/`turn_id`, convention de dossier `<root>/.ideai/conversations/<id>/`.
//
// Format verrouillé : objet JSON plat `{ "<providerId>": "<resumableId>", ... }`.
// Contrat : get/set par provider ; coexistence multi-providers sans perte ;
// absent ⇒ Ok(None) ; corrompu ⇒ Err(Serialization) ; read-modify-write atomique
// (tmp+rename, mutex par conversation) ; isolation par ConversationId.
// ===========================================================================
/// Écrit un `providers.json` brut (corruption volontaire), créant le dossier au besoin.
fn write_raw_providers(tmp: &TempDir, c: ConversationId, content: &str) {
std::fs::create_dir_all(tmp.conversation_dir(c)).unwrap();
std::fs::write(tmp.providers_path(c), content).unwrap();
}
// ---------------------------------------------------------------------------
// get/set par provider + round-trip disque via une nouvelle instance
// ---------------------------------------------------------------------------
#[tokio::test]
async fn provider_set_then_get_round_trips_across_a_fresh_instance() {
let tmp = TempDir::new();
let c = conv_id(1);
// Première instance : set puis get immédiat.
{
let store = FsProviderSessionStore::new(&tmp.project_path());
store.set(c, "claude", "a").await.unwrap();
assert_eq!(
store.get(c, "claude").await.unwrap(),
Some("a".to_string()),
"get doit rendre la valeur posée"
);
}
// Le fichier existe réellement sur disque, au bon emplacement.
assert!(
tmp.providers_path(c).exists(),
"providers.json doit exister après set"
);
// Une nouvelle instance sur le même root relit la persistance (aucun cache mémoire).
let reborn = FsProviderSessionStore::new(&tmp.project_path());
assert_eq!(
reborn.get(c, "claude").await.unwrap(),
Some("a".to_string()),
"persistance réelle : relue via une instance neuve"
);
}
// ---------------------------------------------------------------------------
// coexistence multi-providers : set claude + codex => les deux ; re-set claude
// n'efface pas codex ; sur disque les deux clés sont présentes.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn provider_set_keeps_other_providers_and_disk_holds_both_keys() {
let tmp = TempDir::new();
let store = FsProviderSessionStore::new(&tmp.project_path());
let c = conv_id(2);
store.set(c, "claude", "a").await.unwrap();
store.set(c, "codex", "b").await.unwrap();
// Les deux providers rendent leur valeur.
assert_eq!(store.get(c, "claude").await.unwrap(), Some("a".to_string()));
assert_eq!(store.get(c, "codex").await.unwrap(), Some("b".to_string()));
// Re-set claude (read-modify-write) ne doit PAS effacer codex.
store.set(c, "claude", "a2").await.unwrap();
assert_eq!(
store.get(c, "claude").await.unwrap(),
Some("a2".to_string()),
"claude mis à jour"
);
assert_eq!(
store.get(c, "codex").await.unwrap(),
Some("b".to_string()),
"codex préservé après re-set de claude"
);
// Sur disque, le providers.json contient bien les deux clés (objet JSON plat).
let raw = std::fs::read_to_string(tmp.providers_path(c)).unwrap();
let map: std::collections::HashMap<String, String> = serde_json::from_str(&raw).unwrap();
assert_eq!(map.get("claude").map(String::as_str), Some("a2"));
assert_eq!(map.get("codex").map(String::as_str), Some("b"));
assert_eq!(map.len(), 2, "exactement les deux providers, got: {raw:?}");
}
// ---------------------------------------------------------------------------
// absent ⇒ Ok(None) (conversation/provider jamais écrit ; dossier inexistant)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn provider_get_absent_yields_none() {
let tmp = TempDir::new();
let store = FsProviderSessionStore::new(&tmp.project_path());
// Conversation jamais écrite : pas de dossier .ideai du tout.
assert_eq!(
store.get(conv_id(99), "claude").await.unwrap(),
None,
"conversation inexistante ⇒ None (pas d'erreur)"
);
// Conversation avec un provider posé, mais provider demandé absent ⇒ None.
let c = conv_id(98);
store.set(c, "claude", "a").await.unwrap();
assert_eq!(
store.get(c, "codex").await.unwrap(),
None,
"clé provider absente ⇒ None"
);
}
// ---------------------------------------------------------------------------
// corrompu ⇒ Err(Serialization), aucun panic
// ---------------------------------------------------------------------------
#[tokio::test]
async fn provider_corrupted_file_yields_serialization_error_no_panic() {
let tmp = TempDir::new();
let store = FsProviderSessionStore::new(&tmp.project_path());
// Cas de contenus non-désérialisables en map<String,String>.
let cases: [(u128, &str, &str); 3] = [
(
201,
"{ ceci n'est pas du json",
"JSON syntaxiquement invalide",
),
(
202,
"[\"pas\", \"un\", \"objet\"]",
"JSON valide mais pas un objet map",
),
(203, "{\"claude\": 123}", "valeur non-String"),
];
for (n, content, label) in cases {
let c = conv_id(n);
write_raw_providers(&tmp, c, content);
match store.get(c, "claude").await {
Err(StoreError::Serialization(msg)) => {
assert!(
msg.starts_with("providers.json:"),
"cas «{label}» : message préfixé `providers.json:` attendu, got: {msg:?}"
);
}
other => panic!("cas «{label}» : Err(Serialization) attendu, got: {other:?}"),
}
}
}
// ---------------------------------------------------------------------------
// atomicité / concurrence : N set concurrents (providers distincts, même conv)
// => la map finale contient les N entrées (aucune perte) ; pas de .tmp résiduel.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn provider_concurrent_sets_keep_all_entries_no_tmp_left() {
use std::sync::Arc;
let tmp = TempDir::new();
let store = Arc::new(FsProviderSessionStore::new(&tmp.project_path()));
let c = conv_id(42);
const N: usize = 12;
let mut handles = Vec::new();
for i in 0..N {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let provider = format!("provider-{i}");
let id = format!("id-{i}");
s.set(c, &provider, &id).await.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
// Les N entrées sont toutes présentes : aucun set concurrent n'en a écrasé un autre.
for i in 0..N {
assert_eq!(
store.get(c, &format!("provider-{i}")).await.unwrap(),
Some(format!("id-{i}")),
"provider-{i} perdu sous concurrence"
);
}
// Le fichier sur disque contient bien les N clés.
let raw = std::fs::read_to_string(tmp.providers_path(c)).unwrap();
let map: std::collections::HashMap<String, String> = serde_json::from_str(&raw).unwrap();
assert_eq!(map.len(), N, "map finale = N entrées, got: {raw:?}");
// Aucun fichier temporaire résiduel après les renames atomiques.
assert!(
!tmp.providers_tmp_path(c).exists(),
"providers.json.tmp ne doit pas subsister après les set"
);
}
// ---------------------------------------------------------------------------
// isolation entre deux ConversationId : pas de fuite croisée
// ---------------------------------------------------------------------------
#[tokio::test]
async fn provider_is_isolated_per_conversation() {
let tmp = TempDir::new();
let store = FsProviderSessionStore::new(&tmp.project_path());
let c1 = conv_id(10);
let c2 = conv_id(20);
store.set(c1, "claude", "c1-id").await.unwrap();
store.set(c2, "claude", "c2-id").await.unwrap();
// Chacune relit la sienne.
assert_eq!(
store.get(c1, "claude").await.unwrap(),
Some("c1-id".to_string())
);
assert_eq!(
store.get(c2, "claude").await.unwrap(),
Some("c2-id".to_string())
);
// Deux fichiers distincts sur disque, sans fuite de contenu.
let p1 = tmp.providers_path(c1);
let p2 = tmp.providers_path(c2);
assert_ne!(p1, p2, "deux conversations ⇒ deux providers.json distincts");
assert!(p1.exists() && p2.exists());
let raw2 = std::fs::read_to_string(&p2).unwrap();
assert!(
!raw2.contains("c1-id"),
"aucune fuite de c1 dans le providers.json de c2"
);
}
// ===========================================================================
// LS6 — rotation (ConversationArchive) + pagination humaine, I/O réelle.
// Réutilise le scaffolding L2 (TempDir absolu, constructeurs déterministes).
// Convention d'archive : `<conversationId>/log.<k>.jsonl` (k≥1, le plus ancien=1).
// ===========================================================================
/// Liste, triés croissants, les indices `k` des segments d'archive `log.<k>.jsonl`
/// présents sur disque pour `c` (l'actif `log.jsonl` est exclu).
fn archive_segment_indices(tmp: &TempDir, c: ConversationId) -> Vec<usize> {
let dir = tmp.conversation_dir(c);
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for entry in rd.flatten() {
if let Some(name) = entry.file_name().to_str() {
if let Some(mid) = name
.strip_prefix("log.")
.and_then(|s| s.strip_suffix(".jsonl"))
{
if let Ok(k) = mid.parse::<usize>() {
out.push(k);
}
}
}
}
}
out.sort_unstable();
out
}
/// Écrit un segment d'archive brut `log.<k>.jsonl` (simulation de crash / dédoublonnage).
fn write_raw_archive_segment(
tmp: &TempDir,
c: ConversationId,
k: usize,
turns: &[ConversationTurn],
) {
let dir = tmp.conversation_dir(c);
std::fs::create_dir_all(&dir).unwrap();
let mut buf = String::new();
for t in turns {
buf.push_str(&serde_json::to_string(t).unwrap());
buf.push('\n');
}
std::fs::write(dir.join(format!("log.{k}.jsonl")), buf).unwrap();
}
/// Tous les `.tmp` restant dans le dossier de la conversation (doivent être absents).
fn leftover_tmp_files(tmp: &TempDir, c: ConversationId) -> Vec<String> {
let dir = tmp.conversation_dir(c);
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for entry in rd.flatten() {
if let Some(name) = entry.file_name().to_str() {
if name.ends_with(".tmp") {
out.push(name.to_owned());
}
}
}
}
out
}
fn ids(turns: &[ConversationTurn]) -> Vec<TurnId> {
turns.iter().map(|t| t.id).collect()
}
/// Enregistre `n` tours (id 1..=n) dans l'actif et renvoie le log.
async fn seed_turns(log: &FsConversationLog, c: ConversationId, n: u128) {
for i in 1..=n {
let role = if i % 2 == 1 {
TurnRole::Prompt
} else {
TurnRole::Response
};
log.append(c, turn(c, turn_id(i), role, &format!("texte du tour {i}")))
.await
.unwrap();
}
}
// --- INV-LS6 : le test pivot -------------------------------------------------
/// INV-LS6 (PIVOT) : après `rotate(keep_from=up_to)`, le curseur `up_to` reste
/// trouvable dans l'ACTIF, donc `ConversationLog::read(since=up_to)` renvoie toujours
/// le bon incrément, et `last(n)` fonctionne. C'est l'invariant qui garantit que la
/// rotation ne casse jamais le fold incrémental du handoff.
#[tokio::test]
async fn inv_ls6_read_since_up_to_survives_rotation() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
seed_turns(&log, c, 10).await;
// up_to = curseur du handoff (tour 5). On rote en gardant tout depuis 5.
let up_to = turn_id(5);
log.rotate(c, up_to).await.expect("rotate ok");
// Le curseur est dans l'actif ⇒ l'incrément strictement postérieur est intact.
let inc = log.read(c, Some(up_to)).await.unwrap();
assert_eq!(
texts(&inc),
(6..=10)
.map(|i| format!("texte du tour {i}"))
.collect::<Vec<_>>(),
"read(since=up_to) renvoie l'incrément 6..=10 après rotation"
);
// last(n) lit bien la fin (archive-unaware mais l'actif contient la queue).
let last3 = log.last(c, 3).await.unwrap();
assert_eq!(ids(&last3), vec![turn_id(8), turn_id(9), turn_id(10)]);
}
// --- archive : déplacement de tête, aucune perte, idempotence ----------------
/// `rotate(keep_from=5)` déplace la tête froide (1..4) dans un segment, garde
/// `[5..10]` dans l'actif, sans perdre aucun tour ( actif+archives = tous).
#[tokio::test]
async fn rotate_moves_cold_head_to_a_segment_without_loss() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
seed_turns(&log, c, 10).await;
log.rotate(c, turn_id(5)).await.expect("rotate ok");
// Un seul segment d'archive créé (le plus ancien = index 1).
assert_eq!(archive_segment_indices(&tmp, c), vec![1]);
// L'actif = [5..10] (keep_from inclus en tête).
let active = log.read(c, None).await.unwrap();
assert_eq!(ids(&active), (5..=10).map(turn_id).collect::<Vec<_>>());
// Aucun tour perdu : la page archive-aware réunit tout, 1..10 en ordre croissant.
let page = log
.page(
c,
PageCursor {
anchor: None,
direction: PageDirection::Forward,
},
200,
)
.await
.unwrap();
assert_eq!(ids(&page.turns), (1..=10).map(turn_id).collect::<Vec<_>>());
assert!(!page.has_more, "10 tours ≤ limit ⇒ pas d'autre page");
}
/// `rotate` est idempotente : un 2e appel avec le même `keep_from` (désormais en tête
/// de l'actif) est un no-op — aucun nouveau segment, actif inchangé.
#[tokio::test]
async fn rotate_is_idempotent() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
seed_turns(&log, c, 10).await;
log.rotate(c, turn_id(5)).await.expect("rotate 1");
let after1 = log.read(c, None).await.unwrap();
let segs1 = archive_segment_indices(&tmp, c);
log.rotate(c, turn_id(5)).await.expect("rotate 2 (no-op)");
let after2 = log.read(c, None).await.unwrap();
let segs2 = archive_segment_indices(&tmp, c);
assert_eq!(ids(&after1), ids(&after2), "actif inchangé au 2e rotate");
assert_eq!(segs1, segs2, "aucun nouveau segment au 2e rotate");
assert_eq!(segs2, vec![1], "toujours exactement un segment");
}
/// `keep_from` absent de l'actif (déjà roté / inconnu) ⇒ no-op, jamais de perte.
#[tokio::test]
async fn rotate_with_unknown_keep_from_is_noop() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
seed_turns(&log, c, 5).await;
log.rotate(c, turn_id(999)).await.expect("rotate noop");
assert!(archive_segment_indices(&tmp, c).is_empty(), "aucun segment");
let active = log.read(c, None).await.unwrap();
assert_eq!(ids(&active), (1..=5).map(turn_id).collect::<Vec<_>>());
}
/// Réécriture de l'actif **atomique** : après rotation, aucun `.tmp` ne traîne et la
/// queue active est intégralement présente (jamais perdue).
#[tokio::test]
async fn rotate_leaves_no_tmp_and_preserves_active_queue() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
seed_turns(&log, c, 8).await;
log.rotate(c, turn_id(4)).await.expect("rotate ok");
assert!(
leftover_tmp_files(&tmp, c).is_empty(),
"aucun .tmp résiduel après écriture atomique : {:?}",
leftover_tmp_files(&tmp, c)
);
let active = log.read(c, None).await.unwrap();
assert_eq!(
ids(&active),
(4..=8).map(turn_id).collect::<Vec<_>>(),
"queue active [4..8] intégralement préservée"
);
}
/// Tête dupliquée (simulation de crash : archive écrite mais actif non swappé) ⇒ la
/// lecture paginée **dédoublonne par TurnId** (chaque tour une seule fois).
#[tokio::test]
async fn page_dedups_duplicated_head_after_crash() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
seed_turns(&log, c, 6).await; // actif = [1..6]
// Crash simulé : un segment d'archive contenant la tête (1..3) est écrit, mais
// l'actif n'a PAS été tronqué (il contient toujours 1..6).
let active_all = log.read(c, None).await.unwrap();
let head: Vec<ConversationTurn> = active_all[..3].to_vec();
write_raw_archive_segment(&tmp, c, 1, &head);
// La page réunit archive(1..3) + actif(1..6) MAIS dédoublonne ⇒ 1..6, pas de doublon.
let page = log
.page(
c,
PageCursor {
anchor: None,
direction: PageDirection::Forward,
},
200,
)
.await
.unwrap();
assert_eq!(
ids(&page.turns),
(1..=6).map(turn_id).collect::<Vec<_>>(),
"dédoublonnage par TurnId : chaque tour une seule fois"
);
}
/// `append` ne déclenche **jamais** la rotation : grossir l'actif au-delà du seuil
/// d'octets sans appeler `rotate` ne crée aucun segment d'archive.
#[tokio::test]
async fn append_never_triggers_rotation_even_over_threshold() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
// Dépasser le seuil d'octets (1 MiB) : ~16 tours de 80 000 caractères.
let big = "m".repeat(80_000);
for i in 1..=16u128 {
log.append(c, turn(c, turn_id(i), TurnRole::Response, &big))
.await
.unwrap();
}
let stats = log.stats(c).await.unwrap();
assert!(
stats.active_bytes > ROTATE_AFTER_BYTES,
"pré-condition : actif au-dessus du seuil d'octets ({})",
stats.active_bytes
);
// Pourtant : aucun segment d'archive (la rotation est hors-chemin, jamais sur append).
assert!(
archive_segment_indices(&tmp, c).is_empty(),
"append ne crée jamais d'archive : {:?}",
archive_segment_indices(&tmp, c)
);
}
// --- pagination humaine ------------------------------------------------------
/// Page vide pour une conversation vide.
#[tokio::test]
async fn page_of_empty_conversation_is_empty() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
let page = log
.page(
c,
PageCursor {
anchor: None,
direction: PageDirection::Forward,
},
50,
)
.await
.unwrap();
assert!(page.turns.is_empty() && !page.has_more);
}
/// Forward depuis le début, Backward depuis la fin, ancré dans les deux sens ;
/// `has_more` correct ; ordre **chronologique croissant** dans chaque page.
#[tokio::test]
async fn page_forward_backward_anchored_and_has_more() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
seed_turns(&log, c, 10).await;
// Forward depuis le début, limit 4 ⇒ [1..4], has_more.
let p = log
.page(
c,
PageCursor {
anchor: None,
direction: PageDirection::Forward,
},
4,
)
.await
.unwrap();
assert_eq!(ids(&p.turns), (1..=4).map(turn_id).collect::<Vec<_>>());
assert!(p.has_more);
// Backward depuis la fin, limit 4 ⇒ [7..10] (ordre croissant !), has_more.
let p = log
.page(
c,
PageCursor {
anchor: None,
direction: PageDirection::Backward,
},
4,
)
.await
.unwrap();
assert_eq!(ids(&p.turns), (7..=10).map(turn_id).collect::<Vec<_>>());
assert!(p.has_more);
// Ancré Forward après le tour 4, limit 3 ⇒ [5,6,7], has_more.
let p = log
.page(
c,
PageCursor {
anchor: Some(turn_id(4)),
direction: PageDirection::Forward,
},
3,
)
.await
.unwrap();
assert_eq!(ids(&p.turns), vec![turn_id(5), turn_id(6), turn_id(7)]);
assert!(p.has_more);
// Ancré Backward avant le tour 4, limit 10 ⇒ [1,2,3], plus rien avant.
let p = log
.page(
c,
PageCursor {
anchor: Some(turn_id(4)),
direction: PageDirection::Backward,
},
10,
)
.await
.unwrap();
assert_eq!(ids(&p.turns), vec![turn_id(1), turn_id(2), turn_id(3)]);
assert!(!p.has_more, "rien avant le tour 1");
// Dernière page Forward (fin atteinte) ⇒ has_more = false.
let p = log
.page(
c,
PageCursor {
anchor: Some(turn_id(7)),
direction: PageDirection::Forward,
},
50,
)
.await
.unwrap();
assert_eq!(ids(&p.turns), vec![turn_id(8), turn_id(9), turn_id(10)]);
assert!(!p.has_more);
}
/// La pagination est **archive-aware** : après rotation, une page peut chevaucher
/// plusieurs segments (archive + actif) et reste en ordre croissant continu.
#[tokio::test]
async fn page_spans_multiple_segments_after_rotation() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
seed_turns(&log, c, 12).await;
log.rotate(c, turn_id(7)).await.expect("rotate"); // archive [1..6], actif [7..12]
// Une page ancrée Forward autour de la frontière archive/actif renvoie un span continu.
let p = log
.page(
c,
PageCursor {
anchor: Some(turn_id(5)),
direction: PageDirection::Forward,
},
4,
)
.await
.unwrap();
// Tours 6 (archive) puis 7,8,9 (actif) : continuité par-delà la frontière de segment.
assert_eq!(
ids(&p.turns),
vec![turn_id(6), turn_id(7), turn_id(8), turn_id(9)]
);
assert!(p.has_more);
}
/// Le **texte complet** d'un tour est préservé (non borné) à la lecture paginée — la
/// borne LS5 ne s'applique qu'au handoff, jamais au log canonique.
#[tokio::test]
async fn page_preserves_full_unbounded_turn_text() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
let big = "Z".repeat(50_000);
log.append(c, turn(c, turn_id(1), TurnRole::Response, &big))
.await
.unwrap();
let p = log
.page(
c,
PageCursor {
anchor: None,
direction: PageDirection::Forward,
},
50,
)
.await
.unwrap();
assert_eq!(p.turns.len(), 1);
assert_eq!(
p.turns[0].text.chars().count(),
50_000,
"texte complet préservé (non borné) à la pagination"
);
}
// --- backstop MAX_ARCHIVE_SEGMENTS ------------------------------------------
/// Au-delà de [`MAX_ARCHIVE_SEGMENTS`] segments, le(s) plus ANCIEN(s) sont supprimés ;
/// l'actif n'est jamais touché et aucun tour `≥ keep_from` n'est archivé/perdu.
#[tokio::test]
async fn rotation_enforces_max_archive_segments_dropping_oldest() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let c = conv_id(1);
let total = 40u128;
seed_turns(&log, c, total).await;
// Roter `MAX_ARCHIVE_SEGMENTS + 3` fois, en avançant keep_from d'un tour à chaque fois.
// Chaque rotation déplace une tête d'un tour ⇒ crée un segment.
let rotations = (MAX_ARCHIVE_SEGMENTS + 3) as u128;
for k in 2..=(1 + rotations) {
log.rotate(c, turn_id(k)).await.expect("rotate ok");
}
// Backstop : jamais plus de MAX_ARCHIVE_SEGMENTS segments sur disque.
let segs = archive_segment_indices(&tmp, c);
assert_eq!(
segs.len(),
MAX_ARCHIVE_SEGMENTS,
"plafonné à MAX_ARCHIVE_SEGMENTS, obtenu {segs:?}"
);
// L'actif n'a pas été touché par le backstop : il commence au dernier keep_from.
let last_keep = 1 + rotations; // = turn_id du dernier keep_from
let active = log.read(c, None).await.unwrap();
assert_eq!(
active.first().map(|t| t.id),
Some(turn_id(last_keep)),
"l'actif commence à keep_from (jamais archivé/supprimé)"
);
assert_eq!(
active.last().map(|t| t.id),
Some(turn_id(total)),
"la fin de l'actif est intacte"
);
// read(since=last_keep) — le curseur survit (INV-LS6) même après backstop.
let inc = log.read(c, Some(turn_id(last_keep))).await.unwrap();
assert_eq!(
inc.first().map(|t| t.id),
Some(turn_id(last_keep + 1)),
"l'incrément après le dernier curseur reste correct"
);
}
// --- cohérence fold-après-rotation (le must-have) ----------------------------
/// COHÉRENCE (must-have) : beaucoup de tours → `rotate(keep_from = up_to)` → encore des
/// tours → un `fold` ultérieur **étend correctement** le handoff. On lit l'actif depuis
/// `up_to` (INV-LS6 : toujours trouvable après rotation), on replie l'incrément, et
/// `up_to` AVANCE. Prouve que la rotation ne casse jamais le fold incrémental réel.
#[tokio::test]
async fn fold_after_rotation_extends_handoff_correctly() {
let tmp = TempDir::new();
let log = FsConversationLog::new(&tmp.project_path());
let summarizer = HeuristicHandoffSummarizer::new();
let c = conv_id(1);
// 1. Tours 1..7 enregistrés PUIS repliés ⇒ handoff.up_to = 7.
seed_turns(&log, c, 7).await;
let first_batch = log.read(c, None).await.unwrap();
let handoff = summarizer.fold(None, &first_batch).await;
let handoff = bound_handoff_summary(handoff, HANDOFF_SUMMARY_MAX_CHARS);
assert_eq!(handoff.up_to, turn_id(7), "curseur = dernier tour replié");
// 2. Plus de tours arrivent (8..15), non encore repliés, écrits dans l'actif.
for i in 8..=15u128 {
let role = if i % 2 == 1 {
TurnRole::Prompt
} else {
TurnRole::Response
};
log.append(c, turn(c, turn_id(i), role, &format!("texte du tour {i}")))
.await
.unwrap();
}
// 3. Rotation au plancher = up_to du handoff (7) : tête froide 1..6 archivée.
log.rotate(c, handoff.up_to).await.expect("rotate ok");
// L'actif commence bien au curseur (INV-LS6) ⇒ le curseur reste trouvable.
assert_eq!(
ids(&log.read(c, None).await.unwrap()),
(7..=15).map(turn_id).collect::<Vec<_>>(),
"actif = [up_to..fin] après rotation"
);
// 4. Fold ultérieur : on lit l'incrément depuis up_to (8..15) DANS L'ACTIF, on replie.
let increment = log.read(c, Some(handoff.up_to)).await.unwrap();
assert_eq!(
ids(&increment),
(8..=15).map(turn_id).collect::<Vec<_>>(),
"read(since=up_to) renvoie l'incrément 8..15 malgré la rotation"
);
let advanced = summarizer.fold(Some(handoff.clone()), &increment).await;
let advanced = bound_handoff_summary(advanced, HANDOFF_SUMMARY_MAX_CHARS);
// up_to a AVANCÉ jusqu'au dernier tour, et le résumé intègre les tours récents.
assert_eq!(
advanced.up_to,
turn_id(15),
"le curseur avance après le fold"
);
assert!(
advanced.summary_md.contains("texte du tour 15"),
"le fold intègre le tour le plus récent : {}",
advanced.summary_md
);
assert_ne!(
advanced.up_to, handoff.up_to,
"la rotation ne fige pas le curseur du handoff"
);
}