1use crate::agent::{Agent, Conversation, Taint};
8use crate::config::{Config, PermissionMode, TrifectaPolicy};
9use crate::message::{Effort, Message, Usage};
10use anyhow::{Context, Result};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "record", rename_all = "snake_case")]
17pub enum Record {
18 Meta(SessionMeta),
19 Message(Message),
20 Summary {
23 usage: Usage,
24 turns: u32,
25 },
26 Config(RunConfig),
34 Taint(Taint),
42 Rewrite {
53 messages: Vec<Message>,
54 },
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(default)]
78pub struct RunConfig {
79 pub mecha_version: String,
81 pub provider: String,
82 pub model: String,
83 pub workspace: PathBuf,
84 pub system_prompt: Option<String>,
86 pub tools: Vec<String>,
90
91 pub effort: Option<Effort>,
93 pub temperature: Option<f64>,
96 pub seed: Option<u64>,
97 pub thinking: bool,
98 pub cache_prompt: bool,
100 pub max_tokens: u32,
101
102 pub max_turns: u32,
104 pub max_output_tokens: Option<u64>,
105 pub max_cost_usd: Option<f64>,
106 pub compact_at_tokens: Option<u64>,
107 pub compact_keep_recent: usize,
108
109 pub permission_mode: PermissionMode,
113 pub trifecta: TrifectaPolicy,
114 pub sandbox: String,
121 pub sandbox_network: bool,
122}
123
124impl Default for RunConfig {
125 fn default() -> Self {
126 RunConfig {
127 mecha_version: String::new(),
128 provider: String::new(),
129 model: String::new(),
130 workspace: PathBuf::new(),
131 system_prompt: None,
132 tools: Vec::new(),
133 effort: None,
134 temperature: None,
135 seed: None,
136 thinking: false,
137 cache_prompt: false,
138 max_tokens: 0,
139 max_turns: 0,
140 max_output_tokens: None,
141 max_cost_usd: None,
142 compact_at_tokens: None,
143 compact_keep_recent: 0,
144 permission_mode: PermissionMode::Ask,
145 trifecta: TrifectaPolicy::Block,
146 sandbox: "none".into(),
147 sandbox_network: false,
148 }
149 }
150}
151
152impl RunConfig {
153 pub fn of(agent: &Agent, config: &Config, provider: &str) -> Self {
157 let cfg = agent.config();
158 RunConfig {
159 mecha_version: crate::VERSION.to_string(),
160 provider: provider.to_string(),
161 model: agent.model().to_string(),
162 workspace: agent.ctx().workspace.clone(),
163 system_prompt: agent.system().map(str::to_string),
164 tools: agent
165 .registry()
166 .iter()
167 .map(|t| t.name().to_string())
168 .collect(),
169 effort: cfg.effort,
170 temperature: config.providers.get(provider).and_then(|p| p.temperature),
171 seed: config.providers.get(provider).and_then(|p| p.seed),
172 thinking: cfg.thinking,
173 cache_prompt: cfg.cache_prompt,
174 max_tokens: cfg.max_tokens,
175 max_turns: cfg.max_turns,
176 max_output_tokens: cfg.max_output_tokens,
177 max_cost_usd: cfg.max_cost_usd,
178 compact_at_tokens: cfg.compact_at_tokens,
179 compact_keep_recent: cfg.compact_keep_recent,
180 permission_mode: config.tools.permission_mode,
181 trifecta: config.security.trifecta,
182 sandbox: config.sandbox.kind.as_str().to_string(),
183 sandbox_network: config.sandbox.network,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct SessionMeta {
190 pub id: String,
191 pub created_at: DateTime<Utc>,
192 pub provider: String,
193 pub model: String,
194 pub workspace: PathBuf,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub title: Option<String>,
197}
198
199pub struct Session {
200 pub meta: SessionMeta,
201 pub path: PathBuf,
202}
203
204impl Session {
205 pub fn default_dir() -> Result<PathBuf> {
207 if let Ok(dir) = std::env::var("MECHA_SESSION_DIR") {
208 return Ok(PathBuf::from(dir));
209 }
210 Ok(crate::work::mecha_home()?.join("sessions"))
211 }
212
213 pub fn create(dir: &Path, meta: SessionMeta) -> Result<Self> {
214 crate::create_private_dir(dir)
215 .with_context(|| format!("creating session directory {}", dir.display()))?;
216 let path = dir.join(format!("{}.jsonl", meta.id));
217 let session = Session {
218 meta: meta.clone(),
219 path,
220 };
221 session.append(&Record::Meta(meta))?;
222 Ok(session)
223 }
224
225 pub fn new_id() -> String {
226 format!(
229 "{}-{}",
230 Utc::now().format("%Y%m%dT%H%M%S"),
231 &uuid::Uuid::new_v4().to_string()[..8]
232 )
233 }
234
235 pub fn append(&self, record: &Record) -> Result<()> {
236 use std::io::Write;
237 let mut file = std::fs::OpenOptions::new()
238 .create(true)
239 .append(true)
240 .open(&self.path)
241 .with_context(|| format!("opening {}", self.path.display()))?;
242 writeln!(file, "{}", serde_json::to_string(record)?)?;
243 Ok(())
244 }
245
246 pub fn append_messages(&self, messages: &[Message]) -> Result<()> {
247 for m in messages {
248 self.append(&Record::Message(m.clone()))?;
249 }
250 Ok(())
251 }
252
253 pub fn record_run(&self, before: &[Message], convo: &Conversation) -> Result<()> {
269 let mut prev: &[Message] = before;
270 for state in &convo.rewritten {
271 self.record_transition(prev, state)?;
272 prev = state;
273 }
274 self.record_transition(prev, &convo.messages)
275 }
276
277 fn record_transition(&self, before: &[Message], after: &[Message]) -> Result<()> {
288 let appended_only = after.len() >= before.len() && after[..before.len()] == *before;
289 if appended_only {
290 self.append_messages(&after[before.len()..])
291 } else {
292 self.append(&Record::Rewrite {
293 messages: after.to_vec(),
294 })
295 }
296 }
297
298 pub fn load(path: &Path) -> Result<(SessionMeta, Conversation)> {
303 let text =
304 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
305
306 let mut meta = None;
307 let mut messages = Vec::new();
308 let mut taint = Taint::default();
309 for line in text.lines().filter(|l| !l.trim().is_empty()) {
310 match serde_json::from_str::<Record>(line) {
311 Ok(Record::Meta(m)) => meta = Some(m),
312 Ok(Record::Message(m)) => messages.push(m),
313 Ok(Record::Rewrite { messages: m }) => messages = m,
317 Ok(Record::Taint(t)) => taint.merge(t),
320 Ok(Record::Summary { .. }) | Ok(Record::Config(_)) => {}
321 Err(e) => tracing::warn!(error = %e, "skipping malformed transcript line"),
322 }
323 }
324
325 let meta = meta.with_context(|| format!("{} has no session header", path.display()))?;
326 Ok((meta, Conversation::resumed(messages, taint)))
327 }
328
329 pub fn taint_timeline(path: &Path) -> Result<TaintTimeline> {
339 let text =
340 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
341 Ok(TaintTimeline::from_records(
342 text.lines()
343 .filter(|l| !l.trim().is_empty())
344 .filter_map(|l| serde_json::from_str::<Record>(l).ok()),
345 ))
346 }
347
348 pub fn run_configs(path: &Path) -> Result<Vec<RunConfig>> {
356 let text =
357 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
358 Ok(text
359 .lines()
360 .filter(|l| !l.trim().is_empty())
361 .filter_map(|l| match serde_json::from_str::<Record>(l) {
362 Ok(Record::Config(c)) => Some(c),
363 _ => None,
364 })
365 .collect())
366 }
367
368 pub fn peek_meta(path: &Path) -> Option<SessionMeta> {
378 use std::io::BufRead;
379 let file = std::fs::File::open(path).ok()?;
380 let mut reader = std::io::BufReader::new(file);
381 let mut first = String::new();
382 loop {
383 first.clear();
384 if reader.read_line(&mut first).ok()? == 0 {
385 return None;
386 }
387 if !first.trim().is_empty() {
388 break;
389 }
390 }
391 match serde_json::from_str::<Record>(&first).ok()? {
392 Record::Meta(m) => Some(m),
393 _ => None,
394 }
395 }
396
397 pub fn usage_totals(path: &Path) -> Result<(Usage, u32)> {
402 let text =
403 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
404 let mut usage = Usage::default();
405 let mut turns = 0u32;
406 for line in text.lines().filter(|l| !l.trim().is_empty()) {
407 if let Ok(Record::Summary { usage: u, turns: t }) = serde_json::from_str(line) {
408 usage.add(&u);
409 turns += t;
410 }
411 }
412 Ok((usage, turns))
413 }
414
415 pub fn list(dir: &Path) -> Result<Vec<(SessionMeta, PathBuf)>> {
417 if !dir.exists() {
418 return Ok(Vec::new());
419 }
420 let mut out = Vec::new();
421 for entry in std::fs::read_dir(dir)? {
422 let path = entry?.path();
423 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
424 continue;
425 }
426 if let Some(meta) = Session::peek_meta(&path) {
428 out.push((meta, path));
429 }
430 }
431 out.sort_by_key(|(meta, _)| std::cmp::Reverse(meta.created_at));
432 Ok(out)
433 }
434
435 pub fn find(dir: &Path, id_prefix: &str) -> Result<PathBuf> {
437 let matches: Vec<_> = Session::list(dir)?
438 .into_iter()
439 .filter(|(m, _)| m.id.starts_with(id_prefix))
440 .collect();
441 match matches.len() {
442 0 => anyhow::bail!("no session matching {id_prefix:?}"),
443 1 => Ok(matches.into_iter().next().unwrap().1),
444 n => anyhow::bail!("{id_prefix:?} matches {n} sessions; use a longer prefix"),
445 }
446 }
447}
448
449#[derive(Debug, Clone, Default)]
453pub struct TaintTimeline {
454 checkpoints: Vec<(usize, Taint)>,
458}
459
460impl TaintTimeline {
461 pub fn from_records(records: impl IntoIterator<Item = Record>) -> Self {
462 let mut checkpoints: Vec<(usize, Taint)> = Vec::new();
463 let mut messages = 0usize;
464 let mut merged = Taint::default();
465 for record in records {
466 match record {
467 Record::Message(_) => messages += 1,
468 Record::Rewrite { messages: m } => {
483 messages = m.len();
484 checkpoints.clear();
485 }
486 Record::Taint(t) => {
487 merged.merge(t);
488 checkpoints.push((messages, merged));
489 }
490 _ => {}
491 }
492 }
493 TaintTimeline { checkpoints }
494 }
495
496 pub fn covering(&self, index: usize) -> Option<Taint> {
501 self.checkpoints
502 .iter()
503 .find(|(n, _)| *n > index)
504 .map(|(_, t)| *t)
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use crate::message::Block;
512
513 fn tmpdir() -> PathBuf {
514 let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
515 std::fs::create_dir_all(&dir).unwrap();
516 dir
517 }
518
519 fn meta_with_id(id: &str) -> SessionMeta {
520 SessionMeta {
521 id: id.to_string(),
522 created_at: Utc::now(),
523 provider: "scripted".into(),
524 model: "test-model".into(),
525 workspace: PathBuf::from("/tmp"),
526 title: None,
527 }
528 }
529
530 #[test]
531 fn a_transcript_round_trips_its_messages_and_its_taint() {
532 let dir = tmpdir();
533 let session = Session::create(&dir, meta_with_id("20260101T000000-round")).unwrap();
534 session
535 .append_messages(&[
536 Message::user("summarise this page"),
537 Message::assistant(vec![Block::text("done")]),
538 ])
539 .unwrap();
540 session
541 .append(&Record::Taint(Taint {
542 private: true,
543 untrusted: true,
544 }))
545 .unwrap();
546
547 let (meta, convo) = Session::load(&session.path).unwrap();
548
549 assert_eq!(meta.model, "test-model");
550 assert_eq!(convo.messages.len(), 2);
551 assert_eq!(convo.messages[0].text(), "summarise this page");
552 assert_eq!(convo.messages[1].text(), "done");
553 assert!(convo.taint.trifecta_armed());
557
558 std::fs::remove_dir_all(&dir).ok();
559 }
560
561 #[test]
562 fn record_run_appends_the_tail_when_the_run_only_appended() {
563 let dir = tmpdir();
564 let session = Session::create(&dir, meta_with_id("20260101T000000-tail")).unwrap();
565 let before = vec![Message::user("go")];
566 session.append_messages(&before).unwrap();
567
568 let mut after = Conversation::from(before.clone());
569 after.push(Message::assistant(vec![Block::text("done")]));
570 session.record_run(&before, &after).unwrap();
571
572 let (_, convo) = Session::load(&session.path).unwrap();
573 assert_eq!(convo.messages.len(), 2);
574 assert_eq!(convo.messages[1].text(), "done");
575 let text = std::fs::read_to_string(&session.path).unwrap();
578 assert!(!text.contains("\"record\":\"rewrite\""), "{text}");
579
580 std::fs::remove_dir_all(&dir).ok();
581 }
582
583 #[test]
584 fn record_run_records_a_rewrite_when_compaction_touched_the_head() {
585 let dir = tmpdir();
592 let session = Session::create(&dir, meta_with_id("20260101T000000-rw")).unwrap();
593 let before = vec![Message::user("go")];
594 session.append_messages(&before).unwrap();
595
596 let mut head = before[0].clone();
599 head.content
600 .push(Block::text("[Earlier turns were compacted]"));
601 let after = Conversation::from(vec![head, Message::assistant(vec![Block::text("done")])]);
602 session.record_run(&before, &after).unwrap();
603
604 let (_, convo) = Session::load(&session.path).unwrap();
605 assert_eq!(convo.messages.len(), 2);
606 assert!(
607 convo.messages[0].text().contains("compacted"),
608 "the rebuilt head must be what loads: {:?}",
609 convo.messages[0].text()
610 );
611 assert_eq!(convo.messages[1].text(), "done");
612
613 std::fs::remove_dir_all(&dir).ok();
614 }
615
616 #[test]
622 fn record_run_walks_the_states_a_mid_run_rewrite_replaced() {
623 let dir = tmpdir();
624 let session = Session::create(&dir, meta_with_id("20260101T000000-midrun")).unwrap();
625 let before = vec![Message::user("go")];
626 session.append_messages(&before).unwrap();
627
628 let mut reached = before.clone();
631 reached.push(Message::assistant(vec![Block::text(
632 "the magic number is 74656",
633 )]));
634 let compacted = vec![
636 Message::user("[summary: a number was computed]"),
637 Message::assistant(vec![Block::text("done")]),
638 ];
639 let mut convo = Conversation::from(compacted);
640 convo.rewritten = vec![reached];
641
642 session.record_run(&before, &convo).unwrap();
643
644 let (_, loaded) = Session::load(&session.path).unwrap();
646 assert_eq!(loaded.messages.len(), 2);
647 assert!(loaded.messages[0].text().contains("summary"));
648
649 let text = std::fs::read_to_string(&session.path).unwrap();
652 assert!(
653 text.contains("74656"),
654 "the pre-rewrite turn never reached the file: {text}"
655 );
656
657 std::fs::remove_dir_all(&dir).ok();
658 }
659
660 #[test]
661 fn a_rewrite_drops_stale_taint_positions_instead_of_shadowing_later_ones() {
662 let msg = || Message::user("m");
672 let mut records: Vec<Record> = (0..10).map(|_| Record::Message(msg())).collect();
673 records.push(Record::Taint(Taint {
674 private: true,
675 untrusted: false,
676 }));
677 records.push(Record::Rewrite {
678 messages: vec![msg(), msg()],
679 });
680 records.push(Record::Taint(Taint {
681 private: true,
682 untrusted: true,
683 }));
684
685 let timeline = TaintTimeline::from_records(records);
686 for index in 0..2 {
690 let covering = timeline.covering(index).expect("a checkpoint covers it");
691 assert!(
692 covering.untrusted,
693 "message {index} classified by a stale pre-rewrite checkpoint"
694 );
695 assert!(covering.private, "the dropped checkpoint's taint was lost");
696 }
697 }
698
699 #[test]
700 fn a_transcript_torn_after_a_rewrite_reports_unknown_not_clean() {
701 let msg = || Message::user("m");
706 let records = vec![
707 Record::Message(msg()),
708 Record::Taint(Taint {
709 private: true,
710 untrusted: true,
711 }),
712 Record::Rewrite {
713 messages: vec![msg(), msg()],
714 },
715 ];
716 let timeline = TaintTimeline::from_records(records);
717 assert_eq!(timeline.covering(0), None);
718 assert_eq!(timeline.covering(1), None);
719 }
720
721 #[test]
722 fn taint_records_merge_so_a_later_clean_one_cannot_disarm_the_interlock() {
723 let dir = tmpdir();
724 let session = Session::create(&dir, meta_with_id("20260101T000000-merge")).unwrap();
725
726 session
729 .append(&Record::Taint(Taint {
730 untrusted: true,
731 private: false,
732 }))
733 .unwrap();
734 session
735 .append(&Record::Taint(Taint {
736 private: true,
737 untrusted: false,
738 }))
739 .unwrap();
740 session.append(&Record::Taint(Taint::default())).unwrap();
741
742 let (_, convo) = Session::load(&session.path).unwrap();
743
744 assert!(convo.taint.private, "an earlier private leg was dropped");
747 assert!(
748 convo.taint.untrusted,
749 "an earlier untrusted leg was dropped"
750 );
751 assert!(convo.taint.trifecta_armed());
752
753 std::fs::remove_dir_all(&dir).ok();
754 }
755
756 #[test]
757 fn a_transcript_written_before_taint_was_recorded_loads_clean() {
758 let dir = tmpdir();
759 let session = Session::create(&dir, meta_with_id("20260101T000000-old")).unwrap();
760 session.append_messages(&[Message::user("hello")]).unwrap();
761
762 let (_, convo) = Session::load(&session.path).unwrap();
763
764 assert_eq!(convo.messages.len(), 1);
765 assert!(!convo.taint.private);
766 assert!(!convo.taint.untrusted);
767
768 std::fs::remove_dir_all(&dir).ok();
769 }
770
771 #[test]
772 fn a_truncated_final_line_does_not_lose_the_rest_of_the_transcript() {
773 use std::io::Write;
774 let dir = tmpdir();
775 let session = Session::create(&dir, meta_with_id("20260101T000000-killed")).unwrap();
776 session.append_messages(&[Message::user("first")]).unwrap();
777 session
778 .append(&Record::Taint(Taint {
779 private: true,
780 untrusted: false,
781 }))
782 .unwrap();
783
784 let mut file = std::fs::OpenOptions::new()
786 .append(true)
787 .open(&session.path)
788 .unwrap();
789 write!(file, "{{\"record\":\"message\",\"role\":\"assis").unwrap();
790 drop(file);
791
792 let (_, convo) = Session::load(&session.path).unwrap();
793
794 assert_eq!(convo.messages.len(), 1);
795 assert_eq!(convo.messages[0].text(), "first");
796 assert!(
797 convo.taint.private,
798 "a torn last line lost the taint before it"
799 );
800
801 std::fs::remove_dir_all(&dir).ok();
802 }
803
804 #[test]
805 fn run_configs_come_back_in_order_one_per_attach() {
806 let dir = tmpdir();
807 let session = Session::create(&dir, meta_with_id("20260101T000000-cfg")).unwrap();
808
809 let first = RunConfig {
811 compact_at_tokens: None,
812 ..RunConfig::default()
813 };
814 let second = RunConfig {
815 compact_at_tokens: Some(1200),
816 ..RunConfig::default()
817 };
818 session.append(&Record::Config(first)).unwrap();
819 session
820 .append_messages(&[Message::user("first run")])
821 .unwrap();
822 session.append(&Record::Config(second)).unwrap();
823
824 let configs = Session::run_configs(&session.path).unwrap();
825
826 assert_eq!(configs.len(), 2, "one record per attach, in order");
827 assert_eq!(configs[0].compact_at_tokens, None);
828 assert_eq!(configs[1].compact_at_tokens, Some(1200));
831
832 let (_, convo) = Session::load(&session.path).unwrap();
834 assert_eq!(convo.messages.len(), 1);
835
836 std::fs::remove_dir_all(&dir).ok();
837 }
838
839 #[test]
840 fn a_transcript_recorded_before_this_existed_reports_no_configs() {
841 let dir = tmpdir();
844 let session = Session::create(&dir, meta_with_id("20260101T000000-legacy")).unwrap();
845 session.append_messages(&[Message::user("hello")]).unwrap();
846
847 assert!(Session::run_configs(&session.path).unwrap().is_empty());
848
849 std::fs::remove_dir_all(&dir).ok();
850 }
851
852 #[test]
853 fn the_taint_timeline_covers_each_message_with_its_runs_checkpoint() {
854 let dir = tmpdir();
855 let session = Session::create(&dir, meta_with_id("20260101T000000-tl")).unwrap();
856
857 session
859 .append_messages(&[Message::user("list the files")])
860 .unwrap();
861 session
862 .append_messages(&[Message::assistant(vec![Block::text("done")])])
863 .unwrap();
864 session.append(&Record::Taint(Taint::default())).unwrap();
865 session
867 .append_messages(&[Message::user("fetch that page")])
868 .unwrap();
869 session
870 .append_messages(&[Message::assistant(vec![Block::text("fetched")])])
871 .unwrap();
872 session
873 .append(&Record::Taint(Taint {
874 untrusted: true,
875 private: false,
876 }))
877 .unwrap();
878
879 let tl = Session::taint_timeline(&session.path).unwrap();
880
881 assert!(!tl.covering(0).unwrap().untrusted);
883 assert!(!tl.covering(1).unwrap().untrusted);
884 assert!(tl.covering(2).unwrap().untrusted);
888 assert!(tl.covering(3).unwrap().untrusted);
889 assert_eq!(tl.covering(4).map(|t| t.untrusted), None);
892
893 std::fs::remove_dir_all(&dir).ok();
894 }
895
896 #[test]
897 fn a_pre_taint_transcript_has_an_empty_timeline() {
898 let dir = tmpdir();
902 let session = Session::create(&dir, meta_with_id("20260101T000000-notl")).unwrap();
903 session.append_messages(&[Message::user("hello")]).unwrap();
904
905 let tl = Session::taint_timeline(&session.path).unwrap();
906 assert!(tl.covering(0).is_none());
907
908 std::fs::remove_dir_all(&dir).ok();
909 }
910
911 #[test]
912 fn listing_reads_only_the_first_record_and_skips_files_without_a_header() {
913 let dir = tmpdir();
914 let session = Session::create(&dir, meta_with_id("20260101T000000-peek")).unwrap();
915 session.append_messages(&[Message::user("hello")]).unwrap();
916
917 let stray = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
922 let meta = serde_json::to_string(&Record::Meta(meta_with_id("buried"))).unwrap();
923 std::fs::write(dir.join("stray.jsonl"), format!("{stray}\n{meta}\n")).unwrap();
924
925 let listed = Session::list(&dir).unwrap();
926 assert_eq!(listed.len(), 1);
927 assert_eq!(listed[0].0.id, "20260101T000000-peek");
928
929 let peeked = Session::peek_meta(&session.path).unwrap();
931 let (loaded, _) = Session::load(&session.path).unwrap();
932 assert_eq!(peeked.id, loaded.id);
933 assert_eq!(peeked.model, loaded.model);
934
935 std::fs::remove_dir_all(&dir).ok();
936 }
937
938 #[test]
939 fn usage_totals_sum_every_run_and_report_zero_for_a_summaryless_file() {
940 let dir = tmpdir();
941 let session = Session::create(&dir, meta_with_id("20260101T000000-usage")).unwrap();
942
943 assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
945
946 session
948 .append(&Record::Summary {
949 usage: Usage {
950 input_tokens: 100,
951 output_tokens: 10,
952 ..Default::default()
953 },
954 turns: 2,
955 })
956 .unwrap();
957 session
958 .append(&Record::Summary {
959 usage: Usage {
960 input_tokens: 50,
961 output_tokens: 5,
962 ..Default::default()
963 },
964 turns: 1,
965 })
966 .unwrap();
967
968 let (usage, turns) = Session::usage_totals(&session.path).unwrap();
969 assert_eq!(usage.input_tokens, 150);
970 assert_eq!(usage.output_tokens, 15);
971 assert_eq!(turns, 3);
972
973 std::fs::remove_dir_all(&dir).ok();
974 }
975
976 #[cfg(unix)]
977 #[test]
978 fn the_session_directory_is_owner_only() {
979 use std::os::unix::fs::PermissionsExt;
980 let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
982 Session::create(&dir, meta_with_id("20260101T000000-perms")).unwrap();
983
984 let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
987 assert_eq!(mode & 0o777, 0o700);
988
989 std::fs::remove_dir_all(&dir).ok();
990 }
991
992 #[test]
993 fn a_transcript_with_no_header_is_refused() {
994 let dir = tmpdir();
995 let path = dir.join("headerless.jsonl");
996 let line = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
997 std::fs::write(&path, format!("{line}\n")).unwrap();
998
999 let err = Session::load(&path).unwrap_err().to_string();
1000 assert!(err.contains("no session header"), "unexpected error: {err}");
1001
1002 std::fs::remove_dir_all(&dir).ok();
1003 }
1004
1005 #[test]
1006 fn an_ambiguous_id_prefix_is_an_error_rather_than_a_guess() {
1007 let dir = tmpdir();
1008 Session::create(&dir, meta_with_id("20260101T000000-aaaaaaaa")).unwrap();
1009 Session::create(&dir, meta_with_id("20260101T000000-bbbbbbbb")).unwrap();
1010
1011 let err = Session::find(&dir, "20260101").unwrap_err().to_string();
1012 assert!(
1013 err.contains("matches 2 sessions"),
1014 "unexpected error: {err}"
1015 );
1016
1017 let path = Session::find(&dir, "20260101T000000-aaaaaaaa").unwrap();
1020 assert!(path.ends_with("20260101T000000-aaaaaaaa.jsonl"));
1021
1022 assert!(Session::find(&dir, "nothing-like-this").is_err());
1023
1024 std::fs::remove_dir_all(&dir).ok();
1025 }
1026}