Skip to main content

wcode_harness/
session.rs

1use std::fs;
2use std::io::{self, Write};
3use std::path::{Path, PathBuf};
4
5use chrono::{SecondsFormat, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::event::TodoItem;
9use crate::message::{AgentMessage, Usage};
10
11#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum SessionEntry {
14    Header {
15        version: u32,
16        id: String,
17        cwd: String,
18        created: String, // RFC3339
19    },
20    Message {
21        id: String,
22        /// Reserved for future tree/fork sessions; the kernel is linear today
23        /// and always records `None`.
24        parent_id: Option<String>,
25        message: AgentMessage,
26    },
27    ModelChange {
28        id: String,
29        model: String,
30    },
31    EffortChange {
32        id: String,
33        /// None = effort cleared (send nothing).
34        effort: Option<String>,
35    },
36    /// A compaction boundary: `summary` stands in for every `Message` before
37    /// `first_kept_message` (an ordinal into the `Message` sequence).
38    Compaction {
39        id: String,
40        summary: String,
41        first_kept_message: usize,
42        #[serde(default, skip_serializing_if = "Option::is_none")]
43        tokens_before: Option<u64>,
44        #[serde(default, skip_serializing_if = "Option::is_none")]
45        usage: Option<Usage>,
46    },
47    /// The persisted checklist (the `todo` tool's state): last write wins.
48    /// Written by the `todo` tool on every write so plan→todo survives
49    /// `--resume`/`/reload`; read by the tool's seed path and by `/verify` (D8).
50    Todo {
51        id: String,
52        todos: Vec<TodoItem>,
53    },
54    /// The session's semantic name (W031): last write wins, like `Todo`.
55    /// Appended by the auto-titler at a turn boundary (`loop_`) and by an
56    /// explicit `/rename` (`Agent::set_title`) — never edited in place, never
57    /// re-written by the loop once one exists (§5.5).
58    ///
59    /// Wire tag is `title` (`{"type":"title","id":…,"title":…}`): purely
60    /// additive, so `PROTOCOL_VERSION` stays 1 and an older binary reads it as
61    /// `SessionEntry::Unknown` — the forward-compat path pinned by
62    /// `unknown_entries_tolerated`.
63    Title {
64        id: String,
65        title: String,
66    },
67    #[serde(other)]
68    Unknown,
69}
70
71pub struct Session {
72    path: Option<PathBuf>,
73    entries: Vec<SessionEntry>,
74}
75
76impl Session {
77    pub fn create(dir: &Path) -> io::Result<Session> {
78        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
79        Session::create_with_cwd(dir, &cwd)
80    }
81
82    /// Like [`create`], but records `cwd` in the session header instead of the
83    /// process working directory. An agent whose tools run against a custom
84    /// `working_dir` (see `AgentConfig.working_dir`) should stamp that same
85    /// directory here so a resumed session's header agrees with where the tools
86    /// actually ran — otherwise the header's `cwd` describes a directory the
87    /// tools never touched.
88    pub fn create_with_cwd(dir: &Path, cwd: &Path) -> io::Result<Session> {
89        let id = uuid::Uuid::new_v4();
90        let name = format!(
91            "{}_{}.jsonl",
92            Utc::now().timestamp_millis(),
93            &id.simple().to_string()[..8]
94        );
95        Self::open_new(dir, name, id, cwd)
96    }
97
98    /// Like [`create`], but the file is exactly `<dir>/<name>.jsonl` — the
99    /// deterministic tree session groups need (`root.jsonl`, `members/<name>.jsonl`),
100    /// where a `{millis}_{uuid8}` name would not map back to an address. Header
101    /// content identical to [`create_with_cwd`]; only the file name is caller-
102    /// supplied.
103    pub fn create_named(dir: &Path, name: &str) -> io::Result<Session> {
104        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
105        Session::create_named_with_cwd(dir, name, &cwd)
106    }
107
108    /// [`create_named`] with an explicit `cwd` (see [`create_with_cwd`]).
109    pub fn create_named_with_cwd(dir: &Path, name: &str, cwd: &Path) -> io::Result<Session> {
110        Self::open_new(dir, format!("{name}.jsonl"), uuid::Uuid::new_v4(), cwd)
111    }
112
113    /// The shared constructor: fresh dirs, a header stamped first, one file.
114    fn open_new(dir: &Path, filename: String, id: uuid::Uuid, cwd: &Path) -> io::Result<Session> {
115        fs::create_dir_all(dir)?;
116        let path = dir.join(filename);
117        let mut session = Session {
118            path: Some(path.clone()),
119            entries: Vec::new(),
120        };
121        let header = SessionEntry::Header {
122            version: 1,
123            id: id.to_string(),
124            cwd: cwd.to_string_lossy().into_owned(),
125            created: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
126        };
127        session.append(header)?;
128        Ok(session)
129    }
130
131    pub fn open(path: &Path) -> io::Result<Session> {
132        let raw = fs::read_to_string(path)?;
133        let lines: Vec<&str> = raw.lines().collect();
134        let last_non_empty = lines.iter().rposition(|l| !l.trim().is_empty());
135        let mut entries = Vec::new();
136        for (i, line) in lines.iter().enumerate() {
137            if line.trim().is_empty() {
138                continue;
139            }
140            match serde_json::from_str::<SessionEntry>(line) {
141                Ok(e) => entries.push(e),
142                Err(e) => {
143                    if Some(i) == last_non_empty {
144                        break; // torn final write; rest is blank
145                    }
146                    return Err(io::Error::new(io::ErrorKind::InvalidData, e));
147                }
148            }
149        }
150        Ok(Session {
151            path: Some(path.to_path_buf()),
152            entries,
153        })
154    }
155
156    pub fn in_memory() -> Session {
157        Session {
158            path: None,
159            entries: Vec::new(),
160        }
161    }
162
163    pub fn append(&mut self, e: SessionEntry) -> io::Result<()> {
164        if let Some(path) = &self.path {
165            let mut f = fs::OpenOptions::new()
166                .create(true)
167                .append(true)
168                .open(path)?;
169            let line = serde_json::to_string(&e)
170                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
171            f.write_all(format!("{line}\n").as_bytes())?;
172            f.flush()?;
173        }
174        self.entries.push(e);
175        Ok(())
176    }
177
178    pub fn entries(&self) -> &[SessionEntry] {
179        &self.entries
180    }
181
182    pub fn messages(&self) -> Vec<AgentMessage> {
183        // The last compaction governs: everything before its kept boundary is
184        // represented by the summary; everything after is kept verbatim.
185        let boundary = self.entries.iter().rev().find_map(|e| match e {
186            SessionEntry::Compaction {
187                summary,
188                first_kept_message,
189                ..
190            } => Some((summary.clone(), *first_kept_message)),
191            _ => None,
192        });
193        let (summary, skip) = match boundary {
194            Some((s, k)) => (Some(s), k),
195            None => (None, 0),
196        };
197
198        let mut out = Vec::new();
199        if let Some(s) = summary {
200            out.push(AgentMessage::summary(s));
201        }
202        let mut ordinal = 0usize;
203        for entry in &self.entries {
204            if let SessionEntry::Message { message, .. } = entry {
205                if ordinal >= skip {
206                    out.push(message.clone());
207                }
208                ordinal += 1;
209            }
210        }
211        out
212    }
213
214    /// Number of persisted `Message` entries — the ordinal space that
215    /// [`SessionEntry::Compaction::first_kept_message`] indexes into.
216    pub fn message_count(&self) -> usize {
217        self.entries
218            .iter()
219            .filter(|e| matches!(e, SessionEntry::Message { .. }))
220            .count()
221    }
222
223    /// Record a compaction: `summary` stands in for every `Message` before the
224    /// most recent `kept_messages`, which stay. The boundary ordinal is derived
225    /// from the current message count, so it stays correct as messages append.
226    pub fn record_compaction(
227        &mut self,
228        summary: &str,
229        kept_messages: usize,
230        tokens_before: Option<u64>,
231        usage: Option<Usage>,
232    ) -> io::Result<()> {
233        let first_kept_message = self.message_count().saturating_sub(kept_messages);
234        self.append(SessionEntry::Compaction {
235            id: uuid::Uuid::new_v4().to_string(),
236            summary: summary.to_string(),
237            first_kept_message,
238            tokens_before,
239            usage,
240        })
241    }
242
243    pub fn path(&self) -> Option<&Path> {
244        self.path.as_deref()
245    }
246
247    pub fn model(&self) -> Option<String> {
248        self.entries.iter().rev().find_map(|e| match e {
249            SessionEntry::ModelChange { model, .. } => Some(model.clone()),
250            _ => None,
251        })
252    }
253
254    pub fn effort(&self) -> Option<Option<String>> {
255        self.entries.iter().rev().find_map(|e| match e {
256            SessionEntry::EffortChange { effort, .. } => Some(effort.clone()),
257            _ => None,
258        })
259    }
260
261    /// The most recently persisted checklist, if any (`None` = never written).
262    /// Last write wins, like [`Self::model`]/[`Self::effort`].
263    pub fn todo(&self) -> Option<Vec<TodoItem>> {
264        self.entries.iter().rev().find_map(|e| match e {
265            SessionEntry::Todo { todos, .. } => Some(todos.clone()),
266            _ => None,
267        })
268    }
269
270    /// The session's most recently persisted title, if any (`None` = never
271    /// titled). Last write wins, exactly like [`Self::model`]/[`Self::effort`]/
272    /// [`Self::todo`].
273    ///
274    /// The loop's auto-trigger calls this FIRST, before counting turns
275    /// (W031 **D2**): an existing title short-circuits, so a `--resume` never
276    /// re-titles and a `/rename` permanently disables the auto-titler for that
277    /// session (§5.5).
278    pub fn title(&self) -> Option<String> {
279        self.entries.iter().rev().find_map(|e| match e {
280            SessionEntry::Title { title, .. } => Some(title.clone()),
281            _ => None,
282        })
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::event::TodoStatus;
290    use crate::message::{ContentBlock, StopReason};
291    use serde_json::json;
292
293    fn msg(text: &str) -> SessionEntry {
294        SessionEntry::Message {
295            id: uuid::Uuid::new_v4().to_string(),
296            parent_id: None,
297            message: AgentMessage::user_text(text),
298        }
299    }
300
301    #[test]
302    fn create_writes_header_with_timestamped_name() {
303        let dir = tempfile::tempdir().unwrap();
304        let s = Session::create(dir.path()).unwrap();
305        let name = s.path().unwrap().file_name().unwrap().to_str().unwrap();
306        let (ts, rest) = name.split_once('_').unwrap();
307        assert!(ts.parse::<i64>().is_ok());
308        let (hex, ext) = rest.split_once('.').unwrap();
309        assert_eq!(ext, "jsonl");
310        assert_eq!(hex.len(), 8);
311        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
312        assert_eq!(s.entries().len(), 1);
313        match &s.entries()[0] {
314            SessionEntry::Header {
315                version, id, cwd, ..
316            } => {
317                assert_eq!(*version, 1);
318                assert!(!id.is_empty());
319                assert!(!cwd.is_empty());
320            }
321            _ => panic!("first entry not header"),
322        }
323    }
324
325    #[test]
326    fn create_with_cwd_stamps_header_cwd() {
327        let dir = tempfile::tempdir().unwrap();
328        let cwd = std::path::Path::new("/custom/workdir");
329        let s = Session::create_with_cwd(dir.path(), cwd).unwrap();
330        match &s.entries()[0] {
331            SessionEntry::Header { cwd, .. } => assert_eq!(cwd, "/custom/workdir"),
332            _ => panic!("first entry not header"),
333        }
334    }
335
336    #[test]
337    fn create_named_writes_to_the_named_file() {
338        let dir = tempfile::tempdir().unwrap();
339        let root = Session::create_named_with_cwd(dir.path(), "root", std::path::Path::new("/wd"))
340            .unwrap();
341        assert_eq!(root.path().unwrap().file_name().unwrap(), "root.jsonl");
342        match &root.entries()[0] {
343            SessionEntry::Header { version, cwd, .. } => {
344                assert_eq!(*version, 1);
345                assert_eq!(cwd, "/wd");
346            }
347            _ => panic!("first entry not header"),
348        }
349
350        // The bare `create_named` stamps the process cwd, and the file name is
351        // exactly `<name>.jsonl` — the deterministic tree session groups need.
352        let member = Session::create_named(dir.path(), "w1").unwrap();
353        assert_eq!(member.path().unwrap().file_name().unwrap(), "w1.jsonl");
354
355        // Reopen round-trips: an append comes back, so group transcripts are
356        // ordinary kernel sessions with caller-supplied names.
357        let mut reopened = Session::open(&dir.path().join("root.jsonl")).unwrap();
358        reopened
359            .append(SessionEntry::ModelChange {
360                id: "m1".into(),
361                model: "m".into(),
362            })
363            .unwrap();
364        assert_eq!(reopened.entries().len(), 2);
365        assert_eq!(
366            Session::open(&dir.path().join("root.jsonl"))
367                .unwrap()
368                .model()
369                .as_deref(),
370            Some("m")
371        );
372    }
373
374    #[test]
375    fn create_append_open_roundtrip() {
376        let dir = tempfile::tempdir().unwrap();
377        let mut s = Session::create(dir.path()).unwrap();
378        s.append(msg("hello")).unwrap();
379        s.append(SessionEntry::ModelChange {
380            id: "m1".into(),
381            model: "claude-x".into(),
382        })
383        .unwrap();
384        s.append(SessionEntry::Message {
385            id: "a2".into(),
386            parent_id: Some("a1".into()),
387            message: AgentMessage::Assistant {
388                content: vec![ContentBlock::Text {
389                    text: "reply".into(),
390                }],
391                stop_reason: StopReason::Stop,
392                usage: None,
393                model: Some("claude-x".into()),
394            },
395        })
396        .unwrap();
397
398        s.append(SessionEntry::Todo {
399            id: "td1".into(),
400            todos: vec![TodoItem {
401                content: "step".into(),
402                status: TodoStatus::Pending,
403            }],
404        })
405        .unwrap();
406
407        let path = s.path().unwrap().to_path_buf();
408        let reopened = Session::open(&path).unwrap();
409        assert_eq!(reopened.entries(), s.entries());
410        assert_eq!(reopened.messages().len(), 2);
411        assert_eq!(reopened.model().as_deref(), Some("claude-x"));
412        assert_eq!(reopened.todo().unwrap()[0].content, "step");
413    }
414
415    #[test]
416    fn torn_final_line_tolerated() {
417        let dir = tempfile::tempdir().unwrap();
418        let path = dir.path().join("s.jsonl");
419        fs::write(
420            &path,
421            format!(
422                "{}\n{}",
423                json!({"type":"message","id":"1","parent_id":null,"message":{"role":"user","content":[{"type":"text","text":"q"}]}}),
424                "{\"type\":\"mess" // torn
425            ),
426        )
427        .unwrap();
428        let s = Session::open(&path).unwrap();
429        assert_eq!(s.entries().len(), 1);
430        assert_eq!(s.messages().len(), 1);
431    }
432
433    #[test]
434    fn torn_line_followed_by_blank_lines_tolerated() {
435        let dir = tempfile::tempdir().unwrap();
436        let path = dir.path().join("s.jsonl");
437        fs::write(
438            &path,
439            format!(
440                "{}\n{{\"type\":\"mess\n\n",
441                json!({"type":"message","id":"1","parent_id":null,"message":{"role":"user","content":[{"type":"text","text":"q"}]}})
442            ),
443        )
444        .unwrap();
445        let s = Session::open(&path).unwrap();
446        assert_eq!(s.entries().len(), 1);
447        assert_eq!(s.messages().len(), 1);
448    }
449
450    #[test]
451    fn torn_earlier_line_is_error() {
452        let dir = tempfile::tempdir().unwrap();
453        let path = dir.path().join("s.jsonl");
454        fs::write(
455            &path,
456            "{\"type\":\"mess\n{\"type\":\"model_change\",\"id\":\"m\",\"model\":\"x\"}\n",
457        )
458        .unwrap();
459        assert!(Session::open(&path).is_err());
460    }
461
462    #[test]
463    fn unknown_entries_tolerated() {
464        let dir = tempfile::tempdir().unwrap();
465        let path = dir.path().join("s.jsonl");
466        fs::write(
467            &path,
468            format!(
469                "{}\n{}\n{}\n",
470                json!({"type":"header","version":1,"id":"x","cwd":"/","created":"2026-01-01T00:00:00Z"}),
471                json!({"type":"future_thing","data":42}),
472                json!({"type":"message","id":"1","parent_id":null,"message":{"role":"user","content":[{"type":"text","text":"q"}]}})
473            ),
474        )
475        .unwrap();
476        let s = Session::open(&path).unwrap();
477        assert_eq!(s.entries().len(), 3);
478        assert_eq!(s.entries()[1], SessionEntry::Unknown);
479        assert_eq!(s.messages().len(), 1);
480    }
481
482    #[test]
483    fn model_latest_wins() {
484        let mut s = Session::in_memory();
485        assert_eq!(s.model(), None);
486        s.append(SessionEntry::ModelChange {
487            id: "1".into(),
488            model: "old".into(),
489        })
490        .unwrap();
491        s.append(SessionEntry::ModelChange {
492            id: "2".into(),
493            model: "new".into(),
494        })
495        .unwrap();
496        assert_eq!(s.model().as_deref(), Some("new"));
497    }
498
499    #[test]
500    fn effort_latest_wins_and_none_means_cleared() {
501        let mut s = Session::in_memory();
502        assert_eq!(s.effort(), None);
503        s.append(SessionEntry::EffortChange {
504            id: "1".into(),
505            effort: Some("high".into()),
506        })
507        .unwrap();
508        assert_eq!(s.effort(), Some(Some("high".into())));
509        s.append(SessionEntry::EffortChange {
510            id: "2".into(),
511            effort: None,
512        })
513        .unwrap();
514        assert_eq!(s.effort(), Some(None));
515    }
516
517    #[test]
518    fn effort_roundtrips_through_jsonl() {
519        let dir = tempfile::tempdir().unwrap();
520        let mut s = Session::create(dir.path()).unwrap();
521        s.append(SessionEntry::EffortChange {
522            id: "e1".into(),
523            effort: Some("low".into()),
524        })
525        .unwrap();
526        let path = s.path().unwrap().to_path_buf();
527        let reopened = Session::open(&path).unwrap();
528        assert_eq!(reopened.effort(), Some(Some("low".into())));
529    }
530
531    #[test]
532    fn todo_latest_wins_and_roundtrips() {
533        let dir = tempfile::tempdir().unwrap();
534        let mut s = Session::create(dir.path()).unwrap();
535        s.append(SessionEntry::Todo {
536            id: "t1".into(),
537            todos: vec![TodoItem {
538                content: "first".into(),
539                status: TodoStatus::Pending,
540            }],
541        })
542        .unwrap();
543        s.append(SessionEntry::Todo {
544            id: "t2".into(),
545            todos: vec![TodoItem {
546                content: "second".into(),
547                status: TodoStatus::InProgress,
548            }],
549        })
550        .unwrap();
551
552        let path = s.path().unwrap().to_path_buf();
553        let reopened = Session::open(&path).unwrap();
554        let restored = reopened.todo().unwrap();
555        assert_eq!(restored.len(), 1, "last write wins");
556        assert_eq!(restored[0].content, "second");
557        assert_eq!(restored[0].status, TodoStatus::InProgress);
558
559        // The serde wire tag is `todo` (the extensible-`type` path).
560        let raw = fs::read_to_string(&path).unwrap();
561        assert!(raw.contains(r#""type":"todo""#), "{raw}");
562    }
563
564    #[test]
565    fn title_latest_wins_and_roundtrips() {
566        let dir = tempfile::tempdir().unwrap();
567        let mut s = Session::create(dir.path()).unwrap();
568        assert_eq!(s.title(), None, "a fresh session has no title");
569        s.append(SessionEntry::Title {
570            id: "ti1".into(),
571            title: "first name".into(),
572        })
573        .unwrap();
574        s.append(SessionEntry::Title {
575            id: "ti2".into(),
576            title: "retry backoff fix".into(),
577        })
578        .unwrap();
579
580        let path = s.path().unwrap().to_path_buf();
581        let raw = fs::read_to_string(&path).unwrap();
582        // The serde wire tag is `title` (the extensible-`type` path); the entry
583        // is appended, never edited, so both writes are on disk.
584        assert!(raw.contains(r#""type":"title""#), "{raw}");
585        assert_eq!(raw.matches(r#""type":"title""#).count(), 2, "{raw}");
586
587        let reopened = Session::open(&path).unwrap();
588        assert_eq!(
589            reopened.title().as_deref(),
590            Some("retry backoff fix"),
591            "last write wins"
592        );
593    }
594
595    #[test]
596    fn open_skips_an_unknown_entry() {
597        let dir = tempfile::tempdir().unwrap();
598        let path = dir.path().join("s.jsonl");
599        // A future binary's `title`-family entry this build cannot read, sitting
600        // in the MIDDLE (so the torn-final-line tolerance cannot hide it). The
601        // `#[serde(other)]` catch-all must take it, not fail the whole session.
602        fs::write(
603            &path,
604            format!(
605                "{}\n{}\n{}\n",
606                json!({"type":"header","version":1,"id":"x","cwd":"/","created":"2026-01-01T00:00:00Z"}),
607                json!({"type":"title_v2","id":"t1","title":"x","slug":"future"}),
608                json!({"type":"message","id":"1","parent_id":null,"message":{"role":"user","content":[{"type":"text","text":"q"}]}})
609            ),
610        )
611        .unwrap();
612        let s = Session::open(&path).expect("an unknown catch-all entry must not fail open");
613        assert_eq!(s.entries().len(), 3);
614        assert_eq!(s.entries()[1], SessionEntry::Unknown);
615        assert_eq!(s.messages().len(), 1);
616        assert_eq!(s.title(), None, "an unknown entry carries no title");
617    }
618
619    #[test]
620    fn in_memory_writes_nothing() {
621        let dir = tempfile::tempdir().unwrap();
622        let mut s = Session::in_memory();
623        s.append(msg("hi")).unwrap();
624        assert!(s.path().is_none());
625        assert_eq!(s.entries().len(), 1);
626        assert_eq!(s.messages().len(), 1);
627        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
628    }
629}
630
631#[cfg(test)]
632mod compaction_tests {
633    use super::*;
634
635    fn msg(text: &str) -> SessionEntry {
636        SessionEntry::Message {
637            id: uuid::Uuid::new_v4().to_string(),
638            parent_id: None,
639            message: AgentMessage::user_text(text),
640        }
641    }
642
643    fn texts(s: &Session) -> Vec<String> {
644        s.messages().iter().map(|m| m.as_text()).collect()
645    }
646
647    #[test]
648    fn no_compaction_returns_every_message() {
649        let mut s = Session::in_memory();
650        s.append(msg("a")).unwrap();
651        s.append(msg("b")).unwrap();
652        assert_eq!(texts(&s), ["a", "b"]);
653        assert_eq!(s.message_count(), 2);
654    }
655
656    #[test]
657    fn compaction_rebuilds_summary_plus_kept_tail_across_reopen() {
658        let dir = tempfile::tempdir().unwrap();
659        let mut s = Session::create(dir.path()).unwrap();
660        for t in ["m1", "m2", "m3", "m4", "m5"] {
661            s.append(msg(t)).unwrap();
662        }
663        // Keep the newest two (m4, m5); summarize the rest.
664        s.record_compaction("did 1..3", 2, Some(123), None).unwrap();
665
666        let path = s.path().unwrap().to_path_buf();
667        let mut reopened = Session::open(&path).unwrap();
668        let got = texts(&reopened);
669        assert_eq!(got.len(), 3, "summary + two kept");
670        assert!(got[0].contains("did 1..3"), "summary leads: {got:?}");
671        assert_eq!(got[1], "m4");
672        assert_eq!(got[2], "m5");
673
674        // Messages appended after the compaction are kept too.
675        reopened.append(msg("m6")).unwrap();
676        assert_eq!(texts(&reopened), [got[0].clone(), "m4".into(), "m5".into(), "m6".into()]);
677        assert_eq!(reopened.message_count(), 6);
678    }
679
680    #[test]
681    fn second_compaction_supersedes_the_first() {
682        let mut s = Session::in_memory();
683        for t in ["m1", "m2", "m3", "m4", "m5", "m6"] {
684            s.append(msg(t)).unwrap();
685        }
686        s.record_compaction("first", 4, None, None).unwrap(); // keep m3..m6
687        assert_eq!(
688            texts(&s),
689            [
690                "Summary of the earlier conversation (auto-generated by compaction):\n\nfirst",
691                "m3",
692                "m4",
693                "m5",
694                "m6"
695            ]
696        );
697        s.record_compaction("second", 2, None, None).unwrap(); // keep m5, m6
698        let got = texts(&s);
699        assert_eq!(got.len(), 3);
700        assert!(got[0].contains("second"), "the latest summary wins: {got:?}");
701        assert_eq!(got[1], "m5");
702        assert_eq!(got[2], "m6");
703    }
704}