1use std::sync::Arc;
22
23use parking_lot::Mutex;
24
25use serde_json::Value;
26
27use crate::client::LLMClient;
28use crate::micro::salvage_json;
29
30const SCORE_SYSTEM: &str = "You rate one conversation turn for long-term value. \
32Reply with ONLY a JSON object {\"score\":N} where N is 1-5: \
335 = critical fact or decision worth keeping verbatim, \
344 = useful detail, \
353 = mild context value, \
362 = mostly filler, \
371 = worthless. \
38Score low when unsure.";
39
40const AUDIT_SYSTEM: &str = "You re-rate conversation turns for long-term value. \
42Each turn is prefixed with its sequence number [seq]. \
43Reply with ONLY a JSON array [{\"seq\":N,\"score\":N}] covering EVERY listed seq, \
44scores 1-5: \
455 = critical fact or decision worth keeping verbatim, \
464 = useful detail, \
473 = mild context value, \
482 = mostly filler, \
491 = worthless. \
50Score low when unsure.";
51
52const MEMORY_SYSTEM: &str = "You compress raw conversation notes into a dense rolling summary. \
54Keep only durable facts, decisions and preferences; drop filler. \
55Preserve concrete names, numbers and dates. \
56Output ONLY the summary text, nothing else.";
57
58const S_TIER_SCORE: u8 = 5;
60const MID_TIER_MIN: u8 = 3;
62const MID_TIER_MAX: u8 = 4;
64const LOW_EVICT_SCORE: u8 = 2;
66
67#[derive(Debug, Clone)]
73pub struct CompactConfig {
74 pub trigger_turns: usize,
76 pub history_turns: usize,
79 pub grace_turns: usize,
81 pub memory_max_chars: usize,
83 pub critical_max_items: usize,
86 pub critical_reaudit: usize,
89}
90
91impl Default for CompactConfig {
92 fn default() -> Self {
93 Self {
94 trigger_turns: 6,
95 history_turns: 6,
96 grace_turns: 3,
97 memory_max_chars: 500,
98 critical_max_items: 8,
99 critical_reaudit: 6,
100 }
101 }
102}
103
104use serde::{Deserialize, Serialize};
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct TurnEntry {
109 pub seq: u64,
111 pub user: String,
113 pub assistant: String,
115 pub score: Option<u8>,
117}
118
119#[derive(Debug, Default, Clone, Deserialize, Serialize)]
121pub struct CompactionState {
122 entries: Vec<TurnEntry>,
123 critical: Vec<String>,
124 memory: String,
125 last_audit_seq: u64,
126}
127
128impl CompactionState {
129 pub fn from_parts(
133 entries: Vec<TurnEntry>,
134 critical: Vec<String>,
135 memory: String,
136 last_audit_seq: u64,
137 ) -> Self {
138 Self {
139 entries,
140 critical,
141 memory,
142 last_audit_seq,
143 }
144 }
145
146 pub fn entries(&self) -> &[TurnEntry] {
148 &self.entries
149 }
150
151 pub fn critical(&self) -> &[String] {
153 &self.critical
154 }
155
156 pub fn memory(&self) -> &str {
158 &self.memory
159 }
160
161 pub fn last_audit_seq(&self) -> u64 {
163 self.last_audit_seq
164 }
165
166 fn next_seq(&self) -> u64 {
168 self.entries.last().map(|e| e.seq + 1).unwrap_or(1)
169 }
170
171 fn turns_since_audit(&self) -> usize {
173 self.entries
174 .iter()
175 .filter(|e| e.seq > self.last_audit_seq)
176 .count()
177 }
178
179 fn apply_score(&mut self, seq: u64, score: u8) {
180 if let Some(entry) = self.entries.iter_mut().find(|e| e.seq == seq) {
181 entry.score = Some(score);
182 }
183 }
184
185 fn apply_scores(&mut self, updates: &[(u64, u8)]) {
186 for (seq, score) in updates {
187 self.apply_score(*seq, *score);
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct CompactionSnapshot {
195 pub turn_count: usize,
197 pub scored_count: usize,
199 pub critical_count: usize,
201 pub memory_chars: usize,
203 pub last_audit_seq: u64,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum CompactEvent {
211 Scored {
213 seq: u64,
215 score: u8,
217 },
218 Audited {
220 critical_kept: usize,
222 memory_chars: usize,
224 dropped_seqs: Vec<u64>,
226 },
227 Skipped {
229 reason: &'static str,
232 },
233}
234
235pub struct Compactor {
238 config: CompactConfig,
239 client: Arc<dyn LLMClient>,
240 state: Mutex<CompactionState>,
241}
242
243impl Compactor {
244 pub fn new(config: CompactConfig, client: Arc<dyn LLMClient>) -> Self {
246 Self {
247 config,
248 client,
249 state: Mutex::new(CompactionState::default()),
250 }
251 }
252
253 pub fn with_client(client: Arc<dyn LLMClient>) -> Self {
255 Self::new(CompactConfig::default(), client)
256 }
257
258 pub async fn record_turn(&self, user: String, assistant: String) -> CompactEvent {
265 let seq = {
266 let mut state = self.lock();
267 let seq = state.next_seq();
268 state.entries.push(TurnEntry {
269 seq,
270 user: user.clone(),
271 assistant: assistant.clone(),
272 score: None,
273 });
274 seq
275 };
276
277 let input = format!("user: {}\nassistant: {}", user, assistant);
278 let Ok(text) = self.client.generate_with_system(SCORE_SYSTEM, &input).await else {
279 return CompactEvent::Skipped {
280 reason: "score-call",
281 };
282 };
283 match parse_score(&text) {
284 Some(score) => {
285 self.lock().apply_score(seq, score);
286 CompactEvent::Scored { seq, score }
287 }
288 None => CompactEvent::Skipped {
289 reason: "score-parse",
290 },
291 }
292 }
293
294 pub async fn audit_if_due(&self) -> Vec<CompactEvent> {
306 let (due, candidates) = {
307 let state = self.lock();
308 let due = state.turns_since_audit() >= self.config.trigger_turns
309 || state.entries.len() > self.config.history_turns;
310 let candidates: Vec<TurnEntry> = state
311 .entries
312 .iter()
313 .filter(|e| {
314 e.score.is_none() || e.score <= Some(self.config.critical_reaudit as u8)
315 })
316 .cloned()
317 .collect();
318 (due, candidates)
319 };
320 if !due {
321 return vec![CompactEvent::Skipped { reason: "not-due" }];
322 }
323
324 let listing = candidates
326 .iter()
327 .map(|e| format!("[{}] user: {}\nassistant: {}", e.seq, e.user, e.assistant))
328 .collect::<Vec<_>>()
329 .join("\n---\n");
330 let Ok(text) = self
331 .client
332 .generate_with_system(AUDIT_SYSTEM, &listing)
333 .await
334 else {
335 return vec![CompactEvent::Skipped {
336 reason: "audit-call",
337 }];
338 };
339 let updates = parse_audit_scores(&text);
340 if updates.is_empty() {
341 return vec![CompactEvent::Skipped {
342 reason: "audit-parse",
343 }];
344 }
345
346 let (dropped_seqs, mid_facts, previous_memory) = {
348 let mut state = self.lock();
349 state.apply_scores(&updates);
350
351 let keep_from = state.entries.len().saturating_sub(self.config.grace_turns);
354 let mut dropped_seqs = Vec::new();
355 let mut kept: Vec<TurnEntry> = Vec::with_capacity(state.entries.len());
356 for (position, entry) in state.entries.drain(..).enumerate() {
357 let low = matches!(entry.score, Some(score) if score <= LOW_EVICT_SCORE);
358 let protected = position >= keep_from;
359 if protected || !low {
360 kept.push(entry);
361 } else {
362 dropped_seqs.push(entry.seq);
363 }
364 }
365 state.entries = kept;
366
367 let s_tier_items: Vec<String> = state
371 .entries
372 .iter()
373 .filter(|entry| entry.score == Some(S_TIER_SCORE))
374 .map(critical_item_text)
375 .collect();
376 for item in s_tier_items {
377 if !state.critical.contains(&item) {
378 state.critical.push(item);
379 }
380 }
381 while state.critical.len() > self.config.critical_max_items {
382 state.critical.remove(0);
383 }
384
385 let window_end = state.entries.len().saturating_sub(self.config.grace_turns);
388 let mid_facts: Vec<String> = state
389 .entries
390 .iter()
391 .take(window_end)
392 .filter(|e| {
393 matches!(e.score, Some(score) if (MID_TIER_MIN..=MID_TIER_MAX).contains(&score))
394 })
395 .map(mid_fact_text)
396 .collect();
397 let previous_memory = state.memory.clone();
398
399 state.last_audit_seq = state
400 .entries
401 .last()
402 .map(|e| e.seq)
403 .unwrap_or(state.last_audit_seq);
404 (dropped_seqs, mid_facts, previous_memory)
405 };
406
407 if !mid_facts.is_empty() {
410 let input = format!(
411 "Previous summary:\n{}\n\nNew notes:\n{}",
412 previous_memory,
413 mid_facts.join("\n")
414 );
415 if let Ok(summary) = self
416 .client
417 .generate_with_system(MEMORY_SYSTEM, &input)
418 .await
419 {
420 let trimmed = summary.trim();
421 if !trimmed.is_empty() {
422 self.lock().memory = truncate_chars(trimmed, self.config.memory_max_chars);
423 }
424 }
425 }
426
427 let event = {
428 let state = self.lock();
429 CompactEvent::Audited {
430 critical_kept: state.critical.len(),
431 memory_chars: state.memory.chars().count(),
432 dropped_seqs,
433 }
434 };
435 vec![event]
436 }
437
438 pub fn build_context(&self, base: &str, recent_window: usize) -> Vec<(String, String)> {
443 let state = self.lock();
444 let mut messages = vec![("system".to_string(), base.to_string())];
445 if !state.critical.is_empty() {
446 messages.push((
447 "system".to_string(),
448 format!(
449 "Critical facts to preserve verbatim:\n{}",
450 state
451 .critical
452 .iter()
453 .map(|item| format!("- {}", item))
454 .collect::<Vec<_>>()
455 .join("\n")
456 ),
457 ));
458 }
459 if !state.memory.is_empty() {
460 messages.push((
461 "system".to_string(),
462 format!("Conversation memory summary:\n{}", state.memory),
463 ));
464 }
465 let start = state.entries.len().saturating_sub(recent_window);
466 for entry in &state.entries[start..] {
467 messages.push(("user".to_string(), entry.user.clone()));
468 messages.push(("assistant".to_string(), entry.assistant.clone()));
469 }
470 messages
471 }
472
473 pub fn export(&self) -> CompactionState {
475 self.lock().clone()
476 }
477
478 pub fn hydrate(
481 config: CompactConfig,
482 client: Arc<dyn LLMClient>,
483 state: CompactionState,
484 ) -> Self {
485 Self {
486 config,
487 client,
488 state: Mutex::new(state),
489 }
490 }
491
492 pub fn state_snapshot(&self) -> CompactionSnapshot {
494 let state = self.lock();
495 CompactionSnapshot {
496 turn_count: state.entries.len(),
497 scored_count: state.entries.iter().filter(|e| e.score.is_some()).count(),
498 critical_count: state.critical.len(),
499 memory_chars: state.memory.chars().count(),
500 last_audit_seq: state.last_audit_seq,
501 }
502 }
503
504 fn lock(&self) -> parking_lot::MutexGuard<'_, CompactionState> {
507 self.state.lock()
508 }
509}
510
511fn critical_item_text(entry: &TurnEntry) -> String {
513 format!("user: {}\nassistant: {}", entry.user, entry.assistant)
514}
515
516fn mid_fact_text(entry: &TurnEntry) -> String {
518 format!(
519 "[{}] user: {}; assistant: {}",
520 entry.seq, entry.user, entry.assistant
521 )
522}
523
524fn truncate_chars(text: &str, max: usize) -> String {
526 text.char_indices()
527 .nth(max)
528 .map_or_else(|| text.to_string(), |(idx, _)| text[..idx].to_string())
529}
530
531fn parse_score(text: &str) -> Option<u8> {
533 let value = salvage_json(text)?;
534 let raw = value.get("score").and_then(|field| {
535 field.as_i64().or_else(|| {
536 field
537 .as_str()
538 .and_then(|string| string.trim().parse::<i64>().ok())
539 })
540 })?;
541 Some(raw.clamp(1, 5) as u8)
542}
543
544fn parse_audit_scores(text: &str) -> Vec<(u64, u8)> {
547 let Some(value) = salvage_json(text) else {
548 return Vec::new();
549 };
550 let rows: Vec<Value> = match value {
551 Value::Array(rows) => rows,
552 Value::Object(_) => vec![value],
553 _ => return Vec::new(),
554 };
555 rows.iter()
556 .filter_map(|row| {
557 let seq = row.get("seq")?.as_u64()?;
558 let score = row.get("score")?.as_i64()?;
559 Some((seq, score.clamp(1, 5) as u8))
560 })
561 .collect()
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use ares_types::types::{AppError, Result};
568 use async_trait::async_trait;
569 use std::sync::atomic::{AtomicUsize, Ordering};
570
571 type Step = std::result::Result<String, AppError>;
572
573 struct ScriptedClient {
576 replies: Box<dyn Fn(usize) -> Step + Send + Sync>,
577 calls: AtomicUsize,
578 }
579
580 impl ScriptedClient {
581 fn new<F>(replies: F) -> Self
582 where
583 F: Fn(usize) -> Step + Send + Sync + 'static,
584 {
585 Self {
586 replies: Box::new(replies),
587 calls: AtomicUsize::new(0),
588 }
589 }
590
591 fn call_index(&self) -> usize {
592 self.calls.fetch_add(1, Ordering::SeqCst)
593 }
594 }
595
596 #[async_trait]
597 impl LLMClient for ScriptedClient {
598 async fn generate(&self, _prompt: &str) -> Result<String> {
599 Err(AppError::Internal("unused".into()))
600 }
601
602 async fn generate_with_system(&self, _system: &str, _prompt: &str) -> Result<String> {
603 (self.replies)(self.call_index())
604 }
605
606 async fn generate_with_history(
607 &self,
608 _messages: &[(String, String)],
609 ) -> Result<crate::client::LLMResponse> {
610 Err(AppError::Internal("unused".into()))
611 }
612
613 async fn generate_with_tools(
614 &self,
615 _prompt: &str,
616 _tools: &[ares_types::types::ToolDefinition],
617 ) -> Result<crate::client::LLMResponse> {
618 Err(AppError::Internal("unused".into()))
619 }
620
621 async fn generate_with_tools_and_history(
622 &self,
623 _messages: &[crate::coordinator::ConversationMessage],
624 _tools: &[ares_types::types::ToolDefinition],
625 ) -> Result<crate::client::LLMResponse> {
626 Err(AppError::Internal("unused".into()))
627 }
628
629 async fn stream(
630 &self,
631 _prompt: &str,
632 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
633 Err(AppError::Internal("unused".into()))
634 }
635
636 async fn stream_with_system(
637 &self,
638 _system: &str,
639 _prompt: &str,
640 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
641 Err(AppError::Internal("unused".into()))
642 }
643
644 async fn stream_with_history(
645 &self,
646 _messages: &[(String, String)],
647 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
648 Err(AppError::Internal("unused".into()))
649 }
650
651 fn model_name(&self) -> &str {
652 "compact-scripted-mock"
653 }
654 }
655
656 fn config() -> CompactConfig {
657 CompactConfig {
658 trigger_turns: 1,
659 history_turns: 16,
660 grace_turns: 1,
661 memory_max_chars: 500,
662 critical_max_items: 8,
663 critical_reaudit: 6,
664 }
665 }
666
667 #[tokio::test]
668 async fn record_turn_scores_the_pair() {
669 let client = Arc::new(ScriptedClient::new(|_| Ok("{\"score\": 4}".into())));
670 let compactor = Compactor::with_client(client);
671
672 let event = compactor
673 .record_turn("what is ares?".to_string(), "an api gateway".to_string())
674 .await;
675
676 assert_eq!(event, CompactEvent::Scored { seq: 1, score: 4 });
677 let snapshot = compactor.state_snapshot();
678 assert_eq!(snapshot.turn_count, 1);
679 assert_eq!(snapshot.scored_count, 1);
680 assert_eq!(snapshot.last_audit_seq, 0, "scoring alone is not an audit");
681 }
682
683 #[tokio::test]
684 async fn record_turn_parse_failure_is_skipped_not_err() {
685 let client = Arc::new(ScriptedClient::new(|_| Ok("no json at all".into())));
686 let compactor = Compactor::with_client(client);
687
688 let event = compactor
689 .record_turn("u".to_string(), "a".to_string())
690 .await;
691
692 assert_eq!(
693 event,
694 CompactEvent::Skipped {
695 reason: "score-parse"
696 }
697 );
698 let snapshot = compactor.state_snapshot();
699 assert_eq!(snapshot.turn_count, 1, "turn is kept unscored");
700 assert_eq!(snapshot.scored_count, 0);
701 }
702
703 #[tokio::test]
704 async fn record_turn_transport_failure_is_skipped_not_err() {
705 let client = Arc::new(ScriptedClient::new(|_| {
706 Err(AppError::External("down".into()))
707 }));
708 let compactor = Compactor::with_client(client);
709
710 let event = compactor
711 .record_turn("u".to_string(), "a".to_string())
712 .await;
713
714 assert_eq!(
715 event,
716 CompactEvent::Skipped {
717 reason: "score-call"
718 }
719 );
720 assert_eq!(compactor.state_snapshot().turn_count, 1);
721 }
722
723 #[tokio::test]
724 async fn audit_hoists_s_tier_verbatim_and_evicts_low_outside_grace() {
725 let client = Arc::new(ScriptedClient::new(|call| {
728 match call {
729 0..=3 => Ok(format!("{{\"score\":{}}}", [5, 1, 4, 1][call])),
730 4 => Ok("[{\"seq\":1,\"score\":5},{\"seq\":2,\"score\":1},{\"seq\":3,\"score\":4},{\"seq\":4,\"score\":1}]".into()),
731 5 => Ok("likes rust; dislikes yaml".into()),
732 _ => Err(AppError::Internal("unexpected call".into())),
733 }
734 }));
735 let compactor = Compactor::new(config(), client);
736
737 for index in 0..4u64 {
738 let event = compactor
739 .record_turn(format!("u{}", index), format!("a{}", index))
740 .await;
741 assert!(
742 matches!(event, CompactEvent::Scored { .. }),
743 "seed turn {} should score",
744 index
745 );
746 }
747
748 let events = compactor.audit_if_due().await;
749 assert_eq!(events.len(), 1);
750 assert_eq!(
753 events[0],
754 CompactEvent::Audited {
755 critical_kept: 1,
756 memory_chars: "likes rust; dislikes yaml".chars().count(),
757 dropped_seqs: vec![2],
758 }
759 );
760
761 let snapshot = compactor.state_snapshot();
762 assert_eq!(snapshot.turn_count, 3, "seq 2 evicted; 1, 3, 4 stay");
763 assert_eq!(snapshot.critical_count, 1);
764 assert_eq!(snapshot.last_audit_seq, 4);
765
766 let client = Arc::new(ScriptedClient::new(|call| match call {
769 0 => Ok("{\"score\":1}".into()),
770 1 => Ok("[{\"seq\":5,\"score\":1}]".into()),
771 _ => Err(AppError::Internal("unexpected call".into())),
772 }));
773 let compactor = Compactor::new(config(), client);
774 compactor
775 .record_turn("fresh".to_string(), "low value".to_string())
776 .await;
777 let events = compactor.audit_if_due().await;
778 assert_eq!(
779 events[0],
780 CompactEvent::Audited {
781 critical_kept: 0,
782 memory_chars: 0,
783 dropped_seqs: vec![],
784 },
785 "newest grace_turns entry survives despite score 1"
786 );
787 }
788
789 #[tokio::test]
790 async fn audit_memory_failure_keeps_previous_memory() {
791 let client = Arc::new(ScriptedClient::new(|call| match call {
795 0..=1 => Ok("{\"score\":3}".into()),
796 2 => Ok("[{\"seq\":1,\"score\":3},{\"seq\":2,\"score\":3}]".into()),
797 3 => Ok("first summary".into()),
798 4 => Ok("{\"score\":3}".into()),
799 5 => Ok("[{\"seq\":3,\"score\":3}]".into()),
800 _ => Err(AppError::External("memory call down".into())),
801 }));
802 let compactor = Compactor::new(config(), client);
803
804 compactor.record_turn("u1".into(), "a1".into()).await;
805 compactor.record_turn("u2".into(), "a2".into()).await;
806 let first = compactor.audit_if_due().await;
807 assert_eq!(
808 first[0],
809 CompactEvent::Audited {
810 critical_kept: 0,
811 memory_chars: "first summary".chars().count(),
812 dropped_seqs: vec![],
813 }
814 );
815
816 compactor.record_turn("u3".into(), "a3".into()).await;
817 let second = compactor.audit_if_due().await;
818 assert!(matches!(second[0], CompactEvent::Audited { .. }));
819 let snapshot = compactor.state_snapshot();
820 assert_eq!(
821 snapshot.memory_chars,
822 "first summary".chars().count(),
823 "failed rebuild falls back to previous memory"
824 );
825 }
826
827 #[tokio::test]
828 async fn audit_call_failures_degrade_to_skipped() {
829 let failing = Arc::new(ScriptedClient::new(|call| match call {
831 0 => Ok("{\"score\":1}".into()),
832 _ => Err(AppError::External("audit down".into())),
833 }));
834 let compactor = Compactor::new(config(), failing);
835 compactor.record_turn("u".into(), "a".into()).await;
836 assert_eq!(
837 compactor.audit_if_due().await,
838 vec![CompactEvent::Skipped {
839 reason: "audit-call"
840 }]
841 );
842
843 let garbage = Arc::new(ScriptedClient::new(|call| match call {
845 0 => Ok("{\"score\":1}".into()),
846 1 => Ok("total gibberish".into()),
847 _ => Err(AppError::Internal("unexpected call".into())),
848 }));
849 let compactor = Compactor::new(config(), garbage);
850 compactor.record_turn("u".into(), "a".into()).await;
851 assert_eq!(
852 compactor.audit_if_due().await,
853 vec![CompactEvent::Skipped {
854 reason: "audit-parse"
855 }]
856 );
857 let snapshot = compactor.state_snapshot();
858 assert_eq!(snapshot.turn_count, 1, "skipped audits keep state intact");
859 assert_eq!(snapshot.last_audit_seq, 0);
860 }
861
862 #[tokio::test]
863 async fn audit_skips_when_not_due() {
864 let client = Arc::new(ScriptedClient::new(|_| {
865 Err(AppError::Internal("no calls expected".into()))
866 }));
867 let quiet = CompactConfig {
868 trigger_turns: 100,
869 history_turns: 100,
870 ..config()
871 };
872 let compactor = Compactor::new(quiet, client);
873
874 assert_eq!(
875 compactor.audit_if_due().await,
876 vec![CompactEvent::Skipped { reason: "not-due" }]
877 );
878 }
879
880 #[tokio::test]
881 async fn build_context_orders_base_critical_memory_recent() {
882 let client = Arc::new(ScriptedClient::new(|call| match call {
885 0..=2 => Ok(format!("{{\"score\":{}}}", [4, 5, 2][call])),
886 3 => Ok(
887 "[{\"seq\":1,\"score\":4},{\"seq\":2,\"score\":5},{\"seq\":3,\"score\":2}]".into(),
888 ),
889 4 => Ok("she prefers dark mode".into()),
890 _ => Err(AppError::Internal("unexpected call".into())),
891 }));
892 let compactor = Compactor::new(config(), client);
893 compactor
894 .record_turn("theme?".into(), "dark mode".into())
895 .await;
896 compactor.record_turn("stack?".into(), "rust".into()).await;
897 compactor.record_turn("tabs?".into(), "spaces".into()).await;
898 compactor.audit_if_due().await;
899
900 let messages = compactor.build_context("You are helpful.", 8);
901
902 assert_eq!(messages.len(), 9, "base + critical + memory + 3 turns x2");
903 assert_eq!(
904 messages[0],
905 ("system".to_string(), "You are helpful.".to_string())
906 );
907 assert_eq!(messages[1].0, "system");
908 assert!(
909 messages[1].1.contains("Critical facts") && messages[1].1.contains("user: stack?"),
910 "critical slot comes right after base and holds the S-tier pair"
911 );
912 assert_eq!(messages[2].0, "system");
913 assert!(
914 messages[2].1.contains("Conversation memory summary")
915 && messages[2].1.contains("she prefers dark mode"),
916 "memory slot follows critical"
917 );
918 assert_eq!(messages[3], ("user".to_string(), "theme?".to_string()));
919 assert_eq!(
920 messages[4],
921 ("assistant".to_string(), "dark mode".to_string())
922 );
923 assert_eq!(messages[5], ("user".to_string(), "stack?".to_string()));
924 assert_eq!(messages[6], ("assistant".to_string(), "rust".to_string()));
925 assert_eq!(messages[7], ("user".to_string(), "tabs?".to_string()));
926 assert_eq!(messages[8], ("assistant".to_string(), "spaces".to_string()));
927
928 let trimmed = compactor.build_context("base", 1);
930 assert_eq!(trimmed.len(), 5, "base + critical + memory + 1 turn x2");
931 assert_eq!(trimmed[3], ("user".to_string(), "tabs?".to_string()));
932 assert_eq!(trimmed[4], ("assistant".to_string(), "spaces".to_string()));
933
934 let bare = Compactor::with_client(Arc::new(ScriptedClient::new(|_| {
936 Err(AppError::Internal("unused".into()))
937 })));
938 assert_eq!(
939 bare.build_context("only", 4),
940 vec![("system".to_string(), "only".to_string())]
941 );
942 }
943
944 #[test]
945 fn parse_score_clamps_and_tolerates_strings() {
946 assert_eq!(parse_score("{\"score\":4}"), Some(4));
947 assert_eq!(parse_score("Sure! {\"score\":\"9\"}"), Some(5));
948 assert_eq!(parse_score("{\"score\":0}"), Some(1));
949 assert_eq!(parse_score("garbage"), None);
950 }
951
952 #[test]
953 fn truncate_chars_respects_boundaries() {
954 assert_eq!(truncate_chars("hello", 50), "hello");
955 assert_eq!(truncate_chars("héllo", 2), "hé");
956 }
957
958 #[tokio::test]
961 async fn state_serde_round_trip_preserves_entries_critical_memory_audit_seq() {
962 let client = Arc::new(ScriptedClient::new(|_| {
963 Err(AppError::Internal("unused".into()))
964 }));
965 let compactor = Compactor::with_client(client);
966 {
967 let mut state = compactor.lock();
968 state.entries.push(TurnEntry {
969 seq: 1,
970 user: "theme?".to_string(),
971 assistant: "dark mode".to_string(),
972 score: Some(5),
973 });
974 state.entries.push(TurnEntry {
975 seq: 2,
976 user: "stack?".to_string(),
977 assistant: "rust".to_string(),
978 score: None,
979 });
980 state
981 .critical
982 .push("user: theme?\nassistant: dark mode".to_string());
983 state.memory = "User prefers dark mode.".to_string();
984 state.last_audit_seq = 1;
985 }
986
987 let exported = compactor.export();
989 let json = serde_json::to_string(&exported).expect("serialize");
990 let restored: CompactionState = serde_json::from_str(&json).expect("deserialize");
991 let revived = Compactor::hydrate(
992 CompactConfig::default(),
993 Arc::new(ScriptedClient::new(|_| {
994 Err(AppError::Internal("unused".into()))
995 })),
996 restored,
997 );
998
999 assert_eq!(revived.export().entries, exported.entries);
1000 assert_eq!(revived.export().critical, exported.critical);
1001 assert_eq!(revived.export().memory, exported.memory);
1002 assert_eq!(revived.export().last_audit_seq, 1);
1003 assert_eq!(
1005 revived.audit_if_due().await.first(),
1006 Some(&CompactEvent::Skipped { reason: "not-due" })
1007 );
1008 }
1009}