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