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::collections::HashSet;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "record", rename_all = "snake_case")]
18pub enum Record {
19 Meta(SessionMeta),
20 Message(Message),
21 Summary {
24 usage: Usage,
25 turns: u32,
26 },
27 Outcome(RunStats),
43 Config(RunConfig),
51 Taint(Taint),
59 Rewrite {
70 messages: Vec<Message>,
71 },
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(default)]
95pub struct RunConfig {
96 pub mecha_version: String,
98 pub provider: String,
99 pub model: String,
100 pub workspace: PathBuf,
101 pub system_prompt: Option<String>,
103 pub tools: Vec<String>,
107
108 pub effort: Option<Effort>,
110 pub temperature: Option<f64>,
113 pub seed: Option<u64>,
114 pub thinking: bool,
115 pub cache_prompt: bool,
117 pub max_tokens: u32,
118
119 pub max_turns: u32,
121 pub max_output_tokens: Option<u64>,
122 pub max_cost_usd: Option<f64>,
123 pub compact_at_tokens: Option<u64>,
124 pub compact_keep_recent: usize,
125
126 pub permission_mode: PermissionMode,
130 pub trifecta: TrifectaPolicy,
131 pub sandbox: String,
138 pub sandbox_network: bool,
139}
140
141impl Default for RunConfig {
142 fn default() -> Self {
143 RunConfig {
144 mecha_version: String::new(),
145 provider: String::new(),
146 model: String::new(),
147 workspace: PathBuf::new(),
148 system_prompt: None,
149 tools: Vec::new(),
150 effort: None,
151 temperature: None,
152 seed: None,
153 thinking: false,
154 cache_prompt: false,
155 max_tokens: 0,
156 max_turns: 0,
157 max_output_tokens: None,
158 max_cost_usd: None,
159 compact_at_tokens: None,
160 compact_keep_recent: 0,
161 permission_mode: PermissionMode::Ask,
162 trifecta: TrifectaPolicy::Block,
163 sandbox: "none".into(),
164 sandbox_network: false,
165 }
166 }
167}
168
169impl RunConfig {
170 pub fn of(agent: &Agent, config: &Config, provider: &str) -> Self {
174 let cfg = agent.config();
175 RunConfig {
176 mecha_version: crate::VERSION.to_string(),
177 provider: provider.to_string(),
178 model: agent.model().to_string(),
179 workspace: agent.ctx().workspace.clone(),
180 system_prompt: agent.system().map(str::to_string),
181 tools: agent
182 .registry()
183 .iter()
184 .map(|t| t.name().to_string())
185 .collect(),
186 effort: cfg.effort,
187 temperature: config.providers.get(provider).and_then(|p| p.temperature),
188 seed: config.providers.get(provider).and_then(|p| p.seed),
189 thinking: cfg.thinking,
190 cache_prompt: cfg.cache_prompt,
191 max_tokens: cfg.max_tokens,
192 max_turns: cfg.max_turns,
193 max_output_tokens: cfg.max_output_tokens,
194 max_cost_usd: cfg.max_cost_usd,
195 compact_at_tokens: cfg.compact_at_tokens,
196 compact_keep_recent: cfg.compact_keep_recent,
197 permission_mode: config.tools.permission_mode,
198 trifecta: config.security.trifecta,
199 sandbox: config.sandbox.kind.as_str().to_string(),
200 sandbox_network: config.sandbox.network,
201 }
202 }
203}
204
205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
218pub struct RunStats {
219 #[serde(default)]
220 pub turns: u32,
221 #[serde(default)]
222 pub usage: Usage,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub cost_usd: Option<f64>,
225 #[serde(default)]
227 pub usage_complete: bool,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub stop_cause: Option<crate::agent::StopCause>,
233 #[serde(default)]
234 pub exhausted: bool,
235 #[serde(default)]
237 pub ended_on_failed_call: bool,
238 #[serde(default)]
243 pub tool_calls: u32,
244 #[serde(default)]
245 pub tool_errors: u32,
246 #[serde(default)]
247 pub tool_denied: u32,
248 #[serde(default)]
249 pub tool_staged: u32,
250 #[serde(default)]
251 pub malformed_tool_args: u32,
252 #[serde(default)]
253 pub blocked_sends: u32,
254 #[serde(default)]
255 pub compactions: u32,
256 #[serde(default)]
260 pub taint: Taint,
261}
262
263impl RunStats {
264 pub fn absorb(&mut self, o: &crate::agent::RunOutcome) {
282 self.turns += o.turns;
283 self.usage.add(&o.usage);
284 self.cost_usd = match (self.cost_usd, o.cost_usd) {
285 (Some(a), Some(b)) => Some(a + b),
286 (a, b) => a.or(b),
287 };
288 self.usage_complete &= o.usage_complete;
289 self.stop_cause = Some(o.stop_cause);
290 self.exhausted = o.exhausted;
291 self.ended_on_failed_call = o.ended_on_failed_call;
292 self.tool_calls += o.tool_calls.len() as u32;
293 self.tool_errors += o
299 .tool_calls
300 .iter()
301 .filter(|c| c.unknown || (c.is_error && !c.denied))
302 .count() as u32;
303 self.tool_denied += o.tool_calls.iter().filter(|c| c.denied).count() as u32;
304 self.tool_staged += o.tool_calls.iter().filter(|c| c.staged).count() as u32;
305 self.malformed_tool_args += o.malformed_tool_args;
306 self.blocked_sends += o.blocked_sends;
307 self.compactions += o.compactions;
308 self.taint.merge(o.taint);
309 }
310}
311
312impl From<&crate::agent::RunOutcome> for RunStats {
313 fn from(o: &crate::agent::RunOutcome) -> Self {
314 let mut stats = RunStats {
317 usage_complete: true,
318 ..RunStats::default()
319 };
320 stats.absorb(o);
321 stats
322 }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct SessionMeta {
327 pub id: String,
328 pub created_at: DateTime<Utc>,
329 pub provider: String,
330 pub model: String,
331 pub workspace: PathBuf,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub title: Option<String>,
334}
335
336pub struct Session {
337 pub meta: SessionMeta,
338 pub path: PathBuf,
339}
340
341impl Session {
342 pub fn default_dir() -> Result<PathBuf> {
344 if let Ok(dir) = std::env::var("MECHA_SESSION_DIR") {
345 return Ok(PathBuf::from(dir));
346 }
347 Ok(crate::work::mecha_home()?.join("sessions"))
348 }
349
350 pub fn create(dir: &Path, meta: SessionMeta) -> Result<Self> {
351 crate::create_private_dir(dir)
352 .with_context(|| format!("creating session directory {}", dir.display()))?;
353 let path = dir.join(format!("{}.jsonl", meta.id));
354 let session = Session {
355 meta: meta.clone(),
356 path,
357 };
358 session.append(&Record::Meta(meta))?;
359 Ok(session)
360 }
361
362 pub fn new_id() -> String {
363 format!(
366 "{}-{}",
367 Utc::now().format("%Y%m%dT%H%M%S"),
368 &uuid::Uuid::new_v4().to_string()[..8]
369 )
370 }
371
372 pub fn append(&self, record: &Record) -> Result<()> {
373 use std::io::Write;
374 let mut file = std::fs::OpenOptions::new()
375 .create(true)
376 .append(true)
377 .open(&self.path)
378 .with_context(|| format!("opening {}", self.path.display()))?;
379 writeln!(file, "{}", serde_json::to_string(record)?)?;
380 Ok(())
381 }
382
383 pub fn append_messages(&self, messages: &[Message]) -> Result<()> {
384 for m in messages {
385 self.append(&Record::Message(m.clone()))?;
386 }
387 Ok(())
388 }
389
390 pub fn record_run(&self, before: &[Message], convo: &Conversation) -> Result<()> {
406 let mut prev: &[Message] = before;
407 for state in &convo.rewritten {
408 self.record_transition(prev, state)?;
409 prev = state;
410 }
411 self.record_transition(prev, &convo.messages)
412 }
413
414 pub fn record_outcome(&self, outcome: &crate::agent::RunOutcome) -> Result<()> {
426 self.append(&Record::Outcome(RunStats::from(outcome)))
427 }
428
429 pub fn outcomes_attributed(path: &Path) -> Result<Vec<(String, String, RunStats)>> {
439 let text =
440 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
441 let mut provider = String::new();
442 let mut model = String::new();
443 let mut out = Vec::new();
444 for line in text.lines().filter(|l| !l.trim().is_empty()) {
445 match serde_json::from_str(line) {
446 Ok(Record::Meta(meta)) => {
447 provider = meta.provider;
448 model = meta.model;
449 }
450 Ok(Record::Config(cfg)) => {
451 provider = cfg.provider;
452 model = cfg.model;
453 }
454 Ok(Record::Outcome(stats)) => out.push((provider.clone(), model.clone(), stats)),
455 _ => {}
456 }
457 }
458 Ok(out)
459 }
460
461 pub fn outcomes(path: &Path) -> Result<Vec<RunStats>> {
467 let text =
468 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
469 Ok(text
470 .lines()
471 .filter(|l| !l.trim().is_empty())
472 .filter_map(|l| match serde_json::from_str(l) {
473 Ok(Record::Outcome(s)) => Some(s),
474 _ => None,
475 })
476 .collect())
477 }
478
479 fn record_transition(&self, before: &[Message], after: &[Message]) -> Result<()> {
490 let appended_only = after.len() >= before.len() && after[..before.len()] == *before;
491 if appended_only {
492 self.append_messages(&after[before.len()..])
493 } else {
494 self.append(&Record::Rewrite {
495 messages: after.to_vec(),
496 })
497 }
498 }
499
500 pub fn load(path: &Path) -> Result<(SessionMeta, Conversation)> {
505 let text =
506 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
507
508 let mut meta = None;
509 let mut messages = Vec::new();
510 let mut taint = Taint::default();
511 for line in text.lines().filter(|l| !l.trim().is_empty()) {
512 match serde_json::from_str::<Record>(line) {
513 Ok(Record::Meta(m)) => meta = Some(m),
514 Ok(Record::Message(m)) => messages.push(m),
515 Ok(Record::Rewrite { messages: m }) => messages = m,
519 Ok(Record::Taint(t)) => taint.merge(t),
522 Ok(Record::Summary { .. }) | Ok(Record::Config(_)) | Ok(Record::Outcome(_)) => {}
523 Err(e) => tracing::warn!(error = %e, "skipping malformed transcript line"),
524 }
525 }
526
527 let meta = meta.with_context(|| format!("{} has no session header", path.display()))?;
528 Ok((meta, Conversation::resumed(messages, taint)))
529 }
530
531 pub fn messages_ever(transcript: &str) -> Vec<Message> {
542 let mut seen = HashSet::new();
543 let mut all = Vec::new();
544 let mut admit = |m: Message, all: &mut Vec<Message>| {
545 if let Ok(key) = serde_json::to_string(&m) {
548 if seen.insert(key) {
549 all.push(m);
550 }
551 }
552 };
553 for line in transcript.lines().filter(|l| !l.trim().is_empty()) {
554 match serde_json::from_str::<Record>(line) {
555 Ok(Record::Message(m)) => admit(m, &mut all),
556 Ok(Record::Rewrite { messages }) => {
557 for m in messages {
558 admit(m, &mut all);
559 }
560 }
561 Ok(_) => {}
562 Err(e) => tracing::debug!(error = %e, "skipping malformed transcript line"),
563 }
564 }
565 all
566 }
567
568 pub fn taint_timeline(path: &Path) -> Result<TaintTimeline> {
578 let text =
579 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
580 Ok(TaintTimeline::from_records(
581 text.lines()
582 .filter(|l| !l.trim().is_empty())
583 .filter_map(|l| serde_json::from_str::<Record>(l).ok()),
584 ))
585 }
586
587 pub fn run_configs(path: &Path) -> Result<Vec<RunConfig>> {
595 let text =
596 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
597 Ok(text
598 .lines()
599 .filter(|l| !l.trim().is_empty())
600 .filter_map(|l| match serde_json::from_str::<Record>(l) {
601 Ok(Record::Config(c)) => Some(c),
602 _ => None,
603 })
604 .collect())
605 }
606
607 pub fn peek_meta(path: &Path) -> Option<SessionMeta> {
617 use std::io::BufRead;
618 let file = std::fs::File::open(path).ok()?;
619 let mut reader = std::io::BufReader::new(file);
620 let mut first = String::new();
621 loop {
622 first.clear();
623 if reader.read_line(&mut first).ok()? == 0 {
624 return None;
625 }
626 if !first.trim().is_empty() {
627 break;
628 }
629 }
630 match serde_json::from_str::<Record>(&first).ok()? {
631 Record::Meta(m) => Some(m),
632 _ => None,
633 }
634 }
635
636 pub fn usage_totals(path: &Path) -> Result<(Usage, u32)> {
641 let text =
642 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
643 let mut usage = Usage::default();
644 let mut turns = 0u32;
645 for line in text.lines().filter(|l| !l.trim().is_empty()) {
646 if let Ok(Record::Summary { usage: u, turns: t }) = serde_json::from_str(line) {
647 usage.add(&u);
648 turns += t;
649 }
650 }
651 Ok((usage, turns))
652 }
653
654 pub fn list(dir: &Path) -> Result<Vec<(SessionMeta, PathBuf)>> {
656 if !dir.exists() {
657 return Ok(Vec::new());
658 }
659 let mut out = Vec::new();
660 for entry in std::fs::read_dir(dir)? {
661 let path = entry?.path();
662 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
663 continue;
664 }
665 if let Some(meta) = Session::peek_meta(&path) {
667 out.push((meta, path));
668 }
669 }
670 out.sort_by_key(|(meta, _)| std::cmp::Reverse(meta.created_at));
671 Ok(out)
672 }
673
674 pub fn find(dir: &Path, id_prefix: &str) -> Result<PathBuf> {
676 let matches: Vec<_> = Session::list(dir)?
677 .into_iter()
678 .filter(|(m, _)| m.id.starts_with(id_prefix))
679 .collect();
680 match matches.len() {
681 0 => anyhow::bail!("no session matching {id_prefix:?}"),
682 1 => Ok(matches.into_iter().next().unwrap().1),
683 n => anyhow::bail!("{id_prefix:?} matches {n} sessions; use a longer prefix"),
684 }
685 }
686}
687
688#[derive(Debug, Clone, Default)]
692pub struct TaintTimeline {
693 checkpoints: Vec<(usize, Taint)>,
697}
698
699impl TaintTimeline {
700 pub fn from_records(records: impl IntoIterator<Item = Record>) -> Self {
701 let mut checkpoints: Vec<(usize, Taint)> = Vec::new();
702 let mut messages = 0usize;
703 let mut merged = Taint::default();
704 for record in records {
705 match record {
706 Record::Message(_) => messages += 1,
707 Record::Rewrite { messages: m } => {
722 messages = m.len();
723 checkpoints.clear();
724 }
725 Record::Taint(t) => {
726 merged.merge(t);
727 checkpoints.push((messages, merged));
728 }
729 _ => {}
730 }
731 }
732 TaintTimeline { checkpoints }
733 }
734
735 pub fn covering(&self, index: usize) -> Option<Taint> {
740 self.checkpoints
741 .iter()
742 .find(|(n, _)| *n > index)
743 .map(|(_, t)| *t)
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750 use crate::message::Block;
751
752 fn tmpdir() -> PathBuf {
753 let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
754 std::fs::create_dir_all(&dir).unwrap();
755 dir
756 }
757
758 fn meta_with_id(id: &str) -> SessionMeta {
759 SessionMeta {
760 id: id.to_string(),
761 created_at: Utc::now(),
762 provider: "scripted".into(),
763 model: "test-model".into(),
764 workspace: PathBuf::from("/tmp"),
765 title: None,
766 }
767 }
768
769 #[test]
770 fn a_transcript_round_trips_its_messages_and_its_taint() {
771 let dir = tmpdir();
772 let session = Session::create(&dir, meta_with_id("20260101T000000-round")).unwrap();
773 session
774 .append_messages(&[
775 Message::user("summarise this page"),
776 Message::assistant(vec![Block::text("done")]),
777 ])
778 .unwrap();
779 session
780 .append(&Record::Taint(Taint {
781 private: true,
782 untrusted: true,
783 }))
784 .unwrap();
785
786 let (meta, convo) = Session::load(&session.path).unwrap();
787
788 assert_eq!(meta.model, "test-model");
789 assert_eq!(convo.messages.len(), 2);
790 assert_eq!(convo.messages[0].text(), "summarise this page");
791 assert_eq!(convo.messages[1].text(), "done");
792 assert!(convo.taint.trifecta_armed());
796
797 std::fs::remove_dir_all(&dir).ok();
798 }
799
800 #[test]
801 fn record_run_appends_the_tail_when_the_run_only_appended() {
802 let dir = tmpdir();
803 let session = Session::create(&dir, meta_with_id("20260101T000000-tail")).unwrap();
804 let before = vec![Message::user("go")];
805 session.append_messages(&before).unwrap();
806
807 let mut after = Conversation::from(before.clone());
808 after.push(Message::assistant(vec![Block::text("done")]));
809 session.record_run(&before, &after).unwrap();
810
811 let (_, convo) = Session::load(&session.path).unwrap();
812 assert_eq!(convo.messages.len(), 2);
813 assert_eq!(convo.messages[1].text(), "done");
814 let text = std::fs::read_to_string(&session.path).unwrap();
817 assert!(!text.contains("\"record\":\"rewrite\""), "{text}");
818
819 std::fs::remove_dir_all(&dir).ok();
820 }
821
822 #[test]
823 fn record_run_records_a_rewrite_when_compaction_touched_the_head() {
824 let dir = tmpdir();
831 let session = Session::create(&dir, meta_with_id("20260101T000000-rw")).unwrap();
832 let before = vec![Message::user("go")];
833 session.append_messages(&before).unwrap();
834
835 let mut head = before[0].clone();
838 head.content
839 .push(Block::text("[Earlier turns were compacted]"));
840 let after = Conversation::from(vec![head, Message::assistant(vec![Block::text("done")])]);
841 session.record_run(&before, &after).unwrap();
842
843 let (_, convo) = Session::load(&session.path).unwrap();
844 assert_eq!(convo.messages.len(), 2);
845 assert!(
846 convo.messages[0].text().contains("compacted"),
847 "the rebuilt head must be what loads: {:?}",
848 convo.messages[0].text()
849 );
850 assert_eq!(convo.messages[1].text(), "done");
851
852 std::fs::remove_dir_all(&dir).ok();
853 }
854
855 #[test]
861 fn record_run_walks_the_states_a_mid_run_rewrite_replaced() {
862 let dir = tmpdir();
863 let session = Session::create(&dir, meta_with_id("20260101T000000-midrun")).unwrap();
864 let before = vec![Message::user("go")];
865 session.append_messages(&before).unwrap();
866
867 let mut reached = before.clone();
870 reached.push(Message::assistant(vec![Block::text(
871 "the magic number is 74656",
872 )]));
873 let compacted = vec![
875 Message::user("[summary: a number was computed]"),
876 Message::assistant(vec![Block::text("done")]),
877 ];
878 let mut convo = Conversation::from(compacted);
879 convo.rewritten = vec![reached];
880
881 session.record_run(&before, &convo).unwrap();
882
883 let (_, loaded) = Session::load(&session.path).unwrap();
885 assert_eq!(loaded.messages.len(), 2);
886 assert!(loaded.messages[0].text().contains("summary"));
887
888 let text = std::fs::read_to_string(&session.path).unwrap();
891 assert!(
892 text.contains("74656"),
893 "the pre-rewrite turn never reached the file: {text}"
894 );
895
896 std::fs::remove_dir_all(&dir).ok();
897 }
898
899 #[test]
900 fn a_rewrite_drops_stale_taint_positions_instead_of_shadowing_later_ones() {
901 let msg = || Message::user("m");
911 let mut records: Vec<Record> = (0..10).map(|_| Record::Message(msg())).collect();
912 records.push(Record::Taint(Taint {
913 private: true,
914 untrusted: false,
915 }));
916 records.push(Record::Rewrite {
917 messages: vec![msg(), msg()],
918 });
919 records.push(Record::Taint(Taint {
920 private: true,
921 untrusted: true,
922 }));
923
924 let timeline = TaintTimeline::from_records(records);
925 for index in 0..2 {
929 let covering = timeline.covering(index).expect("a checkpoint covers it");
930 assert!(
931 covering.untrusted,
932 "message {index} classified by a stale pre-rewrite checkpoint"
933 );
934 assert!(covering.private, "the dropped checkpoint's taint was lost");
935 }
936 }
937
938 #[test]
939 fn a_transcript_torn_after_a_rewrite_reports_unknown_not_clean() {
940 let msg = || Message::user("m");
945 let records = vec![
946 Record::Message(msg()),
947 Record::Taint(Taint {
948 private: true,
949 untrusted: true,
950 }),
951 Record::Rewrite {
952 messages: vec![msg(), msg()],
953 },
954 ];
955 let timeline = TaintTimeline::from_records(records);
956 assert_eq!(timeline.covering(0), None);
957 assert_eq!(timeline.covering(1), None);
958 }
959
960 #[test]
961 fn taint_records_merge_so_a_later_clean_one_cannot_disarm_the_interlock() {
962 let dir = tmpdir();
963 let session = Session::create(&dir, meta_with_id("20260101T000000-merge")).unwrap();
964
965 session
968 .append(&Record::Taint(Taint {
969 untrusted: true,
970 private: false,
971 }))
972 .unwrap();
973 session
974 .append(&Record::Taint(Taint {
975 private: true,
976 untrusted: false,
977 }))
978 .unwrap();
979 session.append(&Record::Taint(Taint::default())).unwrap();
980
981 let (_, convo) = Session::load(&session.path).unwrap();
982
983 assert!(convo.taint.private, "an earlier private leg was dropped");
986 assert!(
987 convo.taint.untrusted,
988 "an earlier untrusted leg was dropped"
989 );
990 assert!(convo.taint.trifecta_armed());
991
992 std::fs::remove_dir_all(&dir).ok();
993 }
994
995 #[test]
996 fn a_transcript_written_before_taint_was_recorded_loads_clean() {
997 let dir = tmpdir();
998 let session = Session::create(&dir, meta_with_id("20260101T000000-old")).unwrap();
999 session.append_messages(&[Message::user("hello")]).unwrap();
1000
1001 let (_, convo) = Session::load(&session.path).unwrap();
1002
1003 assert_eq!(convo.messages.len(), 1);
1004 assert!(!convo.taint.private);
1005 assert!(!convo.taint.untrusted);
1006
1007 std::fs::remove_dir_all(&dir).ok();
1008 }
1009
1010 #[test]
1011 fn a_truncated_final_line_does_not_lose_the_rest_of_the_transcript() {
1012 use std::io::Write;
1013 let dir = tmpdir();
1014 let session = Session::create(&dir, meta_with_id("20260101T000000-killed")).unwrap();
1015 session.append_messages(&[Message::user("first")]).unwrap();
1016 session
1017 .append(&Record::Taint(Taint {
1018 private: true,
1019 untrusted: false,
1020 }))
1021 .unwrap();
1022
1023 let mut file = std::fs::OpenOptions::new()
1025 .append(true)
1026 .open(&session.path)
1027 .unwrap();
1028 write!(file, "{{\"record\":\"message\",\"role\":\"assis").unwrap();
1029 drop(file);
1030
1031 let (_, convo) = Session::load(&session.path).unwrap();
1032
1033 assert_eq!(convo.messages.len(), 1);
1034 assert_eq!(convo.messages[0].text(), "first");
1035 assert!(
1036 convo.taint.private,
1037 "a torn last line lost the taint before it"
1038 );
1039
1040 std::fs::remove_dir_all(&dir).ok();
1041 }
1042
1043 #[test]
1044 fn run_configs_come_back_in_order_one_per_attach() {
1045 let dir = tmpdir();
1046 let session = Session::create(&dir, meta_with_id("20260101T000000-cfg")).unwrap();
1047
1048 let first = RunConfig {
1050 compact_at_tokens: None,
1051 ..RunConfig::default()
1052 };
1053 let second = RunConfig {
1054 compact_at_tokens: Some(1200),
1055 ..RunConfig::default()
1056 };
1057 session.append(&Record::Config(first)).unwrap();
1058 session
1059 .append_messages(&[Message::user("first run")])
1060 .unwrap();
1061 session.append(&Record::Config(second)).unwrap();
1062
1063 let configs = Session::run_configs(&session.path).unwrap();
1064
1065 assert_eq!(configs.len(), 2, "one record per attach, in order");
1066 assert_eq!(configs[0].compact_at_tokens, None);
1067 assert_eq!(configs[1].compact_at_tokens, Some(1200));
1070
1071 let (_, convo) = Session::load(&session.path).unwrap();
1073 assert_eq!(convo.messages.len(), 1);
1074
1075 std::fs::remove_dir_all(&dir).ok();
1076 }
1077
1078 #[test]
1079 fn a_transcript_recorded_before_this_existed_reports_no_configs() {
1080 let dir = tmpdir();
1083 let session = Session::create(&dir, meta_with_id("20260101T000000-legacy")).unwrap();
1084 session.append_messages(&[Message::user("hello")]).unwrap();
1085
1086 assert!(Session::run_configs(&session.path).unwrap().is_empty());
1087
1088 std::fs::remove_dir_all(&dir).ok();
1089 }
1090
1091 #[test]
1092 fn the_taint_timeline_covers_each_message_with_its_runs_checkpoint() {
1093 let dir = tmpdir();
1094 let session = Session::create(&dir, meta_with_id("20260101T000000-tl")).unwrap();
1095
1096 session
1098 .append_messages(&[Message::user("list the files")])
1099 .unwrap();
1100 session
1101 .append_messages(&[Message::assistant(vec![Block::text("done")])])
1102 .unwrap();
1103 session.append(&Record::Taint(Taint::default())).unwrap();
1104 session
1106 .append_messages(&[Message::user("fetch that page")])
1107 .unwrap();
1108 session
1109 .append_messages(&[Message::assistant(vec![Block::text("fetched")])])
1110 .unwrap();
1111 session
1112 .append(&Record::Taint(Taint {
1113 untrusted: true,
1114 private: false,
1115 }))
1116 .unwrap();
1117
1118 let tl = Session::taint_timeline(&session.path).unwrap();
1119
1120 assert!(!tl.covering(0).unwrap().untrusted);
1122 assert!(!tl.covering(1).unwrap().untrusted);
1123 assert!(tl.covering(2).unwrap().untrusted);
1127 assert!(tl.covering(3).unwrap().untrusted);
1128 assert_eq!(tl.covering(4).map(|t| t.untrusted), None);
1131
1132 std::fs::remove_dir_all(&dir).ok();
1133 }
1134
1135 #[test]
1136 fn a_pre_taint_transcript_has_an_empty_timeline() {
1137 let dir = tmpdir();
1141 let session = Session::create(&dir, meta_with_id("20260101T000000-notl")).unwrap();
1142 session.append_messages(&[Message::user("hello")]).unwrap();
1143
1144 let tl = Session::taint_timeline(&session.path).unwrap();
1145 assert!(tl.covering(0).is_none());
1146
1147 std::fs::remove_dir_all(&dir).ok();
1148 }
1149
1150 #[test]
1151 fn listing_reads_only_the_first_record_and_skips_files_without_a_header() {
1152 let dir = tmpdir();
1153 let session = Session::create(&dir, meta_with_id("20260101T000000-peek")).unwrap();
1154 session.append_messages(&[Message::user("hello")]).unwrap();
1155
1156 let stray = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
1161 let meta = serde_json::to_string(&Record::Meta(meta_with_id("buried"))).unwrap();
1162 std::fs::write(dir.join("stray.jsonl"), format!("{stray}\n{meta}\n")).unwrap();
1163
1164 let listed = Session::list(&dir).unwrap();
1165 assert_eq!(listed.len(), 1);
1166 assert_eq!(listed[0].0.id, "20260101T000000-peek");
1167
1168 let peeked = Session::peek_meta(&session.path).unwrap();
1170 let (loaded, _) = Session::load(&session.path).unwrap();
1171 assert_eq!(peeked.id, loaded.id);
1172 assert_eq!(peeked.model, loaded.model);
1173
1174 std::fs::remove_dir_all(&dir).ok();
1175 }
1176
1177 #[test]
1178 fn an_outcome_record_survives_a_round_trip_and_does_not_disturb_the_transcript() {
1179 use crate::agent::{RunOutcome, StopCause, ToolCallTrace};
1180 use crate::message::StopReason;
1181
1182 let dir = tmpdir();
1183 let session = Session::create(&dir, meta_with_id("20260101T000000-outcome")).unwrap();
1184 session
1185 .append_messages(&[Message::user("go"), Message::assistant(vec![])])
1186 .unwrap();
1187
1188 let call = |is_error: bool, denied: bool, unknown: bool, staged: bool| ToolCallTrace {
1189 name: "fs_edit".into(),
1190 input: serde_json::json!({}),
1191 is_error,
1192 denied,
1193 unknown,
1194 staged,
1195 };
1196 let outcome = RunOutcome {
1197 text: "done".into(),
1198 stop_reason: StopReason::EndTurn,
1199 usage: Usage {
1200 input_tokens: 10,
1201 output_tokens: 4,
1202 ..Usage::default()
1203 },
1204 turns: 3,
1205 refusal: None,
1206 exhausted: false,
1207 ended_on_failed_call: true,
1208 tool_calls: vec![
1209 call(true, false, false, false),
1210 call(false, false, true, false),
1211 call(false, true, false, false),
1212 call(false, false, false, true),
1213 ],
1214 malformed_tool_args: 1,
1215 blocked_sends: 2,
1216 taint: Taint {
1217 private: true,
1218 untrusted: false,
1219 },
1220 stop_cause: StopCause::Completed,
1221 compactions: 4,
1222 cost_usd: Some(0.5),
1223 usage_complete: true,
1224 };
1225 session.record_outcome(&outcome).unwrap();
1226
1227 let stats = Session::outcomes(&session.path).unwrap();
1228 assert_eq!(stats.len(), 1);
1229 let got = &stats[0];
1230 assert_eq!(got.turns, 3);
1231 assert_eq!(got.stop_cause, Some(StopCause::Completed));
1232 assert!(got.ended_on_failed_call);
1233 assert_eq!(got.tool_calls, 4);
1234 assert_eq!(got.tool_errors, 2);
1237 assert_eq!(got.tool_denied, 1);
1238 assert_eq!(got.tool_staged, 1);
1239 assert_eq!(got.malformed_tool_args, 1);
1240 assert_eq!(got.blocked_sends, 2);
1241 assert_eq!(got.compactions, 4);
1242 assert!(got.taint.private && !got.taint.untrusted);
1243
1244 let (_, convo) = Session::load(&session.path).unwrap();
1248 assert_eq!(convo.messages.len(), 2);
1249 assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
1250 }
1251
1252 #[test]
1253 fn an_episode_of_several_runs_sums_its_costs_and_takes_its_ending_from_the_last() {
1254 use crate::agent::{RunOutcome, StopCause, ToolCallTrace};
1255 use crate::message::StopReason;
1256
1257 let outcome =
1258 |turns: u32, calls: usize, errored: bool, ended_failed: bool, cause| RunOutcome {
1259 text: String::new(),
1260 stop_reason: StopReason::EndTurn,
1261 usage: Usage {
1262 input_tokens: 10,
1263 output_tokens: 1,
1264 ..Usage::default()
1265 },
1266 turns,
1267 refusal: None,
1268 exhausted: false,
1269 ended_on_failed_call: ended_failed,
1270 tool_calls: (0..calls)
1271 .map(|_| ToolCallTrace {
1272 name: "fs_edit".into(),
1273 input: serde_json::json!({}),
1274 is_error: errored,
1275 denied: false,
1276 unknown: false,
1277 staged: false,
1278 })
1279 .collect(),
1280 malformed_tool_args: 1,
1281 blocked_sends: 0,
1282 taint: Taint {
1283 private: true,
1284 untrusted: false,
1285 },
1286 stop_cause: cause,
1287 compactions: 1,
1288 cost_usd: Some(0.25),
1289 usage_complete: true,
1290 };
1291
1292 let mut stats = RunStats {
1293 usage_complete: true,
1294 ..RunStats::default()
1295 };
1296 stats.absorb(&outcome(2, 3, true, true, StopCause::MaxTurns));
1298 stats.absorb(&outcome(4, 5, false, false, StopCause::Completed));
1299
1300 assert_eq!(stats.turns, 6);
1302 assert_eq!(stats.tool_calls, 8);
1303 assert_eq!(stats.tool_errors, 3);
1304 assert_eq!(stats.malformed_tool_args, 2);
1305 assert_eq!(stats.compactions, 2);
1306 assert_eq!(stats.cost_usd, Some(0.5));
1307 assert_eq!(stats.usage.input_tokens, 20);
1308
1309 assert_eq!(stats.stop_cause, Some(StopCause::Completed));
1312 assert!(!stats.ended_on_failed_call);
1313
1314 assert!(stats.taint.private);
1317 }
1318
1319 #[test]
1320 fn one_lower_bound_turn_makes_the_whole_episode_a_lower_bound() {
1321 use crate::agent::{RunOutcome, StopCause};
1322 use crate::message::StopReason;
1323
1324 let mut incomplete = RunOutcome {
1325 text: String::new(),
1326 stop_reason: StopReason::Other,
1327 usage: Usage::default(),
1328 turns: 1,
1329 refusal: None,
1330 exhausted: true,
1331 ended_on_failed_call: false,
1332 tool_calls: Vec::new(),
1333 malformed_tool_args: 0,
1334 blocked_sends: 0,
1335 taint: Taint::default(),
1336 stop_cause: StopCause::Interrupted,
1337 compactions: 0,
1338 cost_usd: None,
1339 usage_complete: false,
1340 };
1341
1342 let mut stats = RunStats {
1343 usage_complete: true,
1344 ..RunStats::default()
1345 };
1346 stats.absorb(&incomplete);
1347 assert!(!stats.usage_complete);
1348
1349 incomplete.usage_complete = true;
1352 stats.absorb(&incomplete);
1353 assert!(!stats.usage_complete);
1354 }
1355
1356 #[test]
1357 fn a_transcript_with_no_outcome_records_reads_as_empty_not_as_an_error() {
1358 let dir = tmpdir();
1362 let session = Session::create(&dir, meta_with_id("20260101T000000-no-outcome")).unwrap();
1363 session.append_messages(&[Message::user("go")]).unwrap();
1364 assert!(Session::outcomes(&session.path).unwrap().is_empty());
1365 }
1366
1367 #[test]
1368 fn usage_totals_sum_every_run_and_report_zero_for_a_summaryless_file() {
1369 let dir = tmpdir();
1370 let session = Session::create(&dir, meta_with_id("20260101T000000-usage")).unwrap();
1371
1372 assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
1374
1375 session
1377 .append(&Record::Summary {
1378 usage: Usage {
1379 input_tokens: 100,
1380 output_tokens: 10,
1381 ..Default::default()
1382 },
1383 turns: 2,
1384 })
1385 .unwrap();
1386 session
1387 .append(&Record::Summary {
1388 usage: Usage {
1389 input_tokens: 50,
1390 output_tokens: 5,
1391 ..Default::default()
1392 },
1393 turns: 1,
1394 })
1395 .unwrap();
1396
1397 let (usage, turns) = Session::usage_totals(&session.path).unwrap();
1398 assert_eq!(usage.input_tokens, 150);
1399 assert_eq!(usage.output_tokens, 15);
1400 assert_eq!(turns, 3);
1401
1402 std::fs::remove_dir_all(&dir).ok();
1403 }
1404
1405 #[cfg(unix)]
1406 #[test]
1407 fn the_session_directory_is_owner_only() {
1408 use std::os::unix::fs::PermissionsExt;
1409 let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
1411 Session::create(&dir, meta_with_id("20260101T000000-perms")).unwrap();
1412
1413 let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
1416 assert_eq!(mode & 0o777, 0o700);
1417
1418 std::fs::remove_dir_all(&dir).ok();
1419 }
1420
1421 #[test]
1422 fn a_transcript_with_no_header_is_refused() {
1423 let dir = tmpdir();
1424 let path = dir.join("headerless.jsonl");
1425 let line = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
1426 std::fs::write(&path, format!("{line}\n")).unwrap();
1427
1428 let err = Session::load(&path).unwrap_err().to_string();
1429 assert!(err.contains("no session header"), "unexpected error: {err}");
1430
1431 std::fs::remove_dir_all(&dir).ok();
1432 }
1433
1434 #[test]
1435 fn an_ambiguous_id_prefix_is_an_error_rather_than_a_guess() {
1436 let dir = tmpdir();
1437 Session::create(&dir, meta_with_id("20260101T000000-aaaaaaaa")).unwrap();
1438 Session::create(&dir, meta_with_id("20260101T000000-bbbbbbbb")).unwrap();
1439
1440 let err = Session::find(&dir, "20260101").unwrap_err().to_string();
1441 assert!(
1442 err.contains("matches 2 sessions"),
1443 "unexpected error: {err}"
1444 );
1445
1446 let path = Session::find(&dir, "20260101T000000-aaaaaaaa").unwrap();
1449 assert!(path.ends_with("20260101T000000-aaaaaaaa.jsonl"));
1450
1451 assert!(Session::find(&dir, "nothing-like-this").is_err());
1452
1453 std::fs::remove_dir_all(&dir).ok();
1454 }
1455}