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    /// The surface those names actually described, by hash.
108    ///
109    /// **Names were never enough, and the comment above says why without
110    /// seeing it.** Add, remove and rename are the three that almost never
111    /// happen; *re-describe* happens constantly — 49 commits touched tool
112    /// definitions in three weeks of this store — and a list of names cannot
113    /// see it. Tools render *before* the system prompt, so a replay was
114    /// rebuilding the second half of the prefix byte-exactly and the first half
115    /// from whatever the registry says today. Measured consequence: 12 of 13
116    /// counterfactual probes inconclusive, deterministically, median divergence
117    /// one tool call in.
118    ///
119    /// The specs themselves live in [`crate::surface::SurfaceStore`] — 69 KB
120    /// against a 25 KB average session is why this is a citation and not the
121    /// text, where `system_prompt` above is the text.
122    ///
123    /// **`None` is a recording from before this existed, and must never read as
124    /// a match** — [`crate::surface::Fidelity`] is the three-state answer, and
125    /// its `Unknown` arm is the one every session on disk today lands in.
126    ///
127    /// **Scope: `registry().specs()`, unfiltered — not necessarily what this
128    /// turn's request actually sent.** The wire request goes through
129    /// `registry.specs_for(cx.phase)`, which also applies `Phase::Plan`'s
130    /// read-only filter and a loaded skill's `tools:` narrowing (matching this
131    /// struct's own `tools` field, so this is not a new gap, only a named
132    /// one). A run under `Plan`, or one that had a narrowing skill loaded,
133    /// sent fewer specs than this hash covers — and since the surface can
134    /// narrow *mid-run*, no single hash can describe every turn's request
135    /// exactly. `Fidelity::Matches` here means "the full registry is
136    /// unchanged since this was recorded", which is what makes a replay
137    /// worth attempting; it is not a claim that the request bytes were
138    /// identical.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub tools_hash: Option<String>,
141
142    // What the request looks like.
143    pub effort: Option<Effort>,
144    /// The temperature and seed actually sent, when the provider config pins
145    /// them. Unset means the server chose, and the run is not repeatable.
146    pub temperature: Option<f64>,
147    pub seed: Option<u64>,
148    pub thinking: bool,
149    /// No effect on semantics; large effect on the token counts a replay diffs.
150    pub cache_prompt: bool,
151    pub max_tokens: u32,
152
153    // Ceilings. A run that hit one looks exactly like a model that gave up.
154    pub max_turns: u32,
155    pub max_output_tokens: Option<u64>,
156    pub max_cost_usd: Option<f64>,
157    pub compact_at_tokens: Option<u64>,
158    pub compact_keep_recent: usize,
159
160    // Policy: what the model was allowed to do at all.
161    /// A denied call redirects the whole trajectory, so replaying a read-only
162    /// session under `--yes` compares nothing.
163    pub permission_mode: PermissionMode,
164    pub trifecta: TrifectaPolicy,
165    /// `none` | `bwrap` | `docker` | `landlock`. Load-bearing beyond the
166    /// obvious: `shell` declares *narrower* capabilities when confined, and
167    /// the interlock believes them, so the same prompt can be refused in one
168    /// and allowed in the other. (`landlock` never narrows `external_send` —
169    /// see the sandbox module — so it patterns with `none` for the interlock
170    /// while still confining files.)
171    pub sandbox: String,
172    pub sandbox_network: bool,
173}
174
175impl Default for RunConfig {
176    fn default() -> Self {
177        RunConfig {
178            mecha_version: String::new(),
179            provider: String::new(),
180            model: String::new(),
181            workspace: PathBuf::new(),
182            system_prompt: None,
183            tools: Vec::new(),
184            tools_hash: None,
185            effort: None,
186            temperature: None,
187            seed: None,
188            thinking: false,
189            cache_prompt: false,
190            max_tokens: 0,
191            max_turns: 0,
192            max_output_tokens: None,
193            max_cost_usd: None,
194            compact_at_tokens: None,
195            compact_keep_recent: 0,
196            permission_mode: PermissionMode::Ask,
197            trifecta: TrifectaPolicy::Block,
198            sandbox: "none".into(),
199            sandbox_network: false,
200        }
201    }
202}
203
204impl RunConfig {
205    /// Read it off the built agent rather than off the config file, so what is
206    /// recorded is what is actually being sent — flags, layered TOML and
207    /// defaults already resolved.
208    pub fn of(agent: &Agent, config: &Config, provider: &str) -> Self {
209        let cfg = agent.config();
210        RunConfig {
211            mecha_version: crate::VERSION.to_string(),
212            provider: provider.to_string(),
213            model: agent.model().to_string(),
214            workspace: agent.ctx().workspace.clone(),
215            system_prompt: agent.system().map(str::to_string),
216            tools: agent
217                .registry()
218                .iter()
219                .map(|t| t.name().to_string())
220                .collect(),
221            // Best-effort: recording the surface is bookkeeping beside a run,
222            // and a full disk must not stop the run. A session that could not
223            // record one carries no hash and reads back as `Unknown`, which is
224            // exactly true rather than a silent downgrade.
225            tools_hash: crate::surface::SurfaceStore::open_default()
226                .and_then(|s| s.record(&agent.registry().specs()).ok()),
227            effort: cfg.effort,
228            temperature: config.providers.get(provider).and_then(|p| p.temperature),
229            seed: config.providers.get(provider).and_then(|p| p.seed),
230            thinking: cfg.thinking,
231            cache_prompt: cfg.cache_prompt,
232            max_tokens: cfg.max_tokens,
233            max_turns: cfg.max_turns,
234            max_output_tokens: cfg.max_output_tokens,
235            max_cost_usd: cfg.max_cost_usd,
236            compact_at_tokens: cfg.compact_at_tokens,
237            compact_keep_recent: cfg.compact_keep_recent,
238            permission_mode: config.tools.permission_mode,
239            trifecta: config.security.trifecta,
240            sandbox: config.sandbox.kind.as_str().to_string(),
241            sandbox_network: config.sandbox.network,
242        }
243    }
244}
245
246/// How a run went, in numbers a machine can compare across sessions.
247///
248/// Every field is a deterministic count taken from
249/// [`crate::agent::RunOutcome`] — nothing here is a model's opinion, and
250/// nothing is derived from the *content* of a tool result. That is the
251/// property that lets this be an input to automated grading: a counter
252/// carries no instructions, so a corpus of these cannot be an injection
253/// surface the way a corpus of transcript excerpts would be.
254///
255/// Every field defaults, so a session written before this record existed
256/// loads, and a field added later does not invalidate the ones already
257/// recorded.
258#[derive(Debug, Clone, Default, Serialize, Deserialize)]
259pub struct RunStats {
260    #[serde(default)]
261    pub turns: u32,
262    #[serde(default)]
263    pub usage: Usage,
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub cost_usd: Option<f64>,
266    /// False when `usage` is a lower bound rather than a measurement.
267    #[serde(default)]
268    pub usage_complete: bool,
269    /// Why the loop stopped. The single most informative field here: it
270    /// separates "the model decided it was done" from every way the harness
271    /// cut it short, and none of that is visible in the answer text.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub stop_cause: Option<crate::agent::StopCause>,
274    #[serde(default)]
275    pub exhausted: bool,
276    /// The model stopped of its own accord with its last call failed.
277    #[serde(default)]
278    pub ended_on_failed_call: bool,
279    /// Tool calls attempted, and how they went. `errors` counts the
280    /// environment refusing (including a call to a tool that does not exist);
281    /// `denied` counts a human or a policy refusing, which is the harness
282    /// working and must not be averaged in with failure.
283    #[serde(default)]
284    pub tool_calls: u32,
285    #[serde(default)]
286    pub tool_errors: u32,
287    #[serde(default)]
288    pub tool_denied: u32,
289    #[serde(default)]
290    pub tool_staged: u32,
291    #[serde(default)]
292    pub malformed_tool_args: u32,
293    #[serde(default)]
294    pub blocked_sends: u32,
295    #[serde(default)]
296    pub compactions: u32,
297    /// Times a prompt was refused as too large and the run recovered.
298    ///
299    /// **`Option`, unlike every other counter here, and the difference is the
300    /// point.** This field exists to be a *baseline* — the thing a change
301    /// claiming to predict overflows is measured against — so the measurement
302    /// spans the moment it was introduced. A row written before that knows
303    /// nothing, and a plain `u32` would read it as a run that overflowed zero
304    /// times, silently diluting the very rate it was added to establish.
305    /// `None` says the sensor was not there. Absent is not zero, the rule
306    /// [`crate::homeostat`] and [`crate::backlog`] both state at length.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub context_overflows: Option<u32>,
309    /// Times the harness told this run an approach had stopped teaching it
310    /// anything (`GOAL-SYSTEM-DESIGN.md` §9.1).
311    ///
312    /// `Option` for `context_overflows`' reason, one field up: every threshold
313    /// behind it was argued rather than measured, and this is the field that
314    /// makes them answerable. A row from before the detector existed knows
315    /// nothing, and reading it as a run that was never bored would dilute the
316    /// rate it was added to establish.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub boredom_notices: Option<u32>,
319    /// How many step-escalation candidates (`GOAL-SYSTEM-DESIGN.md` §5.5)
320    /// actually spent a quarantined call this run. `boredom_notices`'s own
321    /// reason: the pre-filter's thresholds are argued, not measured, and a
322    /// row from before the mechanism existed knows nothing.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub step_escalations_attempted: Option<u32>,
325    /// Of those, how many came back `revise_plan`.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub step_escalations_revised: Option<u32>,
328    /// The conditions this run happened under, when the front-end asked for
329    /// them. Recorded here rather than derived later because a run
330    /// reconstructed against *today's* machine state is measuring the
331    /// afternoon — see `GOAL-SYSTEM-DESIGN.md` §12.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub homeostat: Option<crate::homeostat::Homeostat>,
334    /// What had entered the conversation by the end. Recorded here as well as
335    /// in [`Record::Taint`] because this record is read on its own, by a
336    /// reader that is counting rather than reconstructing.
337    #[serde(default)]
338    pub taint: Taint,
339}
340
341impl RunStats {
342    /// Fold another run's outcome in.
343    ///
344    /// An *episode* — a replayed session, a multi-turn eval case, a batch item
345    /// — is several runs on one conversation, and is one row. Counters sum,
346    /// because the episode really did spend all of it. Three fields do not,
347    /// and the split is the whole reason this is a method rather than a loop
348    /// at each call site:
349    ///
350    /// - `stop_cause`, `exhausted` and `ended_on_failed_call` describe how the
351    ///   episode *ended*, so the last run wins. An episode whose first turn
352    ///   ended on a failure and whose second recovered has not finished over
353    ///   a failure, and summing would say it had.
354    /// - `taint` merges and never resets: it is a property of the
355    ///   conversation, and a later clean run does not un-read what an earlier
356    ///   one read.
357    /// - `usage_complete` is an AND: one lower-bound turn makes the total a
358    ///   lower bound.
359    pub fn absorb(&mut self, o: &crate::agent::RunOutcome) {
360        self.merge(&RunStats::of_run(o));
361    }
362
363    /// Fold another *row* in, by the rules above.
364    ///
365    /// Same code as `absorb`, deliberately: an episode is several runs, and
366    /// something has to be able to rebuild one from the rows a session
367    /// recorded — `harness_probe` sizes its priority signal that way, and it
368    /// has to fold exactly as the arm it will be compared against does. Two
369    /// spellings of this is how a measurement arm and the thing it measures
370    /// stop being comparable without anyone noticing.
371    ///
372    /// `homeostat` is untouched, as it always was: the conditions belong to
373    /// the run that sampled them, and an episode's several runs happened under
374    /// several. The first one set keeps the field.
375    pub fn merge(&mut self, other: &RunStats) {
376        self.turns += other.turns;
377        self.usage.add(&other.usage);
378        self.cost_usd = match (self.cost_usd, other.cost_usd) {
379            (Some(a), Some(b)) => Some(a + b),
380            (a, b) => a.or(b),
381        };
382        self.usage_complete &= other.usage_complete;
383        // Last wins, `None` included. Keeping an earlier cause when the
384        // final row has none would invent the one fact the field is about —
385        // doctor's rule for the same value: unrecorded is unknown, never
386        // assumed complete.
387        self.stop_cause = other.stop_cause;
388        self.exhausted = other.exhausted;
389        self.ended_on_failed_call = other.ended_on_failed_call;
390        self.tool_calls += other.tool_calls;
391        self.tool_errors += other.tool_errors;
392        self.tool_denied += other.tool_denied;
393        self.tool_staged += other.tool_staged;
394        self.malformed_tool_args += other.malformed_tool_args;
395        self.blocked_sends += other.blocked_sends;
396        self.compactions += other.compactions;
397        // Summed through the `Option`, on `cost_usd`'s shape above: a live run
398        // always knows its own count, so the `None` case only arises folding a
399        // row read back off disk, and `or` keeps whichever arm had a sensor.
400        // On `merge` rather than in `of_run` alone, so that `episode_stats` —
401        // which rebuilds an episode from recorded rows — folds it too.
402        self.context_overflows = match (self.context_overflows, other.context_overflows) {
403            (Some(a), Some(b)) => Some(a + b),
404            (a, b) => a.or(b),
405        };
406        // Same shape, same reason: a live run always knows its own count, and
407        // omitting this arm left a session's later runs' notices silently
408        // discarded — `fold` seeds from the first row and this method never
409        // touched the field, so it kept whatever the first row carried
410        // forever regardless of how many more rows followed.
411        self.boredom_notices = match (self.boredom_notices, other.boredom_notices) {
412            (Some(a), Some(b)) => Some(a + b),
413            (a, b) => a.or(b),
414        };
415        // Same shape as boredom_notices, same reason.
416        self.step_escalations_attempted = match (
417            self.step_escalations_attempted,
418            other.step_escalations_attempted,
419        ) {
420            (Some(a), Some(b)) => Some(a + b),
421            (a, b) => a.or(b),
422        };
423        self.step_escalations_revised = match (
424            self.step_escalations_revised,
425            other.step_escalations_revised,
426        ) {
427            (Some(a), Some(b)) => Some(a + b),
428            (a, b) => a.or(b),
429        };
430        self.taint.merge(other.taint);
431    }
432
433    /// Fold a session's recorded rows into the episode they describe.
434    ///
435    /// The seed is the first row rather than `default()`, because
436    /// `usage_complete` is ANDed down — starting from the default's `false`
437    /// would make every folded episode a lower bound.
438    pub fn fold(rows: impl IntoIterator<Item = RunStats>) -> Option<RunStats> {
439        let mut folded: Option<RunStats> = None;
440        for row in rows {
441            match &mut folded {
442                Some(acc) => acc.merge(&row),
443                None => folded = Some(row),
444            }
445        }
446        folded
447    }
448
449    /// One run's outcome as a row, before any folding.
450    fn of_run(o: &crate::agent::RunOutcome) -> RunStats {
451        RunStats {
452            turns: o.turns,
453            usage: o.usage.clone(),
454            cost_usd: o.cost_usd,
455            usage_complete: o.usage_complete,
456            stop_cause: Some(o.stop_cause),
457            exhausted: o.exhausted,
458            ended_on_failed_call: o.ended_on_failed_call,
459            tool_calls: o.tool_calls.len() as u32,
460            // `denied` is excluded, and the exclusion has to be written out: a
461            // denied trace carries `is_error: true` too, so filtering on
462            // `is_error` alone counts every refusal as an environment failure
463            // and averages "the harness working" into the rate the candidate
464            // gate and doctor both threshold on.
465            tool_errors: o
466                .tool_calls
467                .iter()
468                .filter(|c| c.unknown || (c.is_error && !c.denied))
469                .count() as u32,
470            tool_denied: o.tool_calls.iter().filter(|c| c.denied).count() as u32,
471            tool_staged: o.tool_calls.iter().filter(|c| c.staged).count() as u32,
472            malformed_tool_args: o.malformed_tool_args,
473            blocked_sends: o.blocked_sends,
474            compactions: o.compactions,
475            // `Some`, never `None`: a live run always knows its own count, and
476            // the `None` case exists only for rows written before the sensor.
477            context_overflows: Some(o.context_overflows),
478            boredom_notices: Some(o.boredom_notices),
479            step_escalations_attempted: Some(o.step_escalations_attempted),
480            step_escalations_revised: Some(o.step_escalations_revised),
481            homeostat: o.homeostat.clone(),
482            taint: o.taint,
483        }
484    }
485}
486
487impl From<&crate::agent::RunOutcome> for RunStats {
488    fn from(o: &crate::agent::RunOutcome) -> Self {
489        // `usage_complete` starts true and is ANDed down, so the default's
490        // `false` would make every single-run row a lower bound.
491        RunStats::of_run(o)
492    }
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct SessionMeta {
497    pub id: String,
498    pub created_at: DateTime<Utc>,
499    pub provider: String,
500    pub model: String,
501    pub workspace: PathBuf,
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub title: Option<String>,
504}
505
506pub struct Session {
507    pub meta: SessionMeta,
508    pub path: PathBuf,
509}
510
511/// A transcript read once: see [`Session::read`].
512pub struct Transcript {
513    pub meta: SessionMeta,
514    pub convo: Conversation,
515    /// Every `RunConfig` recorded, in order. The first is the run the session
516    /// began under; a `/model` switch appends another.
517    pub configs: Vec<RunConfig>,
518    /// How many messages preceded each entry of `configs` in the *loaded*
519    /// list — parallel to it, and what [`Transcript::config_covering`] reads.
520    /// A front-end writes a `Config` at run start, before the run's own
521    /// messages, so the config in effect at message `i` is the last one with
522    /// a position at or below `i`. A *summarising* `Rewrite` clamps every
523    /// earlier position to zero: the rewritten head's original indices are
524    /// claims about a list that no longer exists, and the config in flight
525    /// at the rewrite — the last of the clamped ones — is the honest answer
526    /// for it (messages the rewrite kept from *earlier attaches* were
527    /// genuinely recorded under older configs, but those turns are exactly
528    /// the "not comparable" case `run_configs`'s own doc names, and the
529    /// replay fidelity caveat is the place that says so). A *truncating*
530    /// rewrite — the failed-turn rollback, whose new list is a strict prefix
531    /// of the one in hand — rewrites nothing, so its positions stay exact;
532    /// see the `Rewrite` arm in [`Session::read`].
533    pub config_positions: Vec<usize>,
534    /// Every recorded outcome, folded into the episode the session describes.
535    pub episode: Option<RunStats>,
536    /// The taint checkpoints, positioned against the loaded messages — the
537    /// same structure [`Session::taint_timeline`] builds from a second full
538    /// read, carried here because this pass already walked every record.
539    /// `mecha distill` paid four complete read-and-parse passes per session
540    /// (`load`, `taint_timeline`, then `for_session`'s own `read` *and*
541    /// `taint_timeline`) for questions this one walk answers together.
542    pub taint_timeline: TaintTimeline,
543}
544
545impl Transcript {
546    /// The run config in effect at message `message_index` of the loaded
547    /// list, or `None` for a transcript recorded before configs were kept.
548    ///
549    /// This is what a replay driver should ask, not `configs.first()`:
550    /// resuming under different flags is a normal thing to do, and replaying
551    /// a later attach's turns under the first attach's system prompt and
552    /// tool list diverges for reasons that say nothing about those turns —
553    /// which a counterfactual reader then mistakes for evidence.
554    pub fn config_covering(&self, message_index: usize) -> Option<&RunConfig> {
555        self.config_positions
556            .iter()
557            .zip(&self.configs)
558            .rfind(|(pos, _)| **pos <= message_index)
559            .map(|(_, cfg)| cfg)
560    }
561}
562
563/// Every `Record::Outcome` in a transcript, in order.
564fn outcomes_in(text: &str) -> impl Iterator<Item = RunStats> + '_ {
565    text.lines()
566        .filter(|l| !l.trim().is_empty())
567        .filter_map(|l| match serde_json::from_str(l) {
568            Ok(Record::Outcome(s)) => Some(s),
569            _ => None,
570        })
571}
572
573impl Session {
574    /// Where transcripts live: `~/.mecha/sessions`, or `$MECHA_SESSION_DIR`.
575    pub fn default_dir() -> Result<PathBuf> {
576        if let Ok(dir) = std::env::var("MECHA_SESSION_DIR") {
577            return Ok(PathBuf::from(dir));
578        }
579        Ok(crate::work::mecha_home()?.join("sessions"))
580    }
581
582    pub fn create(dir: &Path, meta: SessionMeta) -> Result<Self> {
583        crate::create_private_dir(dir)
584            .with_context(|| format!("creating session directory {}", dir.display()))?;
585        let path = dir.join(format!("{}.jsonl", meta.id));
586        let session = Session {
587            meta: meta.clone(),
588            path,
589        };
590        session.append(&Record::Meta(meta))?;
591        Ok(session)
592    }
593
594    pub fn new_id() -> String {
595        // Sortable by name, and still unique when two runs start in the same
596        // second.
597        format!(
598            "{}-{}",
599            Utc::now().format("%Y%m%dT%H%M%S"),
600            &uuid::Uuid::new_v4().to_string()[..8]
601        )
602    }
603
604    pub fn append(&self, record: &Record) -> Result<()> {
605        use std::io::Write;
606        let mut file = std::fs::OpenOptions::new()
607            .create(true)
608            .append(true)
609            .open(&self.path)
610            .with_context(|| format!("opening {}", self.path.display()))?;
611        writeln!(file, "{}", serde_json::to_string(record)?)?;
612        Ok(())
613    }
614
615    pub fn append_messages(&self, messages: &[Message]) -> Result<()> {
616        for m in messages {
617            self.append(&Record::Message(m.clone()))?;
618        }
619        Ok(())
620    }
621
622    /// Record what a run did to the conversation, given the messages it
623    /// started from.
624    ///
625    /// `before` must be what the file already holds — every front-end has
626    /// appended the opening user message (and, resumed, the loaded history)
627    /// before the run starts. The walk visits every state the run's rewrites
628    /// replaced ([`Conversation::rewritten`]) and then the final one, so a
629    /// run long enough to compact *itself* still gets its whole head into
630    /// the file: each pre-rewrite snapshot extends the previous recorded
631    /// state append-only (its cheap tail append), and each post-rewrite
632    /// state lands as the [`Record::Rewrite`] the next transition writes.
633    /// The signature takes the conversation rather than a message slice so a
634    /// caller cannot record the destination while skipping the journey.
635    ///
636    /// [`Conversation::rewritten`]: crate::agent::Conversation
637    pub fn record_run(&self, before: &[Message], convo: &Conversation) -> Result<()> {
638        let mut prev: &[Message] = before;
639        for state in &convo.rewritten {
640            self.record_transition(prev, state)?;
641            prev = state;
642        }
643        self.record_transition(prev, &convo.messages)
644    }
645
646    /// Record how the run went, beside what it said.
647    ///
648    /// Separate from [`record_run`] rather than folded into it, because the
649    /// two answer to different failures: a run that errored mid-flight still
650    /// has messages worth keeping and no outcome to describe, and a caller
651    /// that has an outcome always has it *after* the transcript is safe.
652    /// Deliberately takes the whole outcome rather than the fields, so a new
653    /// counter reaches every front-end by upgrading rather than by
654    /// remembering to thread it through six call sites.
655    ///
656    /// [`record_run`]: Session::record_run
657    pub fn record_outcome(&self, outcome: &crate::agent::RunOutcome) -> Result<()> {
658        self.append(&Record::Outcome(RunStats::from(outcome)))
659    }
660
661    /// Every outcome recorded in a transcript, in order, with the model and
662    /// provider that were in effect when it was written.
663    ///
664    /// Not the session header: the TUI can switch model mid-session and
665    /// records a `Config` when it does, so attributing every run to the
666    /// header would credit the second model's work to the first — and defeat
667    /// the per-model split in exactly the case where blending actually
668    /// happens. Falls back to the header when no `Config` precedes the row,
669    /// which is what an older transcript looks like.
670    pub fn outcomes_attributed(path: &Path) -> Result<Vec<(String, String, RunStats)>> {
671        let text =
672            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
673        let mut provider = String::new();
674        let mut model = String::new();
675        let mut out = Vec::new();
676        for line in text.lines().filter(|l| !l.trim().is_empty()) {
677            match serde_json::from_str(line) {
678                Ok(Record::Meta(meta)) => {
679                    provider = meta.provider;
680                    model = meta.model;
681                }
682                Ok(Record::Config(cfg)) => {
683                    provider = cfg.provider;
684                    model = cfg.model;
685                }
686                Ok(Record::Outcome(stats)) => out.push((provider.clone(), model.clone(), stats)),
687                _ => {}
688            }
689        }
690        Ok(out)
691    }
692
693    /// Every outcome recorded in a transcript, in order.
694    ///
695    /// One per run, so a resumed session has several. Malformed lines are
696    /// skipped rather than fatal, like every other reader here: a torn line
697    /// is the store's problem and must not cost the rows around it.
698    pub fn outcomes(path: &Path) -> Result<Vec<RunStats>> {
699        let text =
700            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
701        Ok(text
702            .lines()
703            .filter(|l| !l.trim().is_empty())
704            .filter_map(|l| match serde_json::from_str(l) {
705                Ok(Record::Outcome(s)) => Some(s),
706                _ => None,
707            })
708            .collect())
709    }
710
711    /// The most recent outcome, without parsing the transcript that precedes
712    /// it.
713    ///
714    /// [`outcomes`](Session::outcomes) reads every line because it answers
715    /// "how did each run on this session go" — right for the corpus, and
716    /// wrong for a display asking only where a session stands *now*. A
717    /// transcript is mostly messages and an outcome is appended last, so
718    /// scanning backwards finds it in one parse instead of thousands.
719    ///
720    /// `Ok(None)` means the transcript held no outcome at all, which is a
721    /// third answer and not a failure: a run that never got as far as
722    /// recording one, or a session written before the record existed.
723    /// Callers must not fold it into either success or failure.
724    ///
725    /// Still one reader of the record format — this lives beside `outcomes`
726    /// rather than in a caller, so a change to `Record` cannot leave a
727    /// second, private parser behind.
728    /// Every outcome a session recorded, folded into the episode it describes.
729    ///
730    /// `last_outcome` answers a different question — how the session *ended* —
731    /// and using it as an episode's stats is a unit mismatch: a resumed chat
732    /// records one row per run, while anything replaying the session drives
733    /// every recorded user turn and folds all of them.
734    pub fn episode_stats(path: &Path) -> Result<Option<RunStats>> {
735        let text =
736            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
737        Ok(RunStats::fold(outcomes_in(&text)))
738    }
739
740    pub fn last_outcome(path: &Path) -> Result<Option<RunStats>> {
741        let text =
742            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
743        Ok(text
744            .lines()
745            .rev()
746            .filter(|l| !l.trim().is_empty())
747            .find_map(|l| match serde_json::from_str(l) {
748                Ok(Record::Outcome(s)) => Some(s),
749                _ => None,
750            }))
751    }
752
753    /// One before→after step. When the run only appended, the new tail is
754    /// appended here too. When it rewrote what was already recorded —
755    /// compaction, eviction, thinning, all of which edit earlier messages in
756    /// place — a [`Record::Rewrite`] carries the whole current list instead,
757    /// because slicing a rewritten transcript records a lie: the old head
758    /// stays in the file, the rebuilt one (summary included) never lands.
759    ///
760    /// Comparison, not a flag from the loop: any mutation the loop grows
761    /// later is caught by construction, and the clone this costs is one more
762    /// beside the one the loop already pays per request.
763    fn record_transition(&self, before: &[Message], after: &[Message]) -> Result<()> {
764        let appended_only = after.len() >= before.len() && after[..before.len()] == *before;
765        if appended_only {
766            self.append_messages(&after[before.len()..])
767        } else {
768            self.append(&Record::Rewrite {
769                messages: after.to_vec(),
770            })
771        }
772    }
773
774    /// Read a transcript back, taint included.
775    ///
776    /// Unparseable lines are skipped rather than failing the load — a truncated
777    /// final line is the normal result of a killed process.
778    pub fn load(path: &Path) -> Result<(SessionMeta, Conversation)> {
779        let t = Session::read(path)?;
780        Ok((t.meta, t.convo))
781    }
782
783    /// Everything a reader can want from a transcript, in **one** pass.
784    ///
785    /// `load`, `run_configs` and `episode_stats` each open the file and walk
786    /// every line, so a caller that wants all three pays three reads and three
787    /// parses of the same JSONL. That is fine for a one-off and is not fine
788    /// for `harness_probe`'s pool, which considers four times the wanted
789    /// episode count on every nightly — sixty-four transcripts, hundreds of KB
790    /// apiece, read three times each to answer questions one walk can answer
791    /// together.
792    ///
793    /// The three keep their own entry points, because most callers want one
794    /// thing and a caller that wants one thing should not have to hold a
795    /// header it has no use for. This is the seam for the caller that wants
796    /// all of them.
797    pub fn read(path: &Path) -> Result<Transcript> {
798        let text =
799            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
800
801        let mut configs = Vec::new();
802        let mut config_positions: Vec<usize> = Vec::new();
803        let mut outcomes = Vec::new();
804        let mut meta = None;
805        let mut messages = Vec::new();
806        let mut taint = Taint::default();
807        // Built here with `TaintTimeline::from_records`'s exact state
808        // machine (a `Rewrite` drops checkpoints — see that function for
809        // why dropping fails closed where clamping under-taints), because
810        // this pass already walks every record and a second full read to
811        // rebuild the same structure is the multi-read mistake this
812        // function exists to end.
813        let mut taint_checkpoints: Vec<(usize, Taint)> = Vec::new();
814        for line in text.lines().filter(|l| !l.trim().is_empty()) {
815            match serde_json::from_str::<Record>(line) {
816                Ok(Record::Meta(m)) => meta = Some(m),
817                Ok(Record::Message(m)) => messages.push(m),
818                // The conversation state as of the rewrite, wholesale. Taint
819                // is deliberately not touched: summarising away the text of a
820                // hostile page does not un-read it.
821                //
822                // Two kinds of rewrite reach this arm, and they earn opposite
823                // treatment of the positions — found on review, after the
824                // failed-turn rollback started writing rewrites too:
825                //
826                // - **A truncation** (the rolled-back failed turn: the new
827                //   list is a strict prefix of the one in hand) rewrites
828                //   *nothing* — every surviving message keeps its index, so
829                //   every config position at or below the new length is
830                //   still exact. Zeroing them here collapsed
831                //   `config_covering` onto the newest attach for the whole
832                //   head, which made one provider error in a resumed session
833                //   reintroduce the replay-under-the-wrong-config divergence
834                //   the positional lookup exists to prevent. Positions above
835                //   the new length clamp to it: that config's own messages
836                //   are gone, and it correctly covers only what a later turn
837                //   appends (the attach is still in flight).
838                // - **A summarising rewrite** (compaction, eviction) replaces
839                //   the head, so old positions are claims about a list that
840                //   no longer exists — clamped to zero, the config in flight
841                //   covering the rewritten head, per
842                //   `Transcript::config_positions`.
843                //
844                // Taint checkpoints drop in BOTH cases, deliberately, and
845                // for a truncation that is a real (safe-direction) cost: the
846                // failed turn's trailing `Record::Taint` then covers the
847                // whole rolled-back list with the run's *cumulative* taint,
848                // so a clean earlier turn in a session that later read a
849                // hostile page and failed classifies untrusted. Over-taint,
850                // never under — and keeping them would diverge from
851                // `TaintTimeline::from_records`, which cannot see message
852                // content to tell the two rewrites apart; provenance must
853                // not depend on which reader classified it.
854                Ok(Record::Rewrite { messages: m }) => {
855                    // Positions survive any rewrite that leaves message `i`
856                    // meaning message `i`. Three writer families produce
857                    // rewrites, and only one shifts indices:
858                    //
859                    // - **Index-preserving, possibly content-changing**: the
860                    //   in-run eviction passes (`evict_superseded_results`,
861                    //   `collapse_repeated_failures`, `thin_old_results` —
862                    //   all `&mut [Message]`, so length-preserving by type),
863                    //   whose recorded list is *at least* as long as the
864                    //   messages persisted so far because it carries the
865                    //   run's unpersisted tail; and the barge-in fold (same
866                    //   length, tail extended). Recognised as `m.len() >=
867                    //   messages.len()` — content comparison would wrongly
868                    //   fail the eviction case, whose whole point is that
869                    //   content changed in place.
870                    // - **A truncation** (the rollback's strict prefix):
871                    //   shorter, head unchanged.
872                    // - **A summarising compaction**: shorter, head
873                    //   *replaced* — the one case indices genuinely die.
874                    //
875                    // A pathological long rewrite could masquerade as
876                    // index-preserving; the bias is deliberate, because the
877                    // two errors are not symmetric — misreading a
878                    // summarising rewrite keeps the *old* positions (the
879                    // pre-positional `configs.first()` behaviour, mildly
880                    // stale), while misreading an in-place one collapses
881                    // every head message onto the newest attach, the exact
882                    // divergence `config_covering` exists to prevent.
883                    // The shorter case compares the FULL new list, not all
884                    // but its last message: the fold shape that needed the
885                    // one-short comparison is length-preserving and already
886                    // admitted by the `>=` arm, and under-comparing here
887                    // made a compaction down to a single message vacuously
888                    // "in place" (found on review — `shared == 0` compares
889                    // nothing at all).
890                    let in_place = m.len() >= messages.len() || messages[..m.len()] == m[..];
891                    if in_place {
892                        for p in &mut config_positions {
893                            *p = (*p).min(m.len());
894                        }
895                    } else {
896                        config_positions.fill(0);
897                    }
898                    messages = m;
899                    taint_checkpoints.clear();
900                }
901                // Merged rather than replaced: taint only ever grows, and a
902                // transcript written by an older build has none at all.
903                Ok(Record::Taint(t)) => {
904                    taint.merge(t);
905                    taint_checkpoints.push((messages.len(), taint));
906                }
907                // Kept rather than discarded: this is the pass that has them
908                // in hand, and the alternative is two more reads of the file
909                // it just walked.
910                Ok(Record::Config(c)) => {
911                    config_positions.push(messages.len());
912                    configs.push(c);
913                }
914                Ok(Record::Outcome(o)) => outcomes.push(o),
915                Ok(Record::Summary { .. }) => {}
916                Err(e) => tracing::warn!(error = %e, "skipping malformed transcript line"),
917            }
918        }
919
920        let meta = meta.with_context(|| format!("{} has no session header", path.display()))?;
921        Ok(Transcript {
922            meta,
923            convo: Conversation::resumed(messages, taint),
924            configs,
925            config_positions,
926            episode: RunStats::fold(outcomes),
927            taint_timeline: TaintTimeline {
928                checkpoints: taint_checkpoints,
929            },
930        })
931    }
932
933    /// Every message the conversation ever contained, in first-seen order.
934    ///
935    /// `Message` records are the append-only common case. A `Rewrite` record is a
936    /// compaction (or eviction, or thinning) replacing the list in place — for
937    /// *loading* a session the replacement is the truth, but for a reader asking what the conversation ever held the whole
938    /// point is what the replacement dropped, so its messages are unioned in
939    /// rather than substituted: anything new (the summary, an edited result)
940    /// joins the corpus, anything already seen is skipped. Malformed lines are
941    /// skipped exactly as [`crate::session::Session::load`] skips them — a
942    /// truncated final line is the normal residue of a killed process.
943    pub fn messages_ever(transcript: &str) -> Vec<Message> {
944        let mut seen = HashSet::new();
945        let mut all = Vec::new();
946        let mut admit = |m: Message, all: &mut Vec<Message>| {
947            // Equality via the serialized form: `Message` is `PartialEq` but not
948            // `Hash`, and the serialization is already the file's own currency.
949            if let Ok(key) = serde_json::to_string(&m) {
950                if seen.insert(key) {
951                    all.push(m);
952                }
953            }
954        };
955        for line in transcript.lines().filter(|l| !l.trim().is_empty()) {
956            match serde_json::from_str::<Record>(line) {
957                Ok(Record::Message(m)) => admit(m, &mut all),
958                Ok(Record::Rewrite { messages }) => {
959                    for m in messages {
960                        admit(m, &mut all);
961                    }
962                }
963                Ok(_) => {}
964                Err(e) => tracing::debug!(error = %e, "skipping malformed transcript line"),
965            }
966        }
967        all
968    }
969
970    /// The taint checkpoints of a transcript, positioned against its messages.
971    ///
972    /// Every front-end appends a `Record::Taint` checkpoint *after* the
973    /// messages of the run it describes, so the checkpoint that covers a
974    /// message is the first one written after it — and by then the taint of
975    /// everything earlier in that run, hostile fetches included, has merged
976    /// in. That ordering is what makes [`TaintTimeline::covering`] safe to
977    /// gate on: it can over-taint a message (a fetch later in the same run
978    /// counts against it), never under-taint one.
979    pub fn taint_timeline(path: &Path) -> Result<TaintTimeline> {
980        let text =
981            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
982        Ok(TaintTimeline::from_records(
983            text.lines()
984                .filter(|l| !l.trim().is_empty())
985                .filter_map(|l| serde_json::from_str::<Record>(l).ok()),
986        ))
987    }
988
989    /// Every run configuration in a transcript, in the order the runs happened.
990    ///
991    /// A replay driver needs this per run rather than per session: resuming
992    /// under different flags is a normal thing to do, and the turns before and
993    /// after are not comparable. An empty result means a transcript written
994    /// before this was recorded — which cannot be replayed faithfully, because
995    /// the system prompt and tool list that shaped it are gone.
996    pub fn run_configs(path: &Path) -> Result<Vec<RunConfig>> {
997        let text =
998            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
999        Ok(text
1000            .lines()
1001            .filter(|l| !l.trim().is_empty())
1002            .filter_map(|l| match serde_json::from_str::<Record>(l) {
1003                Ok(Record::Config(c)) => Some(c),
1004                _ => None,
1005            })
1006            .collect())
1007    }
1008
1009    /// The header alone, without parsing the rest of the file.
1010    ///
1011    /// Listing goes through this rather than [`Session::load`] so `mecha
1012    /// sessions` stays O(number of sessions) instead of O(total transcript
1013    /// bytes) — with reflect-on-close recording every interaction, the full
1014    /// parse re-read the whole store to print one line per file. The header
1015    /// is the first record `create` writes; a file whose first record is
1016    /// anything else is not a session this process wrote, and is skipped
1017    /// exactly as `load`'s no-header error skipped it.
1018    pub fn peek_meta(path: &Path) -> Option<SessionMeta> {
1019        use std::io::BufRead;
1020        let file = std::fs::File::open(path).ok()?;
1021        let mut reader = std::io::BufReader::new(file);
1022        let mut first = String::new();
1023        loop {
1024            first.clear();
1025            if reader.read_line(&mut first).ok()? == 0 {
1026                return None;
1027            }
1028            if !first.trim().is_empty() {
1029                break;
1030            }
1031        }
1032        match serde_json::from_str::<Record>(&first).ok()? {
1033            Record::Meta(m) => Some(m),
1034            _ => None,
1035        }
1036    }
1037
1038    /// The run summaries of a transcript, summed: total usage and turns
1039    /// across every run the file records. Zero for a transcript that
1040    /// predates the summary record or died before writing one — an honest
1041    /// under-count, never a guess.
1042    pub fn usage_totals(path: &Path) -> Result<(Usage, u32)> {
1043        let text =
1044            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1045        let mut usage = Usage::default();
1046        let mut turns = 0u32;
1047        for line in text.lines().filter(|l| !l.trim().is_empty()) {
1048            if let Ok(Record::Summary { usage: u, turns: t }) = serde_json::from_str(line) {
1049                usage.add(&u);
1050                turns += t;
1051            }
1052        }
1053        Ok((usage, turns))
1054    }
1055
1056    /// Sessions in `dir`, newest first.
1057    ///
1058    /// A transcript whose header cannot be read is skipped so the walk
1059    /// stays best-effort — but skipped is not *forgotten*: callers that
1060    /// report on the store should use [`Session::list_counting`], because a
1061    /// store rotting one file at a time is otherwise invisible from every
1062    /// reader at once ("an unreadable store is a finding, not an empty
1063    /// queue" — the outbox gets `outbox_unreadable` for exactly this, and
1064    /// the session store got nothing).
1065    pub fn list(dir: &Path) -> Result<Vec<(SessionMeta, PathBuf)>> {
1066        Ok(Session::list_counting(dir)?.0)
1067    }
1068
1069    /// [`Session::list`], plus how many `.jsonl` files were skipped because
1070    /// no header could be read from them — a torn write, a corrupt file, a
1071    /// permissions hole. The count is the reader's to surface; the walk
1072    /// itself stays best-effort either way.
1073    pub fn list_counting(dir: &Path) -> Result<(Vec<(SessionMeta, PathBuf)>, usize)> {
1074        if !dir.exists() {
1075            return Ok((Vec::new(), 0));
1076        }
1077        let mut out = Vec::new();
1078        let mut unreadable = 0usize;
1079        for entry in std::fs::read_dir(dir)? {
1080            let path = entry?.path();
1081            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
1082                continue;
1083            }
1084            match Session::peek_meta(&path) {
1085                Some(meta) => out.push((meta, path)),
1086                None => unreadable += 1,
1087            }
1088        }
1089        out.sort_by_key(|(meta, _)| std::cmp::Reverse(meta.created_at));
1090        Ok((out, unreadable))
1091    }
1092
1093    /// Find a session by full id or unique prefix.
1094    pub fn find(dir: &Path, id_prefix: &str) -> Result<PathBuf> {
1095        let matches: Vec<_> = Session::list(dir)?
1096            .into_iter()
1097            .filter(|(m, _)| m.id.starts_with(id_prefix))
1098            .collect();
1099        match matches.len() {
1100            0 => anyhow::bail!("no session matching {id_prefix:?}"),
1101            1 => Ok(matches.into_iter().next().unwrap().1),
1102            n => anyhow::bail!("{id_prefix:?} matches {n} sessions; use a longer prefix"),
1103        }
1104    }
1105}
1106
1107/// Where each taint checkpoint sits relative to the messages — built by
1108/// [`Session::taint_timeline`], consumed by provenance classification in
1109/// `learning`.
1110#[derive(Debug, Clone, Default)]
1111pub struct TaintTimeline {
1112    /// (messages recorded before this checkpoint, taint merged up to it).
1113    /// Merged, not raw: taint only grows, so each entry is the union of every
1114    /// checkpoint at or before it.
1115    checkpoints: Vec<(usize, Taint)>,
1116}
1117
1118impl TaintTimeline {
1119    pub fn from_records(records: impl IntoIterator<Item = Record>) -> Self {
1120        let mut checkpoints: Vec<(usize, Taint)> = Vec::new();
1121        let mut messages = 0usize;
1122        let mut merged = Taint::default();
1123        for record in records {
1124            match record {
1125                Record::Message(_) => messages += 1,
1126                // The list was replaced, so every position recorded before it
1127                // is a claim about a list that no longer exists — drop them.
1128                // Not clamp: clamping several stale checkpoints onto the new
1129                // length leaves `covering` resolving to the *first* of them,
1130                // which is the oldest and smallest taint, and in the record
1131                // order the front-ends actually write (`Rewrite` then
1132                // `Taint`, no message between) that under-taints every
1133                // rewritten message — a compacting run that read a hostile
1134                // page would classify clean. Dropping fails the right way
1135                // twice over: `merged` is cumulative, so the checkpoint the
1136                // run writes after the rewrite carries everything the dropped
1137                // ones knew and covers the rewritten head with it; and a file
1138                // torn before that checkpoint leaves the head covered by
1139                // nothing, which `covering` reports as unknown — never clean.
1140                Record::Rewrite { messages: m } => {
1141                    messages = m.len();
1142                    checkpoints.clear();
1143                }
1144                Record::Taint(t) => {
1145                    merged.merge(t);
1146                    checkpoints.push((messages, merged));
1147                }
1148                _ => {}
1149            }
1150        }
1151        TaintTimeline { checkpoints }
1152    }
1153
1154    /// The merged taint covering the message at `index`, or `None` when no
1155    /// checkpoint was written after it — a torn transcript, or one recorded
1156    /// before taint was. The caller must treat `None` as *unknown*, and
1157    /// unknown provenance is never clean.
1158    pub fn covering(&self, index: usize) -> Option<Taint> {
1159        self.checkpoints
1160            .iter()
1161            .find(|(n, _)| *n > index)
1162            .map(|(_, t)| *t)
1163    }
1164}
1165
1166#[cfg(test)]
1167mod homeostat_record_tests {
1168    use super::*;
1169    use crate::backlog::{Backlog, BacklogDelta, Depth};
1170    use crate::homeostat::Homeostat;
1171
1172    /// The snapshot has to reach the record, or rung 3 is a struct nothing
1173    /// writes. `RunStats` is what replay, the gate and the diagnostician read.
1174    #[test]
1175    fn the_conditions_a_run_happened_under_reach_its_record() {
1176        let bare = || crate::agent::RunOutcome {
1177            context_overflows: 0,
1178            boredom_notices: 0,
1179            step_escalations_attempted: 0,
1180            step_escalations_revised: 0,
1181            text: String::new(),
1182            stop_reason: crate::message::StopReason::EndTurn,
1183            usage: crate::message::Usage::default(),
1184            turns: 1,
1185            refusal: None,
1186            exhausted: false,
1187            ended_on_failed_call: false,
1188            tool_calls: Vec::new(),
1189            malformed_tool_args: 0,
1190            blocked_sends: 0,
1191            taint: crate::agent::Taint::default(),
1192            homeostat: None,
1193            stop_cause: crate::agent::StopCause::Completed,
1194            compactions: 0,
1195            usage_complete: true,
1196            cost_usd: None,
1197        };
1198        let mut outcome = bare();
1199        outcome.homeostat = Some(Homeostat {
1200            load_avg_1m: Some(0.56),
1201            backlog: Some(Backlog {
1202                outbox: Some(Depth {
1203                    waiting: 2,
1204                    oldest: Some("2026-08-20T09:00:00Z".into()),
1205                }),
1206                ..Backlog::default()
1207            }),
1208            backlog_delta: Some(BacklogDelta {
1209                outbox: Some(9),
1210                ..BacklogDelta::default()
1211            }),
1212            ..Homeostat::default()
1213        });
1214        let stats = RunStats::from(&outcome);
1215        let h = stats.homeostat.expect("recorded");
1216        assert_eq!(h.load_avg_1m, Some(0.56));
1217        assert_eq!(h.backlog_delta.unwrap().outbox, Some(9));
1218
1219        // A run that did not ask for one records nothing rather than an empty
1220        // snapshot — absent and zero stay different all the way down.
1221        let unsampled = RunStats::from(&bare());
1222        assert_eq!(unsampled.homeostat, None);
1223    }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228
1229    /// One walk has to answer exactly what three walks answered, or the
1230    /// caller that swapped to it is reading a different transcript from the
1231    /// one everything else reads.
1232    #[test]
1233    fn one_pass_agrees_with_the_three_readers_it_replaces() {
1234        let dir = std::env::temp_dir().join(format!("mecha-onepass-{}", std::process::id()));
1235        let _ = std::fs::remove_dir_all(&dir);
1236        let s = Session::create(
1237            &dir,
1238            SessionMeta {
1239                id: "20260826T000000-x".into(),
1240                created_at: chrono::Utc::now(),
1241                provider: "local".into(),
1242                model: "first".into(),
1243                workspace: std::path::PathBuf::from("/tmp"),
1244                title: None,
1245            },
1246        )
1247        .unwrap();
1248        s.append(&Record::Config(RunConfig {
1249            provider: "local".into(),
1250            model: "first".into(),
1251            ..Default::default()
1252        }))
1253        .unwrap();
1254        s.append_messages(&[crate::message::Message::user("go")])
1255            .unwrap();
1256        let row = |turns: u32, calls: u32| RunStats {
1257            turns,
1258            tool_calls: calls,
1259            usage_complete: true,
1260            stop_cause: Some(crate::agent::StopCause::Completed),
1261            ..RunStats::default()
1262        };
1263        s.append(&Record::Outcome(row(2, 3))).unwrap();
1264        s.append(&Record::Outcome(row(5, 7))).unwrap();
1265
1266        let read = Session::read(&s.path).unwrap();
1267        let (meta, convo) = Session::load(&s.path).unwrap();
1268        assert_eq!(read.meta.id, meta.id);
1269        assert_eq!(read.convo.messages, convo.messages);
1270        assert_eq!(
1271            serde_json::to_string(&read.configs).unwrap(),
1272            serde_json::to_string(&Session::run_configs(&s.path).unwrap()).unwrap()
1273        );
1274        assert_eq!(
1275            serde_json::to_string(&read.episode).unwrap(),
1276            serde_json::to_string(&Session::episode_stats(&s.path).unwrap()).unwrap()
1277        );
1278        // And the fold is a fold, not the last row.
1279        let episode = read.episode.clone().unwrap();
1280        assert_eq!(episode.turns, 7);
1281        assert_eq!(episode.tool_calls, 10);
1282
1283        let _ = std::fs::remove_dir_all(&dir);
1284    }
1285    use super::*;
1286    use crate::message::Block;
1287
1288    fn tmpdir() -> PathBuf {
1289        let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
1290        std::fs::create_dir_all(&dir).unwrap();
1291        dir
1292    }
1293
1294    fn meta_with_id(id: &str) -> SessionMeta {
1295        SessionMeta {
1296            id: id.to_string(),
1297            created_at: Utc::now(),
1298            provider: "scripted".into(),
1299            model: "test-model".into(),
1300            workspace: PathBuf::from("/tmp"),
1301            title: None,
1302        }
1303    }
1304
1305    #[test]
1306    fn a_transcript_round_trips_its_messages_and_its_taint() {
1307        let dir = tmpdir();
1308        let session = Session::create(&dir, meta_with_id("20260101T000000-round")).unwrap();
1309        session
1310            .append_messages(&[
1311                Message::user("summarise this page"),
1312                Message::assistant(vec![Block::text("done")]),
1313            ])
1314            .unwrap();
1315        session
1316            .append(&Record::Taint(Taint {
1317                private: true,
1318                untrusted: true,
1319            }))
1320            .unwrap();
1321
1322        let (meta, convo) = Session::load(&session.path).unwrap();
1323
1324        assert_eq!(meta.model, "test-model");
1325        assert_eq!(convo.messages.len(), 2);
1326        assert_eq!(convo.messages[0].text(), "summarise this page");
1327        assert_eq!(convo.messages[1].text(), "done");
1328        // The whole point of recording it: provenance cannot be recovered by
1329        // re-reading the content, so a resumed conversation that had read a
1330        // hostile page must come back with the interlock still armed.
1331        assert!(convo.taint.trifecta_armed());
1332
1333        std::fs::remove_dir_all(&dir).ok();
1334    }
1335
1336    #[test]
1337    fn record_run_appends_the_tail_when_the_run_only_appended() {
1338        let dir = tmpdir();
1339        let session = Session::create(&dir, meta_with_id("20260101T000000-tail")).unwrap();
1340        let before = vec![Message::user("go")];
1341        session.append_messages(&before).unwrap();
1342
1343        let mut after = Conversation::from(before.clone());
1344        after.push(Message::assistant(vec![Block::text("done")]));
1345        session.record_run(&before, &after).unwrap();
1346
1347        let (_, convo) = Session::load(&session.path).unwrap();
1348        assert_eq!(convo.messages.len(), 2);
1349        assert_eq!(convo.messages[1].text(), "done");
1350        // And no rewrite record for the ordinary case: the file stays a plain
1351        // append log unless the run actually rewrote history.
1352        let text = std::fs::read_to_string(&session.path).unwrap();
1353        assert!(!text.contains("\"record\":\"rewrite\""), "{text}");
1354
1355        std::fs::remove_dir_all(&dir).ok();
1356    }
1357
1358    #[test]
1359    fn record_run_records_a_rewrite_when_compaction_touched_the_head() {
1360        // The regression this pins, from a 2026-08-07 benchmark transcript:
1361        // a compacted run recorded via the append-only slice kept the stale
1362        // head and skipped the rebuilt one, so the file held 8 assistant
1363        // turns of a 28-turn run, beginning mid-conversation, with no sign a
1364        // compaction had happened. Resuming that transcript resumes a
1365        // conversation the run never had.
1366        let dir = tmpdir();
1367        let session = Session::create(&dir, meta_with_id("20260101T000000-rw")).unwrap();
1368        let before = vec![Message::user("go")];
1369        session.append_messages(&before).unwrap();
1370
1371        // What compaction leaves behind: the head rewritten in place
1372        // (instruction plus summary), then the surviving tail.
1373        let mut head = before[0].clone();
1374        head.content
1375            .push(Block::text("[Earlier turns were compacted]"));
1376        let after = Conversation::from(vec![head, Message::assistant(vec![Block::text("done")])]);
1377        session.record_run(&before, &after).unwrap();
1378
1379        let (_, convo) = Session::load(&session.path).unwrap();
1380        assert_eq!(convo.messages.len(), 2);
1381        assert!(
1382            convo.messages[0].text().contains("compacted"),
1383            "the rebuilt head must be what loads: {:?}",
1384            convo.messages[0].text()
1385        );
1386        assert_eq!(convo.messages[1].text(), "done");
1387
1388        std::fs::remove_dir_all(&dir).ok();
1389    }
1390
1391    /// The gap this closes: a run long enough to compact *itself* produced
1392    /// turns the file never saw — the front-end records at run end, and the
1393    /// rewrite record carries only what survived. With the pre-rewrite states
1394    /// walked first, the dropped turn is in the file (where `recall` searches
1395    /// the union) while `load` still returns only the final state.
1396    #[test]
1397    fn record_run_walks_the_states_a_mid_run_rewrite_replaced() {
1398        let dir = tmpdir();
1399        let session = Session::create(&dir, meta_with_id("20260101T000000-midrun")).unwrap();
1400        let before = vec![Message::user("go")];
1401        session.append_messages(&before).unwrap();
1402
1403        // The state the run reached before compaction: the opening message
1404        // plus a turn holding the detail the summary will drop.
1405        let mut reached = before.clone();
1406        reached.push(Message::assistant(vec![Block::text(
1407            "the magic number is 74656",
1408        )]));
1409        // What compaction left, then one more turn on top of it.
1410        let compacted = vec![
1411            Message::user("[summary: a number was computed]"),
1412            Message::assistant(vec![Block::text("done")]),
1413        ];
1414        let mut convo = Conversation::from(compacted);
1415        convo.rewritten = vec![reached];
1416
1417        session.record_run(&before, &convo).unwrap();
1418
1419        // Loading replays to the final state — the summary, not the head.
1420        let (_, loaded) = Session::load(&session.path).unwrap();
1421        assert_eq!(loaded.messages.len(), 2);
1422        assert!(loaded.messages[0].text().contains("summary"));
1423
1424        // And the dropped detail is in the file all the same, which is what
1425        // recall's union over every recorded message reads back.
1426        let text = std::fs::read_to_string(&session.path).unwrap();
1427        assert!(
1428            text.contains("74656"),
1429            "the pre-rewrite turn never reached the file: {text}"
1430        );
1431
1432        std::fs::remove_dir_all(&dir).ok();
1433    }
1434
1435    #[test]
1436    fn a_rewrite_drops_stale_taint_positions_instead_of_shadowing_later_ones() {
1437        // The record order the front-ends actually write, across two runs of
1438        // one chat session: run 1's messages and its clean checkpoint, then
1439        // run 2 compacts (a rewrite, shrinking the list) after reading a
1440        // hostile page, and checkpoints — `Rewrite` then `Taint`, with no
1441        // message record between. A stale checkpoint kept in any form sits
1442        // at-or-before the new length, and `covering` takes the *first*
1443        // checkpoint past an index, so keeping it hands every rewritten
1444        // message the older, clean taint — under-tainting, the one direction
1445        // the timeline must never be wrong in.
1446        let msg = || Message::user("m");
1447        let mut records: Vec<Record> = (0..10).map(|_| Record::Message(msg())).collect();
1448        records.push(Record::Taint(Taint {
1449            private: true,
1450            untrusted: false,
1451        }));
1452        records.push(Record::Rewrite {
1453            messages: vec![msg(), msg()],
1454        });
1455        records.push(Record::Taint(Taint {
1456            private: true,
1457            untrusted: true,
1458        }));
1459
1460        let timeline = TaintTimeline::from_records(records);
1461        // Every position in the rewritten list is covered by the post-rewrite
1462        // checkpoint, which merged the dropped one's taint — over-taint,
1463        // never under.
1464        for index in 0..2 {
1465            let covering = timeline.covering(index).expect("a checkpoint covers it");
1466            assert!(
1467                covering.untrusted,
1468                "message {index} classified by a stale pre-rewrite checkpoint"
1469            );
1470            assert!(covering.private, "the dropped checkpoint's taint was lost");
1471        }
1472    }
1473
1474    #[test]
1475    fn a_transcript_torn_after_a_rewrite_reports_unknown_not_clean() {
1476        // The process died between writing the rewrite and its taint
1477        // checkpoint. Nothing covers the rewritten messages, and `covering`
1478        // must say so — the learning classifier treats unknown as untrusted,
1479        // and a clean answer here would be the laundering path.
1480        let msg = || Message::user("m");
1481        let records = vec![
1482            Record::Message(msg()),
1483            Record::Taint(Taint {
1484                private: true,
1485                untrusted: true,
1486            }),
1487            Record::Rewrite {
1488                messages: vec![msg(), msg()],
1489            },
1490        ];
1491        let timeline = TaintTimeline::from_records(records);
1492        assert_eq!(timeline.covering(0), None);
1493        assert_eq!(timeline.covering(1), None);
1494    }
1495
1496    #[test]
1497    fn taint_records_merge_so_a_later_clean_one_cannot_disarm_the_interlock() {
1498        let dir = tmpdir();
1499        let session = Session::create(&dir, meta_with_id("20260101T000000-merge")).unwrap();
1500
1501        // The order a real run writes them in: one leg arrives, then the other,
1502        // and the loop may checkpoint again with nothing new to say.
1503        session
1504            .append(&Record::Taint(Taint {
1505                untrusted: true,
1506                private: false,
1507            }))
1508            .unwrap();
1509        session
1510            .append(&Record::Taint(Taint {
1511                private: true,
1512                untrusted: false,
1513            }))
1514            .unwrap();
1515        session.append(&Record::Taint(Taint::default())).unwrap();
1516
1517        let (_, convo) = Session::load(&session.path).unwrap();
1518
1519        // Replacing rather than merging would leave this clean, and resuming
1520        // would hand the model the attacker's page with the guard switched off.
1521        assert!(convo.taint.private, "an earlier private leg was dropped");
1522        assert!(
1523            convo.taint.untrusted,
1524            "an earlier untrusted leg was dropped"
1525        );
1526        assert!(convo.taint.trifecta_armed());
1527
1528        std::fs::remove_dir_all(&dir).ok();
1529    }
1530
1531    #[test]
1532    fn a_transcript_written_before_taint_was_recorded_loads_clean() {
1533        let dir = tmpdir();
1534        let session = Session::create(&dir, meta_with_id("20260101T000000-old")).unwrap();
1535        session.append_messages(&[Message::user("hello")]).unwrap();
1536
1537        let (_, convo) = Session::load(&session.path).unwrap();
1538
1539        assert_eq!(convo.messages.len(), 1);
1540        assert!(!convo.taint.private);
1541        assert!(!convo.taint.untrusted);
1542
1543        std::fs::remove_dir_all(&dir).ok();
1544    }
1545
1546    #[test]
1547    fn a_truncated_final_line_does_not_lose_the_rest_of_the_transcript() {
1548        use std::io::Write;
1549        let dir = tmpdir();
1550        let session = Session::create(&dir, meta_with_id("20260101T000000-killed")).unwrap();
1551        session.append_messages(&[Message::user("first")]).unwrap();
1552        session
1553            .append(&Record::Taint(Taint {
1554                private: true,
1555                untrusted: false,
1556            }))
1557            .unwrap();
1558
1559        // What a killed process leaves behind: a half-written final record.
1560        let mut file = std::fs::OpenOptions::new()
1561            .append(true)
1562            .open(&session.path)
1563            .unwrap();
1564        write!(file, "{{\"record\":\"message\",\"role\":\"assis").unwrap();
1565        drop(file);
1566
1567        let (_, convo) = Session::load(&session.path).unwrap();
1568
1569        assert_eq!(convo.messages.len(), 1);
1570        assert_eq!(convo.messages[0].text(), "first");
1571        assert!(
1572            convo.taint.private,
1573            "a torn last line lost the taint before it"
1574        );
1575
1576        std::fs::remove_dir_all(&dir).ok();
1577    }
1578
1579    #[test]
1580    fn run_configs_come_back_in_order_one_per_attach() {
1581        let dir = tmpdir();
1582        let session = Session::create(&dir, meta_with_id("20260101T000000-cfg")).unwrap();
1583
1584        // What a resume under different flags looks like on disk.
1585        let first = RunConfig {
1586            compact_at_tokens: None,
1587            ..RunConfig::default()
1588        };
1589        let second = RunConfig {
1590            compact_at_tokens: Some(1200),
1591            ..RunConfig::default()
1592        };
1593        session.append(&Record::Config(first)).unwrap();
1594        session
1595            .append_messages(&[Message::user("first run")])
1596            .unwrap();
1597        session.append(&Record::Config(second)).unwrap();
1598
1599        let configs = Session::run_configs(&session.path).unwrap();
1600
1601        assert_eq!(configs.len(), 2, "one record per attach, in order");
1602        assert_eq!(configs[0].compact_at_tokens, None);
1603        // The turns before and after are not comparable, and only a per-attach
1604        // record can say where the line is.
1605        assert_eq!(configs[1].compact_at_tokens, Some(1200));
1606
1607        // And the messages still load, unbothered by the new record type.
1608        let (_, convo) = Session::load(&session.path).unwrap();
1609        assert_eq!(convo.messages.len(), 1);
1610
1611        std::fs::remove_dir_all(&dir).ok();
1612    }
1613
1614    /// The replay driver's question, answered positionally: which config was
1615    /// in effect *at this message* — not `first()`, which replayed a resumed
1616    /// session's later attach under the first attach's system prompt and
1617    /// tool list, diverging for reasons that said nothing about the turn
1618    /// being probed.
1619    #[test]
1620    fn config_covering_names_the_attach_a_message_actually_ran_under() {
1621        let dir = tmpdir();
1622        let session = Session::create(&dir, meta_with_id("20260101T000000-cover")).unwrap();
1623        let first = RunConfig::default();
1624        let second = RunConfig {
1625            compact_at_tokens: Some(1200),
1626            ..RunConfig::default()
1627        };
1628        session.append(&Record::Config(first)).unwrap();
1629        session
1630            .append_messages(&[
1631                Message::user("first attach"),
1632                Message::assistant(vec![Block::text("done")]),
1633            ])
1634            .unwrap();
1635        session.append(&Record::Config(second)).unwrap();
1636        session
1637            .append_messages(&[Message::user("second attach")])
1638            .unwrap();
1639
1640        let t = Session::read(&session.path).unwrap();
1641        assert_eq!(
1642            t.config_covering(0).unwrap().compact_at_tokens,
1643            None,
1644            "message 0 ran under the first attach"
1645        );
1646        assert_eq!(
1647            t.config_covering(2).unwrap().compact_at_tokens,
1648            Some(1200),
1649            "message 2 ran under the second attach"
1650        );
1651
1652        std::fs::remove_dir_all(&dir).ok();
1653    }
1654
1655    /// The failed-turn rollback writes a *truncating* rewrite — the new list
1656    /// is a strict prefix of the recorded one, nothing rewritten — and the
1657    /// review found the zero-clamp collapsing it anyway: one provider error
1658    /// in a resumed session made every head message report the newest
1659    /// attach's config, reintroducing the replay-under-the-wrong-config
1660    /// divergence the positional lookup exists to prevent. A truncation
1661    /// keeps its positions exact.
1662    #[test]
1663    fn a_truncating_rewrite_keeps_config_positions_exact() {
1664        let dir = tmpdir();
1665        let session = Session::create(&dir, meta_with_id("20260101T000000-trunc")).unwrap();
1666        let a = RunConfig::default();
1667        let b = RunConfig {
1668            compact_at_tokens: Some(1200),
1669            ..RunConfig::default()
1670        };
1671        // Attach A: messages 0-1. Attach B: message 2, a user turn whose run
1672        // then fails.
1673        session.append(&Record::Config(a)).unwrap();
1674        session
1675            .append_messages(&[
1676                Message::user("first attach"),
1677                Message::assistant(vec![Block::text("done")]),
1678            ])
1679            .unwrap();
1680        session.append(&Record::Config(b)).unwrap();
1681        session
1682            .append_messages(&[Message::user("the turn that fails")])
1683            .unwrap();
1684        // The failed-turn rollback, exactly as every error arm now runs it:
1685        // restore-then-pop, then record the rolled-back state — a strict
1686        // prefix, which record_run expresses as a truncating Rewrite.
1687        let before = Session::load(&session.path).unwrap().1.messages;
1688        let mut convo = crate::agent::Conversation::from(before.clone());
1689        convo.roll_back_failed_turn(before.clone());
1690        session.record_run(&before, &convo).unwrap();
1691
1692        let t = Session::read(&session.path).unwrap();
1693        assert_eq!(
1694            t.config_covering(0).unwrap().compact_at_tokens,
1695            None,
1696            "message 0 ran under attach A and must still say so after the rollback"
1697        );
1698        assert_eq!(
1699            t.config_covering(1).unwrap().compact_at_tokens,
1700            None,
1701            "message 1 likewise"
1702        );
1703        // A turn appended after the rollback runs under the attach still in
1704        // flight — B.
1705        assert_eq!(
1706            t.config_covering(2).unwrap().compact_at_tokens,
1707            Some(1200),
1708            "the next appended turn is attach B's"
1709        );
1710
1711        // And a *fold* rewrite — same length, only the tail's content
1712        // extended, which is what a barge-in submit writes — preserves
1713        // positions the same way: message 0 still ran under attach A.
1714        let mut folded = Session::load(&session.path).unwrap().1.messages;
1715        let barged = Message::user("the turn that barged in");
1716        session.append(&Record::Message(barged.clone())).unwrap();
1717        folded.push(barged);
1718        crate::agent::append_user_text(&mut folded, "and another thing".into());
1719        session
1720            .append(&Record::Rewrite {
1721                messages: folded.clone(),
1722            })
1723            .unwrap();
1724        let t = Session::read(&session.path).unwrap();
1725        assert_eq!(
1726            t.config_covering(0).unwrap().compact_at_tokens,
1727            None,
1728            "a fold rewrite must not collapse the head onto the newest attach"
1729        );
1730
1731        // An eviction-shaped rewrite — content changed *in place* (a
1732        // superseded result stubbed out), length at least the persisted
1733        // list's — preserves positions too: it changes what a message says,
1734        // never which message it is. Found on review: the head-equality
1735        // check wrongly failed this exact case, whose whole point is that
1736        // content differs.
1737        let mut evicted = folded.clone();
1738        evicted[1] = Message::assistant(vec![Block::text("[superseded]")]);
1739        session
1740            .append(&Record::Rewrite {
1741                messages: evicted.clone(),
1742            })
1743            .unwrap();
1744        let t = Session::read(&session.path).unwrap();
1745        assert_eq!(
1746            t.config_covering(0).unwrap().compact_at_tokens,
1747            None,
1748            "an in-place eviction must not collapse the head onto the newest attach"
1749        );
1750
1751        std::fs::remove_dir_all(&dir).ok();
1752    }
1753
1754    /// A *summarising* rewrite replaces the list, so positions recorded
1755    /// against the old one are claims about a list that no longer exists —
1756    /// they clamp to zero, and the config in flight at the rewrite (the
1757    /// last of them) covers the rewritten head. This is the `fill(0)`
1758    /// branch's own coverage (found missing on review: the truncation test
1759    /// exercises only the in-place branch), so the fixture carries two
1760    /// configs and asserts the head resolves to the *newest*. A transcript
1761    /// with no configs at all answers `None`, never a default.
1762    #[test]
1763    fn config_covering_survives_a_rewrite_and_answers_none_without_configs() {
1764        let dir = tmpdir();
1765        let session = Session::create(&dir, meta_with_id("20260101T000000-coverrw")).unwrap();
1766        let a = RunConfig::default();
1767        let b = RunConfig {
1768            compact_at_tokens: Some(1200),
1769            ..RunConfig::default()
1770        };
1771        session.append(&Record::Config(a)).unwrap();
1772        session
1773            .append_messages(&[
1774                Message::user("a long history"),
1775                Message::assistant(vec![Block::text("...")]),
1776            ])
1777            .unwrap();
1778        session.append(&Record::Config(b)).unwrap();
1779        session.append_messages(&[Message::user("more")]).unwrap();
1780        session
1781            .append(&Record::Rewrite {
1782                messages: vec![Message::user("[summary]")],
1783            })
1784            .unwrap();
1785
1786        let t = Session::read(&session.path).unwrap();
1787        assert_eq!(
1788            t.config_covering(0).unwrap().compact_at_tokens,
1789            Some(1200),
1790            "a summarising rewrite's head resolves to the config in flight — \
1791             the newest of the clamped ones, not the first attach's"
1792        );
1793
1794        let bare = Session::create(&dir, meta_with_id("20260101T000001-bare")).unwrap();
1795        bare.append_messages(&[Message::user("hello")]).unwrap();
1796        assert!(Session::read(&bare.path)
1797            .unwrap()
1798            .config_covering(0)
1799            .is_none());
1800
1801        std::fs::remove_dir_all(&dir).ok();
1802    }
1803
1804    #[test]
1805    fn a_transcript_recorded_before_this_existed_reports_no_configs() {
1806        // Not an error: it is the honest answer, and it is what tells a replay
1807        // driver the recording cannot be reproduced faithfully.
1808        let dir = tmpdir();
1809        let session = Session::create(&dir, meta_with_id("20260101T000000-legacy")).unwrap();
1810        session.append_messages(&[Message::user("hello")]).unwrap();
1811
1812        assert!(Session::run_configs(&session.path).unwrap().is_empty());
1813
1814        std::fs::remove_dir_all(&dir).ok();
1815    }
1816
1817    #[test]
1818    fn the_taint_timeline_covers_each_message_with_its_runs_checkpoint() {
1819        let dir = tmpdir();
1820        let session = Session::create(&dir, meta_with_id("20260101T000000-tl")).unwrap();
1821
1822        // Run one: clean. Its checkpoint lands after its messages.
1823        session
1824            .append_messages(&[Message::user("list the files")])
1825            .unwrap();
1826        session
1827            .append_messages(&[Message::assistant(vec![Block::text("done")])])
1828            .unwrap();
1829        session.append(&Record::Taint(Taint::default())).unwrap();
1830        // Run two: a hostile page enters; the checkpoint records it.
1831        session
1832            .append_messages(&[Message::user("fetch that page")])
1833            .unwrap();
1834        session
1835            .append_messages(&[Message::assistant(vec![Block::text("fetched")])])
1836            .unwrap();
1837        session
1838            .append(&Record::Taint(Taint {
1839                untrusted: true,
1840                private: false,
1841            }))
1842            .unwrap();
1843
1844        let tl = Session::taint_timeline(&session.path).unwrap();
1845
1846        // Messages 0–1 are covered by the clean checkpoint...
1847        assert!(!tl.covering(0).unwrap().untrusted);
1848        assert!(!tl.covering(1).unwrap().untrusted);
1849        // ...2–3 by the armed one. Over-tainting within a run is the safe
1850        // direction: a fetch later in the same run counts against a message
1851        // before it, never the reverse.
1852        assert!(tl.covering(2).unwrap().untrusted);
1853        assert!(tl.covering(3).unwrap().untrusted);
1854        // Beyond the last checkpoint is unknown, and unknown is the caller's
1855        // cue to fail closed.
1856        assert_eq!(tl.covering(4).map(|t| t.untrusted), None);
1857
1858        // `Session::read` builds the same timeline in its one pass — the
1859        // two must never disagree, or the single-read callers (`distill`,
1860        // the closure appraisal) classify provenance differently from the
1861        // dedicated reader.
1862        let carried = Session::read(&session.path).unwrap().taint_timeline;
1863        for i in 0..5 {
1864            assert_eq!(
1865                carried.covering(i).map(|t| t.untrusted),
1866                tl.covering(i).map(|t| t.untrusted),
1867                "read()'s carried timeline diverged from taint_timeline() at {i}"
1868            );
1869        }
1870
1871        std::fs::remove_dir_all(&dir).ok();
1872    }
1873
1874    #[test]
1875    fn a_pre_taint_transcript_has_an_empty_timeline() {
1876        // Sessions recorded before taint existed can establish nothing, so
1877        // every position must come back None — which classification turns
1878        // into Untrusted, never Clean.
1879        let dir = tmpdir();
1880        let session = Session::create(&dir, meta_with_id("20260101T000000-notl")).unwrap();
1881        session.append_messages(&[Message::user("hello")]).unwrap();
1882
1883        let tl = Session::taint_timeline(&session.path).unwrap();
1884        assert!(tl.covering(0).is_none());
1885
1886        std::fs::remove_dir_all(&dir).ok();
1887    }
1888
1889    #[test]
1890    fn listing_reads_only_the_first_record_and_skips_files_without_a_header() {
1891        let dir = tmpdir();
1892        let session = Session::create(&dir, meta_with_id("20260101T000000-peek")).unwrap();
1893        session.append_messages(&[Message::user("hello")]).unwrap();
1894
1895        // A stray JSONL file whose first record is not a header is skipped —
1896        // the contract is now explicitly "the header is the first record",
1897        // which is where `create` writes it; buried headers no longer count,
1898        // and that is the price of listing without parsing every transcript.
1899        let stray = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
1900        let meta = serde_json::to_string(&Record::Meta(meta_with_id("buried"))).unwrap();
1901        std::fs::write(dir.join("stray.jsonl"), format!("{stray}\n{meta}\n")).unwrap();
1902
1903        let listed = Session::list(&dir).unwrap();
1904        assert_eq!(listed.len(), 1);
1905        assert_eq!(listed[0].0.id, "20260101T000000-peek");
1906
1907        // Skipped is not forgotten: the counting variant reports the same
1908        // sessions plus how many files it had to skip, so a reporting
1909        // caller can surface the rot the best-effort walk steps over.
1910        let (counted, unreadable) = Session::list_counting(&dir).unwrap();
1911        assert_eq!(counted.len(), 1);
1912        assert_eq!(unreadable, 1);
1913
1914        // And the peek agrees with the full load about what the header says.
1915        let peeked = Session::peek_meta(&session.path).unwrap();
1916        let (loaded, _) = Session::load(&session.path).unwrap();
1917        assert_eq!(peeked.id, loaded.id);
1918        assert_eq!(peeked.model, loaded.model);
1919
1920        std::fs::remove_dir_all(&dir).ok();
1921    }
1922
1923    #[test]
1924    fn an_outcome_record_survives_a_round_trip_and_does_not_disturb_the_transcript() {
1925        use crate::agent::{RunOutcome, StopCause, ToolCallTrace};
1926        use crate::message::StopReason;
1927
1928        let dir = tmpdir();
1929        let session = Session::create(&dir, meta_with_id("20260101T000000-outcome")).unwrap();
1930        session
1931            .append_messages(&[Message::user("go"), Message::assistant(vec![])])
1932            .unwrap();
1933
1934        let call = |is_error: bool, denied: bool, unknown: bool, staged: bool| ToolCallTrace {
1935            name: "fs_edit".into(),
1936            input: serde_json::json!({}),
1937            is_error,
1938            denied,
1939            unknown,
1940            staged,
1941        };
1942        let outcome = RunOutcome {
1943            homeostat: None,
1944            context_overflows: 0,
1945            boredom_notices: 0,
1946            step_escalations_attempted: 0,
1947            step_escalations_revised: 0,
1948            text: "done".into(),
1949            stop_reason: StopReason::EndTurn,
1950            usage: Usage {
1951                input_tokens: 10,
1952                output_tokens: 4,
1953                ..Usage::default()
1954            },
1955            turns: 3,
1956            refusal: None,
1957            exhausted: false,
1958            ended_on_failed_call: true,
1959            tool_calls: vec![
1960                call(true, false, false, false),
1961                call(false, false, true, false),
1962                call(false, true, false, false),
1963                call(false, false, false, true),
1964            ],
1965            malformed_tool_args: 1,
1966            blocked_sends: 2,
1967            taint: Taint {
1968                private: true,
1969                untrusted: false,
1970            },
1971            stop_cause: StopCause::Completed,
1972            compactions: 4,
1973            cost_usd: Some(0.5),
1974            usage_complete: true,
1975        };
1976        session.record_outcome(&outcome).unwrap();
1977
1978        let stats = Session::outcomes(&session.path).unwrap();
1979        assert_eq!(stats.len(), 1);
1980        let got = &stats[0];
1981        assert_eq!(got.turns, 3);
1982        assert_eq!(got.stop_cause, Some(StopCause::Completed));
1983        assert!(got.ended_on_failed_call);
1984        assert_eq!(got.tool_calls, 4);
1985        // The environment refusing: the error and the unknown tool. A denial
1986        // is the harness working and must never be averaged in with failure.
1987        assert_eq!(got.tool_errors, 2);
1988        assert_eq!(got.tool_denied, 1);
1989        assert_eq!(got.tool_staged, 1);
1990        assert_eq!(got.malformed_tool_args, 1);
1991        assert_eq!(got.blocked_sends, 2);
1992        assert_eq!(got.compactions, 4);
1993        assert!(got.taint.private && !got.taint.untrusted);
1994
1995        // And it is inert to every existing reader: the record is not a
1996        // message, so the conversation is unchanged, and `usage_totals`
1997        // counts `Summary` records only.
1998        let (_, convo) = Session::load(&session.path).unwrap();
1999        assert_eq!(convo.messages.len(), 2);
2000        assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
2001    }
2002
2003    #[test]
2004    fn an_episode_of_several_runs_sums_its_costs_and_takes_its_ending_from_the_last() {
2005        use crate::agent::{RunOutcome, StopCause, ToolCallTrace};
2006        use crate::message::StopReason;
2007
2008        let outcome =
2009            |turns: u32, calls: usize, errored: bool, ended_failed: bool, cause| RunOutcome {
2010                homeostat: None,
2011                context_overflows: 0,
2012                boredom_notices: 0,
2013                step_escalations_attempted: 0,
2014                step_escalations_revised: 0,
2015                text: String::new(),
2016                stop_reason: StopReason::EndTurn,
2017                usage: Usage {
2018                    input_tokens: 10,
2019                    output_tokens: 1,
2020                    ..Usage::default()
2021                },
2022                turns,
2023                refusal: None,
2024                exhausted: false,
2025                ended_on_failed_call: ended_failed,
2026                tool_calls: (0..calls)
2027                    .map(|_| ToolCallTrace {
2028                        name: "fs_edit".into(),
2029                        input: serde_json::json!({}),
2030                        is_error: errored,
2031                        denied: false,
2032                        unknown: false,
2033                        staged: false,
2034                    })
2035                    .collect(),
2036                malformed_tool_args: 1,
2037                blocked_sends: 0,
2038                taint: Taint {
2039                    private: true,
2040                    untrusted: false,
2041                },
2042                stop_cause: cause,
2043                compactions: 1,
2044                cost_usd: Some(0.25),
2045                usage_complete: true,
2046            };
2047
2048        let mut stats = RunStats {
2049            usage_complete: true,
2050            ..RunStats::default()
2051        };
2052        // Turn one fails and ends over the failure; turn two recovers.
2053        stats.absorb(&outcome(2, 3, true, true, StopCause::MaxTurns));
2054        stats.absorb(&outcome(4, 5, false, false, StopCause::Completed));
2055
2056        // Costs sum: the episode really did spend all of it.
2057        assert_eq!(stats.turns, 6);
2058        assert_eq!(stats.tool_calls, 8);
2059        assert_eq!(stats.tool_errors, 3);
2060        assert_eq!(stats.malformed_tool_args, 2);
2061        assert_eq!(stats.compactions, 2);
2062        assert_eq!(stats.cost_usd, Some(0.5));
2063        assert_eq!(stats.usage.input_tokens, 20);
2064
2065        // The ending is the last run's. An episode whose first turn ended on
2066        // a failure and whose second recovered has not finished over one.
2067        assert_eq!(stats.stop_cause, Some(StopCause::Completed));
2068        assert!(!stats.ended_on_failed_call);
2069
2070        // Taint merges and never resets: a later clean run does not un-read
2071        // what an earlier one read.
2072        assert!(stats.taint.private);
2073    }
2074
2075    /// `merge` had no arm for this field at all, so `fold`'s first-row seed
2076    /// kept whatever the first run recorded and every later run's notices
2077    /// were silently dropped — diluting the exact rate the sensor exists to
2078    /// establish, in the direction `context_overflows`' own `Option` is
2079    /// there to prevent.
2080    #[test]
2081    fn boredom_notices_sum_across_an_episodes_runs_like_context_overflows() {
2082        let mut stats = RunStats {
2083            boredom_notices: Some(2),
2084            ..RunStats::default()
2085        };
2086        stats.merge(&RunStats {
2087            boredom_notices: Some(3),
2088            ..RunStats::default()
2089        });
2090        assert_eq!(stats.boredom_notices, Some(5));
2091
2092        // `None` behaves like `context_overflows`: a live run always knows
2093        // its own count, so `None` only arises from a pre-sensor row, and
2094        // `or` keeps whichever side had a sensor rather than treating the
2095        // other's silence as zero.
2096        let mut unsampled = RunStats {
2097            boredom_notices: None,
2098            ..RunStats::default()
2099        };
2100        unsampled.merge(&RunStats {
2101            boredom_notices: Some(1),
2102            ..RunStats::default()
2103        });
2104        assert_eq!(unsampled.boredom_notices, Some(1));
2105    }
2106
2107    #[test]
2108    fn one_lower_bound_turn_makes_the_whole_episode_a_lower_bound() {
2109        use crate::agent::{RunOutcome, StopCause};
2110        use crate::message::StopReason;
2111
2112        let mut incomplete = RunOutcome {
2113            homeostat: None,
2114            context_overflows: 0,
2115            boredom_notices: 0,
2116            step_escalations_attempted: 0,
2117            step_escalations_revised: 0,
2118            text: String::new(),
2119            stop_reason: StopReason::Other,
2120            usage: Usage::default(),
2121            turns: 1,
2122            refusal: None,
2123            exhausted: true,
2124            ended_on_failed_call: false,
2125            tool_calls: Vec::new(),
2126            malformed_tool_args: 0,
2127            blocked_sends: 0,
2128            taint: Taint::default(),
2129            stop_cause: StopCause::Interrupted,
2130            compactions: 0,
2131            cost_usd: None,
2132            usage_complete: false,
2133        };
2134
2135        let mut stats = RunStats {
2136            usage_complete: true,
2137            ..RunStats::default()
2138        };
2139        stats.absorb(&incomplete);
2140        assert!(!stats.usage_complete);
2141
2142        // And it stays false: a later complete turn cannot repair a total
2143        // that already lost a measurement.
2144        incomplete.usage_complete = true;
2145        stats.absorb(&incomplete);
2146        assert!(!stats.usage_complete);
2147    }
2148
2149    #[test]
2150    fn a_transcript_with_no_outcome_records_reads_as_empty_not_as_an_error() {
2151        // Sessions written before this record existed, and runs that died
2152        // before producing an outcome. Unknown is not zero-with-confidence,
2153        // but it must not be a failure either.
2154        let dir = tmpdir();
2155        let session = Session::create(&dir, meta_with_id("20260101T000000-no-outcome")).unwrap();
2156        session.append_messages(&[Message::user("go")]).unwrap();
2157        assert!(Session::outcomes(&session.path).unwrap().is_empty());
2158    }
2159
2160    #[test]
2161    fn usage_totals_sum_every_run_and_report_zero_for_a_summaryless_file() {
2162        let dir = tmpdir();
2163        let session = Session::create(&dir, meta_with_id("20260101T000000-usage")).unwrap();
2164
2165        // No summary yet — a run that died mid-flight. Zero, not an error.
2166        assert_eq!(Session::usage_totals(&session.path).unwrap().1, 0);
2167
2168        // Two runs on one session (chat, resume): the totals are the sum.
2169        session
2170            .append(&Record::Summary {
2171                usage: Usage {
2172                    input_tokens: 100,
2173                    output_tokens: 10,
2174                    ..Default::default()
2175                },
2176                turns: 2,
2177            })
2178            .unwrap();
2179        session
2180            .append(&Record::Summary {
2181                usage: Usage {
2182                    input_tokens: 50,
2183                    output_tokens: 5,
2184                    ..Default::default()
2185                },
2186                turns: 1,
2187            })
2188            .unwrap();
2189
2190        let (usage, turns) = Session::usage_totals(&session.path).unwrap();
2191        assert_eq!(usage.input_tokens, 150);
2192        assert_eq!(usage.output_tokens, 15);
2193        assert_eq!(turns, 3);
2194
2195        std::fs::remove_dir_all(&dir).ok();
2196    }
2197
2198    #[cfg(unix)]
2199    #[test]
2200    fn the_session_directory_is_owner_only() {
2201        use std::os::unix::fs::PermissionsExt;
2202        // A fresh path, so `create` makes the directory itself.
2203        let dir = std::env::temp_dir().join(format!("mecha-session-{}", uuid::Uuid::new_v4()));
2204        Session::create(&dir, meta_with_id("20260101T000000-perms")).unwrap();
2205
2206        // Transcripts hold whatever the tools returned — mail bodies
2207        // included — so the directory gets the token-file rule.
2208        let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
2209        assert_eq!(mode & 0o777, 0o700);
2210
2211        std::fs::remove_dir_all(&dir).ok();
2212    }
2213
2214    #[test]
2215    fn a_transcript_with_no_header_is_refused() {
2216        let dir = tmpdir();
2217        let path = dir.join("headerless.jsonl");
2218        let line = serde_json::to_string(&Record::Message(Message::user("orphan"))).unwrap();
2219        std::fs::write(&path, format!("{line}\n")).unwrap();
2220
2221        let err = Session::load(&path).unwrap_err().to_string();
2222        assert!(err.contains("no session header"), "unexpected error: {err}");
2223
2224        std::fs::remove_dir_all(&dir).ok();
2225    }
2226
2227    #[test]
2228    fn an_ambiguous_id_prefix_is_an_error_rather_than_a_guess() {
2229        let dir = tmpdir();
2230        Session::create(&dir, meta_with_id("20260101T000000-aaaaaaaa")).unwrap();
2231        Session::create(&dir, meta_with_id("20260101T000000-bbbbbbbb")).unwrap();
2232
2233        let err = Session::find(&dir, "20260101").unwrap_err().to_string();
2234        assert!(
2235            err.contains("matches 2 sessions"),
2236            "unexpected error: {err}"
2237        );
2238
2239        // A full id still resolves, and resuming the wrong transcript is the
2240        // failure being guarded against.
2241        let path = Session::find(&dir, "20260101T000000-aaaaaaaa").unwrap();
2242        assert!(path.ends_with("20260101T000000-aaaaaaaa.jsonl"));
2243
2244        assert!(Session::find(&dir, "nothing-like-this").is_err());
2245
2246        std::fs::remove_dir_all(&dir).ok();
2247    }
2248}