Skip to main content

mecha_core/
session.rs

1//! Session transcripts.
2//!
3//! One JSONL file per run: a header line describing the session, then one line
4//! per message. Append-only, so a crashed run still leaves a readable
5//! transcript, and `mecha sessions resume` can pick it back up.
6
7use 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    /// Written when a run finishes, so `sessions show` can report cost without
21    /// replaying the whole transcript.
22    Summary {
23        usage: Usage,
24        turns: u32,
25    },
26    /// Everything that shaped the request, written each time a process
27    /// attaches to the session — on creation and again on every resume.
28    ///
29    /// Not folded into the header, because a session resumed under different
30    /// flags would make a header written at creation a lie about every turn
31    /// after the first. Within one process the configuration cannot change, so
32    /// one record per attach is exactly the granularity that can differ.
33    Config(RunConfig),
34    /// What had entered the conversation by this point.
35    ///
36    /// Recorded because it cannot be recovered by reading the transcript back:
37    /// taint keys off *provenance* — whether a result actually came from
38    /// outside — and the transcript stores only the content. Without this,
39    /// resuming a session that had read a hostile page would hand the model
40    /// that page again with the interlock disarmed.
41    Taint(Taint),
42    /// The conversation's messages were rewritten in place — compaction
43    /// summarised the head, eviction replaced a stale result, thinning
44    /// shortened an old one. An append-only file cannot express an in-place
45    /// rewrite as more `Message` records: slicing "what the run added" off
46    /// the end of a rewritten list skips the rebuilt head, which is exactly
47    /// where the compaction summary lives, and every trace of the rewrite
48    /// with it — a 2026-08-07 benchmark transcript recorded 8 assistant turns
49    /// of a 28-turn run that way, starting mid-conversation with no sign a
50    /// compaction had ever happened. So the record carries the whole current
51    /// list, and [`Session::load`] replaces what it has accumulated so far.
52    Rewrite {
53        messages: Vec<Message>,
54    },
55}
56
57/// What a run was configured with, recorded so it can be replayed.
58///
59/// The rule behind the field list: **anything that shapes the request or
60/// constrains the run is a confound if it is not recorded.** That is not
61/// theoretical here — compaction on versus off measured 1/5 against 5/5 on the
62/// same task, so a replay that did not know whether compaction was enabled
63/// would compare two incomparable runs and report a model regression.
64///
65/// The system prompt is stored in full rather than hashed. A hash tells you
66/// only *that* something differed; the text lets a replay rebuild the request.
67/// It is no more sensitive than the transcript sitting beside it.
68///
69/// The sampler is recorded only as far as it is pinned: `temperature` and
70/// `seed` hold what this process *sent*, and `None` means the server chose.
71/// Replay against an unpinned run has to be pass@k-shaped rather than
72/// exact-match-shaped; against a pinned, seeded run driven sequentially it can
73/// expect to match. (Not greedy — temperature 0.0 walks qwen3.6 into verbatim
74/// repetition loops. And only sequentially: llama-server's continuous batching
75/// makes concurrent requests perturb each other's numerics, seed or no seed.)
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(default)]
78pub struct RunConfig {
79    /// Which harness produced this. The axis every replay diff is measured on.
80    pub mecha_version: String,
81    pub provider: String,
82    pub model: String,
83    pub workspace: PathBuf,
84    /// The resolved text, not the path it may have come from.
85    pub system_prompt: Option<String>,
86    /// Tool names in registry order — which is the order they are sent, and the
87    /// front of the cached prefix. A tool added, removed or renamed between
88    /// recording and replay changes what the model could have done.
89    pub tools: Vec<String>,
90
91    // What the request looks like.
92    pub effort: Option<Effort>,
93    /// The temperature and seed actually sent, when the provider config pins
94    /// them. Unset means the server chose, and the run is not repeatable.
95    pub temperature: Option<f64>,
96    pub seed: Option<u64>,
97    pub thinking: bool,
98    /// No effect on semantics; large effect on the token counts a replay diffs.
99    pub cache_prompt: bool,
100    pub max_tokens: u32,
101
102    // Ceilings. A run that hit one looks exactly like a model that gave up.
103    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    // Policy: what the model was allowed to do at all.
110    /// A denied call redirects the whole trajectory, so replaying a read-only
111    /// session under `--yes` compares nothing.
112    pub permission_mode: PermissionMode,
113    pub trifecta: TrifectaPolicy,
114    /// `none` | `bwrap` | `docker`. Load-bearing beyond the obvious: `shell`
115    /// declares *narrower* capabilities when confined, and the interlock
116    /// believes them, so the same prompt can be refused in one and allowed in
117    /// the other.
118    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    /// Read it off the built agent rather than off the config file, so what is
152    /// recorded is what is actually being sent — flags, layered TOML and
153    /// defaults already resolved.
154    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    /// Where transcripts live: `~/.mecha/sessions`, or `$MECHA_SESSION_DIR`.
204    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        // Sortable by name, and still unique when two runs start in the same
225        // second.
226        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    /// Record what a run did to the conversation, given the messages it
252    /// started from.
253    ///
254    /// `before` must be what the file already holds — every front-end has
255    /// appended the opening user message (and, resumed, the loaded history)
256    /// before the run starts. When the run only appended, the new tail is
257    /// appended here too. When it rewrote what was already recorded —
258    /// compaction, eviction, thinning, all of which edit earlier messages in
259    /// place — a [`Record::Rewrite`] carries the whole current list instead,
260    /// because slicing a rewritten transcript records a lie: the old head
261    /// stays in the file, the rebuilt one (summary included) never lands.
262    ///
263    /// Comparison, not a flag from the loop: any mutation the loop grows
264    /// later is caught by construction, and the clone this costs is one more
265    /// beside the one the loop already pays per request.
266    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    /// Read a transcript back, taint included.
278    ///
279    /// Unparseable lines are skipped rather than failing the load — a truncated
280    /// final line is the normal result of a killed process.
281    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                // The conversation state as of the rewrite, wholesale. Taint
293                // is deliberately not touched: summarising away the text of a
294                // hostile page does not un-read it.
295                Ok(Record::Rewrite { messages: m }) => messages = m,
296                // Merged rather than replaced: taint only ever grows, and a
297                // transcript written by an older build has none at all.
298                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    /// The taint checkpoints of a transcript, positioned against its messages.
309    ///
310    /// Every front-end appends a `Record::Taint` checkpoint *after* the
311    /// messages of the run it describes, so the checkpoint that covers a
312    /// message is the first one written after it — and by then the taint of
313    /// everything earlier in that run, hostile fetches included, has merged
314    /// in. That ordering is what makes [`TaintTimeline::covering`] safe to
315    /// gate on: it can over-taint a message (a fetch later in the same run
316    /// counts against it), never under-taint one.
317    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    /// Every run configuration in a transcript, in the order the runs happened.
328    ///
329    /// A replay driver needs this per run rather than per session: resuming
330    /// under different flags is a normal thing to do, and the turns before and
331    /// after are not comparable. An empty result means a transcript written
332    /// before this was recorded — which cannot be replayed faithfully, because
333    /// the system prompt and tool list that shaped it are gone.
334    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    /// The header alone, without parsing the rest of the file.
348    ///
349    /// Listing goes through this rather than [`Session::load`] so `mecha
350    /// sessions` stays O(number of sessions) instead of O(total transcript
351    /// bytes) — with reflect-on-close recording every interaction, the full
352    /// parse re-read the whole store to print one line per file. The header
353    /// is the first record `create` writes; a file whose first record is
354    /// anything else is not a session this process wrote, and is skipped
355    /// exactly as `load`'s no-header error skipped it.
356    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    /// The run summaries of a transcript, summed: total usage and turns
377    /// across every run the file records. Zero for a transcript that
378    /// predates the summary record or died before writing one — an honest
379    /// under-count, never a guess.
380    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    /// Sessions in `dir`, newest first.
395    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            // A transcript with no header is unusable; skip it quietly.
406            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    /// Find a session by full id or unique prefix.
415    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/// Where each taint checkpoint sits relative to the messages — built by
429/// [`Session::taint_timeline`], consumed by provenance classification in
430/// `learning`.
431#[derive(Debug, Clone, Default)]
432pub struct TaintTimeline {
433    /// (messages recorded before this checkpoint, taint merged up to it).
434    /// Merged, not raw: taint only grows, so each entry is the union of every
435    /// checkpoint at or before it.
436    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                // The list was replaced, so every position recorded before it
448                // is a claim about a list that no longer exists — drop them.
449                // Not clamp: clamping several stale checkpoints onto the new
450                // length leaves `covering` resolving to the *first* of them,
451                // which is the oldest and smallest taint, and in the record
452                // order the front-ends actually write (`Rewrite` then
453                // `Taint`, no message between) that under-taints every
454                // rewritten message — a compacting run that read a hostile
455                // page would classify clean. Dropping fails the right way
456                // twice over: `merged` is cumulative, so the checkpoint the
457                // run writes after the rewrite carries everything the dropped
458                // ones knew and covers the rewritten head with it; and a file
459                // torn before that checkpoint leaves the head covered by
460                // nothing, which `covering` reports as unknown — never clean.
461                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    /// The merged taint covering the message at `index`, or `None` when no
476    /// checkpoint was written after it — a torn transcript, or one recorded
477    /// before taint was. The caller must treat `None` as *unknown*, and
478    /// unknown provenance is never clean.
479    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        // The whole point of recording it: provenance cannot be recovered by
533        // re-reading the content, so a resumed conversation that had read a
534        // hostile page must come back with the interlock still armed.
535        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        // And no rewrite record for the ordinary case: the file stays a plain
555        // append log unless the run actually rewrote history.
556        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        // The regression this pins, from a 2026-08-07 benchmark transcript:
565        // a compacted run recorded via the append-only slice kept the stale
566        // head and skipped the rebuilt one, so the file held 8 assistant
567        // turns of a 28-turn run, beginning mid-conversation, with no sign a
568        // compaction had happened. Resuming that transcript resumes a
569        // conversation the run never had.
570        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        // What compaction leaves behind: the head rewritten in place
576        // (instruction plus summary), then the surviving tail.
577        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        // The record order the front-ends actually write, across two runs of
598        // one chat session: run 1's messages and its clean checkpoint, then
599        // run 2 compacts (a rewrite, shrinking the list) after reading a
600        // hostile page, and checkpoints — `Rewrite` then `Taint`, with no
601        // message record between. A stale checkpoint kept in any form sits
602        // at-or-before the new length, and `covering` takes the *first*
603        // checkpoint past an index, so keeping it hands every rewritten
604        // message the older, clean taint — under-tainting, the one direction
605        // the timeline must never be wrong in.
606        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        // Every position in the rewritten list is covered by the post-rewrite
622        // checkpoint, which merged the dropped one's taint — over-taint,
623        // never under.
624        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        // The process died between writing the rewrite and its taint
637        // checkpoint. Nothing covers the rewritten messages, and `covering`
638        // must say so — the learning classifier treats unknown as untrusted,
639        // and a clean answer here would be the laundering path.
640        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        // The order a real run writes them in: one leg arrives, then the other,
662        // and the loop may checkpoint again with nothing new to say.
663        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        // Replacing rather than merging would leave this clean, and resuming
680        // would hand the model the attacker's page with the guard switched off.
681        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        // What a killed process leaves behind: a half-written final record.
720        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        // What a resume under different flags looks like on disk.
745        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        // The turns before and after are not comparable, and only a per-attach
764        // record can say where the line is.
765        assert_eq!(configs[1].compact_at_tokens, Some(1200));
766
767        // And the messages still load, unbothered by the new record type.
768        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        // Not an error: it is the honest answer, and it is what tells a replay
777        // driver the recording cannot be reproduced faithfully.
778        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        // Run one: clean. Its checkpoint lands after its messages.
793        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        // Run two: a hostile page enters; the checkpoint records it.
801        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        // Messages 0–1 are covered by the clean checkpoint...
817        assert!(!tl.covering(0).unwrap().untrusted);
818        assert!(!tl.covering(1).unwrap().untrusted);
819        // ...2–3 by the armed one. Over-tainting within a run is the safe
820        // direction: a fetch later in the same run counts against a message
821        // before it, never the reverse.
822        assert!(tl.covering(2).unwrap().untrusted);
823        assert!(tl.covering(3).unwrap().untrusted);
824        // Beyond the last checkpoint is unknown, and unknown is the caller's
825        // cue to fail closed.
826        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        // Sessions recorded before taint existed can establish nothing, so
834        // every position must come back None — which classification turns
835        // into Untrusted, never Clean.
836        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        // A stray JSONL file whose first record is not a header is skipped —
853        // the contract is now explicitly "the header is the first record",
854        // which is where `create` writes it; buried headers no longer count,
855        // and that is the price of listing without parsing every transcript.
856        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        // And the peek agrees with the full load about what the header says.
865        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        // No summary yet — a run that died mid-flight. Zero, not an error.
879        assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
880
881        // Two runs on one session (chat, resume): the totals are the sum.
882        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        // A fresh path, so `create` makes the directory itself.
916        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        // Transcripts hold whatever the tools returned — mail bodies
920        // included — so the directory gets the token-file rule.
921        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        // A full id still resolves, and resuming the wrong transcript is the
953        // failure being guarded against.
954        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}