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,
119 pub sandbox_network: bool,
120}
121
122impl Default for RunConfig {
123 fn default() -> Self {
124 RunConfig {
125 mecha_version: String::new(),
126 provider: String::new(),
127 model: String::new(),
128 workspace: PathBuf::new(),
129 system_prompt: None,
130 tools: Vec::new(),
131 effort: None,
132 temperature: None,
133 seed: None,
134 thinking: false,
135 cache_prompt: false,
136 max_tokens: 0,
137 max_turns: 0,
138 max_output_tokens: None,
139 max_cost_usd: None,
140 compact_at_tokens: None,
141 compact_keep_recent: 0,
142 permission_mode: PermissionMode::Ask,
143 trifecta: TrifectaPolicy::Block,
144 sandbox: "none".into(),
145 sandbox_network: false,
146 }
147 }
148}
149
150impl RunConfig {
151 pub fn of(agent: &Agent, config: &Config, provider: &str) -> Self {
155 let cfg = agent.config();
156 RunConfig {
157 mecha_version: crate::VERSION.to_string(),
158 provider: provider.to_string(),
159 model: agent.model().to_string(),
160 workspace: agent.ctx().workspace.clone(),
161 system_prompt: agent.system().map(str::to_string),
162 tools: agent
163 .registry()
164 .iter()
165 .map(|t| t.name().to_string())
166 .collect(),
167 effort: cfg.effort,
168 temperature: config.providers.get(provider).and_then(|p| p.temperature),
169 seed: config.providers.get(provider).and_then(|p| p.seed),
170 thinking: cfg.thinking,
171 cache_prompt: cfg.cache_prompt,
172 max_tokens: cfg.max_tokens,
173 max_turns: cfg.max_turns,
174 max_output_tokens: cfg.max_output_tokens,
175 max_cost_usd: cfg.max_cost_usd,
176 compact_at_tokens: cfg.compact_at_tokens,
177 compact_keep_recent: cfg.compact_keep_recent,
178 permission_mode: config.tools.permission_mode,
179 trifecta: config.security.trifecta,
180 sandbox: config.sandbox.kind.as_str().to_string(),
181 sandbox_network: config.sandbox.network,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SessionMeta {
188 pub id: String,
189 pub created_at: DateTime<Utc>,
190 pub provider: String,
191 pub model: String,
192 pub workspace: PathBuf,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub title: Option<String>,
195}
196
197pub struct Session {
198 pub meta: SessionMeta,
199 pub path: PathBuf,
200}
201
202impl Session {
203 pub fn default_dir() -> Result<PathBuf> {
205 if let Ok(dir) = std::env::var("MECHA_SESSION_DIR") {
206 return Ok(PathBuf::from(dir));
207 }
208 Ok(crate::work::mecha_home()?.join("sessions"))
209 }
210
211 pub fn create(dir: &Path, meta: SessionMeta) -> Result<Self> {
212 crate::create_private_dir(dir)
213 .with_context(|| format!("creating session directory {}", dir.display()))?;
214 let path = dir.join(format!("{}.jsonl", meta.id));
215 let session = Session {
216 meta: meta.clone(),
217 path,
218 };
219 session.append(&Record::Meta(meta))?;
220 Ok(session)
221 }
222
223 pub fn new_id() -> String {
224 format!(
227 "{}-{}",
228 Utc::now().format("%Y%m%dT%H%M%S"),
229 &uuid::Uuid::new_v4().to_string()[..8]
230 )
231 }
232
233 pub fn append(&self, record: &Record) -> Result<()> {
234 use std::io::Write;
235 let mut file = std::fs::OpenOptions::new()
236 .create(true)
237 .append(true)
238 .open(&self.path)
239 .with_context(|| format!("opening {}", self.path.display()))?;
240 writeln!(file, "{}", serde_json::to_string(record)?)?;
241 Ok(())
242 }
243
244 pub fn append_messages(&self, messages: &[Message]) -> Result<()> {
245 for m in messages {
246 self.append(&Record::Message(m.clone()))?;
247 }
248 Ok(())
249 }
250
251 pub fn record_run(&self, before: &[Message], after: &[Message]) -> Result<()> {
267 let appended_only = after.len() >= before.len() && after[..before.len()] == *before;
268 if appended_only {
269 self.append_messages(&after[before.len()..])
270 } else {
271 self.append(&Record::Rewrite {
272 messages: after.to_vec(),
273 })
274 }
275 }
276
277 pub fn load(path: &Path) -> Result<(SessionMeta, Conversation)> {
282 let text =
283 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
284
285 let mut meta = None;
286 let mut messages = Vec::new();
287 let mut taint = Taint::default();
288 for line in text.lines().filter(|l| !l.trim().is_empty()) {
289 match serde_json::from_str::<Record>(line) {
290 Ok(Record::Meta(m)) => meta = Some(m),
291 Ok(Record::Message(m)) => messages.push(m),
292 Ok(Record::Rewrite { messages: m }) => messages = m,
296 Ok(Record::Taint(t)) => taint.merge(t),
299 Ok(Record::Summary { .. }) | Ok(Record::Config(_)) => {}
300 Err(e) => tracing::warn!(error = %e, "skipping malformed transcript line"),
301 }
302 }
303
304 let meta = meta.with_context(|| format!("{} has no session header", path.display()))?;
305 Ok((meta, Conversation::resumed(messages, taint)))
306 }
307
308 pub fn taint_timeline(path: &Path) -> Result<TaintTimeline> {
318 let text =
319 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
320 Ok(TaintTimeline::from_records(
321 text.lines()
322 .filter(|l| !l.trim().is_empty())
323 .filter_map(|l| serde_json::from_str::<Record>(l).ok()),
324 ))
325 }
326
327 pub fn run_configs(path: &Path) -> Result<Vec<RunConfig>> {
335 let text =
336 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
337 Ok(text
338 .lines()
339 .filter(|l| !l.trim().is_empty())
340 .filter_map(|l| match serde_json::from_str::<Record>(l) {
341 Ok(Record::Config(c)) => Some(c),
342 _ => None,
343 })
344 .collect())
345 }
346
347 pub fn peek_meta(path: &Path) -> Option<SessionMeta> {
357 use std::io::BufRead;
358 let file = std::fs::File::open(path).ok()?;
359 let mut reader = std::io::BufReader::new(file);
360 let mut first = String::new();
361 loop {
362 first.clear();
363 if reader.read_line(&mut first).ok()? == 0 {
364 return None;
365 }
366 if !first.trim().is_empty() {
367 break;
368 }
369 }
370 match serde_json::from_str::<Record>(&first).ok()? {
371 Record::Meta(m) => Some(m),
372 _ => None,
373 }
374 }
375
376 pub fn usage_totals(path: &Path) -> Result<(Usage, u32)> {
381 let text =
382 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
383 let mut usage = Usage::default();
384 let mut turns = 0u32;
385 for line in text.lines().filter(|l| !l.trim().is_empty()) {
386 if let Ok(Record::Summary { usage: u, turns: t }) = serde_json::from_str(line) {
387 usage.add(&u);
388 turns += t;
389 }
390 }
391 Ok((usage, turns))
392 }
393
394 pub fn list(dir: &Path) -> Result<Vec<(SessionMeta, PathBuf)>> {
396 if !dir.exists() {
397 return Ok(Vec::new());
398 }
399 let mut out = Vec::new();
400 for entry in std::fs::read_dir(dir)? {
401 let path = entry?.path();
402 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
403 continue;
404 }
405 if let Some(meta) = Session::peek_meta(&path) {
407 out.push((meta, path));
408 }
409 }
410 out.sort_by_key(|(meta, _)| std::cmp::Reverse(meta.created_at));
411 Ok(out)
412 }
413
414 pub fn find(dir: &Path, id_prefix: &str) -> Result<PathBuf> {
416 let matches: Vec<_> = Session::list(dir)?
417 .into_iter()
418 .filter(|(m, _)| m.id.starts_with(id_prefix))
419 .collect();
420 match matches.len() {
421 0 => anyhow::bail!("no session matching {id_prefix:?}"),
422 1 => Ok(matches.into_iter().next().unwrap().1),
423 n => anyhow::bail!("{id_prefix:?} matches {n} sessions; use a longer prefix"),
424 }
425 }
426}
427
428#[derive(Debug, Clone, Default)]
432pub struct TaintTimeline {
433 checkpoints: Vec<(usize, Taint)>,
437}
438
439impl TaintTimeline {
440 pub fn from_records(records: impl IntoIterator<Item = Record>) -> Self {
441 let mut checkpoints: Vec<(usize, Taint)> = Vec::new();
442 let mut messages = 0usize;
443 let mut merged = Taint::default();
444 for record in records {
445 match record {
446 Record::Message(_) => messages += 1,
447 Record::Rewrite { messages: m } => {
462 messages = m.len();
463 checkpoints.clear();
464 }
465 Record::Taint(t) => {
466 merged.merge(t);
467 checkpoints.push((messages, merged));
468 }
469 _ => {}
470 }
471 }
472 TaintTimeline { checkpoints }
473 }
474
475 pub fn covering(&self, index: usize) -> Option<Taint> {
480 self.checkpoints
481 .iter()
482 .find(|(n, _)| *n > index)
483 .map(|(_, t)| *t)
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use crate::message::Block;
491
492 fn tmpdir() -> PathBuf {
493 let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
494 std::fs::create_dir_all(&dir).unwrap();
495 dir
496 }
497
498 fn meta_with_id(id: &str) -> SessionMeta {
499 SessionMeta {
500 id: id.to_string(),
501 created_at: Utc::now(),
502 provider: "scripted".into(),
503 model: "test-model".into(),
504 workspace: PathBuf::from("/tmp"),
505 title: None,
506 }
507 }
508
509 #[test]
510 fn a_transcript_round_trips_its_messages_and_its_taint() {
511 let dir = tmpdir();
512 let session = Session::create(&dir, meta_with_id("20260101T000000-round")).unwrap();
513 session
514 .append_messages(&[
515 Message::user("summarise this page"),
516 Message::assistant(vec![Block::text("done")]),
517 ])
518 .unwrap();
519 session
520 .append(&Record::Taint(Taint {
521 private: true,
522 untrusted: true,
523 }))
524 .unwrap();
525
526 let (meta, convo) = Session::load(&session.path).unwrap();
527
528 assert_eq!(meta.model, "test-model");
529 assert_eq!(convo.messages.len(), 2);
530 assert_eq!(convo.messages[0].text(), "summarise this page");
531 assert_eq!(convo.messages[1].text(), "done");
532 assert!(convo.taint.trifecta_armed());
536
537 std::fs::remove_dir_all(&dir).ok();
538 }
539
540 #[test]
541 fn record_run_appends_the_tail_when_the_run_only_appended() {
542 let dir = tmpdir();
543 let session = Session::create(&dir, meta_with_id("20260101T000000-tail")).unwrap();
544 let before = vec![Message::user("go")];
545 session.append_messages(&before).unwrap();
546
547 let mut after = before.clone();
548 after.push(Message::assistant(vec![Block::text("done")]));
549 session.record_run(&before, &after).unwrap();
550
551 let (_, convo) = Session::load(&session.path).unwrap();
552 assert_eq!(convo.messages.len(), 2);
553 assert_eq!(convo.messages[1].text(), "done");
554 let text = std::fs::read_to_string(&session.path).unwrap();
557 assert!(!text.contains("\"record\":\"rewrite\""), "{text}");
558
559 std::fs::remove_dir_all(&dir).ok();
560 }
561
562 #[test]
563 fn record_run_records_a_rewrite_when_compaction_touched_the_head() {
564 let dir = tmpdir();
571 let session = Session::create(&dir, meta_with_id("20260101T000000-rw")).unwrap();
572 let before = vec![Message::user("go")];
573 session.append_messages(&before).unwrap();
574
575 let mut head = before[0].clone();
578 head.content
579 .push(Block::text("[Earlier turns were compacted]"));
580 let after = vec![head, Message::assistant(vec![Block::text("done")])];
581 session.record_run(&before, &after).unwrap();
582
583 let (_, convo) = Session::load(&session.path).unwrap();
584 assert_eq!(convo.messages.len(), 2);
585 assert!(
586 convo.messages[0].text().contains("compacted"),
587 "the rebuilt head must be what loads: {:?}",
588 convo.messages[0].text()
589 );
590 assert_eq!(convo.messages[1].text(), "done");
591
592 std::fs::remove_dir_all(&dir).ok();
593 }
594
595 #[test]
596 fn a_rewrite_drops_stale_taint_positions_instead_of_shadowing_later_ones() {
597 let msg = || Message::user("m");
607 let mut records: Vec<Record> = (0..10).map(|_| Record::Message(msg())).collect();
608 records.push(Record::Taint(Taint {
609 private: true,
610 untrusted: false,
611 }));
612 records.push(Record::Rewrite {
613 messages: vec![msg(), msg()],
614 });
615 records.push(Record::Taint(Taint {
616 private: true,
617 untrusted: true,
618 }));
619
620 let timeline = TaintTimeline::from_records(records);
621 for index in 0..2 {
625 let covering = timeline.covering(index).expect("a checkpoint covers it");
626 assert!(
627 covering.untrusted,
628 "message {index} classified by a stale pre-rewrite checkpoint"
629 );
630 assert!(covering.private, "the dropped checkpoint's taint was lost");
631 }
632 }
633
634 #[test]
635 fn a_transcript_torn_after_a_rewrite_reports_unknown_not_clean() {
636 let msg = || Message::user("m");
641 let records = vec![
642 Record::Message(msg()),
643 Record::Taint(Taint {
644 private: true,
645 untrusted: true,
646 }),
647 Record::Rewrite {
648 messages: vec![msg(), msg()],
649 },
650 ];
651 let timeline = TaintTimeline::from_records(records);
652 assert_eq!(timeline.covering(0), None);
653 assert_eq!(timeline.covering(1), None);
654 }
655
656 #[test]
657 fn taint_records_merge_so_a_later_clean_one_cannot_disarm_the_interlock() {
658 let dir = tmpdir();
659 let session = Session::create(&dir, meta_with_id("20260101T000000-merge")).unwrap();
660
661 session
664 .append(&Record::Taint(Taint {
665 untrusted: true,
666 private: false,
667 }))
668 .unwrap();
669 session
670 .append(&Record::Taint(Taint {
671 private: true,
672 untrusted: false,
673 }))
674 .unwrap();
675 session.append(&Record::Taint(Taint::default())).unwrap();
676
677 let (_, convo) = Session::load(&session.path).unwrap();
678
679 assert!(convo.taint.private, "an earlier private leg was dropped");
682 assert!(
683 convo.taint.untrusted,
684 "an earlier untrusted leg was dropped"
685 );
686 assert!(convo.taint.trifecta_armed());
687
688 std::fs::remove_dir_all(&dir).ok();
689 }
690
691 #[test]
692 fn a_transcript_written_before_taint_was_recorded_loads_clean() {
693 let dir = tmpdir();
694 let session = Session::create(&dir, meta_with_id("20260101T000000-old")).unwrap();
695 session.append_messages(&[Message::user("hello")]).unwrap();
696
697 let (_, convo) = Session::load(&session.path).unwrap();
698
699 assert_eq!(convo.messages.len(), 1);
700 assert!(!convo.taint.private);
701 assert!(!convo.taint.untrusted);
702
703 std::fs::remove_dir_all(&dir).ok();
704 }
705
706 #[test]
707 fn a_truncated_final_line_does_not_lose_the_rest_of_the_transcript() {
708 use std::io::Write;
709 let dir = tmpdir();
710 let session = Session::create(&dir, meta_with_id("20260101T000000-killed")).unwrap();
711 session.append_messages(&[Message::user("first")]).unwrap();
712 session
713 .append(&Record::Taint(Taint {
714 private: true,
715 untrusted: false,
716 }))
717 .unwrap();
718
719 let mut file = std::fs::OpenOptions::new()
721 .append(true)
722 .open(&session.path)
723 .unwrap();
724 write!(file, "{{\"record\":\"message\",\"role\":\"assis").unwrap();
725 drop(file);
726
727 let (_, convo) = Session::load(&session.path).unwrap();
728
729 assert_eq!(convo.messages.len(), 1);
730 assert_eq!(convo.messages[0].text(), "first");
731 assert!(
732 convo.taint.private,
733 "a torn last line lost the taint before it"
734 );
735
736 std::fs::remove_dir_all(&dir).ok();
737 }
738
739 #[test]
740 fn run_configs_come_back_in_order_one_per_attach() {
741 let dir = tmpdir();
742 let session = Session::create(&dir, meta_with_id("20260101T000000-cfg")).unwrap();
743
744 let first = RunConfig {
746 compact_at_tokens: None,
747 ..RunConfig::default()
748 };
749 let second = RunConfig {
750 compact_at_tokens: Some(1200),
751 ..RunConfig::default()
752 };
753 session.append(&Record::Config(first)).unwrap();
754 session
755 .append_messages(&[Message::user("first run")])
756 .unwrap();
757 session.append(&Record::Config(second)).unwrap();
758
759 let configs = Session::run_configs(&session.path).unwrap();
760
761 assert_eq!(configs.len(), 2, "one record per attach, in order");
762 assert_eq!(configs[0].compact_at_tokens, None);
763 assert_eq!(configs[1].compact_at_tokens, Some(1200));
766
767 let (_, convo) = Session::load(&session.path).unwrap();
769 assert_eq!(convo.messages.len(), 1);
770
771 std::fs::remove_dir_all(&dir).ok();
772 }
773
774 #[test]
775 fn a_transcript_recorded_before_this_existed_reports_no_configs() {
776 let dir = tmpdir();
779 let session = Session::create(&dir, meta_with_id("20260101T000000-legacy")).unwrap();
780 session.append_messages(&[Message::user("hello")]).unwrap();
781
782 assert!(Session::run_configs(&session.path).unwrap().is_empty());
783
784 std::fs::remove_dir_all(&dir).ok();
785 }
786
787 #[test]
788 fn the_taint_timeline_covers_each_message_with_its_runs_checkpoint() {
789 let dir = tmpdir();
790 let session = Session::create(&dir, meta_with_id("20260101T000000-tl")).unwrap();
791
792 session
794 .append_messages(&[Message::user("list the files")])
795 .unwrap();
796 session
797 .append_messages(&[Message::assistant(vec![Block::text("done")])])
798 .unwrap();
799 session.append(&Record::Taint(Taint::default())).unwrap();
800 session
802 .append_messages(&[Message::user("fetch that page")])
803 .unwrap();
804 session
805 .append_messages(&[Message::assistant(vec![Block::text("fetched")])])
806 .unwrap();
807 session
808 .append(&Record::Taint(Taint {
809 untrusted: true,
810 private: false,
811 }))
812 .unwrap();
813
814 let tl = Session::taint_timeline(&session.path).unwrap();
815
816 assert!(!tl.covering(0).unwrap().untrusted);
818 assert!(!tl.covering(1).unwrap().untrusted);
819 assert!(tl.covering(2).unwrap().untrusted);
823 assert!(tl.covering(3).unwrap().untrusted);
824 assert_eq!(tl.covering(4).map(|t| t.untrusted), None);
827
828 std::fs::remove_dir_all(&dir).ok();
829 }
830
831 #[test]
832 fn a_pre_taint_transcript_has_an_empty_timeline() {
833 let dir = tmpdir();
837 let session = Session::create(&dir, meta_with_id("20260101T000000-notl")).unwrap();
838 session.append_messages(&[Message::user("hello")]).unwrap();
839
840 let tl = Session::taint_timeline(&session.path).unwrap();
841 assert!(tl.covering(0).is_none());
842
843 std::fs::remove_dir_all(&dir).ok();
844 }
845
846 #[test]
847 fn listing_reads_only_the_first_record_and_skips_files_without_a_header() {
848 let dir = tmpdir();
849 let session = Session::create(&dir, meta_with_id("20260101T000000-peek")).unwrap();
850 session.append_messages(&[Message::user("hello")]).unwrap();
851
852 let stray = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
857 let meta = serde_json::to_string(&Record::Meta(meta_with_id("buried"))).unwrap();
858 std::fs::write(dir.join("stray.jsonl"), format!("{stray}\n{meta}\n")).unwrap();
859
860 let listed = Session::list(&dir).unwrap();
861 assert_eq!(listed.len(), 1);
862 assert_eq!(listed[0].0.id, "20260101T000000-peek");
863
864 let peeked = Session::peek_meta(&session.path).unwrap();
866 let (loaded, _) = Session::load(&session.path).unwrap();
867 assert_eq!(peeked.id, loaded.id);
868 assert_eq!(peeked.model, loaded.model);
869
870 std::fs::remove_dir_all(&dir).ok();
871 }
872
873 #[test]
874 fn usage_totals_sum_every_run_and_report_zero_for_a_summaryless_file() {
875 let dir = tmpdir();
876 let session = Session::create(&dir, meta_with_id("20260101T000000-usage")).unwrap();
877
878 assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
880
881 session
883 .append(&Record::Summary {
884 usage: Usage {
885 input_tokens: 100,
886 output_tokens: 10,
887 ..Default::default()
888 },
889 turns: 2,
890 })
891 .unwrap();
892 session
893 .append(&Record::Summary {
894 usage: Usage {
895 input_tokens: 50,
896 output_tokens: 5,
897 ..Default::default()
898 },
899 turns: 1,
900 })
901 .unwrap();
902
903 let (usage, turns) = Session::usage_totals(&session.path).unwrap();
904 assert_eq!(usage.input_tokens, 150);
905 assert_eq!(usage.output_tokens, 15);
906 assert_eq!(turns, 3);
907
908 std::fs::remove_dir_all(&dir).ok();
909 }
910
911 #[cfg(unix)]
912 #[test]
913 fn the_session_directory_is_owner_only() {
914 use std::os::unix::fs::PermissionsExt;
915 let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
917 Session::create(&dir, meta_with_id("20260101T000000-perms")).unwrap();
918
919 let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
922 assert_eq!(mode & 0o777, 0o700);
923
924 std::fs::remove_dir_all(&dir).ok();
925 }
926
927 #[test]
928 fn a_transcript_with_no_header_is_refused() {
929 let dir = tmpdir();
930 let path = dir.join("headerless.jsonl");
931 let line = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
932 std::fs::write(&path, format!("{line}\n")).unwrap();
933
934 let err = Session::load(&path).unwrap_err().to_string();
935 assert!(err.contains("no session header"), "unexpected error: {err}");
936
937 std::fs::remove_dir_all(&dir).ok();
938 }
939
940 #[test]
941 fn an_ambiguous_id_prefix_is_an_error_rather_than_a_guess() {
942 let dir = tmpdir();
943 Session::create(&dir, meta_with_id("20260101T000000-aaaaaaaa")).unwrap();
944 Session::create(&dir, meta_with_id("20260101T000000-bbbbbbbb")).unwrap();
945
946 let err = Session::find(&dir, "20260101").unwrap_err().to_string();
947 assert!(
948 err.contains("matches 2 sessions"),
949 "unexpected error: {err}"
950 );
951
952 let path = Session::find(&dir, "20260101T000000-aaaaaaaa").unwrap();
955 assert!(path.ends_with("20260101T000000-aaaaaaaa.jsonl"));
956
957 assert!(Session::find(&dir, "nothing-like-this").is_err());
958
959 std::fs::remove_dir_all(&dir).ok();
960 }
961}