Backend uniquement (UI React repoussée à LS7) : - domain : port ConversationArchive + structs SegmentStats/PageCursor/PageDirection/ TurnSlice/RotationDecision/RotationThresholds, fn pures rotation_plan/clamp_page_limit + consts ; re-exports lib. - infrastructure : impl ConversationArchive pour FsConversationLog (stats/rotate/page + helpers), archive segmentée hors chemin chaud. - application : ReadConversationPage + DTO + ConversationArchiveProvider (conversation/paginate), RotateConversationLog (conversation/rotate), exports mod/lib. - app-tauri : AppConversationArchiveProvider + wiring (state), rotation détachée dans launch_agent + commande read_conversation_page (commands), DTOs (dto), commande enregistrée (generate_handler!). - tests (QA, verts) : conversation_log, conversation_rotate_paginate (nouveau), dto (module test). Pivots INV-LS6 et cohérence fold-après-rotation verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1115 lines
44 KiB
Rust
1115 lines
44 KiB
Rust
//! 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<String>,
|
|
) -> 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<dyn ConversationLog>` 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<TurnId>,
|
|
) -> Result<Vec<ConversationTurn>, 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<Vec<ConversationTurn>, 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<String>,
|
|
}
|
|
|
|
impl Handoff {
|
|
/// Construit un handoff à partir de ses composants.
|
|
#[must_use]
|
|
pub fn new(summary_md: impl Into<String>, up_to: TurnId, objective: Option<String>) -> 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 `- **<role>:** <texte>`. 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<String> = 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 `- **<role>:** ` 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<dyn HandoffStore>` 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<Option<Handoff>, 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<dyn HandoffSummarizer>` 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<Handoff>, 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<dyn ProviderSessionStore>`
|
|
/// 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<Option<String>, 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.<k>.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<TurnId>,
|
|
/// 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<ConversationTurn>,
|
|
/// `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<TurnId>,
|
|
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<dyn ConversationArchive>` 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<SegmentStats, StoreError>;
|
|
|
|
/// 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<TurnSlice, StoreError>;
|
|
}
|
|
|
|
#[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<String> {
|
|
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<HashMap<ConversationId, Vec<ConversationTurn>>>,
|
|
}
|
|
|
|
#[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<TurnId>,
|
|
) -> Result<Vec<ConversationTurn>, 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<Vec<ConversationTurn>, 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<String> = (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<String> = (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"]);
|
|
}
|
|
}
|