//! Log canonique de conversation (cadrage « persistance conversationnelle », lot P1). //! //! Aujourd'hui la continuité d'un fil repose sur le `resumable_id` CLI du provider //! ([`crate::conversation::Conversation::resumable_id`]) : fragile (perdu si le run //! est nettoyé) et **non portable** d'un provider à l'autre (un swap Claude→Codex //! repart de zéro). Pour y remédier, IdeA tient sa **propre mémoire de conversation**, //! indépendante du provider, dont la **source de vérité durable** est ce **log //! canonique append-only, par conversation (paire)** (ARCHITECTURE §19, décision //! D19-1a). //! //! Ce module est **pur** (règle de dépendance du domaine) : ni `tokio`, ni `std::fs`, //! ni I/O. Il possède le value object [`ConversationTurn`] (un tour de conversation), //! ses newtypes/énumérés ([`TurnId`], [`TurnRole`]) et le port driven //! [`ConversationLog`]. L'écriture/lecture réelle du `log.jsonl` est une affaire //! d'infrastructure (l'adapter `FsConversationLog`, lot P2). //! //! ## Frontière (D19-2) //! //! Ce log est **distinct** de la mémoire durable `.ideai/memory/` (savoir projet //! stable, peu bruité) et de la live-state (busy, sessions en cours). Le log est //! **volumineux et bruité** par nature : il ne **pollue jamais** `memory/`. use serde::{Deserialize, Serialize}; use crate::conversation::ConversationId; use crate::input::InputSource; use crate::ports::StoreError; /// Identifie un [`ConversationTurn`] dans le log d'une conversation. /// /// Newtype autour d'[`uuid::Uuid`], calqué sur [`crate::conversation::ConversationId`] /// / [`crate::mailbox::TicketId`]. Sa monotonie (un id frappé à l'`append`) sert de /// **curseur** à la relecture incrémentale ([`ConversationLog::read`] avec `since`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct TurnId(pub uuid::Uuid); impl TurnId { /// Enrobe un [`uuid::Uuid`] existant. #[must_use] pub const fn from_uuid(id: uuid::Uuid) -> Self { Self(id) } /// Frappe un nouvel identifiant de tour aléatoire. #[must_use] pub fn new_random() -> Self { Self(uuid::Uuid::new_v4()) } /// Renvoie l'[`uuid::Uuid`] interne. #[must_use] pub const fn as_uuid(&self) -> uuid::Uuid { self.0 } } impl std::fmt::Display for TurnId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } /// Nature d'un tour dans le fil (qui parle / ce qui se passe). /// /// Sépare l'invite émise vers l'agent ([`TurnRole::Prompt`]), sa réponse /// ([`TurnRole::Response`]) et l'activité outillée intermédiaire /// ([`TurnRole::ToolActivity`]) — assez pour rejouer/résumer fidèlement le travail /// utile sans dépendre du format d'un provider donné (D19-3). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TurnRole { /// Une invite adressée à l'agent (humaine ou déléguée par un autre agent). Prompt, /// Une réponse rendue par l'agent (le `result`/`final` d'un tour). Response, /// Une activité outillée intermédiaire (appel d'outil, trace) au sein d'un tour. ToolActivity, } /// Un tour de conversation : qui a parlé, quand, dans quel fil, et quoi. /// /// Value object pur (aucun comportement, aucune I/O), sérialisable : c'est l'unité /// **append-only** persistée (une ligne JSON par tour dans `log.jsonl`, lot P2). La /// [`InputSource`] est la *source de vérité* de l'origine (Humain ou Agent), réutilisée /// telle quelle depuis le domaine de l'entrée médiée. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConversationTurn { /// Identifiant stable de ce tour (frappé à l'`append`) ; sert aussi de curseur. pub id: TurnId, /// La conversation (paire) à laquelle ce tour appartient. pub conversation: ConversationId, /// Horodatage (epoch millisecondes) du tour. pub at_ms: u64, /// L'origine de l'entrée (Humain ou Agent délégant). pub source: InputSource, /// La nature du tour (invite, réponse, activité outillée). pub role: TurnRole, /// Le contenu textuel du tour. pub text: String, } impl ConversationTurn { /// Construit un tour à partir de ses composants. #[must_use] pub fn new( id: TurnId, conversation: ConversationId, at_ms: u64, source: InputSource, role: TurnRole, text: impl Into, ) -> Self { Self { id, conversation, at_ms, source, role, text: text.into(), } } } /// Le log canonique append-only des conversations, par paire (port driven, lot P1). /// /// **Source de vérité durable** (D19-1a) : on **ajoute** un tour à chaque checkpoint /// (fin de tour, D19-5) et on **relit** à la reprise ou pour recalculer le handoff. Le /// log est gardé **par conversation** : deux fils distincts n'interfèrent jamais. /// /// `#[async_trait]` et erreur [`StoreError`] comme les autres ports driven de /// persistance ([`crate::ports::TemplateStore`]…) : injecté en /// `Arc` au composition root, donc gardé object-safe (les ports /// async dyn-compatibles passent par `async_trait` qui box le futur — cf. note /// d'en-tête de [`crate::ports`]). #[async_trait::async_trait] pub trait ConversationLog: Send + Sync { /// Ajoute `turn` à la fin du log canonique de `conversation`. /// /// # Errors /// [`StoreError`] en cas d'échec d'écriture ou de sérialisation. async fn append( &self, conversation: ConversationId, turn: ConversationTurn, ) -> Result<(), StoreError>; /// Relit les tours de `conversation`, dans l'ordre d'ajout. /// /// `since` est un curseur **exclusif** : quand il vaut `Some(id)`, seuls les tours /// **postérieurs** au tour `id` sont renvoyés (relecture incrémentale pour le /// recalcul de handoff) ; `None` relit tout le fil depuis le début. /// /// # Errors /// [`StoreError`] en cas d'échec de lecture ou de désérialisation. async fn read( &self, conversation: ConversationId, since: Option, ) -> Result, StoreError>; /// Renvoie les `n` derniers tours de `conversation`, dans l'ordre d'ajout. /// /// Renvoie moins de `n` éléments si le fil est plus court (et un `Vec` vide pour /// `n == 0` ou un fil vide). Sert au résumé incrémental (les N derniers tours). /// /// # Errors /// [`StoreError`] en cas d'échec de lecture ou de désérialisation. async fn last( &self, conversation: ConversationId, n: usize, ) -> Result, StoreError>; } /// Résumé cumulatif de reprise d'une conversation (handoff, lot P3). /// /// Value object pur (aucune I/O, aucun comportement) : c'est le **point de reprise** /// portable d'une conversation (ARCHITECTURE §19.2/§19.3). À la reprise d'un fil — ou /// au swap d'un provider à l'autre — IdeA injecte ce `summary_md` plutôt que de /// dépendre du `resumable_id` CLI d'un provider donné. Le champ [`Handoff::up_to`] /// est le **curseur** ([`TurnId`]) jusqu'auquel le résumé a été calculé : un recalcul /// incrémental relit le log à partir de ce curseur ([`ConversationLog::read`] avec /// `since`) pour étendre le résumé aux tours postérieurs. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Handoff { /// Le résumé cumulatif, en Markdown : ce qu'un agent doit savoir pour reprendre. pub summary_md: String, /// Le curseur : dernier [`TurnId`] couvert par ce résumé (borne du recalcul). pub up_to: TurnId, /// L'objectif courant de la conversation, le cas échéant (fil sans but explicite). pub objective: Option, } impl Handoff { /// Construit un handoff à partir de ses composants. #[must_use] pub fn new(summary_md: impl Into, up_to: TurnId, objective: Option) -> Self { Self { summary_md: summary_md.into(), up_to, objective, } } } /// Borne dure (en **caractères**) du `summary_md` d'un [`Handoff`] (lot LS5). /// /// Plafond universel **indépendant du résumeur** : qu'il soit produit par /// l'heuristique zéro-dépendance ou par un futur résumeur LLM, le résumé injecté dans /// le contexte d'un agent (et persisté) ne dépasse jamais cette taille. `4096` /// caractères tiennent largement un objectif + une fenêtre de tours déjà bornés /// individuellement, tout en gardant le contexte de reprise petit (chemin chaud). pub const HANDOFF_SUMMARY_MAX_CHARS: usize = 4096; /// Préfixe d'une **ligne de tour** dans un `summary_md` rendu (cf. le résumeur /// heuristique d'infra) : une ligne `- **:** `. Sert ici à séparer le /// **préambule d'objectif** des **lignes de tours** pour la troncature distillée. const TURN_LINE_PREFIX: &str = "- **"; /// Borne le `summary_md` d'un [`Handoff`] à `max_chars` caractères, **best-effort et /// idempotent** (lot LS5). /// /// - Sous la borne (`summary_md.chars().count() <= max_chars`) ⇒ handoff **inchangé** /// (coût nul, cas chaud nominal). /// - Au-dessus ⇒ **troncature distillée** : on garde le **préambule d'objectif** (le /// 1er bloc, avant la première ligne de tour) PUIS le **maximum de lignes de tours /// les plus récentes** (fin de fenêtre) tenant sous `max_chars`, en **droppant les /// plus anciennes d'abord**. Si la ligne conservée la plus récente dépasse à elle /// seule le budget, sa **fin** est tronquée en gardant le préfixe `- **role:**` /// intact (le résultat reste **toujours reparsable** comme un `summary_md`). /// /// [`Handoff::up_to`] et [`Handoff::objective`] ne sont **jamais** modifiés. La fonction /// ne **re-fold jamais**, ne **rejette jamais** (elle ne doit pas pouvoir bloquer un /// append/checkpoint) et garantit `bound(bound(x)) == bound(x)`. #[must_use] pub fn bound_handoff_summary(handoff: Handoff, max_chars: usize) -> Handoff { if handoff.summary_md.chars().count() <= max_chars { return handoff; } let summary_md = distill_summary(&handoff.summary_md, max_chars); Handoff { summary_md, up_to: handoff.up_to, objective: handoff.objective, } } /// Troncature distillée d'un `summary_md` au-dessus de la borne (cf. /// [`bound_handoff_summary`]). Sépare le préambule d'objectif des lignes de tours, garde /// le préambule puis le suffixe le plus récent de tours tenant sous `max_chars`. fn distill_summary(summary: &str, max_chars: usize) -> String { // Préambule d'objectif = lignes de tête jusqu'à la 1re ligne de tour ; lignes de // tours = celles préfixées `- **` (les seules reparsées par le résumeur). Toute // ligne non-tour APRÈS la 1re ligne de tour est du bruit inter-tours, droppée. let mut objective_block: Vec<&str> = Vec::new(); let mut turn_lines: Vec<&str> = Vec::new(); let mut seen_turn = false; for line in summary.lines() { if line.starts_with(TURN_LINE_PREFIX) { seen_turn = true; turn_lines.push(line); } else if !seen_turn { objective_block.push(line); } } while objective_block.last().is_some_and(|l| l.trim().is_empty()) { objective_block.pop(); } let objective_md = objective_block.join("\n"); let objective_len = objective_md.chars().count(); // Séparateur `\n\n` entre l'objectif et la fenêtre, seulement si les deux existent. let separator_len = if objective_md.is_empty() { 0 } else { 2 }; let budget_for_turns = max_chars.saturating_sub(objective_len + separator_len); // Garder les lignes de tours les plus RÉCENTES (fin) tenant sous le budget ; dropper // les plus anciennes d'abord. On parcourt depuis la fin et on s'arrête au 1er dépassement. let mut kept_rev: Vec = Vec::new(); let mut used = 0usize; for line in turn_lines.iter().rev() { let join_cost = usize::from(!kept_rev.is_empty()); // le `\n` de jointure let line_len = line.chars().count(); if used + join_cost + line_len <= budget_for_turns { used += join_cost + line_len; kept_rev.push((*line).to_owned()); } else { // La ligne la plus récente ne tient pas même seule ⇒ hard-truncate sa fin // en gardant le préfixe `- **role:**` intact (reparse toujours valide). if kept_rev.is_empty() { let truncated = truncate_turn_line_keeping_prefix(line, budget_for_turns); if !truncated.is_empty() { kept_rev.push(truncated); } } break; } } kept_rev.reverse(); let mut out = String::new(); if !objective_md.is_empty() { out.push_str(&objective_md); if !kept_rev.is_empty() { out.push_str("\n\n"); } } if !kept_rev.is_empty() { out.push_str(&kept_rev.join("\n")); } // Clamp final de sûreté : garantit `out.chars().count() <= max_chars` (donc // l'idempotence via la garde d'entrée) même sur une entrée pathologique (objectif // gigantesque, aucune ligne de tour). Char-boundary safe. if out.chars().count() > max_chars { out = out.chars().take(max_chars).collect(); } out } /// Tronque la **fin** d'une ligne de tour à `budget` caractères en gardant intact le /// préfixe `- **:** ` pour que la ligne reste reparsable comme un tour. fn truncate_turn_line_keeping_prefix(line: &str, budget: usize) -> String { if line.chars().count() <= budget { return line.to_owned(); } // Préfixe = `- **Role:** ` (inclus l'espace) quand présent, sinon a minima `- **`. let prefix: &str = match line.find(":** ") { Some(pos) => &line[..pos + 4], None => TURN_LINE_PREFIX, }; let prefix_len = prefix.chars().count(); if budget <= prefix_len { // Budget trop petit pour le moindre texte : on garde juste le préfixe reparsable. return prefix.to_owned(); } let tail: String = line[prefix.len()..] .chars() .take(budget - prefix_len) .collect(); format!("{prefix}{tail}") } /// Le store du résumé de reprise (handoff) d'une conversation (port driven, lot P3). /// /// Distinct du [`ConversationLog`] append-only : ici on garde **un seul** [`Handoff`] /// par conversation (le dernier point de reprise), écrasé à chaque recalcul. La /// persistance réelle (`handoff.md`) est une affaire d'infrastructure (l'adapter /// `FsHandoffStore`, lot P3). /// /// `#[async_trait]` et erreur [`StoreError`] comme le port voisin [`ConversationLog`] : /// injecté en `Arc` au composition root, gardé object-safe. #[async_trait::async_trait] pub trait HandoffStore: Send + Sync { /// Charge le handoff de `conversation`, ou `None` si aucun n'a encore été écrit. /// /// L'absence d'un handoff n'est **jamais** une erreur (`Ok(None)`). /// /// # Errors /// [`StoreError`] en cas d'échec de lecture ou de désérialisation. async fn load(&self, conversation: ConversationId) -> Result, StoreError>; /// Écrit (en écrasant) le handoff de `conversation`. /// /// # Errors /// [`StoreError`] en cas d'échec d'écriture ou de sérialisation. async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError>; } /// Le résumeur incrémental de handoff (port driving, lot P4). /// /// **Replie** (au sens d'un *fold*) un [`Handoff`] : on part du résumé précédent (s'il /// existe) et on n'intègre que **l'incrément** de tours nouveaux ([`new_turns`]), sans /// jamais relire tout le fil — c'est le seul appelant (l'application) qui relit le log à /// partir de [`Handoff::up_to`] ([`ConversationLog::read`] avec `since`) pour fournir cet /// incrément. /// /// ## Pourquoi `async` (OCP, décision tranchée par Main) /// /// Le port est **async** même si l'implémentation heuristique zéro-dépendance /// ([`HeuristicHandoffSummarizer`] côté infra) n'`await` rien : on fige **maintenant** le /// seam pour qu'un futur `LlmHandoffSummarizer` (P10, qui fera de l'I/O réseau async) se /// **substitue sans modifier ni le trait ni l'application** (open/closed). `#[async_trait]` /// pour rester object-safe (injecté en `Arc` au composition root). /// /// ## Pas de `Result` (best-effort, D19-6) /// /// Le repli est **best-effort** : il ne doit **jamais** bloquer la persistance du log /// canonique (la source de vérité durable). Une heuristique ne peut pas échouer ; un futur /// adapter LLM qui échouerait renverra simplement le `prev` (ou un repli heuristique) plutôt /// que de propager une erreur. Le port ne porte donc **aucune** erreur. /// /// [`new_turns`]: HandoffSummarizer::fold #[async_trait::async_trait] pub trait HandoffSummarizer: Send + Sync { /// Replie `prev` (s'il existe) avec **uniquement** `new_turns` et renvoie le handoff /// étendu. /// /// `prev` est le dernier point de reprise connu (ou `None` pour un premier calcul) ; /// `new_turns` est l'incrément des tours postérieurs à `prev.up_to`, dans l'ordre /// d'ajout. Le résultat couvre jusqu'au dernier tour vu (cf. [`Handoff::up_to`]). /// Best-effort : ne renvoie jamais d'erreur (cf. note du trait). async fn fold(&self, prev: Option, new_turns: &[ConversationTurn]) -> Handoff; } /// Le store des `resumable_id` CLI, rangés **par (conversation, provider)** (port /// driven, lot P5). /// /// Le `resumable_id` est l'identifiant **propre au moteur** (le `--resume`/`--continue` /// d'une CLI : `"claude"`, `"codex"`…) qui permet de **réattacher** la session native du /// provider à la reprise (ARCHITECTURE §19.2/§19.3). Contrairement au [`Handoff`] /// (résumé **portable**, indépendant du provider, source de vérité de la continuité), /// ce store garde l'optimisation **non portable** : chaque provider a **son** id, et /// plusieurs providers peuvent coexister pour une même conversation (après un swap /// Claude→Codex, on garde l'id de chacun). La clé est donc bien le couple /// `(conversation, provider_id)`. /// /// `#[async_trait]` et erreur [`StoreError`] comme les ports voisins /// ([`ConversationLog`], [`HandoffStore`]) : injecté en `Arc` /// au composition root, gardé object-safe. La persistance réelle (`providers.json`) est /// une affaire d'infrastructure (l'adapter `FsProviderSessionStore`, lot P5). #[async_trait::async_trait] pub trait ProviderSessionStore: Send + Sync { /// Charge le `resumable_id` du provider `provider_id` pour `conversation`. /// /// Renvoie `Ok(Some(id))` si un id a été rangé pour ce couple, `Ok(None)` si le /// provider (ou la conversation) n'a encore rien rangé. L'absence n'est **jamais** /// une erreur. /// /// # Errors /// [`StoreError`] en cas d'échec de lecture ou de désérialisation. async fn get( &self, conversation: ConversationId, provider_id: &str, ) -> Result, StoreError>; /// Range (en écrasant) le `resumable_id` du provider `provider_id` pour /// `conversation`. /// /// Les autres providers déjà rangés pour la même conversation **coexistent** : seul /// l'enregistrement de `provider_id` est créé/écrasé. /// /// # Errors /// [`StoreError`] en cas d'échec d'écriture ou de sérialisation. async fn set( &self, conversation: ConversationId, provider_id: &str, resumable_id: &str, ) -> Result<(), StoreError>; } // ===================================================================================== // Rotation & pagination du log (lot LS6) // ===================================================================================== // // Le `log.jsonl` actif est **borné** par rotation : au-delà d'un seuil (tours OU octets) // la **tête froide** est déplacée dans des **segments d'archive** (`log..jsonl`), pour // que le segment actif (et donc tout `read`/`last`/append) reste petit. La lecture // **humaine** paginée traverse archives + actif. // // ## Invariant pivot (INV-LS6) // // La rotation ne déplace/élague **JAMAIS** un tour d'id ≥ `up_to` du handoff courant : le // tour `up_to` et tous ses postérieurs restent dans le segment **actif**. Sans handoff (ou // `up_to` = sentinelle nil de LS5) ⇒ **aucune** rotation. C'est ce qui garantit que // [`ConversationLog::read`] avec `since = up_to` (fold incrémental) et la reprise restent // corrects après rotation. /// Seuil de rotation en **nombre de tours** du segment actif : au-delà, la tête froide /// est archivée (lot LS6). pub const ROTATE_AFTER_TURNS: usize = 500; /// Seuil de rotation en **octets** du segment actif (1 MiB) : au-delà, la tête froide est /// archivée (lot LS6). L'un OU l'autre seuil déclenche. pub const ROTATE_AFTER_BYTES: u64 = 1024 * 1024; /// Nombre maximum de **segments d'archive** conservés par conversation (lot LS6). Au-delà, /// le segment d'archive **le plus ancien** est supprimé (backstop) ; jamais l'actif, /// jamais un segment contenant un tour ≥ `up_to` (les archives n'en contiennent jamais). pub const MAX_ARCHIVE_SEGMENTS: usize = 20; /// Taille de page par défaut de la lecture humaine paginée (lot LS6), appliquée quand /// l'appelant ne précise pas de limite (`limit == 0`). pub const PAGE_DEFAULT_LIMIT: usize = 50; /// Borne **dure** de la taille d'une page (lot LS6) : toute limite est clampée à au plus /// cette valeur (anti-dump, la lecture humaine reste paginée). pub const PAGE_MAX_LIMIT: usize = 200; /// Seuils de rotation passés à [`rotation_plan`] (lot LS6). Regroupés pour garder la /// fonction pure simple et testable avec des seuils ad hoc. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RotationThresholds { /// Seuil en nombre de tours du segment actif (cf. [`ROTATE_AFTER_TURNS`]). pub max_turns: usize, /// Seuil en octets du segment actif (cf. [`ROTATE_AFTER_BYTES`]). pub max_bytes: u64, } impl RotationThresholds { /// Les seuils par défaut du projet ([`ROTATE_AFTER_TURNS`] / [`ROTATE_AFTER_BYTES`]). #[must_use] pub const fn defaults() -> Self { Self { max_turns: ROTATE_AFTER_TURNS, max_bytes: ROTATE_AFTER_BYTES, } } } /// Statistiques **bon marché** du segment actif d'une conversation (lot LS6), servant de /// **déclencheur** de rotation sans relire/élaguer quoi que ce soit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SegmentStats { /// Nombre de tours (lignes valides) du segment actif. pub active_turns: usize, /// Taille en octets du segment actif. pub active_bytes: u64, } /// Sens de pagination d'une page humaine (lot LS6). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PageDirection { /// Vers les tours **plus récents** (postérieurs à l'ancre). Forward, /// Vers les tours **plus anciens** (antérieurs à l'ancre). Backward, } /// Curseur d'une lecture paginée (lot LS6). /// /// `anchor = None` ⇒ on part d'un **bout** du fil : `Forward` ⇒ le tout début (plus /// anciens), `Backward` ⇒ la toute fin (plus récents). `anchor = Some(id)` ⇒ on pagine /// **strictement** autour de ce tour, dans la `direction` donnée. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PageCursor { /// Le tour pivot, ou `None` pour partir d'un bout du fil. pub anchor: Option, /// Le sens de progression. pub direction: PageDirection, } /// Une tranche paginée de tours (lot LS6), **toujours en ordre chronologique croissant**. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TurnSlice { /// Les tours de la page, du plus ancien au plus récent. pub turns: Vec, /// `true` s'il existe d'autres tours **dans le sens de progression** au-delà de la page. pub has_more: bool, } /// La décision **pure** de rotation calculée par [`rotation_plan`] (lot LS6). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RotationDecision { /// Ne rien faire (sous les seuils, ou pas de plancher `up_to`). Skip, /// Archiver la tête froide, en **gardant** tout à partir de `keep_from` dans l'actif. Archive { /// Le plancher : premier tour conservé dans l'actif (= `up_to` du handoff). Tous /// les tours **strictement antérieurs** sont archivés ; `keep_from` et ses /// postérieurs restent actifs (INV-LS6). keep_from: TurnId, }, } /// Décide **purement** s'il faut archiver la tête froide du segment actif (lot LS6). /// /// - `floor = None` ⇒ [`RotationDecision::Skip`] : sans plancher `up_to` (pas de handoff), /// on ne rote jamais (INV-LS6). De même si `floor` est la **sentinelle nil** de LS5 /// (`up_to` d'un handoff vide) : rien à garder en aval de façon fiable ⇒ `Skip`. /// - Sous **les deux** seuils ⇒ `Skip`. /// - Au-dessus de **l'un** des seuils ⇒ [`RotationDecision::Archive`] avec /// `keep_from = floor` : `keep_from` n'est **jamais** antérieur à `up_to` (il **est** /// `up_to`), donc l'invariant pivot est respecté par construction. /// /// Pure et idempotente (mêmes entrées ⇒ même sortie ; aucune I/O). #[must_use] pub fn rotation_plan( active_turns: usize, active_bytes: u64, floor: Option, thresholds: RotationThresholds, ) -> RotationDecision { // Pas de plancher, ou plancher = sentinelle nil (handoff vide LS5) ⇒ jamais de rotation. let Some(keep_from) = floor else { return RotationDecision::Skip; }; if keep_from.as_uuid().is_nil() { return RotationDecision::Skip; } let over_turns = active_turns > thresholds.max_turns; let over_bytes = active_bytes > thresholds.max_bytes; if over_turns || over_bytes { RotationDecision::Archive { keep_from } } else { RotationDecision::Skip } } /// Clampe une taille de page demandée dans `[1, PAGE_MAX_LIMIT]` (lot LS6), en appliquant /// [`PAGE_DEFAULT_LIMIT`] quand l'appelant ne précise rien (`limit == 0`). Pure/testable. #[must_use] pub fn clamp_page_limit(limit: usize) -> usize { let limit = if limit == 0 { PAGE_DEFAULT_LIMIT } else { limit }; limit.clamp(1, PAGE_MAX_LIMIT) } /// Le port d'**archivage & pagination** du log d'une conversation (port driven, lot LS6). /// /// Complète [`ConversationLog`] (append/read/last, **inchangé**) sans le modifier : la /// rotation est une opération **hors chemin chaud** (jamais déclenchée par un `append`) et /// la pagination est une lecture **humaine** archive-aware. Implémenté par le **même** /// adapter FS que [`ConversationLog`] (il détient déjà les fichiers). /// /// `#[async_trait]` + erreur [`StoreError`] comme les ports voisins ; injecté en /// `Arc` au composition root, gardé object-safe. #[async_trait::async_trait] pub trait ConversationArchive: Send + Sync { /// Statistiques bon marché du segment actif (déclencheur de rotation). /// /// # Errors /// [`StoreError`] en cas d'échec de lecture. async fn stats(&self, conversation: ConversationId) -> Result; /// Archive la tête froide en **gardant** tout à partir de `keep_from` dans l'actif /// (INV-LS6). Idempotente ; ne perd jamais de tour ; n'archive jamais un tour /// `≥ keep_from`. No-op si `keep_from` est absent de l'actif (déjà roté/inconnu). /// /// # Errors /// [`StoreError`] en cas d'échec d'I/O. async fn rotate( &self, conversation: ConversationId, keep_from: TurnId, ) -> Result<(), StoreError>; /// Lit une **page humaine** (archive-aware), toujours en ordre chronologique croissant, /// selon le `cursor` et la `limit` (clampée `[1, PAGE_MAX_LIMIT]`). /// /// # Errors /// [`StoreError`] en cas d'échec de lecture. async fn page( &self, conversation: ConversationId, cursor: PageCursor, limit: usize, ) -> Result; } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; use std::sync::Mutex; // --------------------------------------------------------------------- // Deterministic constructors (calqués sur conversation.rs / mailbox.rs) // --------------------------------------------------------------------- fn conv_id(n: u128) -> ConversationId { ConversationId::from_uuid(uuid::Uuid::from_u128(n)) } fn turn_id(n: u128) -> TurnId { TurnId::from_uuid(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() } // --------------------------------------------------------------------- // In-memory double of the port (the normal way to lock a pure port's // contract before the Fs adapter lands in P2). Vec-backed, per conversation. // // The trait is async but the double's state is purely in-memory: the sync // Mutex is locked briefly and **never** held across an `.await` point. // --------------------------------------------------------------------- #[derive(Default)] struct InMemoryConversationLog { threads: Mutex>>, } #[async_trait::async_trait] impl ConversationLog for InMemoryConversationLog { async fn append( &self, conversation: ConversationId, turn: ConversationTurn, ) -> Result<(), StoreError> { self.threads .lock() .unwrap() .entry(conversation) .or_default() .push(turn); Ok(()) } async fn read( &self, conversation: ConversationId, since: Option, ) -> Result, StoreError> { let guard = self.threads.lock().unwrap(); let Some(thread) = guard.get(&conversation) else { return Ok(Vec::new()); }; let out = match since { None => thread.clone(), Some(cursor) => { // Strictly-after semantics: everything past the cursor's position. match thread.iter().position(|t| t.id == cursor) { Some(idx) => thread[idx + 1..].to_vec(), None => Vec::new(), } } }; Ok(out) } async fn last( &self, conversation: ConversationId, n: usize, ) -> Result, StoreError> { let guard = self.threads.lock().unwrap(); let Some(thread) = guard.get(&conversation) else { return Ok(Vec::new()); }; let start = thread.len().saturating_sub(n); Ok(thread[start..].to_vec()) } } // --------------------------------------------------------------------- // Serde round-trips (verrouille camelCase / transparent) // --------------------------------------------------------------------- #[test] fn turn_id_serializes_transparently() { let u = uuid::Uuid::from_u128(7); let id = TurnId::from_uuid(u); let json = serde_json::to_string(&id).unwrap(); // `#[serde(transparent)]` => bare quoted uuid, no wrapper object. assert_eq!(json, format!("\"{u}\"")); let back: TurnId = serde_json::from_str(&json).unwrap(); assert_eq!(back, id); } #[test] fn turn_role_serializes_camel_case() { assert_eq!( serde_json::to_string(&TurnRole::Prompt).unwrap(), "\"prompt\"" ); assert_eq!( serde_json::to_string(&TurnRole::Response).unwrap(), "\"response\"" ); assert_eq!( serde_json::to_string(&TurnRole::ToolActivity).unwrap(), "\"toolActivity\"" ); for role in [TurnRole::Prompt, TurnRole::Response, TurnRole::ToolActivity] { let json = serde_json::to_string(&role).unwrap(); let back: TurnRole = serde_json::from_str(&json).unwrap(); assert_eq!(back, role); } } #[test] fn conversation_turn_round_trips_in_camel_case() { let t = ConversationTurn::new( turn_id(3), conv_id(9), 1_700_000_000_123, InputSource::Human, TurnRole::Prompt, "hello", ); let json = serde_json::to_string(&t).unwrap(); // camelCase field renaming on the struct. assert!(json.contains("\"atMs\":1700000000123"), "got: {json}"); assert!(!json.contains("at_ms"), "snake_case leaked: {json}"); let back: ConversationTurn = serde_json::from_str(&json).unwrap(); assert_eq!(back, t); } #[test] fn conversation_turn_round_trips_agent_source() { let from = crate::ids::AgentId::from_uuid(uuid::Uuid::from_u128(11)); let t = ConversationTurn::new( turn_id(4), conv_id(9), 42, InputSource::agent(from), TurnRole::ToolActivity, "ran tool", ); let json = serde_json::to_string(&t).unwrap(); let back: ConversationTurn = serde_json::from_str(&json).unwrap(); assert_eq!(back, t); assert_eq!(back.source.as_agent(), Some(from)); } // --------------------------------------------------------------------- // Port contract — via the in-memory double // --------------------------------------------------------------------- // --------------------------------------------------------------------- // bound_handoff_summary (lot LS5) — borne universelle du summary_md // --------------------------------------------------------------------- fn handoff(summary: &str) -> Handoff { Handoff::new(summary, turn_id(1), Some("garder l'objectif".to_owned())) } #[test] fn bound_under_limit_is_unchanged() { let h = handoff("**Objectif :** x\n\n- **Prompt:** a\n- **Response:** b"); let bounded = bound_handoff_summary(h.clone(), HANDOFF_SUMMARY_MAX_CHARS); assert_eq!(bounded, h, "sous la borne ⇒ inchangé (coût nul)"); } #[test] fn bound_preserves_objective_and_cursor_and_drops_oldest_turns() { // 10 lignes de tours ; borne minuscule ⇒ on garde l'objectif + le suffixe récent. let mut s = String::from("**Objectif :** garder ceci"); for i in 0..10 { s.push_str(&format!("\n\n")); s.push_str(&format!("- **Prompt:** tour-{i}")); } // Reconstruire au bon format (objectif + lignes jointes par \n). let summary = { let mut out = String::from("**Objectif :** garder ceci\n\n"); let lines: Vec = (0..10).map(|i| format!("- **Prompt:** tour-{i}")).collect(); out.push_str(&lines.join("\n")); out }; let h = Handoff::new(summary, turn_id(7), Some("garder ceci".to_owned())); let bounded = bound_handoff_summary(h.clone(), 60); assert!(bounded.summary_md.chars().count() <= 60); // Objectif + curseur jamais modifiés. assert_eq!(bounded.objective, h.objective); assert_eq!(bounded.up_to, h.up_to); assert!(bounded.summary_md.contains("**Objectif :** garder ceci")); // Les plus récents survivent, les plus anciens sont droppés. assert!( bounded.summary_md.contains("tour-9"), "le plus récent reste" ); assert!( !bounded.summary_md.contains("tour-0"), "le plus ancien droppé" ); // Chaque ligne de tour conservée reste reparsable. for line in bounded.summary_md.lines().filter(|l| l.starts_with("- **")) { assert!(line.starts_with("- **")); } } #[test] fn bound_hard_truncates_a_single_oversize_line_keeping_prefix() { let long = "x".repeat(500); let summary = format!("- **Response:** {long}"); let h = Handoff::new(summary, turn_id(3), None); let bounded = bound_handoff_summary(h, 60); assert!(bounded.summary_md.chars().count() <= 60); assert!( bounded.summary_md.starts_with("- **Response:** "), "préfixe de tour intact ⇒ reparse valide : {}", bounded.summary_md ); } #[test] fn bound_is_idempotent() { let summary = { let mut out = String::from("**Objectif :** garder ceci\n\n"); let lines: Vec = (0..30) .map(|i| { format!( "- **Prompt:** un tour relativement long numero {i} {}", "y".repeat(50) ) }) .collect(); out.push_str(&lines.join("\n")); out }; let h = Handoff::new(summary, turn_id(9), Some("garder ceci".to_owned())); let once = bound_handoff_summary(h, 200); let twice = bound_handoff_summary(once.clone(), 200); assert_eq!(once, twice, "bound(bound(x)) == bound(x)"); assert!(once.summary_md.chars().count() <= 200); } // --------------------------------------------------------------------- // rotation_plan + clamp_page_limit (lot LS6) — fonctions pures // --------------------------------------------------------------------- #[test] fn rotation_plan_skips_without_floor() { // Largement au-dessus des seuils mais pas de plancher ⇒ jamais de rotation. let d = rotation_plan(10_000, 10 << 20, None, RotationThresholds::defaults()); assert_eq!(d, RotationDecision::Skip); } #[test] fn rotation_plan_skips_with_nil_sentinel_floor() { let nil = TurnId::from_uuid(uuid::Uuid::nil()); let d = rotation_plan(10_000, 10 << 20, Some(nil), RotationThresholds::defaults()); assert_eq!( d, RotationDecision::Skip, "sentinelle nil ⇒ pas de rotation" ); } #[test] fn rotation_plan_skips_under_both_thresholds() { let floor = turn_id(7); let d = rotation_plan(10, 1_000, Some(floor), RotationThresholds::defaults()); assert_eq!(d, RotationDecision::Skip); } #[test] fn rotation_plan_archives_over_either_threshold_keeping_floor() { let floor = turn_id(7); // Au-dessus du seuil de tours seulement. assert_eq!( rotation_plan( ROTATE_AFTER_TURNS + 1, 0, Some(floor), RotationThresholds::defaults() ), RotationDecision::Archive { keep_from: floor } ); // Au-dessus du seuil d'octets seulement. assert_eq!( rotation_plan( 0, ROTATE_AFTER_BYTES + 1, Some(floor), RotationThresholds::defaults() ), RotationDecision::Archive { keep_from: floor } ); } #[test] fn rotation_plan_is_idempotent() { let floor = turn_id(7); let inputs = (ROTATE_AFTER_TURNS + 5, ROTATE_AFTER_BYTES + 5); let once = rotation_plan( inputs.0, inputs.1, Some(floor), RotationThresholds::defaults(), ); let twice = rotation_plan( inputs.0, inputs.1, Some(floor), RotationThresholds::defaults(), ); assert_eq!(once, twice); } #[test] fn clamp_page_limit_applies_default_and_bounds() { assert_eq!(clamp_page_limit(0), PAGE_DEFAULT_LIMIT, "0 ⇒ défaut"); assert_eq!(clamp_page_limit(1), 1); assert_eq!(clamp_page_limit(50), 50); assert_eq!(clamp_page_limit(10_000), PAGE_MAX_LIMIT, "clampé au max"); } #[tokio::test] async fn append_then_read_all_preserves_insertion_order() { let log = InMemoryConversationLog::default(); 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(); let all = log.read(c, None).await.unwrap(); assert_eq!(texts(&all), vec!["a", "b", "c"]); } #[tokio::test] async fn read_with_cursor_is_strictly_exclusive() { let log = InMemoryConversationLog::default(); 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(); } // since = id of "a" => only the strictly-posterior tours, "a" excluded. let after_a = log.read(c, Some(turn_id(1))).await.unwrap(); assert_eq!(texts(&after_a), vec!["b", "c"]); let after_b = log.read(c, Some(turn_id(2))).await.unwrap(); assert_eq!(texts(&after_b), vec!["c"]); } #[tokio::test] async fn read_cursor_on_last_id_yields_empty() { let log = InMemoryConversationLog::default(); 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(); let after_last = log.read(c, Some(turn_id(2))).await.unwrap(); assert!(after_last.is_empty(), "cursor on last id => nothing after"); } #[tokio::test] async fn last_zero_is_empty() { let log = InMemoryConversationLog::default(); let c = conv_id(1); log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "a")) .await .unwrap(); assert!(log.last(c, 0).await.unwrap().is_empty()); } #[tokio::test] async fn last_on_empty_thread_is_empty() { let log = InMemoryConversationLog::default(); // Never appended to this conversation. assert!(log.last(conv_id(99), 5).await.unwrap().is_empty()); assert!(log.read(conv_id(99), None).await.unwrap().is_empty()); } #[tokio::test] async fn last_n_greater_than_len_returns_everything() { let log = InMemoryConversationLog::default(); let c = conv_id(1); for (id, txt) in [(1, "a"), (2, "b")] { log.append(c, turn(c, turn_id(id), TurnRole::Prompt, txt)) .await .unwrap(); } let last = log.last(c, 10).await.unwrap(); assert_eq!(texts(&last), vec!["a", "b"]); } #[tokio::test] async fn last_n_less_than_len_returns_the_n_last_in_order() { let log = InMemoryConversationLog::default(); 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(); } let last2 = log.last(c, 2).await.unwrap(); assert_eq!( texts(&last2), vec!["c", "d"], "the 2 last, in insertion order" ); } #[tokio::test] async fn conversations_are_disjoint_threads() { let log = InMemoryConversationLog::default(); 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(); 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"]); // A cursor from one thread never leaks tours from another. assert_eq!(texts(&log.last(c2, 10).await.unwrap()), vec!["c2-a"]); } }