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, },
20 Message {
21 id: String,
22 parent_id: Option<String>,
25 message: AgentMessage,
26 },
27 ModelChange {
28 id: String,
29 model: String,
30 },
31 EffortChange {
32 id: String,
33 effort: Option<String>,
35 },
36 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 Todo {
51 id: String,
52 todos: Vec<TodoItem>,
53 },
54 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 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 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 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 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; }
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 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 pub fn message_count(&self) -> usize {
217 self.entries
218 .iter()
219 .filter(|e| matches!(e, SessionEntry::Message { .. }))
220 .count()
221 }
222
223 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 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 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 let member = Session::create_named(dir.path(), "w1").unwrap();
353 assert_eq!(member.path().unwrap().file_name().unwrap(), "w1.jsonl");
354
355 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" ),
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 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 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 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 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 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(); 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(); 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}