Sauvegarde de l'arbre de travail en cours (persistance P8, conversations C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le support de la délégation inter-agents pour les profils Codex. Le round-trip inter-agent question/réponse est couvert sans tokens par les tests loopback existants (state::mcp_e2e_loopback_tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1144 lines
42 KiB
Rust
1144 lines
42 KiB
Rust
//! L2 integration tests for [`FsConversationLog`] against a **real** temp directory
|
|
//! (cadrage « persistance conversationnelle », lot P2, critères §19.6).
|
|
//!
|
|
//! These lock the *durable* behaviour the in-memory double of P1 cannot prove:
|
|
//! - one JSONL line per appended turn, each line valid JSON;
|
|
//! - persistence survives a "restart" (a fresh instance on the same root relits all);
|
|
//! - two conversations land in two disjoint `log.jsonl` files (no leak);
|
|
//! - a corrupted/truncated line is silently skipped (no panic, no hard error);
|
|
//! - missing file/conversation => empty (never an error);
|
|
//! - the cursor/`last` contract (exclusive `since`, `last(0)`, `n>len`, `n<len`);
|
|
//! - two concurrent appends on the same conversation => 2 lines, no corruption.
|
|
//!
|
|
//! Convention: a hand-rolled [`TempDir`] over the OS temp dir (calqué sur
|
|
//! `project_store.rs`) — it yields an **absolute** path, as `ProjectPath::new`
|
|
//! requires, and is cleaned up on drop. No extra dependency is pulled in.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use domain::conversation::ConversationId;
|
|
use domain::conversation_log::{
|
|
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
|
ProviderSessionStore, TurnId, TurnRole,
|
|
};
|
|
use domain::input::InputSource;
|
|
use domain::ports::StoreError;
|
|
use domain::project::ProjectPath;
|
|
use infrastructure::{
|
|
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, WINDOW,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test scaffolding
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A unique scratch directory under the OS temp dir, cleaned up on drop. Its path
|
|
/// is absolute, so `ProjectPath::new` accepts it directly as a project root.
|
|
struct TempDir(PathBuf);
|
|
impl TempDir {
|
|
fn new() -> Self {
|
|
let p = std::env::temp_dir().join(format!("idea-l2-convlog-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
Self(p)
|
|
}
|
|
/// The project root as a [`ProjectPath`] (absolute, as the composition root passes).
|
|
fn project_path(&self) -> ProjectPath {
|
|
ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap()
|
|
}
|
|
/// `<root>/.ideai/conversations/<conversationId>/log.jsonl` — the raw log file.
|
|
fn log_path(&self, conversation: ConversationId) -> PathBuf {
|
|
self.conversation_dir(conversation).join("log.jsonl")
|
|
}
|
|
/// `<root>/.ideai/conversations/<conversationId>/` — a conversation's dir.
|
|
fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
|
|
self.0
|
|
.join(".ideai")
|
|
.join("conversations")
|
|
.join(conversation.to_string())
|
|
}
|
|
/// `<root>/.ideai/conversations/<conversationId>/handoff.md` — the handoff file (P3).
|
|
fn handoff_path(&self, conversation: ConversationId) -> PathBuf {
|
|
self.conversation_dir(conversation).join("handoff.md")
|
|
}
|
|
/// `<root>/.ideai/conversations/<conversationId>/handoff.md.tmp` — the atomic-write tmp.
|
|
fn handoff_tmp_path(&self, conversation: ConversationId) -> PathBuf {
|
|
self.conversation_dir(conversation).join("handoff.md.tmp")
|
|
}
|
|
/// `<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));
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 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"
|
|
);
|
|
}
|