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 Outcome(RunStats),
42 Config(RunConfig),
50 Taint(Taint),
58 Rewrite {
69 messages: Vec<Message>,
70 },
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(default)]
94pub struct RunConfig {
95 pub mecha_version: String,
97 pub provider: String,
98 pub model: String,
99 pub workspace: PathBuf,
100 pub system_prompt: Option<String>,
102 pub tools: Vec<String>,
106
107 pub effort: Option<Effort>,
109 pub temperature: Option<f64>,
112 pub seed: Option<u64>,
113 pub thinking: bool,
114 pub cache_prompt: bool,
116 pub max_tokens: u32,
117
118 pub max_turns: u32,
120 pub max_output_tokens: Option<u64>,
121 pub max_cost_usd: Option<f64>,
122 pub compact_at_tokens: Option<u64>,
123 pub compact_keep_recent: usize,
124
125 pub permission_mode: PermissionMode,
129 pub trifecta: TrifectaPolicy,
130 pub sandbox: String,
137 pub sandbox_network: bool,
138}
139
140impl Default for RunConfig {
141 fn default() -> Self {
142 RunConfig {
143 mecha_version: String::new(),
144 provider: String::new(),
145 model: String::new(),
146 workspace: PathBuf::new(),
147 system_prompt: None,
148 tools: Vec::new(),
149 effort: None,
150 temperature: None,
151 seed: None,
152 thinking: false,
153 cache_prompt: false,
154 max_tokens: 0,
155 max_turns: 0,
156 max_output_tokens: None,
157 max_cost_usd: None,
158 compact_at_tokens: None,
159 compact_keep_recent: 0,
160 permission_mode: PermissionMode::Ask,
161 trifecta: TrifectaPolicy::Block,
162 sandbox: "none".into(),
163 sandbox_network: false,
164 }
165 }
166}
167
168impl RunConfig {
169 pub fn of(agent: &Agent, config: &Config, provider: &str) -> Self {
173 let cfg = agent.config();
174 RunConfig {
175 mecha_version: crate::VERSION.to_string(),
176 provider: provider.to_string(),
177 model: agent.model().to_string(),
178 workspace: agent.ctx().workspace.clone(),
179 system_prompt: agent.system().map(str::to_string),
180 tools: agent
181 .registry()
182 .iter()
183 .map(|t| t.name().to_string())
184 .collect(),
185 effort: cfg.effort,
186 temperature: config.providers.get(provider).and_then(|p| p.temperature),
187 seed: config.providers.get(provider).and_then(|p| p.seed),
188 thinking: cfg.thinking,
189 cache_prompt: cfg.cache_prompt,
190 max_tokens: cfg.max_tokens,
191 max_turns: cfg.max_turns,
192 max_output_tokens: cfg.max_output_tokens,
193 max_cost_usd: cfg.max_cost_usd,
194 compact_at_tokens: cfg.compact_at_tokens,
195 compact_keep_recent: cfg.compact_keep_recent,
196 permission_mode: config.tools.permission_mode,
197 trifecta: config.security.trifecta,
198 sandbox: config.sandbox.kind.as_str().to_string(),
199 sandbox_network: config.sandbox.network,
200 }
201 }
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217pub struct RunStats {
218 #[serde(default)]
219 pub turns: u32,
220 #[serde(default)]
221 pub usage: Usage,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub cost_usd: Option<f64>,
224 #[serde(default)]
226 pub usage_complete: bool,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub stop_cause: Option<crate::agent::StopCause>,
232 #[serde(default)]
233 pub exhausted: bool,
234 #[serde(default)]
236 pub ended_on_failed_call: bool,
237 #[serde(default)]
242 pub tool_calls: u32,
243 #[serde(default)]
244 pub tool_errors: u32,
245 #[serde(default)]
246 pub tool_denied: u32,
247 #[serde(default)]
248 pub tool_staged: u32,
249 #[serde(default)]
250 pub malformed_tool_args: u32,
251 #[serde(default)]
252 pub blocked_sends: u32,
253 #[serde(default)]
254 pub compactions: u32,
255 #[serde(default)]
259 pub taint: Taint,
260}
261
262impl RunStats {
263 pub fn absorb(&mut self, o: &crate::agent::RunOutcome) {
281 self.turns += o.turns;
282 self.usage.add(&o.usage);
283 self.cost_usd = match (self.cost_usd, o.cost_usd) {
284 (Some(a), Some(b)) => Some(a + b),
285 (a, b) => a.or(b),
286 };
287 self.usage_complete &= o.usage_complete;
288 self.stop_cause = Some(o.stop_cause);
289 self.exhausted = o.exhausted;
290 self.ended_on_failed_call = o.ended_on_failed_call;
291 self.tool_calls += o.tool_calls.len() as u32;
292 self.tool_errors += o
298 .tool_calls
299 .iter()
300 .filter(|c| c.unknown || (c.is_error && !c.denied))
301 .count() as u32;
302 self.tool_denied += o.tool_calls.iter().filter(|c| c.denied).count() as u32;
303 self.tool_staged += o.tool_calls.iter().filter(|c| c.staged).count() as u32;
304 self.malformed_tool_args += o.malformed_tool_args;
305 self.blocked_sends += o.blocked_sends;
306 self.compactions += o.compactions;
307 self.taint.merge(o.taint);
308 }
309}
310
311impl From<&crate::agent::RunOutcome> for RunStats {
312 fn from(o: &crate::agent::RunOutcome) -> Self {
313 let mut stats = RunStats {
316 usage_complete: true,
317 ..RunStats::default()
318 };
319 stats.absorb(o);
320 stats
321 }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct SessionMeta {
326 pub id: String,
327 pub created_at: DateTime<Utc>,
328 pub provider: String,
329 pub model: String,
330 pub workspace: PathBuf,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub title: Option<String>,
333}
334
335pub struct Session {
336 pub meta: SessionMeta,
337 pub path: PathBuf,
338}
339
340impl Session {
341 pub fn default_dir() -> Result<PathBuf> {
343 if let Ok(dir) = std::env::var("MECHA_SESSION_DIR") {
344 return Ok(PathBuf::from(dir));
345 }
346 Ok(crate::work::mecha_home()?.join("sessions"))
347 }
348
349 pub fn create(dir: &Path, meta: SessionMeta) -> Result<Self> {
350 crate::create_private_dir(dir)
351 .with_context(|| format!("creating session directory {}", dir.display()))?;
352 let path = dir.join(format!("{}.jsonl", meta.id));
353 let session = Session {
354 meta: meta.clone(),
355 path,
356 };
357 session.append(&Record::Meta(meta))?;
358 Ok(session)
359 }
360
361 pub fn new_id() -> String {
362 format!(
365 "{}-{}",
366 Utc::now().format("%Y%m%dT%H%M%S"),
367 &uuid::Uuid::new_v4().to_string()[..8]
368 )
369 }
370
371 pub fn append(&self, record: &Record) -> Result<()> {
372 use std::io::Write;
373 let mut file = std::fs::OpenOptions::new()
374 .create(true)
375 .append(true)
376 .open(&self.path)
377 .with_context(|| format!("opening {}", self.path.display()))?;
378 writeln!(file, "{}", serde_json::to_string(record)?)?;
379 Ok(())
380 }
381
382 pub fn append_messages(&self, messages: &[Message]) -> Result<()> {
383 for m in messages {
384 self.append(&Record::Message(m.clone()))?;
385 }
386 Ok(())
387 }
388
389 pub fn record_run(&self, before: &[Message], convo: &Conversation) -> Result<()> {
405 let mut prev: &[Message] = before;
406 for state in &convo.rewritten {
407 self.record_transition(prev, state)?;
408 prev = state;
409 }
410 self.record_transition(prev, &convo.messages)
411 }
412
413 pub fn record_outcome(&self, outcome: &crate::agent::RunOutcome) -> Result<()> {
425 self.append(&Record::Outcome(RunStats::from(outcome)))
426 }
427
428 pub fn outcomes_attributed(path: &Path) -> Result<Vec<(String, String, RunStats)>> {
438 let text =
439 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
440 let mut provider = String::new();
441 let mut model = String::new();
442 let mut out = Vec::new();
443 for line in text.lines().filter(|l| !l.trim().is_empty()) {
444 match serde_json::from_str(line) {
445 Ok(Record::Meta(meta)) => {
446 provider = meta.provider;
447 model = meta.model;
448 }
449 Ok(Record::Config(cfg)) => {
450 provider = cfg.provider;
451 model = cfg.model;
452 }
453 Ok(Record::Outcome(stats)) => out.push((provider.clone(), model.clone(), stats)),
454 _ => {}
455 }
456 }
457 Ok(out)
458 }
459
460 pub fn outcomes(path: &Path) -> Result<Vec<RunStats>> {
466 let text =
467 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
468 Ok(text
469 .lines()
470 .filter(|l| !l.trim().is_empty())
471 .filter_map(|l| match serde_json::from_str(l) {
472 Ok(Record::Outcome(s)) => Some(s),
473 _ => None,
474 })
475 .collect())
476 }
477
478 fn record_transition(&self, before: &[Message], after: &[Message]) -> Result<()> {
489 let appended_only = after.len() >= before.len() && after[..before.len()] == *before;
490 if appended_only {
491 self.append_messages(&after[before.len()..])
492 } else {
493 self.append(&Record::Rewrite {
494 messages: after.to_vec(),
495 })
496 }
497 }
498
499 pub fn load(path: &Path) -> Result<(SessionMeta, Conversation)> {
504 let text =
505 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
506
507 let mut meta = None;
508 let mut messages = Vec::new();
509 let mut taint = Taint::default();
510 for line in text.lines().filter(|l| !l.trim().is_empty()) {
511 match serde_json::from_str::<Record>(line) {
512 Ok(Record::Meta(m)) => meta = Some(m),
513 Ok(Record::Message(m)) => messages.push(m),
514 Ok(Record::Rewrite { messages: m }) => messages = m,
518 Ok(Record::Taint(t)) => taint.merge(t),
521 Ok(Record::Summary { .. }) | Ok(Record::Config(_)) | Ok(Record::Outcome(_)) => {}
522 Err(e) => tracing::warn!(error = %e, "skipping malformed transcript line"),
523 }
524 }
525
526 let meta = meta.with_context(|| format!("{} has no session header", path.display()))?;
527 Ok((meta, Conversation::resumed(messages, taint)))
528 }
529
530 pub fn taint_timeline(path: &Path) -> Result<TaintTimeline> {
540 let text =
541 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
542 Ok(TaintTimeline::from_records(
543 text.lines()
544 .filter(|l| !l.trim().is_empty())
545 .filter_map(|l| serde_json::from_str::<Record>(l).ok()),
546 ))
547 }
548
549 pub fn run_configs(path: &Path) -> Result<Vec<RunConfig>> {
557 let text =
558 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
559 Ok(text
560 .lines()
561 .filter(|l| !l.trim().is_empty())
562 .filter_map(|l| match serde_json::from_str::<Record>(l) {
563 Ok(Record::Config(c)) => Some(c),
564 _ => None,
565 })
566 .collect())
567 }
568
569 pub fn peek_meta(path: &Path) -> Option<SessionMeta> {
579 use std::io::BufRead;
580 let file = std::fs::File::open(path).ok()?;
581 let mut reader = std::io::BufReader::new(file);
582 let mut first = String::new();
583 loop {
584 first.clear();
585 if reader.read_line(&mut first).ok()? == 0 {
586 return None;
587 }
588 if !first.trim().is_empty() {
589 break;
590 }
591 }
592 match serde_json::from_str::<Record>(&first).ok()? {
593 Record::Meta(m) => Some(m),
594 _ => None,
595 }
596 }
597
598 pub fn usage_totals(path: &Path) -> Result<(Usage, u32)> {
603 let text =
604 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
605 let mut usage = Usage::default();
606 let mut turns = 0u32;
607 for line in text.lines().filter(|l| !l.trim().is_empty()) {
608 if let Ok(Record::Summary { usage: u, turns: t }) = serde_json::from_str(line) {
609 usage.add(&u);
610 turns += t;
611 }
612 }
613 Ok((usage, turns))
614 }
615
616 pub fn list(dir: &Path) -> Result<Vec<(SessionMeta, PathBuf)>> {
618 if !dir.exists() {
619 return Ok(Vec::new());
620 }
621 let mut out = Vec::new();
622 for entry in std::fs::read_dir(dir)? {
623 let path = entry?.path();
624 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
625 continue;
626 }
627 if let Some(meta) = Session::peek_meta(&path) {
629 out.push((meta, path));
630 }
631 }
632 out.sort_by_key(|(meta, _)| std::cmp::Reverse(meta.created_at));
633 Ok(out)
634 }
635
636 pub fn find(dir: &Path, id_prefix: &str) -> Result<PathBuf> {
638 let matches: Vec<_> = Session::list(dir)?
639 .into_iter()
640 .filter(|(m, _)| m.id.starts_with(id_prefix))
641 .collect();
642 match matches.len() {
643 0 => anyhow::bail!("no session matching {id_prefix:?}"),
644 1 => Ok(matches.into_iter().next().unwrap().1),
645 n => anyhow::bail!("{id_prefix:?} matches {n} sessions; use a longer prefix"),
646 }
647 }
648}
649
650#[derive(Debug, Clone, Default)]
654pub struct TaintTimeline {
655 checkpoints: Vec<(usize, Taint)>,
659}
660
661impl TaintTimeline {
662 pub fn from_records(records: impl IntoIterator<Item = Record>) -> Self {
663 let mut checkpoints: Vec<(usize, Taint)> = Vec::new();
664 let mut messages = 0usize;
665 let mut merged = Taint::default();
666 for record in records {
667 match record {
668 Record::Message(_) => messages += 1,
669 Record::Rewrite { messages: m } => {
684 messages = m.len();
685 checkpoints.clear();
686 }
687 Record::Taint(t) => {
688 merged.merge(t);
689 checkpoints.push((messages, merged));
690 }
691 _ => {}
692 }
693 }
694 TaintTimeline { checkpoints }
695 }
696
697 pub fn covering(&self, index: usize) -> Option<Taint> {
702 self.checkpoints
703 .iter()
704 .find(|(n, _)| *n > index)
705 .map(|(_, t)| *t)
706 }
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use crate::message::Block;
713
714 fn tmpdir() -> PathBuf {
715 let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
716 std::fs::create_dir_all(&dir).unwrap();
717 dir
718 }
719
720 fn meta_with_id(id: &str) -> SessionMeta {
721 SessionMeta {
722 id: id.to_string(),
723 created_at: Utc::now(),
724 provider: "scripted".into(),
725 model: "test-model".into(),
726 workspace: PathBuf::from("/tmp"),
727 title: None,
728 }
729 }
730
731 #[test]
732 fn a_transcript_round_trips_its_messages_and_its_taint() {
733 let dir = tmpdir();
734 let session = Session::create(&dir, meta_with_id("20260101T000000-round")).unwrap();
735 session
736 .append_messages(&[
737 Message::user("summarise this page"),
738 Message::assistant(vec![Block::text("done")]),
739 ])
740 .unwrap();
741 session
742 .append(&Record::Taint(Taint {
743 private: true,
744 untrusted: true,
745 }))
746 .unwrap();
747
748 let (meta, convo) = Session::load(&session.path).unwrap();
749
750 assert_eq!(meta.model, "test-model");
751 assert_eq!(convo.messages.len(), 2);
752 assert_eq!(convo.messages[0].text(), "summarise this page");
753 assert_eq!(convo.messages[1].text(), "done");
754 assert!(convo.taint.trifecta_armed());
758
759 std::fs::remove_dir_all(&dir).ok();
760 }
761
762 #[test]
763 fn record_run_appends_the_tail_when_the_run_only_appended() {
764 let dir = tmpdir();
765 let session = Session::create(&dir, meta_with_id("20260101T000000-tail")).unwrap();
766 let before = vec![Message::user("go")];
767 session.append_messages(&before).unwrap();
768
769 let mut after = Conversation::from(before.clone());
770 after.push(Message::assistant(vec![Block::text("done")]));
771 session.record_run(&before, &after).unwrap();
772
773 let (_, convo) = Session::load(&session.path).unwrap();
774 assert_eq!(convo.messages.len(), 2);
775 assert_eq!(convo.messages[1].text(), "done");
776 let text = std::fs::read_to_string(&session.path).unwrap();
779 assert!(!text.contains("\"record\":\"rewrite\""), "{text}");
780
781 std::fs::remove_dir_all(&dir).ok();
782 }
783
784 #[test]
785 fn record_run_records_a_rewrite_when_compaction_touched_the_head() {
786 let dir = tmpdir();
793 let session = Session::create(&dir, meta_with_id("20260101T000000-rw")).unwrap();
794 let before = vec![Message::user("go")];
795 session.append_messages(&before).unwrap();
796
797 let mut head = before[0].clone();
800 head.content
801 .push(Block::text("[Earlier turns were compacted]"));
802 let after = Conversation::from(vec![head, Message::assistant(vec![Block::text("done")])]);
803 session.record_run(&before, &after).unwrap();
804
805 let (_, convo) = Session::load(&session.path).unwrap();
806 assert_eq!(convo.messages.len(), 2);
807 assert!(
808 convo.messages[0].text().contains("compacted"),
809 "the rebuilt head must be what loads: {:?}",
810 convo.messages[0].text()
811 );
812 assert_eq!(convo.messages[1].text(), "done");
813
814 std::fs::remove_dir_all(&dir).ok();
815 }
816
817 #[test]
823 fn record_run_walks_the_states_a_mid_run_rewrite_replaced() {
824 let dir = tmpdir();
825 let session = Session::create(&dir, meta_with_id("20260101T000000-midrun")).unwrap();
826 let before = vec![Message::user("go")];
827 session.append_messages(&before).unwrap();
828
829 let mut reached = before.clone();
832 reached.push(Message::assistant(vec![Block::text(
833 "the magic number is 74656",
834 )]));
835 let compacted = vec![
837 Message::user("[summary: a number was computed]"),
838 Message::assistant(vec![Block::text("done")]),
839 ];
840 let mut convo = Conversation::from(compacted);
841 convo.rewritten = vec![reached];
842
843 session.record_run(&before, &convo).unwrap();
844
845 let (_, loaded) = Session::load(&session.path).unwrap();
847 assert_eq!(loaded.messages.len(), 2);
848 assert!(loaded.messages[0].text().contains("summary"));
849
850 let text = std::fs::read_to_string(&session.path).unwrap();
853 assert!(
854 text.contains("74656"),
855 "the pre-rewrite turn never reached the file: {text}"
856 );
857
858 std::fs::remove_dir_all(&dir).ok();
859 }
860
861 #[test]
862 fn a_rewrite_drops_stale_taint_positions_instead_of_shadowing_later_ones() {
863 let msg = || Message::user("m");
873 let mut records: Vec<Record> = (0..10).map(|_| Record::Message(msg())).collect();
874 records.push(Record::Taint(Taint {
875 private: true,
876 untrusted: false,
877 }));
878 records.push(Record::Rewrite {
879 messages: vec![msg(), msg()],
880 });
881 records.push(Record::Taint(Taint {
882 private: true,
883 untrusted: true,
884 }));
885
886 let timeline = TaintTimeline::from_records(records);
887 for index in 0..2 {
891 let covering = timeline.covering(index).expect("a checkpoint covers it");
892 assert!(
893 covering.untrusted,
894 "message {index} classified by a stale pre-rewrite checkpoint"
895 );
896 assert!(covering.private, "the dropped checkpoint's taint was lost");
897 }
898 }
899
900 #[test]
901 fn a_transcript_torn_after_a_rewrite_reports_unknown_not_clean() {
902 let msg = || Message::user("m");
907 let records = vec![
908 Record::Message(msg()),
909 Record::Taint(Taint {
910 private: true,
911 untrusted: true,
912 }),
913 Record::Rewrite {
914 messages: vec![msg(), msg()],
915 },
916 ];
917 let timeline = TaintTimeline::from_records(records);
918 assert_eq!(timeline.covering(0), None);
919 assert_eq!(timeline.covering(1), None);
920 }
921
922 #[test]
923 fn taint_records_merge_so_a_later_clean_one_cannot_disarm_the_interlock() {
924 let dir = tmpdir();
925 let session = Session::create(&dir, meta_with_id("20260101T000000-merge")).unwrap();
926
927 session
930 .append(&Record::Taint(Taint {
931 untrusted: true,
932 private: false,
933 }))
934 .unwrap();
935 session
936 .append(&Record::Taint(Taint {
937 private: true,
938 untrusted: false,
939 }))
940 .unwrap();
941 session.append(&Record::Taint(Taint::default())).unwrap();
942
943 let (_, convo) = Session::load(&session.path).unwrap();
944
945 assert!(convo.taint.private, "an earlier private leg was dropped");
948 assert!(
949 convo.taint.untrusted,
950 "an earlier untrusted leg was dropped"
951 );
952 assert!(convo.taint.trifecta_armed());
953
954 std::fs::remove_dir_all(&dir).ok();
955 }
956
957 #[test]
958 fn a_transcript_written_before_taint_was_recorded_loads_clean() {
959 let dir = tmpdir();
960 let session = Session::create(&dir, meta_with_id("20260101T000000-old")).unwrap();
961 session.append_messages(&[Message::user("hello")]).unwrap();
962
963 let (_, convo) = Session::load(&session.path).unwrap();
964
965 assert_eq!(convo.messages.len(), 1);
966 assert!(!convo.taint.private);
967 assert!(!convo.taint.untrusted);
968
969 std::fs::remove_dir_all(&dir).ok();
970 }
971
972 #[test]
973 fn a_truncated_final_line_does_not_lose_the_rest_of_the_transcript() {
974 use std::io::Write;
975 let dir = tmpdir();
976 let session = Session::create(&dir, meta_with_id("20260101T000000-killed")).unwrap();
977 session.append_messages(&[Message::user("first")]).unwrap();
978 session
979 .append(&Record::Taint(Taint {
980 private: true,
981 untrusted: false,
982 }))
983 .unwrap();
984
985 let mut file = std::fs::OpenOptions::new()
987 .append(true)
988 .open(&session.path)
989 .unwrap();
990 write!(file, "{{\"record\":\"message\",\"role\":\"assis").unwrap();
991 drop(file);
992
993 let (_, convo) = Session::load(&session.path).unwrap();
994
995 assert_eq!(convo.messages.len(), 1);
996 assert_eq!(convo.messages[0].text(), "first");
997 assert!(
998 convo.taint.private,
999 "a torn last line lost the taint before it"
1000 );
1001
1002 std::fs::remove_dir_all(&dir).ok();
1003 }
1004
1005 #[test]
1006 fn run_configs_come_back_in_order_one_per_attach() {
1007 let dir = tmpdir();
1008 let session = Session::create(&dir, meta_with_id("20260101T000000-cfg")).unwrap();
1009
1010 let first = RunConfig {
1012 compact_at_tokens: None,
1013 ..RunConfig::default()
1014 };
1015 let second = RunConfig {
1016 compact_at_tokens: Some(1200),
1017 ..RunConfig::default()
1018 };
1019 session.append(&Record::Config(first)).unwrap();
1020 session
1021 .append_messages(&[Message::user("first run")])
1022 .unwrap();
1023 session.append(&Record::Config(second)).unwrap();
1024
1025 let configs = Session::run_configs(&session.path).unwrap();
1026
1027 assert_eq!(configs.len(), 2, "one record per attach, in order");
1028 assert_eq!(configs[0].compact_at_tokens, None);
1029 assert_eq!(configs[1].compact_at_tokens, Some(1200));
1032
1033 let (_, convo) = Session::load(&session.path).unwrap();
1035 assert_eq!(convo.messages.len(), 1);
1036
1037 std::fs::remove_dir_all(&dir).ok();
1038 }
1039
1040 #[test]
1041 fn a_transcript_recorded_before_this_existed_reports_no_configs() {
1042 let dir = tmpdir();
1045 let session = Session::create(&dir, meta_with_id("20260101T000000-legacy")).unwrap();
1046 session.append_messages(&[Message::user("hello")]).unwrap();
1047
1048 assert!(Session::run_configs(&session.path).unwrap().is_empty());
1049
1050 std::fs::remove_dir_all(&dir).ok();
1051 }
1052
1053 #[test]
1054 fn the_taint_timeline_covers_each_message_with_its_runs_checkpoint() {
1055 let dir = tmpdir();
1056 let session = Session::create(&dir, meta_with_id("20260101T000000-tl")).unwrap();
1057
1058 session
1060 .append_messages(&[Message::user("list the files")])
1061 .unwrap();
1062 session
1063 .append_messages(&[Message::assistant(vec![Block::text("done")])])
1064 .unwrap();
1065 session.append(&Record::Taint(Taint::default())).unwrap();
1066 session
1068 .append_messages(&[Message::user("fetch that page")])
1069 .unwrap();
1070 session
1071 .append_messages(&[Message::assistant(vec![Block::text("fetched")])])
1072 .unwrap();
1073 session
1074 .append(&Record::Taint(Taint {
1075 untrusted: true,
1076 private: false,
1077 }))
1078 .unwrap();
1079
1080 let tl = Session::taint_timeline(&session.path).unwrap();
1081
1082 assert!(!tl.covering(0).unwrap().untrusted);
1084 assert!(!tl.covering(1).unwrap().untrusted);
1085 assert!(tl.covering(2).unwrap().untrusted);
1089 assert!(tl.covering(3).unwrap().untrusted);
1090 assert_eq!(tl.covering(4).map(|t| t.untrusted), None);
1093
1094 std::fs::remove_dir_all(&dir).ok();
1095 }
1096
1097 #[test]
1098 fn a_pre_taint_transcript_has_an_empty_timeline() {
1099 let dir = tmpdir();
1103 let session = Session::create(&dir, meta_with_id("20260101T000000-notl")).unwrap();
1104 session.append_messages(&[Message::user("hello")]).unwrap();
1105
1106 let tl = Session::taint_timeline(&session.path).unwrap();
1107 assert!(tl.covering(0).is_none());
1108
1109 std::fs::remove_dir_all(&dir).ok();
1110 }
1111
1112 #[test]
1113 fn listing_reads_only_the_first_record_and_skips_files_without_a_header() {
1114 let dir = tmpdir();
1115 let session = Session::create(&dir, meta_with_id("20260101T000000-peek")).unwrap();
1116 session.append_messages(&[Message::user("hello")]).unwrap();
1117
1118 let stray = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
1123 let meta = serde_json::to_string(&Record::Meta(meta_with_id("buried"))).unwrap();
1124 std::fs::write(dir.join("stray.jsonl"), format!("{stray}\n{meta}\n")).unwrap();
1125
1126 let listed = Session::list(&dir).unwrap();
1127 assert_eq!(listed.len(), 1);
1128 assert_eq!(listed[0].0.id, "20260101T000000-peek");
1129
1130 let peeked = Session::peek_meta(&session.path).unwrap();
1132 let (loaded, _) = Session::load(&session.path).unwrap();
1133 assert_eq!(peeked.id, loaded.id);
1134 assert_eq!(peeked.model, loaded.model);
1135
1136 std::fs::remove_dir_all(&dir).ok();
1137 }
1138
1139 #[test]
1140 fn an_outcome_record_survives_a_round_trip_and_does_not_disturb_the_transcript() {
1141 use crate::agent::{RunOutcome, StopCause, ToolCallTrace};
1142 use crate::message::StopReason;
1143
1144 let dir = tmpdir();
1145 let session = Session::create(&dir, meta_with_id("20260101T000000-outcome")).unwrap();
1146 session
1147 .append_messages(&[Message::user("go"), Message::assistant(vec![])])
1148 .unwrap();
1149
1150 let call = |is_error: bool, denied: bool, unknown: bool, staged: bool| ToolCallTrace {
1151 name: "fs_edit".into(),
1152 input: serde_json::json!({}),
1153 is_error,
1154 denied,
1155 unknown,
1156 staged,
1157 };
1158 let outcome = RunOutcome {
1159 text: "done".into(),
1160 stop_reason: StopReason::EndTurn,
1161 usage: Usage {
1162 input_tokens: 10,
1163 output_tokens: 4,
1164 ..Usage::default()
1165 },
1166 turns: 3,
1167 refusal: None,
1168 exhausted: false,
1169 ended_on_failed_call: true,
1170 tool_calls: vec![
1171 call(true, false, false, false),
1172 call(false, false, true, false),
1173 call(false, true, false, false),
1174 call(false, false, false, true),
1175 ],
1176 malformed_tool_args: 1,
1177 blocked_sends: 2,
1178 taint: Taint {
1179 private: true,
1180 untrusted: false,
1181 },
1182 stop_cause: StopCause::Completed,
1183 compactions: 4,
1184 cost_usd: Some(0.5),
1185 usage_complete: true,
1186 };
1187 session.record_outcome(&outcome).unwrap();
1188
1189 let stats = Session::outcomes(&session.path).unwrap();
1190 assert_eq!(stats.len(), 1);
1191 let got = &stats[0];
1192 assert_eq!(got.turns, 3);
1193 assert_eq!(got.stop_cause, Some(StopCause::Completed));
1194 assert!(got.ended_on_failed_call);
1195 assert_eq!(got.tool_calls, 4);
1196 assert_eq!(got.tool_errors, 2);
1199 assert_eq!(got.tool_denied, 1);
1200 assert_eq!(got.tool_staged, 1);
1201 assert_eq!(got.malformed_tool_args, 1);
1202 assert_eq!(got.blocked_sends, 2);
1203 assert_eq!(got.compactions, 4);
1204 assert!(got.taint.private && !got.taint.untrusted);
1205
1206 let (_, convo) = Session::load(&session.path).unwrap();
1210 assert_eq!(convo.messages.len(), 2);
1211 assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
1212 }
1213
1214 #[test]
1215 fn an_episode_of_several_runs_sums_its_costs_and_takes_its_ending_from_the_last() {
1216 use crate::agent::{RunOutcome, StopCause, ToolCallTrace};
1217 use crate::message::StopReason;
1218
1219 let outcome =
1220 |turns: u32, calls: usize, errored: bool, ended_failed: bool, cause| RunOutcome {
1221 text: String::new(),
1222 stop_reason: StopReason::EndTurn,
1223 usage: Usage {
1224 input_tokens: 10,
1225 output_tokens: 1,
1226 ..Usage::default()
1227 },
1228 turns,
1229 refusal: None,
1230 exhausted: false,
1231 ended_on_failed_call: ended_failed,
1232 tool_calls: (0..calls)
1233 .map(|_| ToolCallTrace {
1234 name: "fs_edit".into(),
1235 input: serde_json::json!({}),
1236 is_error: errored,
1237 denied: false,
1238 unknown: false,
1239 staged: false,
1240 })
1241 .collect(),
1242 malformed_tool_args: 1,
1243 blocked_sends: 0,
1244 taint: Taint {
1245 private: true,
1246 untrusted: false,
1247 },
1248 stop_cause: cause,
1249 compactions: 1,
1250 cost_usd: Some(0.25),
1251 usage_complete: true,
1252 };
1253
1254 let mut stats = RunStats {
1255 usage_complete: true,
1256 ..RunStats::default()
1257 };
1258 stats.absorb(&outcome(2, 3, true, true, StopCause::MaxTurns));
1260 stats.absorb(&outcome(4, 5, false, false, StopCause::Completed));
1261
1262 assert_eq!(stats.turns, 6);
1264 assert_eq!(stats.tool_calls, 8);
1265 assert_eq!(stats.tool_errors, 3);
1266 assert_eq!(stats.malformed_tool_args, 2);
1267 assert_eq!(stats.compactions, 2);
1268 assert_eq!(stats.cost_usd, Some(0.5));
1269 assert_eq!(stats.usage.input_tokens, 20);
1270
1271 assert_eq!(stats.stop_cause, Some(StopCause::Completed));
1274 assert!(!stats.ended_on_failed_call);
1275
1276 assert!(stats.taint.private);
1279 }
1280
1281 #[test]
1282 fn one_lower_bound_turn_makes_the_whole_episode_a_lower_bound() {
1283 use crate::agent::{RunOutcome, StopCause};
1284 use crate::message::StopReason;
1285
1286 let mut incomplete = RunOutcome {
1287 text: String::new(),
1288 stop_reason: StopReason::Other,
1289 usage: Usage::default(),
1290 turns: 1,
1291 refusal: None,
1292 exhausted: true,
1293 ended_on_failed_call: false,
1294 tool_calls: Vec::new(),
1295 malformed_tool_args: 0,
1296 blocked_sends: 0,
1297 taint: Taint::default(),
1298 stop_cause: StopCause::Interrupted,
1299 compactions: 0,
1300 cost_usd: None,
1301 usage_complete: false,
1302 };
1303
1304 let mut stats = RunStats {
1305 usage_complete: true,
1306 ..RunStats::default()
1307 };
1308 stats.absorb(&incomplete);
1309 assert!(!stats.usage_complete);
1310
1311 incomplete.usage_complete = true;
1314 stats.absorb(&incomplete);
1315 assert!(!stats.usage_complete);
1316 }
1317
1318 #[test]
1319 fn a_transcript_with_no_outcome_records_reads_as_empty_not_as_an_error() {
1320 let dir = tmpdir();
1324 let session = Session::create(&dir, meta_with_id("20260101T000000-no-outcome")).unwrap();
1325 session.append_messages(&[Message::user("go")]).unwrap();
1326 assert!(Session::outcomes(&session.path).unwrap().is_empty());
1327 }
1328
1329 #[test]
1330 fn usage_totals_sum_every_run_and_report_zero_for_a_summaryless_file() {
1331 let dir = tmpdir();
1332 let session = Session::create(&dir, meta_with_id("20260101T000000-usage")).unwrap();
1333
1334 assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
1336
1337 session
1339 .append(&Record::Summary {
1340 usage: Usage {
1341 input_tokens: 100,
1342 output_tokens: 10,
1343 ..Default::default()
1344 },
1345 turns: 2,
1346 })
1347 .unwrap();
1348 session
1349 .append(&Record::Summary {
1350 usage: Usage {
1351 input_tokens: 50,
1352 output_tokens: 5,
1353 ..Default::default()
1354 },
1355 turns: 1,
1356 })
1357 .unwrap();
1358
1359 let (usage, turns) = Session::usage_totals(&session.path).unwrap();
1360 assert_eq!(usage.input_tokens, 150);
1361 assert_eq!(usage.output_tokens, 15);
1362 assert_eq!(turns, 3);
1363
1364 std::fs::remove_dir_all(&dir).ok();
1365 }
1366
1367 #[cfg(unix)]
1368 #[test]
1369 fn the_session_directory_is_owner_only() {
1370 use std::os::unix::fs::PermissionsExt;
1371 let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
1373 Session::create(&dir, meta_with_id("20260101T000000-perms")).unwrap();
1374
1375 let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
1378 assert_eq!(mode & 0o777, 0o700);
1379
1380 std::fs::remove_dir_all(&dir).ok();
1381 }
1382
1383 #[test]
1384 fn a_transcript_with_no_header_is_refused() {
1385 let dir = tmpdir();
1386 let path = dir.join("headerless.jsonl");
1387 let line = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
1388 std::fs::write(&path, format!("{line}\n")).unwrap();
1389
1390 let err = Session::load(&path).unwrap_err().to_string();
1391 assert!(err.contains("no session header"), "unexpected error: {err}");
1392
1393 std::fs::remove_dir_all(&dir).ok();
1394 }
1395
1396 #[test]
1397 fn an_ambiguous_id_prefix_is_an_error_rather_than_a_guess() {
1398 let dir = tmpdir();
1399 Session::create(&dir, meta_with_id("20260101T000000-aaaaaaaa")).unwrap();
1400 Session::create(&dir, meta_with_id("20260101T000000-bbbbbbbb")).unwrap();
1401
1402 let err = Session::find(&dir, "20260101").unwrap_err().to_string();
1403 assert!(
1404 err.contains("matches 2 sessions"),
1405 "unexpected error: {err}"
1406 );
1407
1408 let path = Session::find(&dir, "20260101T000000-aaaaaaaa").unwrap();
1411 assert!(path.ends_with("20260101T000000-aaaaaaaa.jsonl"));
1412
1413 assert!(Session::find(&dir, "nothing-like-this").is_err());
1414
1415 std::fs::remove_dir_all(&dir).ok();
1416 }
1417}