feat(domain,infra,app): rotation/rétention log.jsonl + lecture paginée (LS6)
Backend uniquement (UI React repoussée à LS7) : - domain : port ConversationArchive + structs SegmentStats/PageCursor/PageDirection/ TurnSlice/RotationDecision/RotationThresholds, fn pures rotation_plan/clamp_page_limit + consts ; re-exports lib. - infrastructure : impl ConversationArchive pour FsConversationLog (stats/rotate/page + helpers), archive segmentée hors chemin chaud. - application : ReadConversationPage + DTO + ConversationArchiveProvider (conversation/paginate), RotateConversationLog (conversation/rotate), exports mod/lib. - app-tauri : AppConversationArchiveProvider + wiring (state), rotation détachée dans launch_agent + commande read_conversation_page (commands), DTOs (dto), commande enregistrée (generate_handler!). - tests (QA, verts) : conversation_log, conversation_rotate_paginate (nouveau), dto (module test). Pivots INV-LS6 et cohérence fold-après-rotation verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -13,6 +13,13 @@
|
||||
//! the PTY or `app-tauri` (that is P6b). It is fully testable with in-memory fakes
|
||||
//! of the three ports.
|
||||
|
||||
mod paginate;
|
||||
mod record;
|
||||
mod rotate;
|
||||
|
||||
pub use paginate::{
|
||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, TurnPage,
|
||||
TurnSource, TurnView,
|
||||
};
|
||||
pub use record::RecordTurn;
|
||||
pub use rotate::{RotateConversationLog, RotateConversationLogInput};
|
||||
|
||||
148
crates/application/src/conversation/paginate.rs
Normal file
148
crates/application/src/conversation/paginate.rs
Normal file
@ -0,0 +1,148 @@
|
||||
//! [`ReadConversationPage`] — lecture **humaine** paginée d'une conversation (lot LS6).
|
||||
//!
|
||||
//! Lecture archive-aware (segments d'archive + actif) exposée comme un **DTO humain
|
||||
//! riche** : contrairement au handoff (résumé borné) ou au read-model work-state
|
||||
//! (aperçus tronqués), la page humaine porte le **texte complet, non borné** de chaque
|
||||
//! tour — c'est la vue « transcript » destinée à l'utilisateur (le React arrive en LS7).
|
||||
//!
|
||||
//! Hors chemin chaud : composé du seul port [`ConversationArchive`] (via un provider par
|
||||
//! root), jamais d'`append`/de rotation ici.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{AgentId, ConversationArchive, ConversationId, PageCursor, TurnId, TurnRole};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Fournit le [`ConversationArchive`] **lié au project root** courant (lot LS6),
|
||||
/// calqué sur [`crate::HandoffProvider`]/[`crate::RecordTurnProvider`].
|
||||
///
|
||||
/// Les use cases d'archivage/pagination ([`crate::RotateConversationLog`],
|
||||
/// [`ReadConversationPage`]) sont **uniques** pour tous les projets, alors que les logs
|
||||
/// sont **par project root** (`<root>/.ideai/conversations/`) et l'adapter `Fs*` fixe sa
|
||||
/// racine à la construction. Ce port matérialise un archive ciblant le **bon** dossier à
|
||||
/// chaque appel. `None` ⇒ pas d'archivage/pagination (best-effort absent). Implémenté
|
||||
/// dans `app-tauri` (seul détenteur des adapters `Fs*`).
|
||||
pub trait ConversationArchiveProvider: Send + Sync {
|
||||
/// Construit le [`ConversationArchive`] dont la persistance cible `root`, ou `None`.
|
||||
fn conversation_archive_for(&self, root: &ProjectPath) -> Option<Arc<dyn ConversationArchive>>;
|
||||
}
|
||||
|
||||
/// Origine d'un tour, pour la vue humaine paginée (miroir de [`domain::input::InputSource`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TurnSource {
|
||||
/// L'opérateur humain.
|
||||
Human,
|
||||
/// Un autre agent (délégation via `idea_ask_agent`).
|
||||
Agent {
|
||||
/// L'agent à l'origine du tour.
|
||||
agent_id: AgentId,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<domain::input::InputSource> for TurnSource {
|
||||
fn from(source: domain::input::InputSource) -> Self {
|
||||
match source {
|
||||
domain::input::InputSource::Human => Self::Human,
|
||||
domain::input::InputSource::Agent { agent_id } => Self::Agent { agent_id },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Un tour projeté pour la vue humaine (lot LS6) — **texte complet, non borné**.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TurnView {
|
||||
/// Identifiant stable du tour (sert aussi d'ancre de pagination).
|
||||
pub id: TurnId,
|
||||
/// Horodatage (epoch millisecondes).
|
||||
pub at_ms: u64,
|
||||
/// Nature du tour (invite, réponse, activité outillée).
|
||||
pub role: TurnRole,
|
||||
/// Origine (humain ou agent délégant).
|
||||
pub source: TurnSource,
|
||||
/// Le **texte intégral** du tour (jamais tronqué — c'est la vue transcript).
|
||||
pub text: String,
|
||||
/// Longueur (en caractères) du texte, pour l'UI.
|
||||
pub text_len: usize,
|
||||
}
|
||||
|
||||
impl From<domain::ConversationTurn> for TurnView {
|
||||
fn from(turn: domain::ConversationTurn) -> Self {
|
||||
let text_len = turn.text.chars().count();
|
||||
Self {
|
||||
id: turn.id,
|
||||
at_ms: turn.at_ms,
|
||||
role: turn.role,
|
||||
source: turn.source.into(),
|
||||
text: turn.text,
|
||||
text_len,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Une page de tours pour la vue humaine (lot LS6).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TurnPage {
|
||||
/// Les tours de la page, **du plus ancien au plus récent**.
|
||||
pub turns: Vec<TurnView>,
|
||||
/// `true` s'il existe d'autres tours au-delà de la page dans le sens de progression.
|
||||
pub has_more: bool,
|
||||
/// L'id du **dernier** tour de la page (ancre pour la requête suivante) ; `None` si
|
||||
/// la page est vide.
|
||||
pub next_anchor: Option<TurnId>,
|
||||
}
|
||||
|
||||
/// Entrée de [`ReadConversationPage::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadConversationPageInput {
|
||||
/// Le project root dont dériver l'archive.
|
||||
pub project_root: ProjectPath,
|
||||
/// La conversation (paire) à lire.
|
||||
pub conversation: ConversationId,
|
||||
/// Le curseur (ancre + sens) de pagination.
|
||||
pub cursor: PageCursor,
|
||||
/// La taille de page demandée (`0` ⇒ défaut ; clampée par le domaine).
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
/// Use case de lecture humaine paginée (lot LS6) — consomme le seul
|
||||
/// [`ConversationArchive`] (résolu par root via le provider).
|
||||
pub struct ReadConversationPage {
|
||||
archives: Arc<dyn ConversationArchiveProvider>,
|
||||
}
|
||||
|
||||
impl ReadConversationPage {
|
||||
/// Construit le use case à partir du provider d'archive par root.
|
||||
#[must_use]
|
||||
pub fn new(archives: Arc<dyn ConversationArchiveProvider>) -> Self {
|
||||
Self { archives }
|
||||
}
|
||||
|
||||
/// Lit une page de `conversation`, mappée vers le DTO humain riche.
|
||||
///
|
||||
/// Provider d'archive absent pour ce root ⇒ page **vide** (best-effort, jamais une
|
||||
/// erreur dure). Le texte de chaque tour est renvoyé **intégralement**.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] si la lecture du log échoue.
|
||||
pub async fn execute(&self, input: ReadConversationPageInput) -> Result<TurnPage, AppError> {
|
||||
let Some(archive) = self.archives.conversation_archive_for(&input.project_root) else {
|
||||
return Ok(TurnPage {
|
||||
turns: Vec::new(),
|
||||
has_more: false,
|
||||
next_anchor: None,
|
||||
});
|
||||
};
|
||||
let slice = archive
|
||||
.page(input.conversation, input.cursor, input.limit)
|
||||
.await?;
|
||||
let next_anchor = slice.turns.last().map(|t| t.id);
|
||||
let turns = slice.turns.into_iter().map(TurnView::from).collect();
|
||||
Ok(TurnPage {
|
||||
turns,
|
||||
has_more: slice.has_more,
|
||||
next_anchor,
|
||||
})
|
||||
}
|
||||
}
|
||||
95
crates/application/src/conversation/rotate.rs
Normal file
95
crates/application/src/conversation/rotate.rs
Normal file
@ -0,0 +1,95 @@
|
||||
//! [`RotateConversationLog`] — rotation **best-effort** du log d'une conversation (lot LS6).
|
||||
//!
|
||||
//! Déclenchée **hors chemin chaud** (à la reprise/ouverture d'un fil, jamais par un
|
||||
//! `append`), elle borne le segment actif en archivant sa tête froide. L'invariant pivot
|
||||
//! (INV-LS6) est garanti par le **plancher** : `keep_from = up_to` du handoff courant ⇒
|
||||
//! aucun tour `≥ up_to` n'est jamais archivé, donc `read(since = up_to)` (fold incrémental)
|
||||
//! et la reprise restent corrects.
|
||||
//!
|
||||
//! Best-effort strict : pas de handoff (ou `up_to` nil) ⇒ skip ; toute erreur store est
|
||||
//! typée et **avalée par l'appelant** (le câblage app-tauri), jamais propagée en échec de
|
||||
//! reprise.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{rotation_plan, ConversationId, RotationDecision, RotationThresholds};
|
||||
|
||||
use crate::conversation::ConversationArchiveProvider;
|
||||
use crate::error::AppError;
|
||||
use crate::HandoffProvider;
|
||||
|
||||
/// Entrée de [`RotateConversationLog::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RotateConversationLogInput {
|
||||
/// Le project root dont dériver l'archive et le handoff.
|
||||
pub project_root: ProjectPath,
|
||||
/// La conversation (paire) à roter.
|
||||
pub conversation: ConversationId,
|
||||
}
|
||||
|
||||
/// Use case de rotation du log (lot LS6) — consomme un [`ConversationArchive`] et le
|
||||
/// [`HandoffStore`] (pour lire `floor = up_to`), tous deux résolus par root.
|
||||
///
|
||||
/// [`ConversationArchive`]: domain::ConversationArchive
|
||||
/// [`HandoffStore`]: domain::HandoffStore
|
||||
pub struct RotateConversationLog {
|
||||
archives: Arc<dyn ConversationArchiveProvider>,
|
||||
handoffs: Arc<dyn HandoffProvider>,
|
||||
thresholds: RotationThresholds,
|
||||
}
|
||||
|
||||
impl RotateConversationLog {
|
||||
/// Construit le use case à partir des providers par root (seuils par défaut).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
archives: Arc<dyn ConversationArchiveProvider>,
|
||||
handoffs: Arc<dyn HandoffProvider>,
|
||||
) -> Self {
|
||||
Self {
|
||||
archives,
|
||||
handoffs,
|
||||
thresholds: RotationThresholds::defaults(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applique la politique de rotation à `conversation`, si nécessaire.
|
||||
///
|
||||
/// Étapes : résoudre l'archive + le handoff store du root ; lire le plancher
|
||||
/// `floor = up_to` (pas de handoff ⇒ `None` ⇒ skip) ; lire les `stats` bon marché du
|
||||
/// segment actif ; décider via [`rotation_plan`] ; archiver
|
||||
/// (`rotate(keep_from = up_to)`) seulement si la décision est `Archive`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] si la lecture du handoff/stats ou la rotation échoue. L'appelant
|
||||
/// (câblage) **avale** cette erreur : la rotation ne doit jamais casser une reprise.
|
||||
pub async fn execute(&self, input: RotateConversationLogInput) -> Result<(), AppError> {
|
||||
// Sans provider d'archive/handoff câblé pour ce root ⇒ rien à faire (best-effort).
|
||||
let Some(archive) = self.archives.conversation_archive_for(&input.project_root) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(handoff_store) = self.handoffs.handoff_store_for(&input.project_root) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Plancher = `up_to` du handoff courant (pas de handoff ⇒ None ⇒ skip plus bas).
|
||||
let floor = handoff_store
|
||||
.load(input.conversation)
|
||||
.await?
|
||||
.map(|handoff| handoff.up_to);
|
||||
|
||||
let stats = archive.stats(input.conversation).await?;
|
||||
match rotation_plan(
|
||||
stats.active_turns,
|
||||
stats.active_bytes,
|
||||
floor,
|
||||
self.thresholds,
|
||||
) {
|
||||
RotationDecision::Skip => Ok(()),
|
||||
RotationDecision::Archive { keep_from } => archive
|
||||
.rotate(input.conversation, keep_from)
|
||||
.await
|
||||
.map_err(AppError::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -47,7 +47,10 @@ pub use agent::{
|
||||
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
};
|
||||
pub use conversation::RecordTurn;
|
||||
pub use conversation::{
|
||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
||||
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
|
||||
};
|
||||
pub use embedder::{
|
||||
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
|
||||
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice,
|
||||
|
||||
343
crates/application/tests/conversation_rotate_paginate.rs
Normal file
343
crates/application/tests/conversation_rotate_paginate.rs
Normal file
@ -0,0 +1,343 @@
|
||||
//! LS6 tests — `RotateConversationLog` (rotation best-effort, plancher = `up_to`) et
|
||||
//! `ReadConversationPage` (DTO humain riche, texte complet, mapping source), avec des
|
||||
//! fakes in-memory des ports (aucune I/O réelle ; celle-ci est couverte côté infra).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::input::InputSource;
|
||||
use domain::ports::StoreError;
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{
|
||||
AgentId, ConversationArchive, ConversationId, ConversationTurn, Handoff, HandoffStore,
|
||||
PageCursor, PageDirection, SegmentStats, TurnId, TurnRole, TurnSlice,
|
||||
};
|
||||
|
||||
use application::{
|
||||
ConversationArchiveProvider, HandoffProvider, ReadConversationPage, ReadConversationPageInput,
|
||||
RotateConversationLog, RotateConversationLogInput, TurnSource,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructeurs déterministes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 agent_id(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
fn root() -> ProjectPath {
|
||||
ProjectPath::new("/home/me/proj").unwrap()
|
||||
}
|
||||
fn turn(id: TurnId, source: InputSource, role: TurnRole, text: &str) -> ConversationTurn {
|
||||
ConversationTurn::new(id, conv_id(1), 1_700_000_000_000, source, role, text)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes des ports LS6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// [`ConversationArchive`] in-memory : `stats` configurables, `page` servie depuis un
|
||||
/// Vec, et **enregistrement** des appels `rotate` (pour prouver `keep_from = up_to`).
|
||||
/// Optionnellement, force une erreur store pour prouver la propagation typée.
|
||||
struct FakeArchive {
|
||||
stats: Mutex<SegmentStats>,
|
||||
turns: Mutex<Vec<ConversationTurn>>,
|
||||
rotate_calls: Mutex<Vec<TurnId>>,
|
||||
fail: Mutex<bool>,
|
||||
}
|
||||
impl FakeArchive {
|
||||
fn build(stats: SegmentStats, turns: Vec<ConversationTurn>, fail: bool) -> Self {
|
||||
Self {
|
||||
stats: Mutex::new(stats),
|
||||
turns: Mutex::new(turns),
|
||||
rotate_calls: Mutex::new(Vec::new()),
|
||||
fail: Mutex::new(fail),
|
||||
}
|
||||
}
|
||||
fn with_stats(turns: usize, bytes: u64) -> Self {
|
||||
Self::build(
|
||||
SegmentStats {
|
||||
active_turns: turns,
|
||||
active_bytes: bytes,
|
||||
},
|
||||
Vec::new(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
fn with_page(turns: Vec<ConversationTurn>) -> Self {
|
||||
Self::build(
|
||||
SegmentStats {
|
||||
active_turns: 0,
|
||||
active_bytes: 0,
|
||||
},
|
||||
turns,
|
||||
false,
|
||||
)
|
||||
}
|
||||
fn failing() -> Self {
|
||||
Self::build(
|
||||
SegmentStats {
|
||||
active_turns: 10_000,
|
||||
active_bytes: 0,
|
||||
},
|
||||
Vec::new(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
fn rotate_calls(&self) -> Vec<TurnId> {
|
||||
self.rotate_calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl ConversationArchive for FakeArchive {
|
||||
async fn stats(&self, _c: ConversationId) -> Result<SegmentStats, StoreError> {
|
||||
if *self.fail.lock().unwrap() {
|
||||
return Err(StoreError::Io("forced".into()));
|
||||
}
|
||||
Ok(*self.stats.lock().unwrap())
|
||||
}
|
||||
async fn rotate(&self, _c: ConversationId, keep_from: TurnId) -> Result<(), StoreError> {
|
||||
if *self.fail.lock().unwrap() {
|
||||
return Err(StoreError::Io("forced".into()));
|
||||
}
|
||||
self.rotate_calls.lock().unwrap().push(keep_from);
|
||||
Ok(())
|
||||
}
|
||||
async fn page(
|
||||
&self,
|
||||
_c: ConversationId,
|
||||
cursor: PageCursor,
|
||||
limit: usize,
|
||||
) -> Result<TurnSlice, StoreError> {
|
||||
if *self.fail.lock().unwrap() {
|
||||
return Err(StoreError::Io("forced".into()));
|
||||
}
|
||||
// Pagination minimale Forward-depuis-début suffisante pour les tests DTO.
|
||||
let all = self.turns.lock().unwrap().clone();
|
||||
let _ = cursor;
|
||||
let end = limit.min(all.len());
|
||||
Ok(TurnSlice {
|
||||
turns: all[..end].to_vec(),
|
||||
has_more: end < all.len(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeArchiveProvider(Arc<dyn ConversationArchive>);
|
||||
impl ConversationArchiveProvider for FakeArchiveProvider {
|
||||
fn conversation_archive_for(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
) -> Option<Arc<dyn ConversationArchive>> {
|
||||
Some(Arc::clone(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider qui ne fournit **aucune** archive (best-effort absent).
|
||||
struct NoArchiveProvider;
|
||||
impl ConversationArchiveProvider for NoArchiveProvider {
|
||||
fn conversation_archive_for(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
) -> Option<Arc<dyn ConversationArchive>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeHandoffStore(Mutex<Option<Handoff>>);
|
||||
#[async_trait]
|
||||
impl HandoffStore for FakeHandoffStore {
|
||||
async fn load(&self, _c: ConversationId) -> Result<Option<Handoff>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
async fn save(&self, _c: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
*self.0.lock().unwrap() = Some(handoff);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
struct FakeHandoffProvider(Arc<dyn HandoffStore>);
|
||||
impl HandoffProvider for FakeHandoffProvider {
|
||||
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn HandoffStore>> {
|
||||
Some(Arc::clone(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
fn rotate_uc(
|
||||
archive: Arc<dyn ConversationArchive>,
|
||||
handoff: Option<Handoff>,
|
||||
) -> RotateConversationLog {
|
||||
let store = Arc::new(FakeHandoffStore(Mutex::new(handoff))) as Arc<dyn HandoffStore>;
|
||||
RotateConversationLog::new(
|
||||
Arc::new(FakeArchiveProvider(archive)),
|
||||
Arc::new(FakeHandoffProvider(store)),
|
||||
)
|
||||
}
|
||||
|
||||
fn input() -> RotateConversationLogInput {
|
||||
RotateConversationLogInput {
|
||||
project_root: root(),
|
||||
conversation: conv_id(1),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RotateConversationLog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Plancher lu du handoff + stats au-dessus du seuil ⇒ `rotate(keep_from = up_to)`.
|
||||
#[tokio::test]
|
||||
async fn rotate_uses_handoff_up_to_as_floor_and_calls_rotate() {
|
||||
// Stats au-dessus du seuil de tours (défaut 500).
|
||||
let archive = Arc::new(FakeArchive::with_stats(10_000, 0));
|
||||
let handoff = Handoff::new("résumé", turn_id(42), Some("but".to_owned()));
|
||||
let uc = rotate_uc(
|
||||
archive.clone() as Arc<dyn ConversationArchive>,
|
||||
Some(handoff),
|
||||
);
|
||||
|
||||
uc.execute(input()).await.expect("rotate ok");
|
||||
|
||||
assert_eq!(
|
||||
archive.rotate_calls(),
|
||||
vec![turn_id(42)],
|
||||
"rotate appelé avec keep_from = up_to du handoff"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pas de handoff ⇒ `floor = None` ⇒ skip : `rotate` jamais appelé même au-dessus du seuil.
|
||||
#[tokio::test]
|
||||
async fn rotate_skips_without_handoff() {
|
||||
let archive = Arc::new(FakeArchive::with_stats(10_000, u64::MAX));
|
||||
let uc = rotate_uc(archive.clone() as Arc<dyn ConversationArchive>, None);
|
||||
|
||||
uc.execute(input()).await.expect("skip ok");
|
||||
|
||||
assert!(
|
||||
archive.rotate_calls().is_empty(),
|
||||
"sans handoff ⇒ aucun rotate"
|
||||
);
|
||||
}
|
||||
|
||||
/// Handoff présent mais stats SOUS les deux seuils ⇒ skip (pas de rotation).
|
||||
#[tokio::test]
|
||||
async fn rotate_skips_under_thresholds() {
|
||||
let archive = Arc::new(FakeArchive::with_stats(10, 1_000));
|
||||
let handoff = Handoff::new("r", turn_id(5), None);
|
||||
let uc = rotate_uc(
|
||||
archive.clone() as Arc<dyn ConversationArchive>,
|
||||
Some(handoff),
|
||||
);
|
||||
|
||||
uc.execute(input()).await.expect("skip ok");
|
||||
|
||||
assert!(archive.rotate_calls().is_empty(), "sous les seuils ⇒ skip");
|
||||
}
|
||||
|
||||
/// Provider d'archive absent ⇒ no-op silencieux (best-effort), jamais une erreur.
|
||||
#[tokio::test]
|
||||
async fn rotate_without_archive_provider_is_noop() {
|
||||
let store = Arc::new(FakeHandoffStore(Mutex::new(Some(Handoff::new(
|
||||
"r",
|
||||
turn_id(5),
|
||||
None,
|
||||
))))) as Arc<dyn HandoffStore>;
|
||||
let uc = RotateConversationLog::new(
|
||||
Arc::new(NoArchiveProvider),
|
||||
Arc::new(FakeHandoffProvider(store)),
|
||||
);
|
||||
uc.execute(input()).await.expect("no provider ⇒ Ok(())");
|
||||
}
|
||||
|
||||
/// Une erreur store est **propagée typée** (`AppError::Store`) par le use case ; le
|
||||
/// **swallowing best-effort** est la responsabilité du câblage (cf. test app-tauri).
|
||||
#[tokio::test]
|
||||
async fn rotate_propagates_typed_store_error() {
|
||||
let archive = Arc::new(FakeArchive::failing());
|
||||
let handoff = Handoff::new("r", turn_id(9), None);
|
||||
let uc = rotate_uc(archive as Arc<dyn ConversationArchive>, Some(handoff));
|
||||
|
||||
let err = uc.execute(input()).await.expect_err("store error propagée");
|
||||
assert!(
|
||||
matches!(err, application::AppError::Store(_)),
|
||||
"erreur store typée, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReadConversationPage — DTO humain riche
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// La page mappe un DTO riche : texte **complet** (non borné), `text_len` correct,
|
||||
/// mapping source Human/Agent, `has_more` et `next_anchor` (= dernier id de la page).
|
||||
#[tokio::test]
|
||||
async fn read_page_maps_rich_dto_with_full_text_and_sources() {
|
||||
let big = "Z".repeat(30_000);
|
||||
let turns = vec![
|
||||
turn(turn_id(1), InputSource::Human, TurnRole::Prompt, &big),
|
||||
turn(
|
||||
turn_id(2),
|
||||
InputSource::agent(agent_id(7)),
|
||||
TurnRole::Response,
|
||||
"réponse agent",
|
||||
),
|
||||
turn(turn_id(3), InputSource::Human, TurnRole::Prompt, "suite"),
|
||||
];
|
||||
let archive = Arc::new(FakeArchive::with_page(turns));
|
||||
let uc = ReadConversationPage::new(Arc::new(FakeArchiveProvider(archive)));
|
||||
|
||||
// limit 2 ⇒ page [1,2], has_more (il reste le tour 3).
|
||||
let page = uc
|
||||
.execute(ReadConversationPageInput {
|
||||
project_root: root(),
|
||||
conversation: conv_id(1),
|
||||
cursor: PageCursor {
|
||||
anchor: None,
|
||||
direction: PageDirection::Forward,
|
||||
},
|
||||
limit: 2,
|
||||
})
|
||||
.await
|
||||
.expect("page ok");
|
||||
|
||||
assert_eq!(page.turns.len(), 2);
|
||||
// Texte complet préservé + text_len.
|
||||
assert_eq!(page.turns[0].text.chars().count(), 30_000);
|
||||
assert_eq!(page.turns[0].text_len, 30_000);
|
||||
// Mapping source : Human puis Agent{7}.
|
||||
assert_eq!(page.turns[0].source, TurnSource::Human);
|
||||
assert_eq!(
|
||||
page.turns[1].source,
|
||||
TurnSource::Agent {
|
||||
agent_id: agent_id(7)
|
||||
}
|
||||
);
|
||||
// has_more + next_anchor = dernier id de la page (tour 2).
|
||||
assert!(page.has_more, "il reste le tour 3");
|
||||
assert_eq!(page.next_anchor, Some(turn_id(2)));
|
||||
}
|
||||
|
||||
/// Provider d'archive absent ⇒ page **vide** (best-effort), `next_anchor = None`.
|
||||
#[tokio::test]
|
||||
async fn read_page_without_provider_is_empty() {
|
||||
let uc = ReadConversationPage::new(Arc::new(NoArchiveProvider));
|
||||
let page = uc
|
||||
.execute(ReadConversationPageInput {
|
||||
project_root: root(),
|
||||
conversation: conv_id(1),
|
||||
cursor: PageCursor {
|
||||
anchor: None,
|
||||
direction: PageDirection::Forward,
|
||||
},
|
||||
limit: 50,
|
||||
})
|
||||
.await
|
||||
.expect("page ok");
|
||||
assert!(page.turns.is_empty() && !page.has_more && page.next_anchor.is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user