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}