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    /// How the run went, as distinct from what it said.
27    ///
28    /// [`Summary`] answers "what did this cost"; this answers "did it work".
29    /// The distinction earns a second record because the audience is
30    /// different: cost is for a person reading `sessions show`, and this is
31    /// for a machine reading many sessions at once — the sensor a
32    /// harness-improvement loop needs and did not have.
33    ///
34    /// The gap it closes is that [`crate::agent::RunOutcome`] carries fifteen
35    /// fields and the transcript kept two of them, so every chat, TUI and
36    /// Slack run was *less* observable than a trigger, whose ledger recorded
37    /// the rest. The signal was already computed; it was thrown away at the
38    /// end of every interactive run.
39    ///
40    /// [`Summary`]: Record::Summary
41    Outcome(RunStats),
42    /// Everything that shaped the request, written each time a process
43    /// attaches to the session — on creation and again on every resume.
44    ///
45    /// Not folded into the header, because a session resumed under different
46    /// flags would make a header written at creation a lie about every turn
47    /// after the first. Within one process the configuration cannot change, so
48    /// one record per attach is exactly the granularity that can differ.
49    Config(RunConfig),
50    /// What had entered the conversation by this point.
51    ///
52    /// Recorded because it cannot be recovered by reading the transcript back:
53    /// taint keys off *provenance* — whether a result actually came from
54    /// outside — and the transcript stores only the content. Without this,
55    /// resuming a session that had read a hostile page would hand the model
56    /// that page again with the interlock disarmed.
57    Taint(Taint),
58    /// The conversation's messages were rewritten in place — compaction
59    /// summarised the head, eviction replaced a stale result, thinning
60    /// shortened an old one. An append-only file cannot express an in-place
61    /// rewrite as more `Message` records: slicing "what the run added" off
62    /// the end of a rewritten list skips the rebuilt head, which is exactly
63    /// where the compaction summary lives, and every trace of the rewrite
64    /// with it — a 2026-08-07 benchmark transcript recorded 8 assistant turns
65    /// of a 28-turn run that way, starting mid-conversation with no sign a
66    /// compaction had ever happened. So the record carries the whole current
67    /// list, and [`Session::load`] replaces what it has accumulated so far.
68    Rewrite {
69        messages: Vec<Message>,
70    },
71}
72
73/// What a run was configured with, recorded so it can be replayed.
74///
75/// The rule behind the field list: **anything that shapes the request or
76/// constrains the run is a confound if it is not recorded.** That is not
77/// theoretical here — compaction on versus off measured 1/5 against 5/5 on the
78/// same task, so a replay that did not know whether compaction was enabled
79/// would compare two incomparable runs and report a model regression.
80///
81/// The system prompt is stored in full rather than hashed. A hash tells you
82/// only *that* something differed; the text lets a replay rebuild the request.
83/// It is no more sensitive than the transcript sitting beside it.
84///
85/// The sampler is recorded only as far as it is pinned: `temperature` and
86/// `seed` hold what this process *sent*, and `None` means the server chose.
87/// Replay against an unpinned run has to be pass@k-shaped rather than
88/// exact-match-shaped; against a pinned, seeded run driven sequentially it can
89/// expect to match. (Not greedy — temperature 0.0 walks qwen3.6 into verbatim
90/// repetition loops. And only sequentially: llama-server's continuous batching
91/// makes concurrent requests perturb each other's numerics, seed or no seed.)
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(default)]
94pub struct RunConfig {
95    /// Which harness produced this. The axis every replay diff is measured on.
96    pub mecha_version: String,
97    pub provider: String,
98    pub model: String,
99    pub workspace: PathBuf,
100    /// The resolved text, not the path it may have come from.
101    pub system_prompt: Option<String>,
102    /// Tool names in registry order — which is the order they are sent, and the
103    /// front of the cached prefix. A tool added, removed or renamed between
104    /// recording and replay changes what the model could have done.
105    pub tools: Vec<String>,
106
107    // What the request looks like.
108    pub effort: Option<Effort>,
109    /// The temperature and seed actually sent, when the provider config pins
110    /// them. Unset means the server chose, and the run is not repeatable.
111    pub temperature: Option<f64>,
112    pub seed: Option<u64>,
113    pub thinking: bool,
114    /// No effect on semantics; large effect on the token counts a replay diffs.
115    pub cache_prompt: bool,
116    pub max_tokens: u32,
117
118    // Ceilings. A run that hit one looks exactly like a model that gave up.
119    pub max_turns: u32,
120    pub max_output_tokens: Option<u64>,
121    pub max_cost_usd: Option<f64>,
122    pub compact_at_tokens: Option<u64>,
123    pub compact_keep_recent: usize,
124
125    // Policy: what the model was allowed to do at all.
126    /// A denied call redirects the whole trajectory, so replaying a read-only
127    /// session under `--yes` compares nothing.
128    pub permission_mode: PermissionMode,
129    pub trifecta: TrifectaPolicy,
130    /// `none` | `bwrap` | `docker` | `landlock`. Load-bearing beyond the
131    /// obvious: `shell` declares *narrower* capabilities when confined, and
132    /// the interlock believes them, so the same prompt can be refused in one
133    /// and allowed in the other. (`landlock` never narrows `external_send` —
134    /// see the sandbox module — so it patterns with `none` for the interlock
135    /// while still confining files.)
136    pub sandbox: String,
137    pub sandbox_network: bool,
138}
139
140impl Default for RunConfig {
141    fn default() -> Self {
142        RunConfig {
143            mecha_version: String::new(),
144            provider: String::new(),
145            model: String::new(),
146            workspace: PathBuf::new(),
147            system_prompt: None,
148            tools: Vec::new(),
149            effort: None,
150            temperature: None,
151            seed: None,
152            thinking: false,
153            cache_prompt: false,
154            max_tokens: 0,
155            max_turns: 0,
156            max_output_tokens: None,
157            max_cost_usd: None,
158            compact_at_tokens: None,
159            compact_keep_recent: 0,
160            permission_mode: PermissionMode::Ask,
161            trifecta: TrifectaPolicy::Block,
162            sandbox: "none".into(),
163            sandbox_network: false,
164        }
165    }
166}
167
168impl RunConfig {
169    /// Read it off the built agent rather than off the config file, so what is
170    /// recorded is what is actually being sent — flags, layered TOML and
171    /// defaults already resolved.
172    pub fn of(agent: &Agent, config: &Config, provider: &str) -> Self {
173        let cfg = agent.config();
174        RunConfig {
175            mecha_version: crate::VERSION.to_string(),
176            provider: provider.to_string(),
177            model: agent.model().to_string(),
178            workspace: agent.ctx().workspace.clone(),
179            system_prompt: agent.system().map(str::to_string),
180            tools: agent
181                .registry()
182                .iter()
183                .map(|t| t.name().to_string())
184                .collect(),
185            effort: cfg.effort,
186            temperature: config.providers.get(provider).and_then(|p| p.temperature),
187            seed: config.providers.get(provider).and_then(|p| p.seed),
188            thinking: cfg.thinking,
189            cache_prompt: cfg.cache_prompt,
190            max_tokens: cfg.max_tokens,
191            max_turns: cfg.max_turns,
192            max_output_tokens: cfg.max_output_tokens,
193            max_cost_usd: cfg.max_cost_usd,
194            compact_at_tokens: cfg.compact_at_tokens,
195            compact_keep_recent: cfg.compact_keep_recent,
196            permission_mode: config.tools.permission_mode,
197            trifecta: config.security.trifecta,
198            sandbox: config.sandbox.kind.as_str().to_string(),
199            sandbox_network: config.sandbox.network,
200        }
201    }
202}
203
204/// How a run went, in numbers a machine can compare across sessions.
205///
206/// Every field is a deterministic count taken from
207/// [`crate::agent::RunOutcome`] — nothing here is a model's opinion, and
208/// nothing is derived from the *content* of a tool result. That is the
209/// property that lets this be an input to automated grading: a counter
210/// carries no instructions, so a corpus of these cannot be an injection
211/// surface the way a corpus of transcript excerpts would be.
212///
213/// Every field defaults, so a session written before this record existed
214/// loads, and a field added later does not invalidate the ones already
215/// recorded.
216#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217pub struct RunStats {
218    #[serde(default)]
219    pub turns: u32,
220    #[serde(default)]
221    pub usage: Usage,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub cost_usd: Option<f64>,
224    /// False when `usage` is a lower bound rather than a measurement.
225    #[serde(default)]
226    pub usage_complete: bool,
227    /// Why the loop stopped. The single most informative field here: it
228    /// separates "the model decided it was done" from every way the harness
229    /// cut it short, and none of that is visible in the answer text.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub stop_cause: Option<crate::agent::StopCause>,
232    #[serde(default)]
233    pub exhausted: bool,
234    /// The model stopped of its own accord with its last call failed.
235    #[serde(default)]
236    pub ended_on_failed_call: bool,
237    /// Tool calls attempted, and how they went. `errors` counts the
238    /// environment refusing (including a call to a tool that does not exist);
239    /// `denied` counts a human or a policy refusing, which is the harness
240    /// working and must not be averaged in with failure.
241    #[serde(default)]
242    pub tool_calls: u32,
243    #[serde(default)]
244    pub tool_errors: u32,
245    #[serde(default)]
246    pub tool_denied: u32,
247    #[serde(default)]
248    pub tool_staged: u32,
249    #[serde(default)]
250    pub malformed_tool_args: u32,
251    #[serde(default)]
252    pub blocked_sends: u32,
253    #[serde(default)]
254    pub compactions: u32,
255    /// What had entered the conversation by the end. Recorded here as well as
256    /// in [`Record::Taint`] because this record is read on its own, by a
257    /// reader that is counting rather than reconstructing.
258    #[serde(default)]
259    pub taint: Taint,
260}
261
262impl RunStats {
263    /// Fold another run's outcome in.
264    ///
265    /// An *episode* — a replayed session, a multi-turn eval case, a batch item
266    /// — is several runs on one conversation, and is one row. Counters sum,
267    /// because the episode really did spend all of it. Three fields do not,
268    /// and the split is the whole reason this is a method rather than a loop
269    /// at each call site:
270    ///
271    /// - `stop_cause`, `exhausted` and `ended_on_failed_call` describe how the
272    ///   episode *ended*, so the last run wins. An episode whose first turn
273    ///   ended on a failure and whose second recovered has not finished over
274    ///   a failure, and summing would say it had.
275    /// - `taint` merges and never resets: it is a property of the
276    ///   conversation, and a later clean run does not un-read what an earlier
277    ///   one read.
278    /// - `usage_complete` is an AND: one lower-bound turn makes the total a
279    ///   lower bound.
280    pub fn absorb(&mut self, o: &crate::agent::RunOutcome) {
281        self.turns += o.turns;
282        self.usage.add(&o.usage);
283        self.cost_usd = match (self.cost_usd, o.cost_usd) {
284            (Some(a), Some(b)) => Some(a + b),
285            (a, b) => a.or(b),
286        };
287        self.usage_complete &= o.usage_complete;
288        self.stop_cause = Some(o.stop_cause);
289        self.exhausted = o.exhausted;
290        self.ended_on_failed_call = o.ended_on_failed_call;
291        self.tool_calls += o.tool_calls.len() as u32;
292        // `denied` is excluded, and the exclusion has to be written out: a
293        // denied trace carries `is_error: true` too, so filtering on
294        // `is_error` alone counts every refusal as an environment failure and
295        // averages "the harness working" into the rate the candidate gate and
296        // doctor both threshold on.
297        self.tool_errors += o
298            .tool_calls
299            .iter()
300            .filter(|c| c.unknown || (c.is_error && !c.denied))
301            .count() as u32;
302        self.tool_denied += o.tool_calls.iter().filter(|c| c.denied).count() as u32;
303        self.tool_staged += o.tool_calls.iter().filter(|c| c.staged).count() as u32;
304        self.malformed_tool_args += o.malformed_tool_args;
305        self.blocked_sends += o.blocked_sends;
306        self.compactions += o.compactions;
307        self.taint.merge(o.taint);
308    }
309}
310
311impl From<&crate::agent::RunOutcome> for RunStats {
312    fn from(o: &crate::agent::RunOutcome) -> Self {
313        // `usage_complete` starts true and is ANDed down, so the default's
314        // `false` would make every single-run row a lower bound.
315        let mut stats = RunStats {
316            usage_complete: true,
317            ..RunStats::default()
318        };
319        stats.absorb(o);
320        stats
321    }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct SessionMeta {
326    pub id: String,
327    pub created_at: DateTime<Utc>,
328    pub provider: String,
329    pub model: String,
330    pub workspace: PathBuf,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub title: Option<String>,
333}
334
335pub struct Session {
336    pub meta: SessionMeta,
337    pub path: PathBuf,
338}
339
340impl Session {
341    /// Where transcripts live: `~/.mecha/sessions`, or `$MECHA_SESSION_DIR`.
342    pub fn default_dir() -> Result<PathBuf> {
343        if let Ok(dir) = std::env::var("MECHA_SESSION_DIR") {
344            return Ok(PathBuf::from(dir));
345        }
346        Ok(crate::work::mecha_home()?.join("sessions"))
347    }
348
349    pub fn create(dir: &Path, meta: SessionMeta) -> Result<Self> {
350        crate::create_private_dir(dir)
351            .with_context(|| format!("creating session directory {}", dir.display()))?;
352        let path = dir.join(format!("{}.jsonl", meta.id));
353        let session = Session {
354            meta: meta.clone(),
355            path,
356        };
357        session.append(&Record::Meta(meta))?;
358        Ok(session)
359    }
360
361    pub fn new_id() -> String {
362        // Sortable by name, and still unique when two runs start in the same
363        // second.
364        format!(
365            "{}-{}",
366            Utc::now().format("%Y%m%dT%H%M%S"),
367            &uuid::Uuid::new_v4().to_string()[..8]
368        )
369    }
370
371    pub fn append(&self, record: &Record) -> Result<()> {
372        use std::io::Write;
373        let mut file = std::fs::OpenOptions::new()
374            .create(true)
375            .append(true)
376            .open(&self.path)
377            .with_context(|| format!("opening {}", self.path.display()))?;
378        writeln!(file, "{}", serde_json::to_string(record)?)?;
379        Ok(())
380    }
381
382    pub fn append_messages(&self, messages: &[Message]) -> Result<()> {
383        for m in messages {
384            self.append(&Record::Message(m.clone()))?;
385        }
386        Ok(())
387    }
388
389    /// Record what a run did to the conversation, given the messages it
390    /// started from.
391    ///
392    /// `before` must be what the file already holds — every front-end has
393    /// appended the opening user message (and, resumed, the loaded history)
394    /// before the run starts. The walk visits every state the run's rewrites
395    /// replaced ([`Conversation::rewritten`]) and then the final one, so a
396    /// run long enough to compact *itself* still gets its whole head into
397    /// the file: each pre-rewrite snapshot extends the previous recorded
398    /// state append-only (its cheap tail append), and each post-rewrite
399    /// state lands as the [`Record::Rewrite`] the next transition writes.
400    /// The signature takes the conversation rather than a message slice so a
401    /// caller cannot record the destination while skipping the journey.
402    ///
403    /// [`Conversation::rewritten`]: crate::agent::Conversation
404    pub fn record_run(&self, before: &[Message], convo: &Conversation) -> Result<()> {
405        let mut prev: &[Message] = before;
406        for state in &convo.rewritten {
407            self.record_transition(prev, state)?;
408            prev = state;
409        }
410        self.record_transition(prev, &convo.messages)
411    }
412
413    /// Record how the run went, beside what it said.
414    ///
415    /// Separate from [`record_run`] rather than folded into it, because the
416    /// two answer to different failures: a run that errored mid-flight still
417    /// has messages worth keeping and no outcome to describe, and a caller
418    /// that has an outcome always has it *after* the transcript is safe.
419    /// Deliberately takes the whole outcome rather than the fields, so a new
420    /// counter reaches every front-end by upgrading rather than by
421    /// remembering to thread it through six call sites.
422    ///
423    /// [`record_run`]: Session::record_run
424    pub fn record_outcome(&self, outcome: &crate::agent::RunOutcome) -> Result<()> {
425        self.append(&Record::Outcome(RunStats::from(outcome)))
426    }
427
428    /// Every outcome recorded in a transcript, in order, with the model and
429    /// provider that were in effect when it was written.
430    ///
431    /// Not the session header: the TUI can switch model mid-session and
432    /// records a `Config` when it does, so attributing every run to the
433    /// header would credit the second model's work to the first — and defeat
434    /// the per-model split in exactly the case where blending actually
435    /// happens. Falls back to the header when no `Config` precedes the row,
436    /// which is what an older transcript looks like.
437    pub fn outcomes_attributed(path: &Path) -> Result<Vec<(String, String, RunStats)>> {
438        let text =
439            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
440        let mut provider = String::new();
441        let mut model = String::new();
442        let mut out = Vec::new();
443        for line in text.lines().filter(|l| !l.trim().is_empty()) {
444            match serde_json::from_str(line) {
445                Ok(Record::Meta(meta)) => {
446                    provider = meta.provider;
447                    model = meta.model;
448                }
449                Ok(Record::Config(cfg)) => {
450                    provider = cfg.provider;
451                    model = cfg.model;
452                }
453                Ok(Record::Outcome(stats)) => out.push((provider.clone(), model.clone(), stats)),
454                _ => {}
455            }
456        }
457        Ok(out)
458    }
459
460    /// Every outcome recorded in a transcript, in order.
461    ///
462    /// One per run, so a resumed session has several. Malformed lines are
463    /// skipped rather than fatal, like every other reader here: a torn line
464    /// is the store's problem and must not cost the rows around it.
465    pub fn outcomes(path: &Path) -> Result<Vec<RunStats>> {
466        let text =
467            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
468        Ok(text
469            .lines()
470            .filter(|l| !l.trim().is_empty())
471            .filter_map(|l| match serde_json::from_str(l) {
472                Ok(Record::Outcome(s)) => Some(s),
473                _ => None,
474            })
475            .collect())
476    }
477
478    /// One before→after step. When the run only appended, the new tail is
479    /// appended here too. When it rewrote what was already recorded —
480    /// compaction, eviction, thinning, all of which edit earlier messages in
481    /// place — a [`Record::Rewrite`] carries the whole current list instead,
482    /// because slicing a rewritten transcript records a lie: the old head
483    /// stays in the file, the rebuilt one (summary included) never lands.
484    ///
485    /// Comparison, not a flag from the loop: any mutation the loop grows
486    /// later is caught by construction, and the clone this costs is one more
487    /// beside the one the loop already pays per request.
488    fn record_transition(&self, before: &[Message], after: &[Message]) -> Result<()> {
489        let appended_only = after.len() >= before.len() && after[..before.len()] == *before;
490        if appended_only {
491            self.append_messages(&after[before.len()..])
492        } else {
493            self.append(&Record::Rewrite {
494                messages: after.to_vec(),
495            })
496        }
497    }
498
499    /// Read a transcript back, taint included.
500    ///
501    /// Unparseable lines are skipped rather than failing the load — a truncated
502    /// final line is the normal result of a killed process.
503    pub fn load(path: &Path) -> Result<(SessionMeta, Conversation)> {
504        let text =
505            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
506
507        let mut meta = None;
508        let mut messages = Vec::new();
509        let mut taint = Taint::default();
510        for line in text.lines().filter(|l| !l.trim().is_empty()) {
511            match serde_json::from_str::<Record>(line) {
512                Ok(Record::Meta(m)) => meta = Some(m),
513                Ok(Record::Message(m)) => messages.push(m),
514                // The conversation state as of the rewrite, wholesale. Taint
515                // is deliberately not touched: summarising away the text of a
516                // hostile page does not un-read it.
517                Ok(Record::Rewrite { messages: m }) => messages = m,
518                // Merged rather than replaced: taint only ever grows, and a
519                // transcript written by an older build has none at all.
520                Ok(Record::Taint(t)) => taint.merge(t),
521                Ok(Record::Summary { .. }) | Ok(Record::Config(_)) | Ok(Record::Outcome(_)) => {}
522                Err(e) => tracing::warn!(error = %e, "skipping malformed transcript line"),
523            }
524        }
525
526        let meta = meta.with_context(|| format!("{} has no session header", path.display()))?;
527        Ok((meta, Conversation::resumed(messages, taint)))
528    }
529
530    /// The taint checkpoints of a transcript, positioned against its messages.
531    ///
532    /// Every front-end appends a `Record::Taint` checkpoint *after* the
533    /// messages of the run it describes, so the checkpoint that covers a
534    /// message is the first one written after it — and by then the taint of
535    /// everything earlier in that run, hostile fetches included, has merged
536    /// in. That ordering is what makes [`TaintTimeline::covering`] safe to
537    /// gate on: it can over-taint a message (a fetch later in the same run
538    /// counts against it), never under-taint one.
539    pub fn taint_timeline(path: &Path) -> Result<TaintTimeline> {
540        let text =
541            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
542        Ok(TaintTimeline::from_records(
543            text.lines()
544                .filter(|l| !l.trim().is_empty())
545                .filter_map(|l| serde_json::from_str::<Record>(l).ok()),
546        ))
547    }
548
549    /// Every run configuration in a transcript, in the order the runs happened.
550    ///
551    /// A replay driver needs this per run rather than per session: resuming
552    /// under different flags is a normal thing to do, and the turns before and
553    /// after are not comparable. An empty result means a transcript written
554    /// before this was recorded — which cannot be replayed faithfully, because
555    /// the system prompt and tool list that shaped it are gone.
556    pub fn run_configs(path: &Path) -> Result<Vec<RunConfig>> {
557        let text =
558            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
559        Ok(text
560            .lines()
561            .filter(|l| !l.trim().is_empty())
562            .filter_map(|l| match serde_json::from_str::<Record>(l) {
563                Ok(Record::Config(c)) => Some(c),
564                _ => None,
565            })
566            .collect())
567    }
568
569    /// The header alone, without parsing the rest of the file.
570    ///
571    /// Listing goes through this rather than [`Session::load`] so `mecha
572    /// sessions` stays O(number of sessions) instead of O(total transcript
573    /// bytes) — with reflect-on-close recording every interaction, the full
574    /// parse re-read the whole store to print one line per file. The header
575    /// is the first record `create` writes; a file whose first record is
576    /// anything else is not a session this process wrote, and is skipped
577    /// exactly as `load`'s no-header error skipped it.
578    pub fn peek_meta(path: &Path) -> Option<SessionMeta> {
579        use std::io::BufRead;
580        let file = std::fs::File::open(path).ok()?;
581        let mut reader = std::io::BufReader::new(file);
582        let mut first = String::new();
583        loop {
584            first.clear();
585            if reader.read_line(&mut first).ok()? == 0 {
586                return None;
587            }
588            if !first.trim().is_empty() {
589                break;
590            }
591        }
592        match serde_json::from_str::<Record>(&first).ok()? {
593            Record::Meta(m) => Some(m),
594            _ => None,
595        }
596    }
597
598    /// The run summaries of a transcript, summed: total usage and turns
599    /// across every run the file records. Zero for a transcript that
600    /// predates the summary record or died before writing one — an honest
601    /// under-count, never a guess.
602    pub fn usage_totals(path: &Path) -> Result<(Usage, u32)> {
603        let text =
604            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
605        let mut usage = Usage::default();
606        let mut turns = 0u32;
607        for line in text.lines().filter(|l| !l.trim().is_empty()) {
608            if let Ok(Record::Summary { usage: u, turns: t }) = serde_json::from_str(line) {
609                usage.add(&u);
610                turns += t;
611            }
612        }
613        Ok((usage, turns))
614    }
615
616    /// Sessions in `dir`, newest first.
617    pub fn list(dir: &Path) -> Result<Vec<(SessionMeta, PathBuf)>> {
618        if !dir.exists() {
619            return Ok(Vec::new());
620        }
621        let mut out = Vec::new();
622        for entry in std::fs::read_dir(dir)? {
623            let path = entry?.path();
624            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
625                continue;
626            }
627            // A transcript with no header is unusable; skip it quietly.
628            if let Some(meta) = Session::peek_meta(&path) {
629                out.push((meta, path));
630            }
631        }
632        out.sort_by_key(|(meta, _)| std::cmp::Reverse(meta.created_at));
633        Ok(out)
634    }
635
636    /// Find a session by full id or unique prefix.
637    pub fn find(dir: &Path, id_prefix: &str) -> Result<PathBuf> {
638        let matches: Vec<_> = Session::list(dir)?
639            .into_iter()
640            .filter(|(m, _)| m.id.starts_with(id_prefix))
641            .collect();
642        match matches.len() {
643            0 => anyhow::bail!("no session matching {id_prefix:?}"),
644            1 => Ok(matches.into_iter().next().unwrap().1),
645            n => anyhow::bail!("{id_prefix:?} matches {n} sessions; use a longer prefix"),
646        }
647    }
648}
649
650/// Where each taint checkpoint sits relative to the messages — built by
651/// [`Session::taint_timeline`], consumed by provenance classification in
652/// `learning`.
653#[derive(Debug, Clone, Default)]
654pub struct TaintTimeline {
655    /// (messages recorded before this checkpoint, taint merged up to it).
656    /// Merged, not raw: taint only grows, so each entry is the union of every
657    /// checkpoint at or before it.
658    checkpoints: Vec<(usize, Taint)>,
659}
660
661impl TaintTimeline {
662    pub fn from_records(records: impl IntoIterator<Item = Record>) -> Self {
663        let mut checkpoints: Vec<(usize, Taint)> = Vec::new();
664        let mut messages = 0usize;
665        let mut merged = Taint::default();
666        for record in records {
667            match record {
668                Record::Message(_) => messages += 1,
669                // The list was replaced, so every position recorded before it
670                // is a claim about a list that no longer exists — drop them.
671                // Not clamp: clamping several stale checkpoints onto the new
672                // length leaves `covering` resolving to the *first* of them,
673                // which is the oldest and smallest taint, and in the record
674                // order the front-ends actually write (`Rewrite` then
675                // `Taint`, no message between) that under-taints every
676                // rewritten message — a compacting run that read a hostile
677                // page would classify clean. Dropping fails the right way
678                // twice over: `merged` is cumulative, so the checkpoint the
679                // run writes after the rewrite carries everything the dropped
680                // ones knew and covers the rewritten head with it; and a file
681                // torn before that checkpoint leaves the head covered by
682                // nothing, which `covering` reports as unknown — never clean.
683                Record::Rewrite { messages: m } => {
684                    messages = m.len();
685                    checkpoints.clear();
686                }
687                Record::Taint(t) => {
688                    merged.merge(t);
689                    checkpoints.push((messages, merged));
690                }
691                _ => {}
692            }
693        }
694        TaintTimeline { checkpoints }
695    }
696
697    /// The merged taint covering the message at `index`, or `None` when no
698    /// checkpoint was written after it — a torn transcript, or one recorded
699    /// before taint was. The caller must treat `None` as *unknown*, and
700    /// unknown provenance is never clean.
701    pub fn covering(&self, index: usize) -> Option<Taint> {
702        self.checkpoints
703            .iter()
704            .find(|(n, _)| *n > index)
705            .map(|(_, t)| *t)
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use crate::message::Block;
713
714    fn tmpdir() -> PathBuf {
715        let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
716        std::fs::create_dir_all(&dir).unwrap();
717        dir
718    }
719
720    fn meta_with_id(id: &str) -> SessionMeta {
721        SessionMeta {
722            id: id.to_string(),
723            created_at: Utc::now(),
724            provider: "scripted".into(),
725            model: "test-model".into(),
726            workspace: PathBuf::from("/tmp"),
727            title: None,
728        }
729    }
730
731    #[test]
732    fn a_transcript_round_trips_its_messages_and_its_taint() {
733        let dir = tmpdir();
734        let session = Session::create(&dir, meta_with_id("20260101T000000-round")).unwrap();
735        session
736            .append_messages(&[
737                Message::user("summarise this page"),
738                Message::assistant(vec![Block::text("done")]),
739            ])
740            .unwrap();
741        session
742            .append(&Record::Taint(Taint {
743                private: true,
744                untrusted: true,
745            }))
746            .unwrap();
747
748        let (meta, convo) = Session::load(&session.path).unwrap();
749
750        assert_eq!(meta.model, "test-model");
751        assert_eq!(convo.messages.len(), 2);
752        assert_eq!(convo.messages[0].text(), "summarise this page");
753        assert_eq!(convo.messages[1].text(), "done");
754        // The whole point of recording it: provenance cannot be recovered by
755        // re-reading the content, so a resumed conversation that had read a
756        // hostile page must come back with the interlock still armed.
757        assert!(convo.taint.trifecta_armed());
758
759        std::fs::remove_dir_all(&dir).ok();
760    }
761
762    #[test]
763    fn record_run_appends_the_tail_when_the_run_only_appended() {
764        let dir = tmpdir();
765        let session = Session::create(&dir, meta_with_id("20260101T000000-tail")).unwrap();
766        let before = vec![Message::user("go")];
767        session.append_messages(&before).unwrap();
768
769        let mut after = Conversation::from(before.clone());
770        after.push(Message::assistant(vec![Block::text("done")]));
771        session.record_run(&before, &after).unwrap();
772
773        let (_, convo) = Session::load(&session.path).unwrap();
774        assert_eq!(convo.messages.len(), 2);
775        assert_eq!(convo.messages[1].text(), "done");
776        // And no rewrite record for the ordinary case: the file stays a plain
777        // append log unless the run actually rewrote history.
778        let text = std::fs::read_to_string(&session.path).unwrap();
779        assert!(!text.contains("\"record\":\"rewrite\""), "{text}");
780
781        std::fs::remove_dir_all(&dir).ok();
782    }
783
784    #[test]
785    fn record_run_records_a_rewrite_when_compaction_touched_the_head() {
786        // The regression this pins, from a 2026-08-07 benchmark transcript:
787        // a compacted run recorded via the append-only slice kept the stale
788        // head and skipped the rebuilt one, so the file held 8 assistant
789        // turns of a 28-turn run, beginning mid-conversation, with no sign a
790        // compaction had happened. Resuming that transcript resumes a
791        // conversation the run never had.
792        let dir = tmpdir();
793        let session = Session::create(&dir, meta_with_id("20260101T000000-rw")).unwrap();
794        let before = vec![Message::user("go")];
795        session.append_messages(&before).unwrap();
796
797        // What compaction leaves behind: the head rewritten in place
798        // (instruction plus summary), then the surviving tail.
799        let mut head = before[0].clone();
800        head.content
801            .push(Block::text("[Earlier turns were compacted]"));
802        let after = Conversation::from(vec![head, Message::assistant(vec![Block::text("done")])]);
803        session.record_run(&before, &after).unwrap();
804
805        let (_, convo) = Session::load(&session.path).unwrap();
806        assert_eq!(convo.messages.len(), 2);
807        assert!(
808            convo.messages[0].text().contains("compacted"),
809            "the rebuilt head must be what loads: {:?}",
810            convo.messages[0].text()
811        );
812        assert_eq!(convo.messages[1].text(), "done");
813
814        std::fs::remove_dir_all(&dir).ok();
815    }
816
817    /// The gap this closes: a run long enough to compact *itself* produced
818    /// turns the file never saw — the front-end records at run end, and the
819    /// rewrite record carries only what survived. With the pre-rewrite states
820    /// walked first, the dropped turn is in the file (where `recall` searches
821    /// the union) while `load` still returns only the final state.
822    #[test]
823    fn record_run_walks_the_states_a_mid_run_rewrite_replaced() {
824        let dir = tmpdir();
825        let session = Session::create(&dir, meta_with_id("20260101T000000-midrun")).unwrap();
826        let before = vec![Message::user("go")];
827        session.append_messages(&before).unwrap();
828
829        // The state the run reached before compaction: the opening message
830        // plus a turn holding the detail the summary will drop.
831        let mut reached = before.clone();
832        reached.push(Message::assistant(vec![Block::text(
833            "the magic number is 74656",
834        )]));
835        // What compaction left, then one more turn on top of it.
836        let compacted = vec![
837            Message::user("[summary: a number was computed]"),
838            Message::assistant(vec![Block::text("done")]),
839        ];
840        let mut convo = Conversation::from(compacted);
841        convo.rewritten = vec![reached];
842
843        session.record_run(&before, &convo).unwrap();
844
845        // Loading replays to the final state — the summary, not the head.
846        let (_, loaded) = Session::load(&session.path).unwrap();
847        assert_eq!(loaded.messages.len(), 2);
848        assert!(loaded.messages[0].text().contains("summary"));
849
850        // And the dropped detail is in the file all the same, which is what
851        // recall's union over every recorded message reads back.
852        let text = std::fs::read_to_string(&session.path).unwrap();
853        assert!(
854            text.contains("74656"),
855            "the pre-rewrite turn never reached the file: {text}"
856        );
857
858        std::fs::remove_dir_all(&dir).ok();
859    }
860
861    #[test]
862    fn a_rewrite_drops_stale_taint_positions_instead_of_shadowing_later_ones() {
863        // The record order the front-ends actually write, across two runs of
864        // one chat session: run 1's messages and its clean checkpoint, then
865        // run 2 compacts (a rewrite, shrinking the list) after reading a
866        // hostile page, and checkpoints — `Rewrite` then `Taint`, with no
867        // message record between. A stale checkpoint kept in any form sits
868        // at-or-before the new length, and `covering` takes the *first*
869        // checkpoint past an index, so keeping it hands every rewritten
870        // message the older, clean taint — under-tainting, the one direction
871        // the timeline must never be wrong in.
872        let msg = || Message::user("m");
873        let mut records: Vec<Record> = (0..10).map(|_| Record::Message(msg())).collect();
874        records.push(Record::Taint(Taint {
875            private: true,
876            untrusted: false,
877        }));
878        records.push(Record::Rewrite {
879            messages: vec![msg(), msg()],
880        });
881        records.push(Record::Taint(Taint {
882            private: true,
883            untrusted: true,
884        }));
885
886        let timeline = TaintTimeline::from_records(records);
887        // Every position in the rewritten list is covered by the post-rewrite
888        // checkpoint, which merged the dropped one's taint — over-taint,
889        // never under.
890        for index in 0..2 {
891            let covering = timeline.covering(index).expect("a checkpoint covers it");
892            assert!(
893                covering.untrusted,
894                "message {index} classified by a stale pre-rewrite checkpoint"
895            );
896            assert!(covering.private, "the dropped checkpoint's taint was lost");
897        }
898    }
899
900    #[test]
901    fn a_transcript_torn_after_a_rewrite_reports_unknown_not_clean() {
902        // The process died between writing the rewrite and its taint
903        // checkpoint. Nothing covers the rewritten messages, and `covering`
904        // must say so — the learning classifier treats unknown as untrusted,
905        // and a clean answer here would be the laundering path.
906        let msg = || Message::user("m");
907        let records = vec![
908            Record::Message(msg()),
909            Record::Taint(Taint {
910                private: true,
911                untrusted: true,
912            }),
913            Record::Rewrite {
914                messages: vec![msg(), msg()],
915            },
916        ];
917        let timeline = TaintTimeline::from_records(records);
918        assert_eq!(timeline.covering(0), None);
919        assert_eq!(timeline.covering(1), None);
920    }
921
922    #[test]
923    fn taint_records_merge_so_a_later_clean_one_cannot_disarm_the_interlock() {
924        let dir = tmpdir();
925        let session = Session::create(&dir, meta_with_id("20260101T000000-merge")).unwrap();
926
927        // The order a real run writes them in: one leg arrives, then the other,
928        // and the loop may checkpoint again with nothing new to say.
929        session
930            .append(&Record::Taint(Taint {
931                untrusted: true,
932                private: false,
933            }))
934            .unwrap();
935        session
936            .append(&Record::Taint(Taint {
937                private: true,
938                untrusted: false,
939            }))
940            .unwrap();
941        session.append(&Record::Taint(Taint::default())).unwrap();
942
943        let (_, convo) = Session::load(&session.path).unwrap();
944
945        // Replacing rather than merging would leave this clean, and resuming
946        // would hand the model the attacker's page with the guard switched off.
947        assert!(convo.taint.private, "an earlier private leg was dropped");
948        assert!(
949            convo.taint.untrusted,
950            "an earlier untrusted leg was dropped"
951        );
952        assert!(convo.taint.trifecta_armed());
953
954        std::fs::remove_dir_all(&dir).ok();
955    }
956
957    #[test]
958    fn a_transcript_written_before_taint_was_recorded_loads_clean() {
959        let dir = tmpdir();
960        let session = Session::create(&dir, meta_with_id("20260101T000000-old")).unwrap();
961        session.append_messages(&[Message::user("hello")]).unwrap();
962
963        let (_, convo) = Session::load(&session.path).unwrap();
964
965        assert_eq!(convo.messages.len(), 1);
966        assert!(!convo.taint.private);
967        assert!(!convo.taint.untrusted);
968
969        std::fs::remove_dir_all(&dir).ok();
970    }
971
972    #[test]
973    fn a_truncated_final_line_does_not_lose_the_rest_of_the_transcript() {
974        use std::io::Write;
975        let dir = tmpdir();
976        let session = Session::create(&dir, meta_with_id("20260101T000000-killed")).unwrap();
977        session.append_messages(&[Message::user("first")]).unwrap();
978        session
979            .append(&Record::Taint(Taint {
980                private: true,
981                untrusted: false,
982            }))
983            .unwrap();
984
985        // What a killed process leaves behind: a half-written final record.
986        let mut file = std::fs::OpenOptions::new()
987            .append(true)
988            .open(&session.path)
989            .unwrap();
990        write!(file, "{{\"record\":\"message\",\"role\":\"assis").unwrap();
991        drop(file);
992
993        let (_, convo) = Session::load(&session.path).unwrap();
994
995        assert_eq!(convo.messages.len(), 1);
996        assert_eq!(convo.messages[0].text(), "first");
997        assert!(
998            convo.taint.private,
999            "a torn last line lost the taint before it"
1000        );
1001
1002        std::fs::remove_dir_all(&dir).ok();
1003    }
1004
1005    #[test]
1006    fn run_configs_come_back_in_order_one_per_attach() {
1007        let dir = tmpdir();
1008        let session = Session::create(&dir, meta_with_id("20260101T000000-cfg")).unwrap();
1009
1010        // What a resume under different flags looks like on disk.
1011        let first = RunConfig {
1012            compact_at_tokens: None,
1013            ..RunConfig::default()
1014        };
1015        let second = RunConfig {
1016            compact_at_tokens: Some(1200),
1017            ..RunConfig::default()
1018        };
1019        session.append(&Record::Config(first)).unwrap();
1020        session
1021            .append_messages(&[Message::user("first run")])
1022            .unwrap();
1023        session.append(&Record::Config(second)).unwrap();
1024
1025        let configs = Session::run_configs(&session.path).unwrap();
1026
1027        assert_eq!(configs.len(), 2, "one record per attach, in order");
1028        assert_eq!(configs[0].compact_at_tokens, None);
1029        // The turns before and after are not comparable, and only a per-attach
1030        // record can say where the line is.
1031        assert_eq!(configs[1].compact_at_tokens, Some(1200));
1032
1033        // And the messages still load, unbothered by the new record type.
1034        let (_, convo) = Session::load(&session.path).unwrap();
1035        assert_eq!(convo.messages.len(), 1);
1036
1037        std::fs::remove_dir_all(&dir).ok();
1038    }
1039
1040    #[test]
1041    fn a_transcript_recorded_before_this_existed_reports_no_configs() {
1042        // Not an error: it is the honest answer, and it is what tells a replay
1043        // driver the recording cannot be reproduced faithfully.
1044        let dir = tmpdir();
1045        let session = Session::create(&dir, meta_with_id("20260101T000000-legacy")).unwrap();
1046        session.append_messages(&[Message::user("hello")]).unwrap();
1047
1048        assert!(Session::run_configs(&session.path).unwrap().is_empty());
1049
1050        std::fs::remove_dir_all(&dir).ok();
1051    }
1052
1053    #[test]
1054    fn the_taint_timeline_covers_each_message_with_its_runs_checkpoint() {
1055        let dir = tmpdir();
1056        let session = Session::create(&dir, meta_with_id("20260101T000000-tl")).unwrap();
1057
1058        // Run one: clean. Its checkpoint lands after its messages.
1059        session
1060            .append_messages(&[Message::user("list the files")])
1061            .unwrap();
1062        session
1063            .append_messages(&[Message::assistant(vec![Block::text("done")])])
1064            .unwrap();
1065        session.append(&Record::Taint(Taint::default())).unwrap();
1066        // Run two: a hostile page enters; the checkpoint records it.
1067        session
1068            .append_messages(&[Message::user("fetch that page")])
1069            .unwrap();
1070        session
1071            .append_messages(&[Message::assistant(vec![Block::text("fetched")])])
1072            .unwrap();
1073        session
1074            .append(&Record::Taint(Taint {
1075                untrusted: true,
1076                private: false,
1077            }))
1078            .unwrap();
1079
1080        let tl = Session::taint_timeline(&session.path).unwrap();
1081
1082        // Messages 0–1 are covered by the clean checkpoint...
1083        assert!(!tl.covering(0).unwrap().untrusted);
1084        assert!(!tl.covering(1).unwrap().untrusted);
1085        // ...2–3 by the armed one. Over-tainting within a run is the safe
1086        // direction: a fetch later in the same run counts against a message
1087        // before it, never the reverse.
1088        assert!(tl.covering(2).unwrap().untrusted);
1089        assert!(tl.covering(3).unwrap().untrusted);
1090        // Beyond the last checkpoint is unknown, and unknown is the caller's
1091        // cue to fail closed.
1092        assert_eq!(tl.covering(4).map(|t| t.untrusted), None);
1093
1094        std::fs::remove_dir_all(&dir).ok();
1095    }
1096
1097    #[test]
1098    fn a_pre_taint_transcript_has_an_empty_timeline() {
1099        // Sessions recorded before taint existed can establish nothing, so
1100        // every position must come back None — which classification turns
1101        // into Untrusted, never Clean.
1102        let dir = tmpdir();
1103        let session = Session::create(&dir, meta_with_id("20260101T000000-notl")).unwrap();
1104        session.append_messages(&[Message::user("hello")]).unwrap();
1105
1106        let tl = Session::taint_timeline(&session.path).unwrap();
1107        assert!(tl.covering(0).is_none());
1108
1109        std::fs::remove_dir_all(&dir).ok();
1110    }
1111
1112    #[test]
1113    fn listing_reads_only_the_first_record_and_skips_files_without_a_header() {
1114        let dir = tmpdir();
1115        let session = Session::create(&dir, meta_with_id("20260101T000000-peek")).unwrap();
1116        session.append_messages(&[Message::user("hello")]).unwrap();
1117
1118        // A stray JSONL file whose first record is not a header is skipped —
1119        // the contract is now explicitly "the header is the first record",
1120        // which is where `create` writes it; buried headers no longer count,
1121        // and that is the price of listing without parsing every transcript.
1122        let stray = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
1123        let meta = serde_json::to_string(&Record::Meta(meta_with_id("buried"))).unwrap();
1124        std::fs::write(dir.join("stray.jsonl"), format!("{stray}\n{meta}\n")).unwrap();
1125
1126        let listed = Session::list(&dir).unwrap();
1127        assert_eq!(listed.len(), 1);
1128        assert_eq!(listed[0].0.id, "20260101T000000-peek");
1129
1130        // And the peek agrees with the full load about what the header says.
1131        let peeked = Session::peek_meta(&session.path).unwrap();
1132        let (loaded, _) = Session::load(&session.path).unwrap();
1133        assert_eq!(peeked.id, loaded.id);
1134        assert_eq!(peeked.model, loaded.model);
1135
1136        std::fs::remove_dir_all(&dir).ok();
1137    }
1138
1139    #[test]
1140    fn an_outcome_record_survives_a_round_trip_and_does_not_disturb_the_transcript() {
1141        use crate::agent::{RunOutcome, StopCause, ToolCallTrace};
1142        use crate::message::StopReason;
1143
1144        let dir = tmpdir();
1145        let session = Session::create(&dir, meta_with_id("20260101T000000-outcome")).unwrap();
1146        session
1147            .append_messages(&[Message::user("go"), Message::assistant(vec![])])
1148            .unwrap();
1149
1150        let call = |is_error: bool, denied: bool, unknown: bool, staged: bool| ToolCallTrace {
1151            name: "fs_edit".into(),
1152            input: serde_json::json!({}),
1153            is_error,
1154            denied,
1155            unknown,
1156            staged,
1157        };
1158        let outcome = RunOutcome {
1159            text: "done".into(),
1160            stop_reason: StopReason::EndTurn,
1161            usage: Usage {
1162                input_tokens: 10,
1163                output_tokens: 4,
1164                ..Usage::default()
1165            },
1166            turns: 3,
1167            refusal: None,
1168            exhausted: false,
1169            ended_on_failed_call: true,
1170            tool_calls: vec![
1171                call(true, false, false, false),
1172                call(false, false, true, false),
1173                call(false, true, false, false),
1174                call(false, false, false, true),
1175            ],
1176            malformed_tool_args: 1,
1177            blocked_sends: 2,
1178            taint: Taint {
1179                private: true,
1180                untrusted: false,
1181            },
1182            stop_cause: StopCause::Completed,
1183            compactions: 4,
1184            cost_usd: Some(0.5),
1185            usage_complete: true,
1186        };
1187        session.record_outcome(&outcome).unwrap();
1188
1189        let stats = Session::outcomes(&session.path).unwrap();
1190        assert_eq!(stats.len(), 1);
1191        let got = &stats[0];
1192        assert_eq!(got.turns, 3);
1193        assert_eq!(got.stop_cause, Some(StopCause::Completed));
1194        assert!(got.ended_on_failed_call);
1195        assert_eq!(got.tool_calls, 4);
1196        // The environment refusing: the error and the unknown tool. A denial
1197        // is the harness working and must never be averaged in with failure.
1198        assert_eq!(got.tool_errors, 2);
1199        assert_eq!(got.tool_denied, 1);
1200        assert_eq!(got.tool_staged, 1);
1201        assert_eq!(got.malformed_tool_args, 1);
1202        assert_eq!(got.blocked_sends, 2);
1203        assert_eq!(got.compactions, 4);
1204        assert!(got.taint.private && !got.taint.untrusted);
1205
1206        // And it is inert to every existing reader: the record is not a
1207        // message, so the conversation is unchanged, and `usage_totals`
1208        // counts `Summary` records only.
1209        let (_, convo) = Session::load(&session.path).unwrap();
1210        assert_eq!(convo.messages.len(), 2);
1211        assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
1212    }
1213
1214    #[test]
1215    fn an_episode_of_several_runs_sums_its_costs_and_takes_its_ending_from_the_last() {
1216        use crate::agent::{RunOutcome, StopCause, ToolCallTrace};
1217        use crate::message::StopReason;
1218
1219        let outcome =
1220            |turns: u32, calls: usize, errored: bool, ended_failed: bool, cause| RunOutcome {
1221                text: String::new(),
1222                stop_reason: StopReason::EndTurn,
1223                usage: Usage {
1224                    input_tokens: 10,
1225                    output_tokens: 1,
1226                    ..Usage::default()
1227                },
1228                turns,
1229                refusal: None,
1230                exhausted: false,
1231                ended_on_failed_call: ended_failed,
1232                tool_calls: (0..calls)
1233                    .map(|_| ToolCallTrace {
1234                        name: "fs_edit".into(),
1235                        input: serde_json::json!({}),
1236                        is_error: errored,
1237                        denied: false,
1238                        unknown: false,
1239                        staged: false,
1240                    })
1241                    .collect(),
1242                malformed_tool_args: 1,
1243                blocked_sends: 0,
1244                taint: Taint {
1245                    private: true,
1246                    untrusted: false,
1247                },
1248                stop_cause: cause,
1249                compactions: 1,
1250                cost_usd: Some(0.25),
1251                usage_complete: true,
1252            };
1253
1254        let mut stats = RunStats {
1255            usage_complete: true,
1256            ..RunStats::default()
1257        };
1258        // Turn one fails and ends over the failure; turn two recovers.
1259        stats.absorb(&outcome(2, 3, true, true, StopCause::MaxTurns));
1260        stats.absorb(&outcome(4, 5, false, false, StopCause::Completed));
1261
1262        // Costs sum: the episode really did spend all of it.
1263        assert_eq!(stats.turns, 6);
1264        assert_eq!(stats.tool_calls, 8);
1265        assert_eq!(stats.tool_errors, 3);
1266        assert_eq!(stats.malformed_tool_args, 2);
1267        assert_eq!(stats.compactions, 2);
1268        assert_eq!(stats.cost_usd, Some(0.5));
1269        assert_eq!(stats.usage.input_tokens, 20);
1270
1271        // The ending is the last run's. An episode whose first turn ended on
1272        // a failure and whose second recovered has not finished over one.
1273        assert_eq!(stats.stop_cause, Some(StopCause::Completed));
1274        assert!(!stats.ended_on_failed_call);
1275
1276        // Taint merges and never resets: a later clean run does not un-read
1277        // what an earlier one read.
1278        assert!(stats.taint.private);
1279    }
1280
1281    #[test]
1282    fn one_lower_bound_turn_makes_the_whole_episode_a_lower_bound() {
1283        use crate::agent::{RunOutcome, StopCause};
1284        use crate::message::StopReason;
1285
1286        let mut incomplete = RunOutcome {
1287            text: String::new(),
1288            stop_reason: StopReason::Other,
1289            usage: Usage::default(),
1290            turns: 1,
1291            refusal: None,
1292            exhausted: true,
1293            ended_on_failed_call: false,
1294            tool_calls: Vec::new(),
1295            malformed_tool_args: 0,
1296            blocked_sends: 0,
1297            taint: Taint::default(),
1298            stop_cause: StopCause::Interrupted,
1299            compactions: 0,
1300            cost_usd: None,
1301            usage_complete: false,
1302        };
1303
1304        let mut stats = RunStats {
1305            usage_complete: true,
1306            ..RunStats::default()
1307        };
1308        stats.absorb(&incomplete);
1309        assert!(!stats.usage_complete);
1310
1311        // And it stays false: a later complete turn cannot repair a total
1312        // that already lost a measurement.
1313        incomplete.usage_complete = true;
1314        stats.absorb(&incomplete);
1315        assert!(!stats.usage_complete);
1316    }
1317
1318    #[test]
1319    fn a_transcript_with_no_outcome_records_reads_as_empty_not_as_an_error() {
1320        // Sessions written before this record existed, and runs that died
1321        // before producing an outcome. Unknown is not zero-with-confidence,
1322        // but it must not be a failure either.
1323        let dir = tmpdir();
1324        let session = Session::create(&dir, meta_with_id("20260101T000000-no-outcome")).unwrap();
1325        session.append_messages(&[Message::user("go")]).unwrap();
1326        assert!(Session::outcomes(&session.path).unwrap().is_empty());
1327    }
1328
1329    #[test]
1330    fn usage_totals_sum_every_run_and_report_zero_for_a_summaryless_file() {
1331        let dir = tmpdir();
1332        let session = Session::create(&dir, meta_with_id("20260101T000000-usage")).unwrap();
1333
1334        // No summary yet — a run that died mid-flight. Zero, not an error.
1335        assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
1336
1337        // Two runs on one session (chat, resume): the totals are the sum.
1338        session
1339            .append(&Record::Summary {
1340                usage: Usage {
1341                    input_tokens: 100,
1342                    output_tokens: 10,
1343                    ..Default::default()
1344                },
1345                turns: 2,
1346            })
1347            .unwrap();
1348        session
1349            .append(&Record::Summary {
1350                usage: Usage {
1351                    input_tokens: 50,
1352                    output_tokens: 5,
1353                    ..Default::default()
1354                },
1355                turns: 1,
1356            })
1357            .unwrap();
1358
1359        let (usage, turns) = Session::usage_totals(&session.path).unwrap();
1360        assert_eq!(usage.input_tokens, 150);
1361        assert_eq!(usage.output_tokens, 15);
1362        assert_eq!(turns, 3);
1363
1364        std::fs::remove_dir_all(&dir).ok();
1365    }
1366
1367    #[cfg(unix)]
1368    #[test]
1369    fn the_session_directory_is_owner_only() {
1370        use std::os::unix::fs::PermissionsExt;
1371        // A fresh path, so `create` makes the directory itself.
1372        let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
1373        Session::create(&dir, meta_with_id("20260101T000000-perms")).unwrap();
1374
1375        // Transcripts hold whatever the tools returned — mail bodies
1376        // included — so the directory gets the token-file rule.
1377        let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
1378        assert_eq!(mode & 0o777, 0o700);
1379
1380        std::fs::remove_dir_all(&dir).ok();
1381    }
1382
1383    #[test]
1384    fn a_transcript_with_no_header_is_refused() {
1385        let dir = tmpdir();
1386        let path = dir.join("headerless.jsonl");
1387        let line = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
1388        std::fs::write(&path, format!("{line}\n")).unwrap();
1389
1390        let err = Session::load(&path).unwrap_err().to_string();
1391        assert!(err.contains("no session header"), "unexpected error: {err}");
1392
1393        std::fs::remove_dir_all(&dir).ok();
1394    }
1395
1396    #[test]
1397    fn an_ambiguous_id_prefix_is_an_error_rather_than_a_guess() {
1398        let dir = tmpdir();
1399        Session::create(&dir, meta_with_id("20260101T000000-aaaaaaaa")).unwrap();
1400        Session::create(&dir, meta_with_id("20260101T000000-bbbbbbbb")).unwrap();
1401
1402        let err = Session::find(&dir, "20260101").unwrap_err().to_string();
1403        assert!(
1404            err.contains("matches 2 sessions"),
1405            "unexpected error: {err}"
1406        );
1407
1408        // A full id still resolves, and resuming the wrong transcript is the
1409        // failure being guarded against.
1410        let path = Session::find(&dir, "20260101T000000-aaaaaaaa").unwrap();
1411        assert!(path.ends_with("20260101T000000-aaaaaaaa.jsonl"));
1412
1413        assert!(Session::find(&dir, "nothing-like-this").is_err());
1414
1415        std::fs::remove_dir_all(&dir).ok();
1416    }
1417}