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 #[serde(other)]
55 Unknown,
56}
57
58pub struct Session {
59 path: Option<PathBuf>,
60 entries: Vec<SessionEntry>,
61}
62
63impl Session {
64 pub fn create(dir: &Path) -> io::Result<Session> {
65 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
66 Session::create_with_cwd(dir, &cwd)
67 }
68
69 pub fn create_with_cwd(dir: &Path, cwd: &Path) -> io::Result<Session> {
76 let id = uuid::Uuid::new_v4();
77 let name = format!(
78 "{}_{}.jsonl",
79 Utc::now().timestamp_millis(),
80 &id.simple().to_string()[..8]
81 );
82 Self::open_new(dir, name, id, cwd)
83 }
84
85 pub fn create_named(dir: &Path, name: &str) -> io::Result<Session> {
91 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
92 Session::create_named_with_cwd(dir, name, &cwd)
93 }
94
95 pub fn create_named_with_cwd(dir: &Path, name: &str, cwd: &Path) -> io::Result<Session> {
97 Self::open_new(dir, format!("{name}.jsonl"), uuid::Uuid::new_v4(), cwd)
98 }
99
100 fn open_new(dir: &Path, filename: String, id: uuid::Uuid, cwd: &Path) -> io::Result<Session> {
102 fs::create_dir_all(dir)?;
103 let path = dir.join(filename);
104 let mut session = Session {
105 path: Some(path.clone()),
106 entries: Vec::new(),
107 };
108 let header = SessionEntry::Header {
109 version: 1,
110 id: id.to_string(),
111 cwd: cwd.to_string_lossy().into_owned(),
112 created: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
113 };
114 session.append(header)?;
115 Ok(session)
116 }
117
118 pub fn open(path: &Path) -> io::Result<Session> {
119 let raw = fs::read_to_string(path)?;
120 let lines: Vec<&str> = raw.lines().collect();
121 let last_non_empty = lines.iter().rposition(|l| !l.trim().is_empty());
122 let mut entries = Vec::new();
123 for (i, line) in lines.iter().enumerate() {
124 if line.trim().is_empty() {
125 continue;
126 }
127 match serde_json::from_str::<SessionEntry>(line) {
128 Ok(e) => entries.push(e),
129 Err(e) => {
130 if Some(i) == last_non_empty {
131 break; }
133 return Err(io::Error::new(io::ErrorKind::InvalidData, e));
134 }
135 }
136 }
137 Ok(Session {
138 path: Some(path.to_path_buf()),
139 entries,
140 })
141 }
142
143 pub fn in_memory() -> Session {
144 Session {
145 path: None,
146 entries: Vec::new(),
147 }
148 }
149
150 pub fn append(&mut self, e: SessionEntry) -> io::Result<()> {
151 if let Some(path) = &self.path {
152 let mut f = fs::OpenOptions::new()
153 .create(true)
154 .append(true)
155 .open(path)?;
156 let line = serde_json::to_string(&e)
157 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
158 f.write_all(format!("{line}\n").as_bytes())?;
159 f.flush()?;
160 }
161 self.entries.push(e);
162 Ok(())
163 }
164
165 pub fn entries(&self) -> &[SessionEntry] {
166 &self.entries
167 }
168
169 pub fn messages(&self) -> Vec<AgentMessage> {
170 let boundary = self.entries.iter().rev().find_map(|e| match e {
173 SessionEntry::Compaction {
174 summary,
175 first_kept_message,
176 ..
177 } => Some((summary.clone(), *first_kept_message)),
178 _ => None,
179 });
180 let (summary, skip) = match boundary {
181 Some((s, k)) => (Some(s), k),
182 None => (None, 0),
183 };
184
185 let mut out = Vec::new();
186 if let Some(s) = summary {
187 out.push(AgentMessage::summary(s));
188 }
189 let mut ordinal = 0usize;
190 for entry in &self.entries {
191 if let SessionEntry::Message { message, .. } = entry {
192 if ordinal >= skip {
193 out.push(message.clone());
194 }
195 ordinal += 1;
196 }
197 }
198 out
199 }
200
201 pub fn message_count(&self) -> usize {
204 self.entries
205 .iter()
206 .filter(|e| matches!(e, SessionEntry::Message { .. }))
207 .count()
208 }
209
210 pub fn record_compaction(
214 &mut self,
215 summary: &str,
216 kept_messages: usize,
217 tokens_before: Option<u64>,
218 usage: Option<Usage>,
219 ) -> io::Result<()> {
220 let first_kept_message = self.message_count().saturating_sub(kept_messages);
221 self.append(SessionEntry::Compaction {
222 id: uuid::Uuid::new_v4().to_string(),
223 summary: summary.to_string(),
224 first_kept_message,
225 tokens_before,
226 usage,
227 })
228 }
229
230 pub fn path(&self) -> Option<&Path> {
231 self.path.as_deref()
232 }
233
234 pub fn model(&self) -> Option<String> {
235 self.entries.iter().rev().find_map(|e| match e {
236 SessionEntry::ModelChange { model, .. } => Some(model.clone()),
237 _ => None,
238 })
239 }
240
241 pub fn effort(&self) -> Option<Option<String>> {
242 self.entries.iter().rev().find_map(|e| match e {
243 SessionEntry::EffortChange { effort, .. } => Some(effort.clone()),
244 _ => None,
245 })
246 }
247
248 pub fn todo(&self) -> Option<Vec<TodoItem>> {
251 self.entries.iter().rev().find_map(|e| match e {
252 SessionEntry::Todo { todos, .. } => Some(todos.clone()),
253 _ => None,
254 })
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::event::TodoStatus;
262 use crate::message::{ContentBlock, StopReason};
263 use serde_json::json;
264
265 fn msg(text: &str) -> SessionEntry {
266 SessionEntry::Message {
267 id: uuid::Uuid::new_v4().to_string(),
268 parent_id: None,
269 message: AgentMessage::user_text(text),
270 }
271 }
272
273 #[test]
274 fn create_writes_header_with_timestamped_name() {
275 let dir = tempfile::tempdir().unwrap();
276 let s = Session::create(dir.path()).unwrap();
277 let name = s.path().unwrap().file_name().unwrap().to_str().unwrap();
278 let (ts, rest) = name.split_once('_').unwrap();
279 assert!(ts.parse::<i64>().is_ok());
280 let (hex, ext) = rest.split_once('.').unwrap();
281 assert_eq!(ext, "jsonl");
282 assert_eq!(hex.len(), 8);
283 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
284 assert_eq!(s.entries().len(), 1);
285 match &s.entries()[0] {
286 SessionEntry::Header {
287 version, id, cwd, ..
288 } => {
289 assert_eq!(*version, 1);
290 assert!(!id.is_empty());
291 assert!(!cwd.is_empty());
292 }
293 _ => panic!("first entry not header"),
294 }
295 }
296
297 #[test]
298 fn create_with_cwd_stamps_header_cwd() {
299 let dir = tempfile::tempdir().unwrap();
300 let cwd = std::path::Path::new("/custom/workdir");
301 let s = Session::create_with_cwd(dir.path(), cwd).unwrap();
302 match &s.entries()[0] {
303 SessionEntry::Header { cwd, .. } => assert_eq!(cwd, "/custom/workdir"),
304 _ => panic!("first entry not header"),
305 }
306 }
307
308 #[test]
309 fn create_named_writes_to_the_named_file() {
310 let dir = tempfile::tempdir().unwrap();
311 let root = Session::create_named_with_cwd(dir.path(), "root", std::path::Path::new("/wd"))
312 .unwrap();
313 assert_eq!(root.path().unwrap().file_name().unwrap(), "root.jsonl");
314 match &root.entries()[0] {
315 SessionEntry::Header { version, cwd, .. } => {
316 assert_eq!(*version, 1);
317 assert_eq!(cwd, "/wd");
318 }
319 _ => panic!("first entry not header"),
320 }
321
322 let member = Session::create_named(dir.path(), "w1").unwrap();
325 assert_eq!(member.path().unwrap().file_name().unwrap(), "w1.jsonl");
326
327 let mut reopened = Session::open(&dir.path().join("root.jsonl")).unwrap();
330 reopened
331 .append(SessionEntry::ModelChange {
332 id: "m1".into(),
333 model: "m".into(),
334 })
335 .unwrap();
336 assert_eq!(reopened.entries().len(), 2);
337 assert_eq!(
338 Session::open(&dir.path().join("root.jsonl"))
339 .unwrap()
340 .model()
341 .as_deref(),
342 Some("m")
343 );
344 }
345
346 #[test]
347 fn create_append_open_roundtrip() {
348 let dir = tempfile::tempdir().unwrap();
349 let mut s = Session::create(dir.path()).unwrap();
350 s.append(msg("hello")).unwrap();
351 s.append(SessionEntry::ModelChange {
352 id: "m1".into(),
353 model: "claude-x".into(),
354 })
355 .unwrap();
356 s.append(SessionEntry::Message {
357 id: "a2".into(),
358 parent_id: Some("a1".into()),
359 message: AgentMessage::Assistant {
360 content: vec![ContentBlock::Text {
361 text: "reply".into(),
362 }],
363 stop_reason: StopReason::Stop,
364 usage: None,
365 model: Some("claude-x".into()),
366 },
367 })
368 .unwrap();
369
370 s.append(SessionEntry::Todo {
371 id: "td1".into(),
372 todos: vec![TodoItem {
373 content: "step".into(),
374 status: TodoStatus::Pending,
375 }],
376 })
377 .unwrap();
378
379 let path = s.path().unwrap().to_path_buf();
380 let reopened = Session::open(&path).unwrap();
381 assert_eq!(reopened.entries(), s.entries());
382 assert_eq!(reopened.messages().len(), 2);
383 assert_eq!(reopened.model().as_deref(), Some("claude-x"));
384 assert_eq!(reopened.todo().unwrap()[0].content, "step");
385 }
386
387 #[test]
388 fn torn_final_line_tolerated() {
389 let dir = tempfile::tempdir().unwrap();
390 let path = dir.path().join("s.jsonl");
391 fs::write(
392 &path,
393 format!(
394 "{}\n{}",
395 json!({"type":"message","id":"1","parent_id":null,"message":{"role":"user","content":[{"type":"text","text":"q"}]}}),
396 "{\"type\":\"mess" ),
398 )
399 .unwrap();
400 let s = Session::open(&path).unwrap();
401 assert_eq!(s.entries().len(), 1);
402 assert_eq!(s.messages().len(), 1);
403 }
404
405 #[test]
406 fn torn_line_followed_by_blank_lines_tolerated() {
407 let dir = tempfile::tempdir().unwrap();
408 let path = dir.path().join("s.jsonl");
409 fs::write(
410 &path,
411 format!(
412 "{}\n{{\"type\":\"mess\n\n",
413 json!({"type":"message","id":"1","parent_id":null,"message":{"role":"user","content":[{"type":"text","text":"q"}]}})
414 ),
415 )
416 .unwrap();
417 let s = Session::open(&path).unwrap();
418 assert_eq!(s.entries().len(), 1);
419 assert_eq!(s.messages().len(), 1);
420 }
421
422 #[test]
423 fn torn_earlier_line_is_error() {
424 let dir = tempfile::tempdir().unwrap();
425 let path = dir.path().join("s.jsonl");
426 fs::write(
427 &path,
428 "{\"type\":\"mess\n{\"type\":\"model_change\",\"id\":\"m\",\"model\":\"x\"}\n",
429 )
430 .unwrap();
431 assert!(Session::open(&path).is_err());
432 }
433
434 #[test]
435 fn unknown_entries_tolerated() {
436 let dir = tempfile::tempdir().unwrap();
437 let path = dir.path().join("s.jsonl");
438 fs::write(
439 &path,
440 format!(
441 "{}\n{}\n{}\n",
442 json!({"type":"header","version":1,"id":"x","cwd":"/","created":"2026-01-01T00:00:00Z"}),
443 json!({"type":"future_thing","data":42}),
444 json!({"type":"message","id":"1","parent_id":null,"message":{"role":"user","content":[{"type":"text","text":"q"}]}})
445 ),
446 )
447 .unwrap();
448 let s = Session::open(&path).unwrap();
449 assert_eq!(s.entries().len(), 3);
450 assert_eq!(s.entries()[1], SessionEntry::Unknown);
451 assert_eq!(s.messages().len(), 1);
452 }
453
454 #[test]
455 fn model_latest_wins() {
456 let mut s = Session::in_memory();
457 assert_eq!(s.model(), None);
458 s.append(SessionEntry::ModelChange {
459 id: "1".into(),
460 model: "old".into(),
461 })
462 .unwrap();
463 s.append(SessionEntry::ModelChange {
464 id: "2".into(),
465 model: "new".into(),
466 })
467 .unwrap();
468 assert_eq!(s.model().as_deref(), Some("new"));
469 }
470
471 #[test]
472 fn effort_latest_wins_and_none_means_cleared() {
473 let mut s = Session::in_memory();
474 assert_eq!(s.effort(), None);
475 s.append(SessionEntry::EffortChange {
476 id: "1".into(),
477 effort: Some("high".into()),
478 })
479 .unwrap();
480 assert_eq!(s.effort(), Some(Some("high".into())));
481 s.append(SessionEntry::EffortChange {
482 id: "2".into(),
483 effort: None,
484 })
485 .unwrap();
486 assert_eq!(s.effort(), Some(None));
487 }
488
489 #[test]
490 fn effort_roundtrips_through_jsonl() {
491 let dir = tempfile::tempdir().unwrap();
492 let mut s = Session::create(dir.path()).unwrap();
493 s.append(SessionEntry::EffortChange {
494 id: "e1".into(),
495 effort: Some("low".into()),
496 })
497 .unwrap();
498 let path = s.path().unwrap().to_path_buf();
499 let reopened = Session::open(&path).unwrap();
500 assert_eq!(reopened.effort(), Some(Some("low".into())));
501 }
502
503 #[test]
504 fn todo_latest_wins_and_roundtrips() {
505 let dir = tempfile::tempdir().unwrap();
506 let mut s = Session::create(dir.path()).unwrap();
507 s.append(SessionEntry::Todo {
508 id: "t1".into(),
509 todos: vec![TodoItem {
510 content: "first".into(),
511 status: TodoStatus::Pending,
512 }],
513 })
514 .unwrap();
515 s.append(SessionEntry::Todo {
516 id: "t2".into(),
517 todos: vec![TodoItem {
518 content: "second".into(),
519 status: TodoStatus::InProgress,
520 }],
521 })
522 .unwrap();
523
524 let path = s.path().unwrap().to_path_buf();
525 let reopened = Session::open(&path).unwrap();
526 let restored = reopened.todo().unwrap();
527 assert_eq!(restored.len(), 1, "last write wins");
528 assert_eq!(restored[0].content, "second");
529 assert_eq!(restored[0].status, TodoStatus::InProgress);
530
531 let raw = fs::read_to_string(&path).unwrap();
533 assert!(raw.contains(r#""type":"todo""#), "{raw}");
534 }
535
536 #[test]
537 fn in_memory_writes_nothing() {
538 let dir = tempfile::tempdir().unwrap();
539 let mut s = Session::in_memory();
540 s.append(msg("hi")).unwrap();
541 assert!(s.path().is_none());
542 assert_eq!(s.entries().len(), 1);
543 assert_eq!(s.messages().len(), 1);
544 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
545 }
546}
547
548#[cfg(test)]
549mod compaction_tests {
550 use super::*;
551
552 fn msg(text: &str) -> SessionEntry {
553 SessionEntry::Message {
554 id: uuid::Uuid::new_v4().to_string(),
555 parent_id: None,
556 message: AgentMessage::user_text(text),
557 }
558 }
559
560 fn texts(s: &Session) -> Vec<String> {
561 s.messages().iter().map(|m| m.as_text()).collect()
562 }
563
564 #[test]
565 fn no_compaction_returns_every_message() {
566 let mut s = Session::in_memory();
567 s.append(msg("a")).unwrap();
568 s.append(msg("b")).unwrap();
569 assert_eq!(texts(&s), ["a", "b"]);
570 assert_eq!(s.message_count(), 2);
571 }
572
573 #[test]
574 fn compaction_rebuilds_summary_plus_kept_tail_across_reopen() {
575 let dir = tempfile::tempdir().unwrap();
576 let mut s = Session::create(dir.path()).unwrap();
577 for t in ["m1", "m2", "m3", "m4", "m5"] {
578 s.append(msg(t)).unwrap();
579 }
580 s.record_compaction("did 1..3", 2, Some(123), None).unwrap();
582
583 let path = s.path().unwrap().to_path_buf();
584 let mut reopened = Session::open(&path).unwrap();
585 let got = texts(&reopened);
586 assert_eq!(got.len(), 3, "summary + two kept");
587 assert!(got[0].contains("did 1..3"), "summary leads: {got:?}");
588 assert_eq!(got[1], "m4");
589 assert_eq!(got[2], "m5");
590
591 reopened.append(msg("m6")).unwrap();
593 assert_eq!(texts(&reopened), [got[0].clone(), "m4".into(), "m5".into(), "m6".into()]);
594 assert_eq!(reopened.message_count(), 6);
595 }
596
597 #[test]
598 fn second_compaction_supersedes_the_first() {
599 let mut s = Session::in_memory();
600 for t in ["m1", "m2", "m3", "m4", "m5", "m6"] {
601 s.append(msg(t)).unwrap();
602 }
603 s.record_compaction("first", 4, None, None).unwrap(); assert_eq!(
605 texts(&s),
606 [
607 "Summary of the earlier conversation (auto-generated by compaction):\n\nfirst",
608 "m3",
609 "m4",
610 "m5",
611 "m6"
612 ]
613 );
614 s.record_compaction("second", 2, None, None).unwrap(); let got = texts(&s);
616 assert_eq!(got.len(), 3);
617 assert!(got[0].contains("second"), "the latest summary wins: {got:?}");
618 assert_eq!(got[1], "m5");
619 assert_eq!(got[2], "m6");
620 }
621}