//! 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 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() } /// `/.ideai/conversations//log.jsonl` — the raw log file. fn log_path(&self, conversation: ConversationId) -> PathBuf { self.conversation_dir(conversation).join("log.jsonl") } /// `/.ideai/conversations//` — a conversation's dir. fn conversation_dir(&self, conversation: ConversationId) -> PathBuf { self.0 .join(".ideai") .join("conversations") .join(conversation.to_string()) } /// `/.ideai/conversations//handoff.md` — the handoff file (P3). fn handoff_path(&self, conversation: ConversationId) -> PathBuf { self.conversation_dir(conversation).join("handoff.md") } /// `/.ideai/conversations//handoff.md.tmp` — the atomic-write tmp. fn handoff_tmp_path(&self, conversation: ConversationId) -> PathBuf { self.conversation_dir(conversation).join("handoff.md.tmp") } /// `/.ideai/conversations//providers.json` — the per-provider sessions (P5). fn providers_path(&self, conversation: ConversationId) -> PathBuf { self.conversation_dir(conversation).join("providers.json") } /// `/.ideai/conversations//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 { 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::(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 `/.ideai/conversations//`. 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 :** ` // - une ligne par tour : `- **:** ` // =========================================================================== /// 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 `/.ideai/conversations//`. // // Format verrouillé : objet JSON plat `{ "": "", ... }`. // 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 = 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. 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 = 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 : `/log..jsonl` (k≥1, le plus ancien=1). // =========================================================================== /// Liste, triés croissants, les indices `k` des segments d'archive `log..jsonl` /// présents sur disque pour `c` (l'actif `log.jsonl` est exclu). fn archive_segment_indices(tmp: &TempDir, c: ConversationId) -> Vec { 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::() { out.push(k); } } } } } out.sort_unstable(); out } /// Écrit un segment d'archive brut `log..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 { 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 { 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::>(), "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::>()); // 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::>()); 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::>()); } /// 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::>(), "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 = 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::>(), "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::>()); 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::>()); 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::>(), "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::>(), "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" ); }