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` | `landlock`. Load-bearing beyond the
115    /// obvious: `shell` declares *narrower* capabilities when confined, and
116    /// the interlock believes them, so the same prompt can be refused in one
117    /// and allowed in the other. (`landlock` never narrows `external_send` —
118    /// see the sandbox module — so it patterns with `none` for the interlock
119    /// while still confining files.)
120    pub sandbox: String,
121    pub sandbox_network: bool,
122}
123
124impl Default for RunConfig {
125    fn default() -> Self {
126        RunConfig {
127            mecha_version: String::new(),
128            provider: String::new(),
129            model: String::new(),
130            workspace: PathBuf::new(),
131            system_prompt: None,
132            tools: Vec::new(),
133            effort: None,
134            temperature: None,
135            seed: None,
136            thinking: false,
137            cache_prompt: false,
138            max_tokens: 0,
139            max_turns: 0,
140            max_output_tokens: None,
141            max_cost_usd: None,
142            compact_at_tokens: None,
143            compact_keep_recent: 0,
144            permission_mode: PermissionMode::Ask,
145            trifecta: TrifectaPolicy::Block,
146            sandbox: "none".into(),
147            sandbox_network: false,
148        }
149    }
150}
151
152impl RunConfig {
153    /// Read it off the built agent rather than off the config file, so what is
154    /// recorded is what is actually being sent — flags, layered TOML and
155    /// defaults already resolved.
156    pub fn of(agent: &Agent, config: &Config, provider: &str) -> Self {
157        let cfg = agent.config();
158        RunConfig {
159            mecha_version: crate::VERSION.to_string(),
160            provider: provider.to_string(),
161            model: agent.model().to_string(),
162            workspace: agent.ctx().workspace.clone(),
163            system_prompt: agent.system().map(str::to_string),
164            tools: agent
165                .registry()
166                .iter()
167                .map(|t| t.name().to_string())
168                .collect(),
169            effort: cfg.effort,
170            temperature: config.providers.get(provider).and_then(|p| p.temperature),
171            seed: config.providers.get(provider).and_then(|p| p.seed),
172            thinking: cfg.thinking,
173            cache_prompt: cfg.cache_prompt,
174            max_tokens: cfg.max_tokens,
175            max_turns: cfg.max_turns,
176            max_output_tokens: cfg.max_output_tokens,
177            max_cost_usd: cfg.max_cost_usd,
178            compact_at_tokens: cfg.compact_at_tokens,
179            compact_keep_recent: cfg.compact_keep_recent,
180            permission_mode: config.tools.permission_mode,
181            trifecta: config.security.trifecta,
182            sandbox: config.sandbox.kind.as_str().to_string(),
183            sandbox_network: config.sandbox.network,
184        }
185    }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct SessionMeta {
190    pub id: String,
191    pub created_at: DateTime<Utc>,
192    pub provider: String,
193    pub model: String,
194    pub workspace: PathBuf,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub title: Option<String>,
197}
198
199pub struct Session {
200    pub meta: SessionMeta,
201    pub path: PathBuf,
202}
203
204impl Session {
205    /// Where transcripts live: `~/.mecha/sessions`, or `$MECHA_SESSION_DIR`.
206    pub fn default_dir() -> Result<PathBuf> {
207        if let Ok(dir) = std::env::var("MECHA_SESSION_DIR") {
208            return Ok(PathBuf::from(dir));
209        }
210        Ok(crate::work::mecha_home()?.join("sessions"))
211    }
212
213    pub fn create(dir: &Path, meta: SessionMeta) -> Result<Self> {
214        crate::create_private_dir(dir)
215            .with_context(|| format!("creating session directory {}", dir.display()))?;
216        let path = dir.join(format!("{}.jsonl", meta.id));
217        let session = Session {
218            meta: meta.clone(),
219            path,
220        };
221        session.append(&Record::Meta(meta))?;
222        Ok(session)
223    }
224
225    pub fn new_id() -> String {
226        // Sortable by name, and still unique when two runs start in the same
227        // second.
228        format!(
229            "{}-{}",
230            Utc::now().format("%Y%m%dT%H%M%S"),
231            &uuid::Uuid::new_v4().to_string()[..8]
232        )
233    }
234
235    pub fn append(&self, record: &Record) -> Result<()> {
236        use std::io::Write;
237        let mut file = std::fs::OpenOptions::new()
238            .create(true)
239            .append(true)
240            .open(&self.path)
241            .with_context(|| format!("opening {}", self.path.display()))?;
242        writeln!(file, "{}", serde_json::to_string(record)?)?;
243        Ok(())
244    }
245
246    pub fn append_messages(&self, messages: &[Message]) -> Result<()> {
247        for m in messages {
248            self.append(&Record::Message(m.clone()))?;
249        }
250        Ok(())
251    }
252
253    /// Record what a run did to the conversation, given the messages it
254    /// started from.
255    ///
256    /// `before` must be what the file already holds — every front-end has
257    /// appended the opening user message (and, resumed, the loaded history)
258    /// before the run starts. The walk visits every state the run's rewrites
259    /// replaced ([`Conversation::rewritten`]) and then the final one, so a
260    /// run long enough to compact *itself* still gets its whole head into
261    /// the file: each pre-rewrite snapshot extends the previous recorded
262    /// state append-only (its cheap tail append), and each post-rewrite
263    /// state lands as the [`Record::Rewrite`] the next transition writes.
264    /// The signature takes the conversation rather than a message slice so a
265    /// caller cannot record the destination while skipping the journey.
266    ///
267    /// [`Conversation::rewritten`]: crate::agent::Conversation
268    pub fn record_run(&self, before: &[Message], convo: &Conversation) -> Result<()> {
269        let mut prev: &[Message] = before;
270        for state in &convo.rewritten {
271            self.record_transition(prev, state)?;
272            prev = state;
273        }
274        self.record_transition(prev, &convo.messages)
275    }
276
277    /// One before→after step. When the run only appended, the new tail is
278    /// appended here too. When it rewrote what was already recorded —
279    /// compaction, eviction, thinning, all of which edit earlier messages in
280    /// place — a [`Record::Rewrite`] carries the whole current list instead,
281    /// because slicing a rewritten transcript records a lie: the old head
282    /// stays in the file, the rebuilt one (summary included) never lands.
283    ///
284    /// Comparison, not a flag from the loop: any mutation the loop grows
285    /// later is caught by construction, and the clone this costs is one more
286    /// beside the one the loop already pays per request.
287    fn record_transition(&self, before: &[Message], after: &[Message]) -> Result<()> {
288        let appended_only = after.len() >= before.len() && after[..before.len()] == *before;
289        if appended_only {
290            self.append_messages(&after[before.len()..])
291        } else {
292            self.append(&Record::Rewrite {
293                messages: after.to_vec(),
294            })
295        }
296    }
297
298    /// Read a transcript back, taint included.
299    ///
300    /// Unparseable lines are skipped rather than failing the load — a truncated
301    /// final line is the normal result of a killed process.
302    pub fn load(path: &Path) -> Result<(SessionMeta, Conversation)> {
303        let text =
304            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
305
306        let mut meta = None;
307        let mut messages = Vec::new();
308        let mut taint = Taint::default();
309        for line in text.lines().filter(|l| !l.trim().is_empty()) {
310            match serde_json::from_str::<Record>(line) {
311                Ok(Record::Meta(m)) => meta = Some(m),
312                Ok(Record::Message(m)) => messages.push(m),
313                // The conversation state as of the rewrite, wholesale. Taint
314                // is deliberately not touched: summarising away the text of a
315                // hostile page does not un-read it.
316                Ok(Record::Rewrite { messages: m }) => messages = m,
317                // Merged rather than replaced: taint only ever grows, and a
318                // transcript written by an older build has none at all.
319                Ok(Record::Taint(t)) => taint.merge(t),
320                Ok(Record::Summary { .. }) | Ok(Record::Config(_)) => {}
321                Err(e) => tracing::warn!(error = %e, "skipping malformed transcript line"),
322            }
323        }
324
325        let meta = meta.with_context(|| format!("{} has no session header", path.display()))?;
326        Ok((meta, Conversation::resumed(messages, taint)))
327    }
328
329    /// The taint checkpoints of a transcript, positioned against its messages.
330    ///
331    /// Every front-end appends a `Record::Taint` checkpoint *after* the
332    /// messages of the run it describes, so the checkpoint that covers a
333    /// message is the first one written after it — and by then the taint of
334    /// everything earlier in that run, hostile fetches included, has merged
335    /// in. That ordering is what makes [`TaintTimeline::covering`] safe to
336    /// gate on: it can over-taint a message (a fetch later in the same run
337    /// counts against it), never under-taint one.
338    pub fn taint_timeline(path: &Path) -> Result<TaintTimeline> {
339        let text =
340            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
341        Ok(TaintTimeline::from_records(
342            text.lines()
343                .filter(|l| !l.trim().is_empty())
344                .filter_map(|l| serde_json::from_str::<Record>(l).ok()),
345        ))
346    }
347
348    /// Every run configuration in a transcript, in the order the runs happened.
349    ///
350    /// A replay driver needs this per run rather than per session: resuming
351    /// under different flags is a normal thing to do, and the turns before and
352    /// after are not comparable. An empty result means a transcript written
353    /// before this was recorded — which cannot be replayed faithfully, because
354    /// the system prompt and tool list that shaped it are gone.
355    pub fn run_configs(path: &Path) -> Result<Vec<RunConfig>> {
356        let text =
357            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
358        Ok(text
359            .lines()
360            .filter(|l| !l.trim().is_empty())
361            .filter_map(|l| match serde_json::from_str::<Record>(l) {
362                Ok(Record::Config(c)) => Some(c),
363                _ => None,
364            })
365            .collect())
366    }
367
368    /// The header alone, without parsing the rest of the file.
369    ///
370    /// Listing goes through this rather than [`Session::load`] so `mecha
371    /// sessions` stays O(number of sessions) instead of O(total transcript
372    /// bytes) — with reflect-on-close recording every interaction, the full
373    /// parse re-read the whole store to print one line per file. The header
374    /// is the first record `create` writes; a file whose first record is
375    /// anything else is not a session this process wrote, and is skipped
376    /// exactly as `load`'s no-header error skipped it.
377    pub fn peek_meta(path: &Path) -> Option<SessionMeta> {
378        use std::io::BufRead;
379        let file = std::fs::File::open(path).ok()?;
380        let mut reader = std::io::BufReader::new(file);
381        let mut first = String::new();
382        loop {
383            first.clear();
384            if reader.read_line(&mut first).ok()? == 0 {
385                return None;
386            }
387            if !first.trim().is_empty() {
388                break;
389            }
390        }
391        match serde_json::from_str::<Record>(&first).ok()? {
392            Record::Meta(m) => Some(m),
393            _ => None,
394        }
395    }
396
397    /// The run summaries of a transcript, summed: total usage and turns
398    /// across every run the file records. Zero for a transcript that
399    /// predates the summary record or died before writing one — an honest
400    /// under-count, never a guess.
401    pub fn usage_totals(path: &Path) -> Result<(Usage, u32)> {
402        let text =
403            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
404        let mut usage = Usage::default();
405        let mut turns = 0u32;
406        for line in text.lines().filter(|l| !l.trim().is_empty()) {
407            if let Ok(Record::Summary { usage: u, turns: t }) = serde_json::from_str(line) {
408                usage.add(&u);
409                turns += t;
410            }
411        }
412        Ok((usage, turns))
413    }
414
415    /// Sessions in `dir`, newest first.
416    pub fn list(dir: &Path) -> Result<Vec<(SessionMeta, PathBuf)>> {
417        if !dir.exists() {
418            return Ok(Vec::new());
419        }
420        let mut out = Vec::new();
421        for entry in std::fs::read_dir(dir)? {
422            let path = entry?.path();
423            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
424                continue;
425            }
426            // A transcript with no header is unusable; skip it quietly.
427            if let Some(meta) = Session::peek_meta(&path) {
428                out.push((meta, path));
429            }
430        }
431        out.sort_by_key(|(meta, _)| std::cmp::Reverse(meta.created_at));
432        Ok(out)
433    }
434
435    /// Find a session by full id or unique prefix.
436    pub fn find(dir: &Path, id_prefix: &str) -> Result<PathBuf> {
437        let matches: Vec<_> = Session::list(dir)?
438            .into_iter()
439            .filter(|(m, _)| m.id.starts_with(id_prefix))
440            .collect();
441        match matches.len() {
442            0 => anyhow::bail!("no session matching {id_prefix:?}"),
443            1 => Ok(matches.into_iter().next().unwrap().1),
444            n => anyhow::bail!("{id_prefix:?} matches {n} sessions; use a longer prefix"),
445        }
446    }
447}
448
449/// Where each taint checkpoint sits relative to the messages — built by
450/// [`Session::taint_timeline`], consumed by provenance classification in
451/// `learning`.
452#[derive(Debug, Clone, Default)]
453pub struct TaintTimeline {
454    /// (messages recorded before this checkpoint, taint merged up to it).
455    /// Merged, not raw: taint only grows, so each entry is the union of every
456    /// checkpoint at or before it.
457    checkpoints: Vec<(usize, Taint)>,
458}
459
460impl TaintTimeline {
461    pub fn from_records(records: impl IntoIterator<Item = Record>) -> Self {
462        let mut checkpoints: Vec<(usize, Taint)> = Vec::new();
463        let mut messages = 0usize;
464        let mut merged = Taint::default();
465        for record in records {
466            match record {
467                Record::Message(_) => messages += 1,
468                // The list was replaced, so every position recorded before it
469                // is a claim about a list that no longer exists — drop them.
470                // Not clamp: clamping several stale checkpoints onto the new
471                // length leaves `covering` resolving to the *first* of them,
472                // which is the oldest and smallest taint, and in the record
473                // order the front-ends actually write (`Rewrite` then
474                // `Taint`, no message between) that under-taints every
475                // rewritten message — a compacting run that read a hostile
476                // page would classify clean. Dropping fails the right way
477                // twice over: `merged` is cumulative, so the checkpoint the
478                // run writes after the rewrite carries everything the dropped
479                // ones knew and covers the rewritten head with it; and a file
480                // torn before that checkpoint leaves the head covered by
481                // nothing, which `covering` reports as unknown — never clean.
482                Record::Rewrite { messages: m } => {
483                    messages = m.len();
484                    checkpoints.clear();
485                }
486                Record::Taint(t) => {
487                    merged.merge(t);
488                    checkpoints.push((messages, merged));
489                }
490                _ => {}
491            }
492        }
493        TaintTimeline { checkpoints }
494    }
495
496    /// The merged taint covering the message at `index`, or `None` when no
497    /// checkpoint was written after it — a torn transcript, or one recorded
498    /// before taint was. The caller must treat `None` as *unknown*, and
499    /// unknown provenance is never clean.
500    pub fn covering(&self, index: usize) -> Option<Taint> {
501        self.checkpoints
502            .iter()
503            .find(|(n, _)| *n > index)
504            .map(|(_, t)| *t)
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::message::Block;
512
513    fn tmpdir() -> PathBuf {
514        let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
515        std::fs::create_dir_all(&dir).unwrap();
516        dir
517    }
518
519    fn meta_with_id(id: &str) -> SessionMeta {
520        SessionMeta {
521            id: id.to_string(),
522            created_at: Utc::now(),
523            provider: "scripted".into(),
524            model: "test-model".into(),
525            workspace: PathBuf::from("/tmp"),
526            title: None,
527        }
528    }
529
530    #[test]
531    fn a_transcript_round_trips_its_messages_and_its_taint() {
532        let dir = tmpdir();
533        let session = Session::create(&dir, meta_with_id("20260101T000000-round")).unwrap();
534        session
535            .append_messages(&[
536                Message::user("summarise this page"),
537                Message::assistant(vec![Block::text("done")]),
538            ])
539            .unwrap();
540        session
541            .append(&Record::Taint(Taint {
542                private: true,
543                untrusted: true,
544            }))
545            .unwrap();
546
547        let (meta, convo) = Session::load(&session.path).unwrap();
548
549        assert_eq!(meta.model, "test-model");
550        assert_eq!(convo.messages.len(), 2);
551        assert_eq!(convo.messages[0].text(), "summarise this page");
552        assert_eq!(convo.messages[1].text(), "done");
553        // The whole point of recording it: provenance cannot be recovered by
554        // re-reading the content, so a resumed conversation that had read a
555        // hostile page must come back with the interlock still armed.
556        assert!(convo.taint.trifecta_armed());
557
558        std::fs::remove_dir_all(&dir).ok();
559    }
560
561    #[test]
562    fn record_run_appends_the_tail_when_the_run_only_appended() {
563        let dir = tmpdir();
564        let session = Session::create(&dir, meta_with_id("20260101T000000-tail")).unwrap();
565        let before = vec![Message::user("go")];
566        session.append_messages(&before).unwrap();
567
568        let mut after = Conversation::from(before.clone());
569        after.push(Message::assistant(vec![Block::text("done")]));
570        session.record_run(&before, &after).unwrap();
571
572        let (_, convo) = Session::load(&session.path).unwrap();
573        assert_eq!(convo.messages.len(), 2);
574        assert_eq!(convo.messages[1].text(), "done");
575        // And no rewrite record for the ordinary case: the file stays a plain
576        // append log unless the run actually rewrote history.
577        let text = std::fs::read_to_string(&session.path).unwrap();
578        assert!(!text.contains("\"record\":\"rewrite\""), "{text}");
579
580        std::fs::remove_dir_all(&dir).ok();
581    }
582
583    #[test]
584    fn record_run_records_a_rewrite_when_compaction_touched_the_head() {
585        // The regression this pins, from a 2026-08-07 benchmark transcript:
586        // a compacted run recorded via the append-only slice kept the stale
587        // head and skipped the rebuilt one, so the file held 8 assistant
588        // turns of a 28-turn run, beginning mid-conversation, with no sign a
589        // compaction had happened. Resuming that transcript resumes a
590        // conversation the run never had.
591        let dir = tmpdir();
592        let session = Session::create(&dir, meta_with_id("20260101T000000-rw")).unwrap();
593        let before = vec![Message::user("go")];
594        session.append_messages(&before).unwrap();
595
596        // What compaction leaves behind: the head rewritten in place
597        // (instruction plus summary), then the surviving tail.
598        let mut head = before[0].clone();
599        head.content
600            .push(Block::text("[Earlier turns were compacted]"));
601        let after = Conversation::from(vec![head, Message::assistant(vec![Block::text("done")])]);
602        session.record_run(&before, &after).unwrap();
603
604        let (_, convo) = Session::load(&session.path).unwrap();
605        assert_eq!(convo.messages.len(), 2);
606        assert!(
607            convo.messages[0].text().contains("compacted"),
608            "the rebuilt head must be what loads: {:?}",
609            convo.messages[0].text()
610        );
611        assert_eq!(convo.messages[1].text(), "done");
612
613        std::fs::remove_dir_all(&dir).ok();
614    }
615
616    /// The gap this closes: a run long enough to compact *itself* produced
617    /// turns the file never saw — the front-end records at run end, and the
618    /// rewrite record carries only what survived. With the pre-rewrite states
619    /// walked first, the dropped turn is in the file (where `recall` searches
620    /// the union) while `load` still returns only the final state.
621    #[test]
622    fn record_run_walks_the_states_a_mid_run_rewrite_replaced() {
623        let dir = tmpdir();
624        let session = Session::create(&dir, meta_with_id("20260101T000000-midrun")).unwrap();
625        let before = vec![Message::user("go")];
626        session.append_messages(&before).unwrap();
627
628        // The state the run reached before compaction: the opening message
629        // plus a turn holding the detail the summary will drop.
630        let mut reached = before.clone();
631        reached.push(Message::assistant(vec![Block::text(
632            "the magic number is 74656",
633        )]));
634        // What compaction left, then one more turn on top of it.
635        let compacted = vec![
636            Message::user("[summary: a number was computed]"),
637            Message::assistant(vec![Block::text("done")]),
638        ];
639        let mut convo = Conversation::from(compacted);
640        convo.rewritten = vec![reached];
641
642        session.record_run(&before, &convo).unwrap();
643
644        // Loading replays to the final state — the summary, not the head.
645        let (_, loaded) = Session::load(&session.path).unwrap();
646        assert_eq!(loaded.messages.len(), 2);
647        assert!(loaded.messages[0].text().contains("summary"));
648
649        // And the dropped detail is in the file all the same, which is what
650        // recall's union over every recorded message reads back.
651        let text = std::fs::read_to_string(&session.path).unwrap();
652        assert!(
653            text.contains("74656"),
654            "the pre-rewrite turn never reached the file: {text}"
655        );
656
657        std::fs::remove_dir_all(&dir).ok();
658    }
659
660    #[test]
661    fn a_rewrite_drops_stale_taint_positions_instead_of_shadowing_later_ones() {
662        // The record order the front-ends actually write, across two runs of
663        // one chat session: run 1's messages and its clean checkpoint, then
664        // run 2 compacts (a rewrite, shrinking the list) after reading a
665        // hostile page, and checkpoints — `Rewrite` then `Taint`, with no
666        // message record between. A stale checkpoint kept in any form sits
667        // at-or-before the new length, and `covering` takes the *first*
668        // checkpoint past an index, so keeping it hands every rewritten
669        // message the older, clean taint — under-tainting, the one direction
670        // the timeline must never be wrong in.
671        let msg = || Message::user("m");
672        let mut records: Vec<Record> = (0..10).map(|_| Record::Message(msg())).collect();
673        records.push(Record::Taint(Taint {
674            private: true,
675            untrusted: false,
676        }));
677        records.push(Record::Rewrite {
678            messages: vec![msg(), msg()],
679        });
680        records.push(Record::Taint(Taint {
681            private: true,
682            untrusted: true,
683        }));
684
685        let timeline = TaintTimeline::from_records(records);
686        // Every position in the rewritten list is covered by the post-rewrite
687        // checkpoint, which merged the dropped one's taint — over-taint,
688        // never under.
689        for index in 0..2 {
690            let covering = timeline.covering(index).expect("a checkpoint covers it");
691            assert!(
692                covering.untrusted,
693                "message {index} classified by a stale pre-rewrite checkpoint"
694            );
695            assert!(covering.private, "the dropped checkpoint's taint was lost");
696        }
697    }
698
699    #[test]
700    fn a_transcript_torn_after_a_rewrite_reports_unknown_not_clean() {
701        // The process died between writing the rewrite and its taint
702        // checkpoint. Nothing covers the rewritten messages, and `covering`
703        // must say so — the learning classifier treats unknown as untrusted,
704        // and a clean answer here would be the laundering path.
705        let msg = || Message::user("m");
706        let records = vec![
707            Record::Message(msg()),
708            Record::Taint(Taint {
709                private: true,
710                untrusted: true,
711            }),
712            Record::Rewrite {
713                messages: vec![msg(), msg()],
714            },
715        ];
716        let timeline = TaintTimeline::from_records(records);
717        assert_eq!(timeline.covering(0), None);
718        assert_eq!(timeline.covering(1), None);
719    }
720
721    #[test]
722    fn taint_records_merge_so_a_later_clean_one_cannot_disarm_the_interlock() {
723        let dir = tmpdir();
724        let session = Session::create(&dir, meta_with_id("20260101T000000-merge")).unwrap();
725
726        // The order a real run writes them in: one leg arrives, then the other,
727        // and the loop may checkpoint again with nothing new to say.
728        session
729            .append(&Record::Taint(Taint {
730                untrusted: true,
731                private: false,
732            }))
733            .unwrap();
734        session
735            .append(&Record::Taint(Taint {
736                private: true,
737                untrusted: false,
738            }))
739            .unwrap();
740        session.append(&Record::Taint(Taint::default())).unwrap();
741
742        let (_, convo) = Session::load(&session.path).unwrap();
743
744        // Replacing rather than merging would leave this clean, and resuming
745        // would hand the model the attacker's page with the guard switched off.
746        assert!(convo.taint.private, "an earlier private leg was dropped");
747        assert!(
748            convo.taint.untrusted,
749            "an earlier untrusted leg was dropped"
750        );
751        assert!(convo.taint.trifecta_armed());
752
753        std::fs::remove_dir_all(&dir).ok();
754    }
755
756    #[test]
757    fn a_transcript_written_before_taint_was_recorded_loads_clean() {
758        let dir = tmpdir();
759        let session = Session::create(&dir, meta_with_id("20260101T000000-old")).unwrap();
760        session.append_messages(&[Message::user("hello")]).unwrap();
761
762        let (_, convo) = Session::load(&session.path).unwrap();
763
764        assert_eq!(convo.messages.len(), 1);
765        assert!(!convo.taint.private);
766        assert!(!convo.taint.untrusted);
767
768        std::fs::remove_dir_all(&dir).ok();
769    }
770
771    #[test]
772    fn a_truncated_final_line_does_not_lose_the_rest_of_the_transcript() {
773        use std::io::Write;
774        let dir = tmpdir();
775        let session = Session::create(&dir, meta_with_id("20260101T000000-killed")).unwrap();
776        session.append_messages(&[Message::user("first")]).unwrap();
777        session
778            .append(&Record::Taint(Taint {
779                private: true,
780                untrusted: false,
781            }))
782            .unwrap();
783
784        // What a killed process leaves behind: a half-written final record.
785        let mut file = std::fs::OpenOptions::new()
786            .append(true)
787            .open(&session.path)
788            .unwrap();
789        write!(file, "{{\"record\":\"message\",\"role\":\"assis").unwrap();
790        drop(file);
791
792        let (_, convo) = Session::load(&session.path).unwrap();
793
794        assert_eq!(convo.messages.len(), 1);
795        assert_eq!(convo.messages[0].text(), "first");
796        assert!(
797            convo.taint.private,
798            "a torn last line lost the taint before it"
799        );
800
801        std::fs::remove_dir_all(&dir).ok();
802    }
803
804    #[test]
805    fn run_configs_come_back_in_order_one_per_attach() {
806        let dir = tmpdir();
807        let session = Session::create(&dir, meta_with_id("20260101T000000-cfg")).unwrap();
808
809        // What a resume under different flags looks like on disk.
810        let first = RunConfig {
811            compact_at_tokens: None,
812            ..RunConfig::default()
813        };
814        let second = RunConfig {
815            compact_at_tokens: Some(1200),
816            ..RunConfig::default()
817        };
818        session.append(&Record::Config(first)).unwrap();
819        session
820            .append_messages(&[Message::user("first run")])
821            .unwrap();
822        session.append(&Record::Config(second)).unwrap();
823
824        let configs = Session::run_configs(&session.path).unwrap();
825
826        assert_eq!(configs.len(), 2, "one record per attach, in order");
827        assert_eq!(configs[0].compact_at_tokens, None);
828        // The turns before and after are not comparable, and only a per-attach
829        // record can say where the line is.
830        assert_eq!(configs[1].compact_at_tokens, Some(1200));
831
832        // And the messages still load, unbothered by the new record type.
833        let (_, convo) = Session::load(&session.path).unwrap();
834        assert_eq!(convo.messages.len(), 1);
835
836        std::fs::remove_dir_all(&dir).ok();
837    }
838
839    #[test]
840    fn a_transcript_recorded_before_this_existed_reports_no_configs() {
841        // Not an error: it is the honest answer, and it is what tells a replay
842        // driver the recording cannot be reproduced faithfully.
843        let dir = tmpdir();
844        let session = Session::create(&dir, meta_with_id("20260101T000000-legacy")).unwrap();
845        session.append_messages(&[Message::user("hello")]).unwrap();
846
847        assert!(Session::run_configs(&session.path).unwrap().is_empty());
848
849        std::fs::remove_dir_all(&dir).ok();
850    }
851
852    #[test]
853    fn the_taint_timeline_covers_each_message_with_its_runs_checkpoint() {
854        let dir = tmpdir();
855        let session = Session::create(&dir, meta_with_id("20260101T000000-tl")).unwrap();
856
857        // Run one: clean. Its checkpoint lands after its messages.
858        session
859            .append_messages(&[Message::user("list the files")])
860            .unwrap();
861        session
862            .append_messages(&[Message::assistant(vec![Block::text("done")])])
863            .unwrap();
864        session.append(&Record::Taint(Taint::default())).unwrap();
865        // Run two: a hostile page enters; the checkpoint records it.
866        session
867            .append_messages(&[Message::user("fetch that page")])
868            .unwrap();
869        session
870            .append_messages(&[Message::assistant(vec![Block::text("fetched")])])
871            .unwrap();
872        session
873            .append(&Record::Taint(Taint {
874                untrusted: true,
875                private: false,
876            }))
877            .unwrap();
878
879        let tl = Session::taint_timeline(&session.path).unwrap();
880
881        // Messages 0–1 are covered by the clean checkpoint...
882        assert!(!tl.covering(0).unwrap().untrusted);
883        assert!(!tl.covering(1).unwrap().untrusted);
884        // ...2–3 by the armed one. Over-tainting within a run is the safe
885        // direction: a fetch later in the same run counts against a message
886        // before it, never the reverse.
887        assert!(tl.covering(2).unwrap().untrusted);
888        assert!(tl.covering(3).unwrap().untrusted);
889        // Beyond the last checkpoint is unknown, and unknown is the caller's
890        // cue to fail closed.
891        assert_eq!(tl.covering(4).map(|t| t.untrusted), None);
892
893        std::fs::remove_dir_all(&dir).ok();
894    }
895
896    #[test]
897    fn a_pre_taint_transcript_has_an_empty_timeline() {
898        // Sessions recorded before taint existed can establish nothing, so
899        // every position must come back None — which classification turns
900        // into Untrusted, never Clean.
901        let dir = tmpdir();
902        let session = Session::create(&dir, meta_with_id("20260101T000000-notl")).unwrap();
903        session.append_messages(&[Message::user("hello")]).unwrap();
904
905        let tl = Session::taint_timeline(&session.path).unwrap();
906        assert!(tl.covering(0).is_none());
907
908        std::fs::remove_dir_all(&dir).ok();
909    }
910
911    #[test]
912    fn listing_reads_only_the_first_record_and_skips_files_without_a_header() {
913        let dir = tmpdir();
914        let session = Session::create(&dir, meta_with_id("20260101T000000-peek")).unwrap();
915        session.append_messages(&[Message::user("hello")]).unwrap();
916
917        // A stray JSONL file whose first record is not a header is skipped —
918        // the contract is now explicitly "the header is the first record",
919        // which is where `create` writes it; buried headers no longer count,
920        // and that is the price of listing without parsing every transcript.
921        let stray = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
922        let meta = serde_json::to_string(&Record::Meta(meta_with_id("buried"))).unwrap();
923        std::fs::write(dir.join("stray.jsonl"), format!("{stray}\n{meta}\n")).unwrap();
924
925        let listed = Session::list(&dir).unwrap();
926        assert_eq!(listed.len(), 1);
927        assert_eq!(listed[0].0.id, "20260101T000000-peek");
928
929        // And the peek agrees with the full load about what the header says.
930        let peeked = Session::peek_meta(&session.path).unwrap();
931        let (loaded, _) = Session::load(&session.path).unwrap();
932        assert_eq!(peeked.id, loaded.id);
933        assert_eq!(peeked.model, loaded.model);
934
935        std::fs::remove_dir_all(&dir).ok();
936    }
937
938    #[test]
939    fn usage_totals_sum_every_run_and_report_zero_for_a_summaryless_file() {
940        let dir = tmpdir();
941        let session = Session::create(&dir, meta_with_id("20260101T000000-usage")).unwrap();
942
943        // No summary yet — a run that died mid-flight. Zero, not an error.
944        assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
945
946        // Two runs on one session (chat, resume): the totals are the sum.
947        session
948            .append(&Record::Summary {
949                usage: Usage {
950                    input_tokens: 100,
951                    output_tokens: 10,
952                    ..Default::default()
953                },
954                turns: 2,
955            })
956            .unwrap();
957        session
958            .append(&Record::Summary {
959                usage: Usage {
960                    input_tokens: 50,
961                    output_tokens: 5,
962                    ..Default::default()
963                },
964                turns: 1,
965            })
966            .unwrap();
967
968        let (usage, turns) = Session::usage_totals(&session.path).unwrap();
969        assert_eq!(usage.input_tokens, 150);
970        assert_eq!(usage.output_tokens, 15);
971        assert_eq!(turns, 3);
972
973        std::fs::remove_dir_all(&dir).ok();
974    }
975
976    #[cfg(unix)]
977    #[test]
978    fn the_session_directory_is_owner_only() {
979        use std::os::unix::fs::PermissionsExt;
980        // A fresh path, so `create` makes the directory itself.
981        let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
982        Session::create(&dir, meta_with_id("20260101T000000-perms")).unwrap();
983
984        // Transcripts hold whatever the tools returned — mail bodies
985        // included — so the directory gets the token-file rule.
986        let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
987        assert_eq!(mode & 0o777, 0o700);
988
989        std::fs::remove_dir_all(&dir).ok();
990    }
991
992    #[test]
993    fn a_transcript_with_no_header_is_refused() {
994        let dir = tmpdir();
995        let path = dir.join("headerless.jsonl");
996        let line = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
997        std::fs::write(&path, format!("{line}\n")).unwrap();
998
999        let err = Session::load(&path).unwrap_err().to_string();
1000        assert!(err.contains("no session header"), "unexpected error: {err}");
1001
1002        std::fs::remove_dir_all(&dir).ok();
1003    }
1004
1005    #[test]
1006    fn an_ambiguous_id_prefix_is_an_error_rather_than_a_guess() {
1007        let dir = tmpdir();
1008        Session::create(&dir, meta_with_id("20260101T000000-aaaaaaaa")).unwrap();
1009        Session::create(&dir, meta_with_id("20260101T000000-bbbbbbbb")).unwrap();
1010
1011        let err = Session::find(&dir, "20260101").unwrap_err().to_string();
1012        assert!(
1013            err.contains("matches 2 sessions"),
1014            "unexpected error: {err}"
1015        );
1016
1017        // A full id still resolves, and resuming the wrong transcript is the
1018        // failure being guarded against.
1019        let path = Session::find(&dir, "20260101T000000-aaaaaaaa").unwrap();
1020        assert!(path.ends_with("20260101T000000-aaaaaaaa.jsonl"));
1021
1022        assert!(Session::find(&dir, "nothing-like-this").is_err());
1023
1024        std::fs::remove_dir_all(&dir).ok();
1025    }
1026}