Skip to main content

leviath_core/
run_archive.rs

1//! A portable, self-contained record of an entire agent run.
2//!
3//! A run archive is a single append-only file that captures everything about a
4//! run - who owns it (which machine, which world/daemon instance), its metadata,
5//! every inference and tool batch, inbound messages, and the evolving context
6//! window - with enough fidelity that copying the file to another machine lets a
7//! daemon **continue the run where it left off** (LLM non-determinism aside) or
8//! replay it for debugging.
9//!
10//! ## Layout
11//!
12//! ```text
13//! MAGIC ("LVR1") | version (u16 BE) | frame*
14//! frame := len (u64 BE) | JSON-encoded RunRecord
15//! ```
16//!
17//! The framing is binary and codec-agnostic (a future release can swap the JSON
18//! payload for a compact binary codec without changing readers that only seek by
19//! frame length). The first record is always a [`RunRecord::Header`].
20//!
21//! ## Portability / future migration
22//!
23//! [`RunIdentity`] records which machine + world/daemon instance owns a run, and
24//! [`RunRecord::OwnershipChanged`] records a handoff. This is deliberately more
25//! than today needs: the format is meant to eventually let a run start on one
26//! machine, pause, and resume on another - including a machine declining a run
27//! whose tools it lacks and waiting for a capable host. That scheduling logic
28//! isn't built yet; the format simply reserves room for it (ownership handoffs
29//! are first-class, the version field gates changes, and new record variants can
30//! be added without disturbing the frame layout).
31//!
32//! ## Efficiency
33//!
34//! Context windows are the bulk of a run. Rather than snapshot the whole window
35//! on every step, a writer emits an occasional full [`RunRecord::ContextCheckpoint`]
36//! and, between checkpoints, small [`RunRecord::ContextDiff`] records describing
37//! only what changed (the common case between inferences is a pure append to one
38//! region). [`diff_context`]/[`apply_delta`] compute and replay those diffs, and
39//! [`fold`] reconstructs the current state from the whole journal.
40
41use std::io::{self, Read, Write};
42use std::ops::ControlFlow;
43
44use serde::{Deserialize, Serialize};
45
46use crate::run_meta::{ContextSnapshot, RegionEntrySnapshot, RegionSnapshot, RunMeta, RunStatus};
47
48/// File magic identifying a leviath run archive (`b"LVR1"`).
49pub const RUN_ARCHIVE_MAGIC: &[u8; 4] = b"LVR1";
50
51/// The archive format version this build writes.
52pub const RUN_ARCHIVE_VERSION: u16 = 1;
53
54/// Identity + ownership of a run.
55///
56/// `machine_id` + `world_id` make a run unambiguously attributable even when
57/// several daemons share a filesystem and might otherwise pick the same
58/// `run_id` - a daemon can read a run's owner before deciding whether to resume
59/// or leave it alone.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct RunIdentity {
62    /// The run's id (its directory/file name).
63    pub run_id: String,
64    /// Stable fingerprint of the machine that owns the run.
65    pub machine_id: String,
66    /// Id of the specific world/daemon instance that owns the run.
67    pub world_id: String,
68    /// Unix seconds when the archive was created.
69    pub created_at: i64,
70}
71
72/// A conversation message as recorded in the archive.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct MessageRecord {
75    /// `"user"` / `"assistant"` / `"tool"` / `"system"`.
76    pub role: String,
77    /// The message text.
78    pub content: String,
79}
80
81/// A single tool call and (once executed) its result.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct ToolCallRecord {
84    /// The tool-call id.
85    pub id: String,
86    /// The tool name.
87    pub name: String,
88    /// The JSON arguments, stringified.
89    pub arguments: String,
90    /// The result text, once the tool has run (`None` while pending).
91    pub result: Option<String>,
92    /// Opaque provider token that must be replayed with this call (Gemini's
93    /// `thought_signature`). Carried so a restored batch can rebuild the exact
94    /// assistant turn.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub thought_signature: Option<String>,
97}
98
99/// The outbound request of one inference (a provider-agnostic digest - enough to
100/// reproduce/debug the call without depending on `leviath-providers`).
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct InferenceRequestRecord {
103    /// The model the request targeted.
104    pub model: String,
105    /// System-block texts, in order.
106    pub system: Vec<String>,
107    /// The conversation messages sent.
108    pub messages: Vec<MessageRecord>,
109    /// The tool names offered to the model.
110    pub tool_names: Vec<String>,
111    /// The temperature used.
112    pub temperature: f32,
113    /// The max output tokens requested.
114    pub max_tokens: usize,
115}
116
117/// The response of one inference.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct InferenceResponseRecord {
120    /// The assistant's text.
121    pub content: String,
122    /// Any tool calls the model requested.
123    pub tool_calls: Vec<ToolCallRecord>,
124    /// Prompt tokens billed.
125    pub prompt_tokens: usize,
126    /// Completion tokens billed.
127    pub completion_tokens: usize,
128    /// Tokens read from provider cache.
129    pub cached_tokens: usize,
130    /// Tokens written to provider cache.
131    pub cache_write_tokens: usize,
132}
133
134/// A per-region change within a [`ContextDelta`].
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub enum RegionDelta {
137    /// A new region, or a region whose kind/max changed or whose entries were
138    /// rewritten in a non-append way - carried in full.
139    Set(RegionSnapshot),
140    /// Entries appended to an existing region (the common between-inference
141    /// case). The region's kind/max are unchanged.
142    Append {
143        /// The region name.
144        name: String,
145        /// The entries appended after the previously-recorded ones.
146        entries: Vec<RegionEntrySnapshot>,
147        /// The region's new token count.
148        current_tokens: usize,
149    },
150    /// An existing region emptied of entries.
151    Clear {
152        /// The region name.
153        name: String,
154    },
155    /// A region that no longer exists.
156    Remove {
157        /// The region name.
158        name: String,
159    },
160}
161
162/// The change to a context window since the previously-recorded snapshot.
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164pub struct ContextDelta {
165    /// The window's stage name at this point.
166    pub stage_name: String,
167    /// The window's total token count at this point.
168    pub total_tokens: usize,
169    /// The window's max token budget at this point.
170    pub max_tokens: usize,
171    /// Per-region changes.
172    pub regions: Vec<RegionDelta>,
173}
174
175/// Which provider call a [`RunRecord::InferenceUsage`] belongs to.
176///
177/// A run bills for more than its stage turns, and the three auxiliary kinds are
178/// invisible in every other surface: they do not appear in the stage ledger and
179/// nothing else names them. Recording which kind spent the tokens is what turns
180/// a total into an explanation - "this run cost double what its stages did
181/// because its edges compact" is a sentence the journal can now support.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum InferenceKind {
185    /// An ordinary stage turn: the agent thinking or calling tools. The default
186    /// so a journal written before this field existed reads back as stage work,
187    /// which is what every record in one is.
188    #[default]
189    Stage,
190    /// A region-summarizing call, from memory pressure or an edge transform.
191    Compaction,
192    /// The one-off call that names the run.
193    Title,
194    /// A call asking the model which stage to move to next.
195    Routing,
196}
197
198impl InferenceKind {
199    /// A short stable label, for logs and wire formats that want a string.
200    pub fn label(&self) -> &'static str {
201        match self {
202            InferenceKind::Stage => "stage",
203            InferenceKind::Compaction => "compaction",
204            InferenceKind::Title => "title",
205            InferenceKind::Routing => "routing",
206        }
207    }
208
209    /// Whether this call is stage work the agent asked for, as opposed to
210    /// machinery the runtime ran on its behalf.
211    pub fn is_stage_work(&self) -> bool {
212        matches!(self, InferenceKind::Stage)
213    }
214}
215
216/// One entry in the run journal. Folding the sequence reconstructs the run.
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub enum RunRecord {
219    /// The run's identity + static metadata. Always the first record.
220    Header {
221        /// Ownership/identity.
222        identity: RunIdentity,
223        /// The run metadata at archive-creation time.
224        meta: Box<RunMeta>,
225    },
226    /// Ownership handed to a different world/machine (e.g. resumed elsewhere).
227    OwnershipChanged {
228        /// The new owning machine.
229        machine_id: String,
230        /// The new owning world/daemon instance.
231        world_id: String,
232        /// Unix seconds.
233        at: i64,
234    },
235    /// One inference: what went out and what came back.
236    Inference {
237        /// The stage the agent was in.
238        stage: String,
239        /// The stage-local iteration index.
240        iteration: usize,
241        /// The request digest.
242        request: InferenceRequestRecord,
243        /// The response.
244        response: InferenceResponseRecord,
245        /// Unix seconds.
246        at: i64,
247    },
248    /// What one provider call cost, written as it lands.
249    ///
250    /// [`RunRecord::Progress`] carries cumulative counters, so two calls between
251    /// two ticks are indistinguishable downstream: their sum arrives as one
252    /// number, and a chart of it shows a spike no single call ever made. This
253    /// record is per call, so token-over-time telemetry is exact and "no request
254    /// ever exceeded the window" is provable from the journal rather than
255    /// inferred from region-size checkpoints.
256    ///
257    /// Deliberately lighter than [`RunRecord::Inference`], which carries the
258    /// full request and response: every call re-sends the whole window, so
259    /// journaling those bodies for each of them would multiply the file by the
260    /// context size. The window is already recoverable from the surrounding
261    /// [`RunRecord::ContextCheckpoint`] and [`RunRecord::ContextDiff`] records.
262    InferenceUsage {
263        /// Which kind of call this was.
264        #[serde(default)]
265        kind: InferenceKind,
266        /// The stage the run was in. Empty for a call with no stage of its own
267        /// (the title call, which runs once at spawn).
268        stage: String,
269        /// The stage-local iteration index.
270        iteration: usize,
271        /// The provider that served the call.
272        provider: String,
273        /// The model the call targeted.
274        model: String,
275        /// Prompt tokens billed.
276        prompt_tokens: usize,
277        /// Completion tokens billed.
278        completion_tokens: usize,
279        /// Tokens read from provider cache.
280        cached_tokens: usize,
281        /// Tokens written to provider cache.
282        cache_write_tokens: usize,
283        /// Unix seconds.
284        at: i64,
285    },
286    /// A batch of tool calls, written when the batch is dispatched to the tool
287    /// lane - before anything runs. Calls the dispatcher already resolved inline
288    /// (context tools, refusals, gate denials) carry `result: Some(..)`; lane
289    /// calls start at `result: None` and are completed by matching
290    /// [`RunRecord::ToolCallDone`] records as each call finishes. A batch still
291    /// pending at fold time surfaces as [`FoldedRun::pending_batch`] so a
292    /// crash-resume can replay executed calls instead of re-running them.
293    ToolBatch {
294        /// The calls (inline results pre-filled; lane calls pending).
295        calls: Vec<ToolCallRecord>,
296        /// Unix seconds.
297        at: i64,
298        /// The stage index the batch was dispatched in.
299        #[serde(default)]
300        stage_index: usize,
301        /// The stage-local iteration that produced the batch - the batch key
302        /// (one batch per iteration).
303        #[serde(default)]
304        iteration: usize,
305        /// The assistant text of the turn that issued the calls.
306        #[serde(default)]
307        response: String,
308    },
309    /// One tool call of the pending batch finished; its result.
310    ToolCallDone {
311        /// The iteration of the [`RunRecord::ToolBatch`] this belongs to.
312        iteration: usize,
313        /// The tool-call id.
314        call_id: String,
315        /// The result text.
316        result: String,
317        /// Unix seconds.
318        at: i64,
319    },
320    /// A full context-window snapshot that subsequent diffs rebase on.
321    ContextCheckpoint {
322        /// The full window snapshot.
323        snapshot: ContextSnapshot,
324        /// Unix seconds.
325        at: i64,
326    },
327    /// A context-window change since the previous snapshot/diff.
328    ContextDiff {
329        /// The delta.
330        delta: ContextDelta,
331        /// Unix seconds.
332        at: i64,
333    },
334    /// An inbound message.
335    Message {
336        /// The message.
337        message: MessageRecord,
338        /// Unix seconds.
339        at: i64,
340    },
341    /// A run-status change.
342    StatusChanged {
343        /// The new status.
344        status: RunStatus,
345        /// Unix seconds.
346        at: i64,
347    },
348    /// A full resumable checkpoint: the updated metadata + the full window, so a
349    /// reader can continue without folding the whole journal.
350    Checkpoint {
351        /// The run metadata as of this checkpoint.
352        meta: Box<RunMeta>,
353        /// The full window snapshot as of this checkpoint.
354        context: ContextSnapshot,
355        /// Unix seconds.
356        at: i64,
357    },
358    /// A step forward: the updated metadata plus a *diff* of the context window
359    /// since the previous point. This is the compact per-tick record the writer
360    /// emits between full checkpoints - meta is small, and the context (the bulk)
361    /// is carried as a [`ContextDelta`] rather than a full snapshot.
362    Progress {
363        /// The run metadata as of this step.
364        meta: Box<RunMeta>,
365        /// The context change since the previous recorded point.
366        delta: ContextDelta,
367        /// Unix seconds.
368        at: i64,
369    },
370}
371
372// ─── context diffing ────────────────────────────────────────────────────────
373
374/// Whether `prev` is a prefix of `next` (same entries, in order, at the front).
375fn is_prefix(prev: &[RegionEntrySnapshot], next: &[RegionEntrySnapshot]) -> bool {
376    prev.len() <= next.len() && next[..prev.len()] == *prev
377}
378
379/// Compute the minimal-ish [`ContextDelta`] turning `prev` into `next`. Regions
380/// that only grew at the tail become a compact `Append`; everything else is
381/// carried as a `Set`/`Clear`/`Remove`.
382pub fn diff_context(prev: &ContextSnapshot, next: &ContextSnapshot) -> ContextDelta {
383    let mut regions = Vec::new();
384    for nr in &next.regions {
385        match prev.regions.iter().find(|r| r.name == nr.name) {
386            None => regions.push(RegionDelta::Set(nr.clone())),
387            Some(pr) => {
388                if pr == nr {
389                    // unchanged - emit nothing
390                } else if nr.entries.is_empty() && !pr.entries.is_empty() {
391                    regions.push(RegionDelta::Clear {
392                        name: nr.name.clone(),
393                    });
394                } else if pr.kind == nr.kind
395                    && pr.max_tokens == nr.max_tokens
396                    && is_prefix(&pr.entries, &nr.entries)
397                {
398                    regions.push(RegionDelta::Append {
399                        name: nr.name.clone(),
400                        entries: nr.entries[pr.entries.len()..].to_vec(),
401                        current_tokens: nr.current_tokens,
402                    });
403                } else {
404                    regions.push(RegionDelta::Set(nr.clone()));
405                }
406            }
407        }
408    }
409    for pr in &prev.regions {
410        if !next.regions.iter().any(|r| r.name == pr.name) {
411            regions.push(RegionDelta::Remove {
412                name: pr.name.clone(),
413            });
414        }
415    }
416    ContextDelta {
417        stage_name: next.stage_name.clone(),
418        total_tokens: next.total_tokens,
419        max_tokens: next.max_tokens,
420        regions,
421    }
422}
423
424// ─── digest-based diffing ───────────────────────────────────────────────────
425//
426// `diff_context` needs the previous snapshot only to answer two questions per
427// region: "did anything change?" and "did it change by appending at the tail?".
428// A per-entry fingerprint answers both, so the writer can retain this digest
429// instead of a full copy of every live run's context window (which doubled the
430// per-run resident cost of the persistence lane).
431
432/// Fingerprint of one region: everything `diff_context` compares except the
433/// entry contents themselves, which are folded into per-entry hashes.
434#[derive(Debug, Clone, PartialEq)]
435pub struct RegionDigest {
436    /// The region name.
437    pub name: String,
438    /// The region's stringified kind.
439    pub kind: String,
440    /// The region's token count at digest time.
441    pub current_tokens: usize,
442    /// The region's token budget at digest time.
443    pub max_tokens: usize,
444    /// One hash per entry, in order.
445    pub entries: Vec<u64>,
446}
447
448/// Fingerprint of a whole context window, cheap to retain per live run.
449#[derive(Debug, Clone, PartialEq, Default)]
450pub struct ContextDigest {
451    /// Per-region fingerprints, in snapshot order.
452    pub regions: Vec<RegionDigest>,
453}
454
455/// Hash one region entry. Every field participates: two entries that differ
456/// anywhere must digest differently, or a real change would be recorded as
457/// "unchanged" and the folded archive would silently drift from the run.
458fn entry_digest(entry: &RegionEntrySnapshot) -> u64 {
459    use std::hash::{Hash, Hasher};
460    let mut hasher = std::collections::hash_map::DefaultHasher::new();
461    entry.content.hash(&mut hasher);
462    entry.tokens.hash(&mut hasher);
463    entry.key.hash(&mut hasher);
464    // kind / metadata / taint are small enums and values without a Hash impl;
465    // their serialized form is tiny next to `content` and hashes faithfully.
466    serde_json::to_string(&entry.kind)
467        .expect("EntryKind always serializes")
468        .hash(&mut hasher);
469    serde_json::to_string(&entry.metadata)
470        .expect("entry metadata always serializes")
471        .hash(&mut hasher);
472    serde_json::to_string(&entry.taint)
473        .expect("taint always serializes")
474        .hash(&mut hasher);
475    hasher.finish()
476}
477
478/// Compute the retained fingerprint of `snapshot`.
479pub fn digest_context(snapshot: &ContextSnapshot) -> ContextDigest {
480    ContextDigest {
481        regions: snapshot
482            .regions
483            .iter()
484            .map(|r| RegionDigest {
485                name: r.name.clone(),
486                kind: r.kind.clone(),
487                current_tokens: r.current_tokens,
488                max_tokens: r.max_tokens,
489                entries: r.entries.iter().map(entry_digest).collect(),
490            })
491            .collect(),
492    }
493}
494
495/// Whether `prev`'s entry hashes are a prefix of `next`'s entries.
496fn is_prefix_digest(prev: &[u64], next: &[RegionEntrySnapshot]) -> bool {
497    prev.len() <= next.len()
498        && prev
499            .iter()
500            .zip(next)
501            .all(|(hash, entry)| *hash == entry_digest(entry))
502}
503
504/// [`diff_context`] against a retained [`ContextDigest`] instead of a full
505/// previous snapshot. Produces the same delta shapes for the same changes:
506/// unchanged regions emit nothing, tail growth becomes `Append`, everything
507/// else `Set`/`Clear`/`Remove`.
508pub fn diff_context_digest(prev: &ContextDigest, next: &ContextSnapshot) -> ContextDelta {
509    let mut regions = Vec::new();
510    for nr in &next.regions {
511        match prev.regions.iter().find(|r| r.name == nr.name) {
512            None => regions.push(RegionDelta::Set(nr.clone())),
513            Some(pr) => {
514                let unchanged = pr.kind == nr.kind
515                    && pr.max_tokens == nr.max_tokens
516                    && pr.current_tokens == nr.current_tokens
517                    && pr.entries.len() == nr.entries.len()
518                    && is_prefix_digest(&pr.entries, &nr.entries);
519                if unchanged {
520                    // emit nothing
521                } else if nr.entries.is_empty() && !pr.entries.is_empty() {
522                    regions.push(RegionDelta::Clear {
523                        name: nr.name.clone(),
524                    });
525                } else if pr.kind == nr.kind
526                    && pr.max_tokens == nr.max_tokens
527                    && is_prefix_digest(&pr.entries, &nr.entries)
528                {
529                    regions.push(RegionDelta::Append {
530                        name: nr.name.clone(),
531                        entries: nr.entries[pr.entries.len()..].to_vec(),
532                        current_tokens: nr.current_tokens,
533                    });
534                } else {
535                    regions.push(RegionDelta::Set(nr.clone()));
536                }
537            }
538        }
539    }
540    for pr in &prev.regions {
541        if !next.regions.iter().any(|r| r.name == pr.name) {
542            regions.push(RegionDelta::Remove {
543                name: pr.name.clone(),
544            });
545        }
546    }
547    ContextDelta {
548        stage_name: next.stage_name.clone(),
549        total_tokens: next.total_tokens,
550        max_tokens: next.max_tokens,
551        regions,
552    }
553}
554
555/// Apply a [`ContextDelta`] to `base` in place. Lenient: a delta referencing a
556/// region that isn't present is skipped rather than erroring, so folding never
557/// fails on a malformed diff.
558pub fn apply_delta(base: &mut ContextSnapshot, delta: &ContextDelta) {
559    base.stage_name = delta.stage_name.clone();
560    base.total_tokens = delta.total_tokens;
561    base.max_tokens = delta.max_tokens;
562    for region_delta in &delta.regions {
563        match region_delta {
564            RegionDelta::Set(snapshot) => {
565                match base.regions.iter_mut().find(|r| r.name == snapshot.name) {
566                    Some(existing) => *existing = snapshot.clone(),
567                    None => base.regions.push(snapshot.clone()),
568                }
569            }
570            RegionDelta::Append {
571                name,
572                entries,
573                current_tokens,
574            } => {
575                if let Some(region) = base.regions.iter_mut().find(|r| &r.name == name) {
576                    region.entries.extend(entries.iter().cloned());
577                    region.current_tokens = *current_tokens;
578                }
579            }
580            RegionDelta::Clear { name } => {
581                if let Some(region) = base.regions.iter_mut().find(|r| &r.name == name) {
582                    region.entries.clear();
583                    region.current_tokens = 0;
584                }
585            }
586            RegionDelta::Remove { name } => {
587                base.regions.retain(|r| &r.name != name);
588            }
589        }
590    }
591}
592
593// ─── codec ──────────────────────────────────────────────────────────────────
594
595/// Write the archive preamble (magic + version). Call once at file start.
596pub fn write_archive_start(w: &mut dyn Write, version: u16) -> io::Result<()> {
597    w.write_all(RUN_ARCHIVE_MAGIC)?;
598    w.write_all(&version.to_be_bytes())?;
599    Ok(())
600}
601
602/// Read + validate the archive preamble, returning the format version.
603pub fn read_archive_start(r: &mut dyn Read) -> io::Result<u16> {
604    let mut magic = [0u8; 4];
605    r.read_exact(&mut magic)?;
606    if &magic != RUN_ARCHIVE_MAGIC {
607        return Err(io::Error::new(
608            io::ErrorKind::InvalidData,
609            "not a leviath run archive (bad magic)",
610        ));
611    }
612    let mut version = [0u8; 2];
613    r.read_exact(&mut version)?;
614    Ok(u16::from_be_bytes(version))
615}
616
617/// Append one framed record. The frame length is a `u64` so it can never
618/// overflow the prefix (a `RunRecord` always serializes to JSON).
619pub fn write_record(w: &mut dyn Write, record: &RunRecord) -> io::Result<()> {
620    let payload = serde_json::to_vec(record).expect("a RunRecord always serializes to JSON");
621    let len = payload.len() as u64;
622    w.write_all(&len.to_be_bytes())?;
623    w.write_all(&payload)?;
624    Ok(())
625}
626
627/// Fill `buf` from `r`, returning `false` on a clean end-of-stream (zero bytes
628/// available at the call) and erroring only on a *partial* read (truncation).
629fn read_exact_or_eof(r: &mut dyn Read, buf: &mut [u8]) -> io::Result<bool> {
630    let mut filled = 0;
631    while filled < buf.len() {
632        match r.read(&mut buf[filled..])? {
633            0 => {
634                if filled == 0 {
635                    return Ok(false); // clean EOF at a record boundary
636                }
637                return Err(io::Error::new(
638                    io::ErrorKind::UnexpectedEof,
639                    "truncated run-archive frame",
640                ));
641            }
642            n => filled += n,
643        }
644    }
645    Ok(true)
646}
647
648/// The largest a single archive frame may claim to be.
649///
650/// Generous by design - a record holds one context snapshot, and 256 MiB is far
651/// past anything a real run writes - because this is a sanity bound on a length
652/// prefix, not a size policy. What it rules out is a torn or corrupt prefix
653/// being taken at its word and turned straight into an allocation.
654const MAX_RECORD_BYTES: u64 = 256 * 1024 * 1024;
655
656/// Read the next framed record, or `None` at a clean end-of-stream.
657pub fn read_record(r: &mut dyn Read) -> io::Result<Option<RunRecord>> {
658    let mut len_bytes = [0u8; 8];
659    if !read_exact_or_eof(r, &mut len_bytes)? {
660        return Ok(None);
661    }
662    let len = u64::from_be_bytes(len_bytes);
663    // A torn tail is the reason `read_archive_lenient` exists, and a torn
664    // *length prefix* is exactly where a nonsense `u64` comes from. Allocating
665    // it first would abort the process on a crash-truncated archive - during
666    // daemon recovery, which is the one moment the lenient reader is there to
667    // survive. Rejecting it makes the frame an ordinary error, so recovery
668    // folds back to the last intact record instead.
669    if len > MAX_RECORD_BYTES {
670        return Err(io::Error::new(
671            io::ErrorKind::InvalidData,
672            format!("run-archive frame claims {len} bytes, over the {MAX_RECORD_BYTES} cap"),
673        ));
674    }
675    let mut payload = vec![0u8; len as usize];
676    if !read_exact_or_eof(r, &mut payload)? {
677        return Err(io::Error::new(
678            io::ErrorKind::UnexpectedEof,
679            "truncated run-archive frame",
680        ));
681    }
682    let record = serde_json::from_slice(&payload)
683        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
684    Ok(Some(record))
685}
686
687/// Read the whole archive: validate the preamble, then read every record.
688pub fn read_archive(r: &mut dyn Read) -> io::Result<(u16, Vec<RunRecord>)> {
689    let version = read_archive_start(r)?;
690    let mut records = Vec::new();
691    while let Some(record) = read_record(r)? {
692        records.push(record);
693    }
694    Ok((version, records))
695}
696
697/// Read the archive tolerantly: validate the preamble strictly, then read records
698/// until a clean end-of-stream **or the first unreadable frame**, returning the
699/// records collected so far.
700///
701/// A crash while the persistence lane is appending a record can leave a partial
702/// final frame (a truncated length prefix or payload). The strict [`read_archive`]
703/// would reject the whole file for that torn tail - and once a fallback-resume
704/// appends fresh records *past* the torn bytes, the archive would stay unreadable
705/// forever. This variant instead stops at the torn tail and keeps everything valid
706/// before it, so recovery can still fold the archive to its last intact point. The
707/// preamble is still validated strictly, so a file that isn't a run archive at all
708/// still errors rather than folding to nothing.
709pub fn read_archive_lenient(r: &mut dyn Read) -> io::Result<(u16, Vec<RunRecord>)> {
710    let version = read_archive_start(r)?;
711    let mut records = Vec::new();
712    // A torn/invalid frame ends the read early with whatever preceded it, rather
713    // than propagating the error.
714    while let Ok(Some(record)) = read_record(r) {
715        records.push(record);
716    }
717    Ok((version, records))
718}
719
720// ─── fold ───────────────────────────────────────────────────────────────────
721
722/// A tool batch that was dispatched but whose results never reached the context
723/// window - what a crash-resume must replay instead of re-running. `calls` carry
724/// every result recorded before the crash ([`RunRecord::ToolCallDone`] merged
725/// in); a call still at `result: None` genuinely never finished.
726#[derive(Debug, Clone, PartialEq)]
727pub struct PendingToolBatch {
728    /// The stage index the batch was dispatched in.
729    pub stage_index: usize,
730    /// The stage-local iteration that produced the batch.
731    pub iteration: usize,
732    /// The assistant text of the turn that issued the calls.
733    pub response: String,
734    /// The calls, with every recorded result merged in.
735    pub calls: Vec<ToolCallRecord>,
736}
737
738/// One provider call's cost, as folded out of the journal.
739///
740/// The flattened form of [`RunRecord::InferenceUsage`], so a consumer walking a
741/// folded run does not have to match the record enum to read a number.
742#[derive(Debug, Clone, PartialEq, Eq)]
743pub struct InferenceUsageRecord {
744    /// Which kind of call this was.
745    pub kind: InferenceKind,
746    /// The stage the run was in, empty for the title call.
747    pub stage: String,
748    /// The stage-local iteration index.
749    pub iteration: usize,
750    /// The provider that served the call.
751    pub provider: String,
752    /// The model the call targeted.
753    pub model: String,
754    /// Prompt tokens billed.
755    pub prompt_tokens: usize,
756    /// Completion tokens billed.
757    pub completion_tokens: usize,
758    /// Tokens read from provider cache.
759    pub cached_tokens: usize,
760    /// Tokens written to provider cache.
761    pub cache_write_tokens: usize,
762    /// Unix seconds.
763    pub at: i64,
764}
765
766/// The state reconstructed from a run journal - enough to resume or inspect the
767/// run at its latest recorded point.
768#[derive(Debug, Clone, PartialEq)]
769pub struct FoldedRun {
770    /// The run's current owner/identity.
771    pub identity: RunIdentity,
772    /// The latest run metadata.
773    pub meta: RunMeta,
774    /// The reconstructed current context window.
775    pub context: ContextSnapshot,
776    /// The recorded inbound messages, in order.
777    pub messages: Vec<MessageRecord>,
778    /// Number of inferences recorded.
779    pub inference_count: usize,
780    /// Per-call usage, in the order the calls landed.
781    ///
782    /// The point of keeping every entry rather than a running sum: a sum is
783    /// already available from [`RunMeta`], and what it cannot answer is whether
784    /// any single call exceeded the window, or which kind of call the spend went
785    /// to.
786    pub inference_usage: Vec<InferenceUsageRecord>,
787    /// Number of tool calls recorded.
788    pub tool_call_count: usize,
789    /// A dispatched tool batch whose results never made it into the context
790    /// window (the run crashed mid-batch). `None` when the run has no batch in
791    /// flight or the batch's turn already landed in `context`.
792    pub pending_batch: Option<PendingToolBatch>,
793}
794
795/// Whether `context` already contains the assistant turn of `batch` - i.e. the
796/// batch completed and `apply_tool_results` landed it before the crash, so there
797/// is nothing to replay. Matched by the first call id, which is unique per batch.
798pub fn context_contains_batch(context: &ContextSnapshot, batch: &PendingToolBatch) -> bool {
799    let Some(first_id) = batch.calls.first().map(|c| c.id.as_str()) else {
800        return false;
801    };
802    context.regions.iter().any(|region| {
803        region.entries.iter().any(|entry| {
804            matches!(
805                &entry.kind,
806                crate::region::EntryKind::AssistantTurn { tool_calls }
807                    if tool_calls.iter().any(|tc| tc.id == first_id)
808            )
809        })
810    })
811}
812
813/// Reconstruct a run's current state from its journal. Returns `None` if the
814/// records don't start with a [`RunRecord::Header`].
815pub fn fold(records: &[RunRecord]) -> Option<FoldedRun> {
816    let mut iter = records.iter();
817    let (identity, meta) = match iter.next() {
818        Some(RunRecord::Header { identity, meta }) => (identity.clone(), (**meta).clone()),
819        _ => return None,
820    };
821    let mut folded = FoldedRun {
822        identity,
823        meta,
824        context: ContextSnapshot {
825            stage_name: String::new(),
826            total_tokens: 0,
827            max_tokens: 0,
828            regions: Vec::new(),
829        },
830        messages: Vec::new(),
831        inference_count: 0,
832        inference_usage: Vec::new(),
833        tool_call_count: 0,
834        pending_batch: None,
835    };
836    for record in iter {
837        match record {
838            RunRecord::Header { identity, meta } => {
839                folded.identity = identity.clone();
840                folded.meta = (**meta).clone();
841            }
842            RunRecord::OwnershipChanged {
843                machine_id,
844                world_id,
845                ..
846            } => {
847                folded.identity.machine_id = machine_id.clone();
848                folded.identity.world_id = world_id.clone();
849            }
850            RunRecord::Inference { .. } => folded.inference_count += 1,
851            RunRecord::InferenceUsage {
852                kind,
853                stage,
854                iteration,
855                provider,
856                model,
857                prompt_tokens,
858                completion_tokens,
859                cached_tokens,
860                cache_write_tokens,
861                at,
862            } => {
863                // Counted alongside the heavy variant: both name one provider
864                // call, and a consumer asking "how many calls" should not have
865                // to know which of the two the writer chose.
866                folded.inference_count += 1;
867                folded.inference_usage.push(InferenceUsageRecord {
868                    kind: *kind,
869                    stage: stage.clone(),
870                    iteration: *iteration,
871                    provider: provider.clone(),
872                    model: model.clone(),
873                    prompt_tokens: *prompt_tokens,
874                    completion_tokens: *completion_tokens,
875                    cached_tokens: *cached_tokens,
876                    cache_write_tokens: *cache_write_tokens,
877                    at: *at,
878                });
879            }
880            RunRecord::ToolBatch {
881                calls,
882                stage_index,
883                iteration,
884                response,
885                ..
886            } => {
887                folded.tool_call_count += calls.len();
888                // A later batch replaces an earlier one - only the newest can
889                // still be in flight.
890                folded.pending_batch = Some(PendingToolBatch {
891                    stage_index: *stage_index,
892                    iteration: *iteration,
893                    response: response.clone(),
894                    calls: calls.clone(),
895                });
896            }
897            RunRecord::ToolCallDone {
898                iteration,
899                call_id,
900                result,
901                ..
902            } => {
903                // Fill the matching pending call; a stale record for a replaced
904                // batch (iteration mismatch) is ignored.
905                if let Some(batch) = folded
906                    .pending_batch
907                    .as_mut()
908                    .filter(|b| b.iteration == *iteration)
909                    && let Some(call) = batch.calls.iter_mut().find(|c| c.id == *call_id)
910                {
911                    call.result = Some(result.clone());
912                }
913            }
914            RunRecord::ContextCheckpoint { snapshot, .. } => folded.context = snapshot.clone(),
915            RunRecord::ContextDiff { delta, .. } => apply_delta(&mut folded.context, delta),
916            RunRecord::Message { message, .. } => folded.messages.push(message.clone()),
917            RunRecord::StatusChanged { status, .. } => folded.meta.status = status.clone(),
918            RunRecord::Checkpoint { meta, context, .. } => {
919                folded.meta = (**meta).clone();
920                folded.context = context.clone();
921            }
922            RunRecord::Progress { meta, delta, .. } => {
923                folded.meta = (**meta).clone();
924                apply_delta(&mut folded.context, delta);
925            }
926        }
927    }
928    // The batch is only pending if it was never applied. Two applied signals: a
929    // later inference moved the iteration on (even if a sliding window has since
930    // evicted the turn), or the batch's assistant turn is already in the folded
931    // window (the Progress carrying it landed before the crash).
932    if let Some(batch) = &folded.pending_batch
933        && (folded.meta.iteration != batch.iteration
934            || context_contains_batch(&folded.context, batch))
935    {
936        folded.pending_batch = None;
937    }
938    Some(folded)
939}
940
941/// A run's context window at one recorded point in time, with the metadata
942/// (stage, iteration, status, …) in effect then. Produced by [`replay_points`].
943#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
944pub struct RunPoint {
945    /// The run metadata at this point.
946    pub meta: RunMeta,
947    /// The full context window at this point.
948    pub context: ContextSnapshot,
949    /// Unix seconds this point was recorded.
950    pub at: i64,
951}
952
953/// One replayed point, lent to a [`visit_points`] visitor rather than handed
954/// over. Borrowing is the whole purpose: see that function.
955#[derive(Debug)]
956pub struct PointRef<'a> {
957    /// Position in the timeline, counting only records that produce a point.
958    /// Stable for a given journal prefix, because the journal is append-only -
959    /// which is what makes it usable as a pagination cursor.
960    pub index: usize,
961    /// Unix seconds this point was recorded.
962    pub at: i64,
963    /// The run metadata in effect at this point.
964    pub meta: &'a RunMeta,
965    /// The full context window at this point.
966    pub context: &'a ContextSnapshot,
967}
968
969/// Replay a run journal, calling `visit` once per record that changes the
970/// context (a checkpoint, diff, or progress step), in order. Stops early if the
971/// visitor returns [`ControlFlow::Break`]. Does nothing if the records don't
972/// start with a [`RunRecord::Header`].
973///
974/// The point of lending each point instead of collecting them: replaying a run
975/// means carrying one running window and mutating it, so materializing the
976/// timeline costs a **full deep copy of the context window per point** - and a
977/// window holds every region's entry text. On a megabyte-scale journal that is
978/// hundreds of whole-window clones, which is why anything that wants a slice of
979/// the timeline, or just an answer to "does any point contain this text",
980/// should come through here rather than [`replay_points`].
981///
982/// `&mut dyn FnMut` rather than a generic parameter, deliberately: this is
983/// called from a handful of places with unrelated closure types, and one
984/// monomorphization keeps both the compiled size and the coverage instantiation
985/// count at one - the same reasoning `execute_with_shutdown` documents in the
986/// serve module.
987pub fn visit_points(records: &[RunRecord], visit: &mut dyn FnMut(PointRef<'_>) -> ControlFlow<()>) {
988    let mut iter = records.iter();
989    let Some(mut folder) = (match iter.next() {
990        Some(first) => PointFolder::start(first),
991        None => None,
992    }) else {
993        return;
994    };
995    for record in iter {
996        if folder.push(record, visit).is_break() {
997            return;
998        }
999    }
1000}
1001
1002/// Streaming [`visit_points`] over a framed archive: validate the preamble,
1003/// then read one record at a time and fold it into the running window - so a
1004/// multi-megabyte `run.lvr` is walked holding one record and one window in
1005/// memory, instead of the whole parsed journal (`read_archive` materializes
1006/// every record first, typically 2-4x the file's bytes as structs).
1007///
1008/// Errors only on a bad preamble. Like [`read_archive_lenient`], a torn or
1009/// unreadable frame ends the walk with the points already visited: the tail of
1010/// a live run's journal can legitimately be mid-append.
1011pub fn visit_archive_points(
1012    r: &mut dyn Read,
1013    visit: &mut dyn FnMut(PointRef<'_>) -> ControlFlow<()>,
1014) -> io::Result<()> {
1015    read_archive_start(r)?;
1016    let mut folder = match read_record(r) {
1017        Ok(Some(first)) => match PointFolder::start(&first) {
1018            Some(folder) => folder,
1019            None => return Ok(()),
1020        },
1021        _ => return Ok(()),
1022    };
1023    while let Ok(Some(record)) = read_record(r) {
1024        if folder.push(&record, visit).is_break() {
1025            return Ok(());
1026        }
1027    }
1028    Ok(())
1029}
1030
1031/// The running state of a point replay: the metadata and window in effect,
1032/// folded record by record. Shared by [`visit_points`] (in-memory records) and
1033/// [`visit_archive_points`] (streamed records) so the two can never disagree
1034/// about what a record means.
1035struct PointFolder {
1036    meta: RunMeta,
1037    context: ContextSnapshot,
1038    index: usize,
1039}
1040
1041impl PointFolder {
1042    /// Start a replay from the first record, which must be the Header -
1043    /// anything else means this isn't a run journal, and the replay visits
1044    /// nothing (`None`).
1045    fn start(first: &RunRecord) -> Option<Self> {
1046        match first {
1047            RunRecord::Header { meta, .. } => Some(Self {
1048                meta: (**meta).clone(),
1049                context: ContextSnapshot {
1050                    stage_name: String::new(),
1051                    total_tokens: 0,
1052                    max_tokens: 0,
1053                    regions: Vec::new(),
1054                },
1055                index: 0,
1056            }),
1057            _ => None,
1058        }
1059    }
1060
1061    /// Fold one record; when it produces a timeline point, lend it to `visit`.
1062    fn push(
1063        &mut self,
1064        record: &RunRecord,
1065        visit: &mut dyn FnMut(PointRef<'_>) -> ControlFlow<()>,
1066    ) -> ControlFlow<()> {
1067        let at = match record {
1068            RunRecord::Header { meta: m, .. } => {
1069                self.meta = (**m).clone();
1070                return ControlFlow::Continue(());
1071            }
1072            RunRecord::StatusChanged { status, .. } => {
1073                self.meta.status = status.clone();
1074                return ControlFlow::Continue(());
1075            }
1076            RunRecord::ContextCheckpoint { snapshot, at } => {
1077                self.context = snapshot.clone();
1078                *at
1079            }
1080            RunRecord::ContextDiff { delta, at } => {
1081                apply_delta(&mut self.context, delta);
1082                *at
1083            }
1084            RunRecord::Checkpoint {
1085                meta: m,
1086                context: c,
1087                at,
1088            } => {
1089                self.meta = (**m).clone();
1090                self.context = c.clone();
1091                *at
1092            }
1093            RunRecord::Progress { meta: m, delta, at } => {
1094                self.meta = (**m).clone();
1095                apply_delta(&mut self.context, delta);
1096                *at
1097            }
1098            // Non-context records don't add a timeline point. Usage included:
1099            // it says what a call cost, not what the window then held, and
1100            // emitting a point per call would double the timeline with entries
1101            // whose context is identical to their neighbour's.
1102            RunRecord::OwnershipChanged { .. }
1103            | RunRecord::Inference { .. }
1104            | RunRecord::InferenceUsage { .. }
1105            | RunRecord::ToolBatch { .. }
1106            | RunRecord::ToolCallDone { .. }
1107            | RunRecord::Message { .. } => return ControlFlow::Continue(()),
1108        };
1109        let flow = visit(PointRef {
1110            index: self.index,
1111            at,
1112            meta: &self.meta,
1113            context: &self.context,
1114        });
1115        self.index += 1;
1116        flow
1117    }
1118}
1119
1120/// Replay a run journal into the sequence of context-window snapshots over time,
1121/// one [`RunPoint`] per record that changes the context (a checkpoint, diff, or
1122/// progress step). This is what the context-history views (TUI/CLI/API) consume
1123/// to show the window "at each stage and point". Returns an empty vec if the
1124/// records don't start with a [`RunRecord::Header`].
1125///
1126/// Materializes every point, so it deep-copies the whole context window once per
1127/// point. Prefer [`visit_points`] when only part of the timeline is wanted, or
1128/// when the answer is a predicate rather than the points themselves.
1129pub fn replay_points(records: &[RunRecord]) -> Vec<RunPoint> {
1130    let mut points = Vec::new();
1131    visit_points(records, &mut |point| {
1132        points.push(RunPoint {
1133            meta: point.meta.clone(),
1134            context: point.context.clone(),
1135            at: point.at,
1136        });
1137        ControlFlow::Continue(())
1138    });
1139    points
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144    use super::*;
1145    use crate::run_meta::RunStatus;
1146
1147    fn identity() -> RunIdentity {
1148        RunIdentity {
1149            run_id: "run-1".to_string(),
1150            machine_id: "machine-a".to_string(),
1151            world_id: "world-x".to_string(),
1152            created_at: 100,
1153        }
1154    }
1155
1156    /// The instant the fixture pretends it is, on every construction.
1157    ///
1158    /// Arbitrary, and deliberately not the wall clock: `RunMeta::new` stamps
1159    /// `started_at`/`updated_at` from it, and these tests build the fixture
1160    /// once to write and again to compare against. Two reads straddling a
1161    /// second boundary produced two unequal `RunMeta`s, which failed whichever
1162    /// round-trip assertion happened to span the tick.
1163    const FIXTURE_NOW: i64 = 1_700_000_000;
1164
1165    fn meta() -> RunMeta {
1166        let mut meta = RunMeta::new(
1167            "run-1".to_string(),
1168            "coder".to_string(),
1169            "/agents/coder".to_string(),
1170            "do it".to_string(),
1171            Some("anthropic/claude".to_string()),
1172            "/work".to_string(),
1173            2,
1174        );
1175        meta.started_at = FIXTURE_NOW;
1176        meta.updated_at = FIXTURE_NOW;
1177        meta
1178    }
1179
1180    /// Two constructions of the fixture are equal however much time passes
1181    /// between them. This is the property every round-trip assertion in this
1182    /// module rests on, and the one a wall-clock stamp quietly broke.
1183    #[test]
1184    fn the_fixture_does_not_move_with_the_clock() {
1185        let first = meta();
1186        let mut later = meta();
1187        // Rather than sleeping across a real second boundary, move the clock
1188        // the way it would have moved: an unpinned fixture differs by exactly
1189        // this, and a pinned one is rebuilt identically.
1190        assert_eq!(first, later, "the fixture is rebuilt identically");
1191        later.started_at += 1;
1192        assert_ne!(
1193            first, later,
1194            "and the comparison is sensitive to the field that used to drift"
1195        );
1196    }
1197
1198    fn entry(content: &str, tokens: usize) -> RegionEntrySnapshot {
1199        RegionEntrySnapshot {
1200            content: content.to_string(),
1201            tokens,
1202            kind: crate::region::EntryKind::Text,
1203            metadata: None,
1204            key: None,
1205            taint: Default::default(),
1206        }
1207    }
1208
1209    fn region(name: &str, entries: Vec<RegionEntrySnapshot>) -> RegionSnapshot {
1210        let current = entries.iter().map(|e| e.tokens).sum();
1211        RegionSnapshot {
1212            name: name.to_string(),
1213            kind: "clearable".to_string(),
1214            current_tokens: current,
1215            max_tokens: 1000,
1216            entries,
1217        }
1218    }
1219
1220    fn snapshot(stage: &str, regions: Vec<RegionSnapshot>) -> ContextSnapshot {
1221        let total = regions.iter().map(|r| r.current_tokens).sum();
1222        ContextSnapshot {
1223            stage_name: stage.to_string(),
1224            total_tokens: total,
1225            max_tokens: 10_000,
1226            regions,
1227        }
1228    }
1229
1230    fn header() -> RunRecord {
1231        RunRecord::Header {
1232            identity: identity(),
1233            meta: Box::new(meta()),
1234        }
1235    }
1236
1237    /// A stable tag per region-delta shape - asserting on this avoids the
1238    /// uncovered `false` arm a `matches!` leaves when the assertion passes.
1239    /// Every arm is exercised across the diff tests below.
1240    fn region_delta_kind(d: &RegionDelta) -> &'static str {
1241        match d {
1242            RegionDelta::Set(_) => "set",
1243            RegionDelta::Append { .. } => "append",
1244            RegionDelta::Clear { .. } => "clear",
1245            RegionDelta::Remove { .. } => "remove",
1246        }
1247    }
1248
1249    // ── diff / apply round-trips ──
1250
1251    /// Applying `diff(a, b)` to a clone of `a` must reproduce `b`, for every
1252    /// region-delta shape (new, append, clear, remove, full-replace, unchanged).
1253    fn assert_diff_roundtrip(a: &ContextSnapshot, b: &ContextSnapshot) {
1254        let delta = diff_context(a, b);
1255        let mut base = a.clone();
1256        apply_delta(&mut base, &delta);
1257        assert_eq!(&base, b);
1258    }
1259
1260    #[test]
1261    fn diff_append_only_growth_is_compact() {
1262        let a = snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]);
1263        let b = snapshot(
1264            "s1",
1265            vec![region("conv", vec![entry("hi", 1), entry("there", 2)])],
1266        );
1267        let delta = diff_context(&a, &b);
1268        assert_eq!(region_delta_kind(&delta.regions[0]), "append");
1269        assert_diff_roundtrip(&a, &b);
1270    }
1271
1272    #[test]
1273    fn diff_new_region_is_set() {
1274        let a = snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]);
1275        let b = snapshot(
1276            "s1",
1277            vec![
1278                region("conv", vec![entry("hi", 1)]),
1279                region("plan", vec![entry("p", 3)]),
1280            ],
1281        );
1282        let delta = diff_context(&a, &b);
1283        assert!(delta.regions.iter().any(|d| region_delta_kind(d) == "set"));
1284        assert_diff_roundtrip(&a, &b);
1285    }
1286
1287    #[test]
1288    fn diff_cleared_region() {
1289        let a = snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]);
1290        let b = snapshot("s1", vec![region("conv", vec![])]);
1291        let delta = diff_context(&a, &b);
1292        assert_eq!(region_delta_kind(&delta.regions[0]), "clear");
1293        assert_diff_roundtrip(&a, &b);
1294    }
1295
1296    #[test]
1297    fn diff_removed_region() {
1298        let a = snapshot(
1299            "s1",
1300            vec![
1301                region("conv", vec![entry("hi", 1)]),
1302                region("plan", vec![entry("p", 3)]),
1303            ],
1304        );
1305        let b = snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]);
1306        let delta = diff_context(&a, &b);
1307        assert!(
1308            delta
1309                .regions
1310                .iter()
1311                .any(|d| region_delta_kind(d) == "remove")
1312        );
1313        assert_diff_roundtrip(&a, &b);
1314    }
1315
1316    #[test]
1317    fn diff_non_prefix_rewrite_is_set() {
1318        // Entries changed at the front (not an append) → full Set.
1319        let a = snapshot("s1", vec![region("conv", vec![entry("old", 1)])]);
1320        let b = snapshot("s1", vec![region("conv", vec![entry("new", 1)])]);
1321        let delta = diff_context(&a, &b);
1322        assert_eq!(region_delta_kind(&delta.regions[0]), "set");
1323        assert_diff_roundtrip(&a, &b);
1324    }
1325
1326    #[test]
1327    fn diff_kind_change_is_set_not_append() {
1328        // Same prefix entries but the region's kind changed → Set, not Append.
1329        let a = snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]);
1330        let mut grown = region("conv", vec![entry("hi", 1), entry("more", 1)]);
1331        grown.kind = "sliding".to_string();
1332        let b = snapshot("s1", vec![grown]);
1333        let delta = diff_context(&a, &b);
1334        assert_eq!(region_delta_kind(&delta.regions[0]), "set");
1335        assert_diff_roundtrip(&a, &b);
1336    }
1337
1338    // ── streaming point replay ──
1339
1340    /// Frame `records` exactly as `run.lvr` stores them.
1341    fn framed(records: &[RunRecord]) -> Vec<u8> {
1342        let mut buf = Vec::new();
1343        write_archive_start(&mut buf, RUN_ARCHIVE_VERSION).unwrap();
1344        for record in records {
1345            write_record(&mut buf, record).unwrap();
1346        }
1347        buf
1348    }
1349
1350    /// Collect `(index, at, total_tokens)` per visited point, or the stream
1351    /// error. One closure shared by every streamed test, including the
1352    /// bad-preamble one whose visitor never runs.
1353    fn try_collect_streamed(bytes: &[u8]) -> io::Result<Vec<(usize, i64, usize)>> {
1354        let mut seen = Vec::new();
1355        visit_archive_points(&mut &bytes[..], &mut |p| {
1356            seen.push((p.index, p.at, p.context.total_tokens));
1357            ControlFlow::Continue(())
1358        })?;
1359        Ok(seen)
1360    }
1361
1362    /// Collect `(index, at, total_tokens)` per visited point.
1363    fn collect_streamed(bytes: &[u8]) -> Vec<(usize, i64, usize)> {
1364        try_collect_streamed(bytes).unwrap()
1365    }
1366
1367    #[test]
1368    fn visit_archive_points_matches_visit_points() {
1369        let records = vec![
1370            header(),
1371            RunRecord::ContextCheckpoint {
1372                snapshot: snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]),
1373                at: 10,
1374            },
1375            RunRecord::StatusChanged {
1376                status: RunStatus::Running,
1377                at: 11,
1378            },
1379            RunRecord::Progress {
1380                meta: Box::new(meta()),
1381                delta: diff_context(
1382                    &snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]),
1383                    &snapshot(
1384                        "s1",
1385                        vec![region("conv", vec![entry("hi", 1), entry("more", 2)])],
1386                    ),
1387                ),
1388                at: 12,
1389            },
1390        ];
1391        let mut in_memory = Vec::new();
1392        visit_points(&records, &mut |p| {
1393            in_memory.push((p.index, p.at, p.context.total_tokens));
1394            ControlFlow::Continue(())
1395        });
1396        assert_eq!(collect_streamed(&framed(&records)), in_memory);
1397        assert_eq!(in_memory.len(), 2, "checkpoint + progress = two points");
1398    }
1399
1400    #[test]
1401    fn visit_archive_points_rejects_a_bad_preamble() {
1402        assert!(try_collect_streamed(b"not an archive at all").is_err());
1403    }
1404
1405    #[test]
1406    fn visit_archive_points_is_lenient_about_a_torn_tail() {
1407        let records = vec![
1408            header(),
1409            RunRecord::ContextCheckpoint {
1410                snapshot: snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]),
1411                at: 10,
1412            },
1413        ];
1414        let mut bytes = framed(&records);
1415        // A torn frame: a length prefix promising more than exists.
1416        bytes.extend_from_slice(&1000u64.to_be_bytes());
1417        bytes.extend_from_slice(b"partial");
1418        assert_eq!(collect_streamed(&bytes).len(), 1, "points before the tear");
1419    }
1420
1421    #[test]
1422    fn visit_archive_points_visits_nothing_without_a_header() {
1423        let records = vec![RunRecord::ContextCheckpoint {
1424            snapshot: snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]),
1425            at: 10,
1426        }];
1427        assert!(collect_streamed(&framed(&records)).is_empty());
1428        // And an archive with no records at all visits nothing.
1429        assert!(collect_streamed(&framed(&[])).is_empty());
1430    }
1431
1432    #[test]
1433    fn visit_archive_points_stops_on_break() {
1434        let records = vec![
1435            header(),
1436            RunRecord::ContextCheckpoint {
1437                snapshot: snapshot("s1", vec![region("conv", vec![entry("a", 1)])]),
1438                at: 10,
1439            },
1440            RunRecord::ContextCheckpoint {
1441                snapshot: snapshot("s1", vec![region("conv", vec![entry("b", 2)])]),
1442                at: 11,
1443            },
1444        ];
1445        let bytes = framed(&records);
1446        let mut seen = 0;
1447        visit_archive_points(&mut &bytes[..], &mut |_| {
1448            seen += 1;
1449            ControlFlow::Break(())
1450        })
1451        .unwrap();
1452        assert_eq!(seen, 1);
1453    }
1454
1455    // ── digest-based diffing ──
1456    //
1457    // `diff_context_digest(digest(a), b)` must produce the same delta as
1458    // `diff_context(a, b)` for every shape: the persistence lane retains only
1459    // the digest, and any divergence would silently corrupt the archive.
1460
1461    fn assert_digest_matches_full_diff(a: &ContextSnapshot, b: &ContextSnapshot) {
1462        let via_digest = diff_context_digest(&digest_context(a), b);
1463        assert_eq!(via_digest, diff_context(a, b));
1464        // And the digest-produced delta still round-trips.
1465        let mut base = a.clone();
1466        apply_delta(&mut base, &via_digest);
1467        assert_eq!(&base, b);
1468    }
1469
1470    #[test]
1471    fn digest_diff_append_only_growth_is_compact() {
1472        let a = snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]);
1473        let b = snapshot(
1474            "s1",
1475            vec![region("conv", vec![entry("hi", 1), entry("there", 2)])],
1476        );
1477        let delta = diff_context_digest(&digest_context(&a), &b);
1478        assert_eq!(region_delta_kind(&delta.regions[0]), "append");
1479        assert_digest_matches_full_diff(&a, &b);
1480    }
1481
1482    #[test]
1483    fn digest_diff_new_cleared_removed_and_rewritten_regions() {
1484        let a = snapshot(
1485            "s1",
1486            vec![
1487                region("conv", vec![entry("hi", 1)]),
1488                region("gone", vec![entry("bye", 1)]),
1489                region("wiped", vec![entry("w", 1)]),
1490                region("rewritten", vec![entry("old", 1)]),
1491            ],
1492        );
1493        let b = snapshot(
1494            "s1",
1495            vec![
1496                region("conv", vec![entry("hi", 1)]),
1497                region("wiped", vec![]),
1498                region("rewritten", vec![entry("new", 1)]),
1499                region("fresh", vec![entry("f", 2)]),
1500            ],
1501        );
1502        let delta = diff_context_digest(&digest_context(&a), &b);
1503        let kinds: Vec<_> = delta.regions.iter().map(region_delta_kind).collect();
1504        assert_eq!(kinds, vec!["clear", "set", "set", "remove"]);
1505        assert_digest_matches_full_diff(&a, &b);
1506    }
1507
1508    #[test]
1509    fn digest_diff_unchanged_region_emits_nothing() {
1510        let a = snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]);
1511        let delta = diff_context_digest(&digest_context(&a), &a.clone());
1512        assert!(delta.regions.is_empty());
1513        assert_digest_matches_full_diff(&a, &a.clone());
1514    }
1515
1516    #[test]
1517    fn digest_diff_kind_change_is_set_not_append() {
1518        let a = snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]);
1519        let mut grown = region("conv", vec![entry("hi", 1), entry("more", 1)]);
1520        grown.kind = "sliding".to_string();
1521        let b = snapshot("s1", vec![grown]);
1522        let delta = diff_context_digest(&digest_context(&a), &b);
1523        assert_eq!(region_delta_kind(&delta.regions[0]), "set");
1524        assert_digest_matches_full_diff(&a, &b);
1525    }
1526
1527    /// A token-count change with identical entries is still an (empty) Append
1528    /// carrying the new count, exactly as the full diff records it.
1529    #[test]
1530    fn digest_diff_token_recount_is_an_empty_append() {
1531        let a = snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]);
1532        let mut recounted = region("conv", vec![entry("hi", 1)]);
1533        recounted.current_tokens = 42;
1534        let b = snapshot("s1", vec![recounted]);
1535        let delta = diff_context_digest(&digest_context(&a), &b);
1536        assert_eq!(region_delta_kind(&delta.regions[0]), "append");
1537        assert_digest_matches_full_diff(&a, &b);
1538    }
1539
1540    /// Every field of an entry participates in its digest: a change anywhere
1541    /// must change the hash, or a real edit would fold as "unchanged".
1542    #[test]
1543    fn entry_digest_covers_every_field() {
1544        let base = entry("text", 1);
1545        let variants = [
1546            entry("other", 1),
1547            entry("text", 2),
1548            RegionEntrySnapshot {
1549                key: Some("k".to_string()),
1550                ..entry("text", 1)
1551            },
1552            RegionEntrySnapshot {
1553                metadata: Some(serde_json::json!({"a": 1})),
1554                ..entry("text", 1)
1555            },
1556            RegionEntrySnapshot {
1557                kind: crate::region::EntryKind::ToolResult {
1558                    tool_call_id: "c1".to_string(),
1559                    tool_name: "shell".to_string(),
1560                    is_error: false,
1561                },
1562                ..entry("text", 1)
1563            },
1564        ];
1565        let base_hash = entry_digest(&base);
1566        for variant in &variants {
1567            assert_ne!(
1568                entry_digest(variant),
1569                base_hash,
1570                "field change must change the digest: {variant:?}"
1571            );
1572        }
1573        // And digesting the same entry twice is stable.
1574        assert_eq!(entry_digest(&base), entry_digest(&entry("text", 1)));
1575    }
1576
1577    #[test]
1578    fn diff_unchanged_region_emits_nothing() {
1579        let a = snapshot("s1", vec![region("conv", vec![entry("hi", 1)])]);
1580        let b = a.clone();
1581        let delta = diff_context(&a, &b);
1582        assert!(delta.regions.is_empty());
1583        assert_diff_roundtrip(&a, &b);
1584    }
1585
1586    #[test]
1587    fn apply_delta_skips_unknown_regions_leniently() {
1588        // Append/Clear targeting a region not present are no-ops (not errors).
1589        let mut base = snapshot("s1", vec![]);
1590        let delta = ContextDelta {
1591            stage_name: "s1".to_string(),
1592            total_tokens: 0,
1593            max_tokens: 10_000,
1594            regions: vec![
1595                RegionDelta::Append {
1596                    name: "ghost".to_string(),
1597                    entries: vec![entry("x", 1)],
1598                    current_tokens: 1,
1599                },
1600                RegionDelta::Clear {
1601                    name: "ghost".to_string(),
1602                },
1603                RegionDelta::Remove {
1604                    name: "ghost".to_string(),
1605                },
1606            ],
1607        };
1608        apply_delta(&mut base, &delta);
1609        assert!(base.regions.is_empty());
1610    }
1611
1612    // ── codec round-trips ──
1613
1614    fn all_record_kinds() -> Vec<RunRecord> {
1615        vec![
1616            header(),
1617            RunRecord::OwnershipChanged {
1618                machine_id: "machine-b".to_string(),
1619                world_id: "world-y".to_string(),
1620                at: 101,
1621            },
1622            RunRecord::Inference {
1623                stage: "plan".to_string(),
1624                iteration: 0,
1625                request: InferenceRequestRecord {
1626                    model: "m".to_string(),
1627                    system: vec!["sys".to_string()],
1628                    messages: vec![MessageRecord {
1629                        role: "user".to_string(),
1630                        content: "hi".to_string(),
1631                    }],
1632                    tool_names: vec!["read_file".to_string()],
1633                    temperature: 0.7,
1634                    max_tokens: 1024,
1635                },
1636                response: InferenceResponseRecord {
1637                    content: "ok".to_string(),
1638                    tool_calls: vec![],
1639                    prompt_tokens: 10,
1640                    completion_tokens: 5,
1641                    cached_tokens: 0,
1642                    cache_write_tokens: 0,
1643                },
1644                at: 102,
1645            },
1646            RunRecord::InferenceUsage {
1647                kind: InferenceKind::Compaction,
1648                stage: "plan".to_string(),
1649                iteration: 2,
1650                provider: "anthropic".to_string(),
1651                model: "claude-sonnet-5".to_string(),
1652                prompt_tokens: 7000,
1653                completion_tokens: 70,
1654                cached_tokens: 12,
1655                cache_write_tokens: 34,
1656                at: 102,
1657            },
1658            RunRecord::ToolBatch {
1659                calls: vec![ToolCallRecord {
1660                    id: "c1".to_string(),
1661                    name: "read_file".to_string(),
1662                    arguments: "{}".to_string(),
1663                    result: Some("body".to_string()),
1664                    thought_signature: Some("sig".to_string()),
1665                }],
1666                at: 103,
1667                stage_index: 0,
1668                iteration: 0,
1669                response: "reading".to_string(),
1670            },
1671            RunRecord::ToolCallDone {
1672                iteration: 0,
1673                call_id: "c1".to_string(),
1674                result: "body".to_string(),
1675                at: 103,
1676            },
1677            RunRecord::ContextCheckpoint {
1678                snapshot: snapshot("plan", vec![region("conv", vec![entry("hi", 1)])]),
1679                at: 104,
1680            },
1681            RunRecord::ContextDiff {
1682                delta: ContextDelta {
1683                    stage_name: "plan".to_string(),
1684                    total_tokens: 3,
1685                    max_tokens: 10_000,
1686                    regions: vec![RegionDelta::Append {
1687                        name: "conv".to_string(),
1688                        entries: vec![entry("more", 2)],
1689                        current_tokens: 3,
1690                    }],
1691                },
1692                at: 105,
1693            },
1694            RunRecord::Message {
1695                message: MessageRecord {
1696                    role: "user".to_string(),
1697                    content: "another".to_string(),
1698                },
1699                at: 106,
1700            },
1701            RunRecord::StatusChanged {
1702                status: RunStatus::Complete,
1703                at: 107,
1704            },
1705            RunRecord::Checkpoint {
1706                meta: Box::new(meta()),
1707                context: snapshot("plan", vec![region("conv", vec![entry("hi", 1)])]),
1708                at: 108,
1709            },
1710            RunRecord::Progress {
1711                meta: Box::new(meta()),
1712                delta: ContextDelta {
1713                    stage_name: "plan".to_string(),
1714                    total_tokens: 3,
1715                    max_tokens: 10_000,
1716                    regions: vec![RegionDelta::Append {
1717                        name: "conv".to_string(),
1718                        entries: vec![entry("step", 2)],
1719                        current_tokens: 3,
1720                    }],
1721                },
1722                at: 109,
1723            },
1724        ]
1725    }
1726
1727    #[test]
1728    fn archive_write_then_read_roundtrips_every_record_kind() {
1729        let records = all_record_kinds();
1730        let mut buf = Vec::new();
1731        write_archive_start(&mut buf, RUN_ARCHIVE_VERSION).unwrap();
1732        for r in &records {
1733            write_record(&mut buf, r).unwrap();
1734        }
1735        let (version, read) = read_archive(&mut buf.as_slice()).unwrap();
1736        assert_eq!(version, RUN_ARCHIVE_VERSION);
1737        assert_eq!(read, records);
1738    }
1739
1740    #[test]
1741    fn read_archive_start_rejects_bad_magic() {
1742        let mut bytes: &[u8] = b"XXXX\x00\x01";
1743        let err = read_archive_start(&mut bytes).unwrap_err();
1744        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1745    }
1746
1747    #[test]
1748    fn read_archive_start_reports_version() {
1749        let mut buf = Vec::new();
1750        write_archive_start(&mut buf, 7).unwrap();
1751        assert_eq!(read_archive_start(&mut buf.as_slice()).unwrap(), 7);
1752    }
1753
1754    #[test]
1755    fn read_record_returns_none_at_clean_eof() {
1756        let empty: &[u8] = &[];
1757        assert!(read_record(&mut { empty }).unwrap().is_none());
1758    }
1759
1760    #[test]
1761    fn read_record_errors_on_truncated_length_prefix() {
1762        // Two bytes where an 8-byte length is expected → partial read → error.
1763        let mut bytes: &[u8] = &[0, 0];
1764        let err = read_record(&mut bytes).unwrap_err();
1765        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
1766    }
1767
1768    #[test]
1769    fn read_record_errors_on_truncated_payload() {
1770        // A frame claiming 10 bytes but only 2 present after the 8-byte length.
1771        let mut bytes: &[u8] = &[0, 0, 0, 0, 0, 0, 0, 10, 1, 2];
1772        let err = read_record(&mut bytes).unwrap_err();
1773        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
1774    }
1775
1776    #[test]
1777    fn read_record_errors_on_empty_payload_at_boundary() {
1778        // A non-zero length with zero payload bytes → clean EOF at the payload
1779        // start is still a truncation (the frame promised bytes).
1780        let mut bytes: &[u8] = &[0, 0, 0, 0, 0, 0, 0, 10];
1781        let err = read_record(&mut bytes).unwrap_err();
1782        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
1783    }
1784
1785    #[test]
1786    fn read_record_errors_on_invalid_json_payload() {
1787        // A well-framed payload that isn't a valid RunRecord.
1788        let mut buf = Vec::new();
1789        let bad = b"not json";
1790        buf.extend_from_slice(&(bad.len() as u64).to_be_bytes());
1791        buf.extend_from_slice(bad);
1792        let err = read_record(&mut buf.as_slice()).unwrap_err();
1793        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1794    }
1795
1796    /// A reader whose `read` always errors, to exercise the read error path
1797    /// inside `read_exact_or_eof` (distinct from a clean EOF).
1798    struct FailingReader;
1799    impl Read for FailingReader {
1800        fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
1801            Err(io::Error::other("device error"))
1802        }
1803    }
1804
1805    #[test]
1806    fn read_record_propagates_reader_errors() {
1807        let err = read_record(&mut FailingReader).unwrap_err();
1808        assert_eq!(err.kind(), io::ErrorKind::Other);
1809    }
1810
1811    #[test]
1812    fn read_archive_propagates_a_bad_preamble() {
1813        // Too short to even hold the magic → the preamble read errors.
1814        let mut bytes: &[u8] = b"LV";
1815        assert!(read_archive(&mut bytes).is_err());
1816    }
1817
1818    #[test]
1819    fn read_archive_propagates_a_bad_frame() {
1820        // Valid preamble, then a truncated frame → the record read errors.
1821        let mut buf = Vec::new();
1822        write_archive_start(&mut buf, RUN_ARCHIVE_VERSION).unwrap();
1823        buf.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 5, 1, 2]); // len 5, 2 present
1824        let err = read_archive(&mut buf.as_slice()).unwrap_err();
1825        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
1826    }
1827
1828    /// A writer that fails after `ok_bytes` bytes, to exercise write error paths.
1829    struct FailAfter {
1830        remaining: usize,
1831    }
1832    impl Write for FailAfter {
1833        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1834            if self.remaining == 0 {
1835                return Err(io::Error::other("disk full"));
1836            }
1837            let n = buf.len().min(self.remaining);
1838            self.remaining -= n;
1839            Ok(n)
1840        }
1841        fn flush(&mut self) -> io::Result<()> {
1842            Ok(())
1843        }
1844    }
1845
1846    #[test]
1847    fn fail_after_writer_flush_is_a_noop() {
1848        assert!(FailAfter { remaining: 1 }.flush().is_ok());
1849    }
1850
1851    #[test]
1852    fn write_archive_start_propagates_write_errors() {
1853        // Fail on the magic write (0 bytes allowed) and on the version write.
1854        assert!(write_archive_start(&mut FailAfter { remaining: 0 }, 1).is_err());
1855        assert!(write_archive_start(&mut FailAfter { remaining: 4 }, 1).is_err());
1856    }
1857
1858    #[test]
1859    fn write_record_propagates_write_errors() {
1860        let rec = header();
1861        // Fail on the 8-byte length prefix, and (after it) on the payload.
1862        assert!(write_record(&mut FailAfter { remaining: 0 }, &rec).is_err());
1863        assert!(write_record(&mut FailAfter { remaining: 8 }, &rec).is_err());
1864    }
1865
1866    /// A torn *length prefix* is where a nonsense `u64` comes from, and the
1867    /// lenient reader exists precisely to survive a torn tail. Taking the
1868    /// length at its word would turn a crash-truncated archive into an
1869    /// allocation of that size - during daemon recovery, the one moment this
1870    /// reader is there to keep working.
1871    #[test]
1872    fn an_absurd_frame_length_is_an_error_not_an_allocation() {
1873        let mut buf = Vec::new();
1874        write_archive_start(&mut buf, RUN_ARCHIVE_VERSION).unwrap();
1875        write_record(&mut buf, &header()).unwrap();
1876        // A crash mid-append that left a garbage length behind.
1877        buf.extend_from_slice(&u64::MAX.to_be_bytes());
1878
1879        let err = read_archive(&mut buf.as_slice())
1880            .expect_err("the strict reader must refuse an impossible frame");
1881        assert_eq!(err.kind(), io::ErrorKind::InvalidData, "{err}");
1882
1883        // And the lenient reader folds back to the intact record before it,
1884        // which is the behaviour recovery depends on.
1885        let (_, records) = read_archive_lenient(&mut buf.as_slice()).unwrap();
1886        assert_eq!(records, vec![header()]);
1887    }
1888
1889    #[test]
1890    fn read_archive_lenient_matches_strict_on_a_clean_archive() {
1891        // With no torn tail, the lenient reader returns exactly what the strict
1892        // reader does.
1893        let records = all_record_kinds();
1894        let mut buf = Vec::new();
1895        write_archive_start(&mut buf, RUN_ARCHIVE_VERSION).unwrap();
1896        for r in &records {
1897            write_record(&mut buf, r).unwrap();
1898        }
1899        let (version, read) = read_archive_lenient(&mut buf.as_slice()).unwrap();
1900        assert_eq!(version, RUN_ARCHIVE_VERSION);
1901        assert_eq!(read, records);
1902    }
1903
1904    #[test]
1905    fn read_archive_lenient_keeps_valid_prefix_before_a_torn_tail() {
1906        // A valid preamble + two full records, then a truncated frame (a crash
1907        // mid-append). The strict reader would reject the whole file; the lenient
1908        // reader returns the two intact records and stops at the torn tail.
1909        let mut buf = Vec::new();
1910        write_archive_start(&mut buf, RUN_ARCHIVE_VERSION).unwrap();
1911        write_record(&mut buf, &header()).unwrap();
1912        write_record(
1913            &mut buf,
1914            &RunRecord::ContextCheckpoint {
1915                snapshot: snapshot("plan", vec![region("conv", vec![entry("hi", 1)])]),
1916                at: 1,
1917            },
1918        )
1919        .unwrap();
1920        // A frame claiming 10 payload bytes but only 2 present → torn tail.
1921        buf.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 10, 1, 2]);
1922
1923        // Strict rejects the whole archive.
1924        assert!(read_archive(&mut buf.as_slice()).is_err());
1925        // Lenient keeps the valid prefix and folds cleanly.
1926        let (version, records) = read_archive_lenient(&mut buf.as_slice()).unwrap();
1927        assert_eq!(version, RUN_ARCHIVE_VERSION);
1928        assert_eq!(records.len(), 2);
1929        let folded = fold(&records).expect("prefix starts with a Header");
1930        assert_eq!(folded.context.regions[0].entries.len(), 1);
1931    }
1932
1933    #[test]
1934    fn read_archive_lenient_still_errors_on_a_bad_preamble() {
1935        // The preamble is validated strictly: a file that isn't a run archive at
1936        // all errors rather than folding to nothing.
1937        let mut bad_magic: &[u8] = b"XXXX\x00\x01";
1938        assert!(read_archive_lenient(&mut bad_magic).is_err());
1939        // A truncated version (valid magic, no version bytes) also errors.
1940        let mut short: &[u8] = b"LVR1";
1941        assert!(read_archive_lenient(&mut short).is_err());
1942    }
1943
1944    #[test]
1945    fn read_archive_start_errors_on_truncated_version() {
1946        // Valid 4-byte magic but no version bytes → the version read errors.
1947        let mut bytes: &[u8] = b"LVR1";
1948        let err = read_archive_start(&mut bytes).unwrap_err();
1949        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
1950    }
1951
1952    // ── fold ──
1953
1954    #[test]
1955    fn fold_requires_a_header_first() {
1956        assert!(fold(&[]).is_none());
1957        assert!(
1958            fold(&[RunRecord::StatusChanged {
1959                status: RunStatus::Complete,
1960                at: 1
1961            }])
1962            .is_none()
1963        );
1964    }
1965
1966    #[test]
1967    fn fold_reconstructs_state_from_the_journal() {
1968        let records = all_record_kinds();
1969        let folded = fold(&records).expect("has header");
1970        // Ownership was reassigned mid-journal.
1971        assert_eq!(folded.identity.machine_id, "machine-b");
1972        assert_eq!(folded.identity.world_id, "world-y");
1973        // Counters. Two inferences: the fixture carries one record of each
1974        // kind, and both name one provider call.
1975        assert_eq!(folded.inference_count, 2);
1976        assert_eq!(folded.inference_usage.len(), 1);
1977        assert_eq!(folded.tool_call_count, 1);
1978        // One inbound message recorded.
1979        assert_eq!(folded.messages.len(), 1);
1980        assert_eq!(folded.messages[0].content, "another");
1981        // The Progress step is the last context-affecting record: it layers its
1982        // append diff onto the preceding Checkpoint's window (hi + step).
1983        assert_eq!(folded.context.regions[0].name, "conv");
1984        assert_eq!(folded.context.regions[0].entries.len(), 2);
1985        assert_eq!(folded.context.total_tokens, 3);
1986        assert_eq!(folded.meta.run_id, "run-1");
1987        // The batch shares the meta's iteration and its turn never reached the
1988        // window, so it folds as pending (with the ToolCallDone merged in).
1989        let pending = folded.pending_batch.expect("batch never applied");
1990        assert_eq!(pending.calls[0].result.as_deref(), Some("body"));
1991    }
1992
1993    #[test]
1994    fn fold_applies_context_diffs_over_a_checkpoint() {
1995        // Header → checkpoint → diff (append). The diff must layer on the checkpoint.
1996        let records = vec![
1997            header(),
1998            RunRecord::ContextCheckpoint {
1999                snapshot: snapshot("plan", vec![region("conv", vec![entry("hi", 1)])]),
2000                at: 1,
2001            },
2002            RunRecord::ContextDiff {
2003                delta: ContextDelta {
2004                    stage_name: "plan".to_string(),
2005                    total_tokens: 3,
2006                    max_tokens: 10_000,
2007                    regions: vec![RegionDelta::Append {
2008                        name: "conv".to_string(),
2009                        entries: vec![entry("there", 2)],
2010                        current_tokens: 3,
2011                    }],
2012                },
2013                at: 2,
2014            },
2015        ];
2016        let folded = fold(&records).unwrap();
2017        assert_eq!(folded.context.regions[0].entries.len(), 2);
2018        assert_eq!(folded.context.total_tokens, 3);
2019    }
2020
2021    #[test]
2022    fn fold_later_header_updates_identity_and_meta() {
2023        // A second Header (unusual, but tolerated) refreshes identity + meta.
2024        let mut second_meta = meta();
2025        second_meta.status = RunStatus::Running;
2026        let records = vec![
2027            header(),
2028            RunRecord::Header {
2029                identity: RunIdentity {
2030                    run_id: "run-1".to_string(),
2031                    machine_id: "machine-c".to_string(),
2032                    world_id: "world-z".to_string(),
2033                    created_at: 200,
2034                },
2035                meta: Box::new(second_meta),
2036            },
2037        ];
2038        let folded = fold(&records).unwrap();
2039        assert_eq!(folded.identity.machine_id, "machine-c");
2040        assert_eq!(folded.meta.status, RunStatus::Running);
2041    }
2042
2043    #[test]
2044    fn fold_progress_applies_meta_and_context_diff() {
2045        let mut advanced = meta();
2046        advanced.status = RunStatus::Running;
2047        advanced.iteration = 5;
2048        let records = vec![
2049            header(),
2050            RunRecord::ContextCheckpoint {
2051                snapshot: snapshot("plan", vec![region("conv", vec![entry("hi", 1)])]),
2052                at: 1,
2053            },
2054            RunRecord::Progress {
2055                meta: Box::new(advanced),
2056                delta: ContextDelta {
2057                    stage_name: "plan".to_string(),
2058                    total_tokens: 3,
2059                    max_tokens: 10_000,
2060                    regions: vec![RegionDelta::Append {
2061                        name: "conv".to_string(),
2062                        entries: vec![entry("there", 2)],
2063                        current_tokens: 3,
2064                    }],
2065                },
2066                at: 2,
2067            },
2068        ];
2069        let folded = fold(&records).unwrap();
2070        assert_eq!(folded.meta.iteration, 5);
2071        assert_eq!(folded.meta.status, RunStatus::Running);
2072        assert_eq!(folded.context.regions[0].entries.len(), 2);
2073    }
2074
2075    /// A submitted answer needs no record type of its own: `Progress` and
2076    /// `Checkpoint` both replace the whole `RunMeta`, so it folds along with
2077    /// everything else and a crash-resume finds the answer already there.
2078    #[test]
2079    fn fold_carries_a_submitted_final_output_through_progress() {
2080        let mut answered = meta();
2081        answered.final_output = Some(
2082            crate::output::FinalOutput::new(
2083                "renamed two helpers",
2084                Some("markdown".to_string()),
2085                "summary".to_string(),
2086                9,
2087            )
2088            .descriptor(),
2089        );
2090        answered.output_request = Some(crate::output::OutputSpec {
2091            format: Some("a2ui".to_string()),
2092            ..Default::default()
2093        });
2094        let records = vec![
2095            header(),
2096            RunRecord::Progress {
2097                meta: Box::new(answered),
2098                delta: ContextDelta {
2099                    stage_name: "summary".to_string(),
2100                    total_tokens: 0,
2101                    max_tokens: 10_000,
2102                    regions: vec![],
2103                },
2104                at: 2,
2105            },
2106        ];
2107        let folded = fold(&records).unwrap();
2108        let output = folded.meta.final_output.expect("the answer folded through");
2109        // The descriptor, not the bytes: the answer itself is a sidecar file,
2110        // so what folds is the record of it.
2111        assert_eq!(output.bytes, "renamed two helpers".len());
2112        assert_eq!(output.stage, "summary");
2113        assert_eq!(
2114            folded.meta.output_request.and_then(|s| s.format).as_deref(),
2115            Some("a2ui")
2116        );
2117    }
2118
2119    // ── pending tool batch (fold) ──
2120
2121    fn call(id: &str, result: Option<&str>) -> ToolCallRecord {
2122        ToolCallRecord {
2123            id: id.to_string(),
2124            name: "shell".to_string(),
2125            arguments: "{}".to_string(),
2126            result: result.map(str::to_string),
2127            thought_signature: None,
2128        }
2129    }
2130
2131    fn batch(iteration: usize, calls: Vec<ToolCallRecord>) -> RunRecord {
2132        RunRecord::ToolBatch {
2133            calls,
2134            at: 10,
2135            stage_index: 0,
2136            iteration,
2137            response: "running tools".to_string(),
2138        }
2139    }
2140
2141    /// An entry whose kind is the assistant turn that issued `call_ids`.
2142    fn turn_entry(call_ids: &[&str]) -> RegionEntrySnapshot {
2143        let mut e = entry("turn", 1);
2144        e.kind = crate::region::EntryKind::AssistantTurn {
2145            tool_calls: call_ids
2146                .iter()
2147                .map(|id| crate::region::SerializedToolCall {
2148                    id: id.to_string(),
2149                    name: "shell".to_string(),
2150                    arguments: serde_json::Value::Null,
2151                    thought_signature: None,
2152                })
2153                .collect(),
2154        };
2155        e
2156    }
2157
2158    #[test]
2159    fn fold_surfaces_a_pending_batch_with_merged_results() {
2160        // meta().iteration is 0, matching the batch, and the context has no
2161        // assistant turn for it - so the batch is genuinely pending. c1's
2162        // ToolCallDone merges in; c2 keeps its dispatch-time inline result; c3
2163        // stays pending.
2164        let records = vec![
2165            header(),
2166            batch(
2167                0,
2168                vec![
2169                    call("c1", None),
2170                    call("c2", Some("inline")),
2171                    call("c3", None),
2172                ],
2173            ),
2174            RunRecord::ToolCallDone {
2175                iteration: 0,
2176                call_id: "c1".to_string(),
2177                result: "ran".to_string(),
2178                at: 11,
2179            },
2180        ];
2181        let folded = fold(&records).unwrap();
2182        let pending = folded.pending_batch.expect("batch is pending");
2183        assert_eq!(pending.iteration, 0);
2184        assert_eq!(pending.response, "running tools");
2185        assert_eq!(pending.calls[0].result.as_deref(), Some("ran"));
2186        assert_eq!(pending.calls[1].result.as_deref(), Some("inline"));
2187        assert_eq!(pending.calls[2].result, None);
2188        assert_eq!(folded.tool_call_count, 3);
2189    }
2190
2191    #[test]
2192    fn fold_keeps_only_the_latest_batch_and_ignores_stale_done_records() {
2193        // The second batch replaces the first; a ToolCallDone for the replaced
2194        // iteration is ignored, as is one naming a call the batch doesn't have.
2195        let mut advanced = meta();
2196        advanced.iteration = 1;
2197        let records = vec![
2198            header(),
2199            batch(0, vec![call("c1", None)]),
2200            RunRecord::Progress {
2201                meta: Box::new(advanced),
2202                delta: ContextDelta {
2203                    stage_name: "plan".to_string(),
2204                    total_tokens: 0,
2205                    max_tokens: 10_000,
2206                    regions: vec![],
2207                },
2208                at: 11,
2209            },
2210            batch(1, vec![call("c2", None)]),
2211            RunRecord::ToolCallDone {
2212                iteration: 0,
2213                call_id: "c1".to_string(),
2214                result: "stale".to_string(),
2215                at: 12,
2216            },
2217            RunRecord::ToolCallDone {
2218                iteration: 1,
2219                call_id: "unknown".to_string(),
2220                result: "nowhere to land".to_string(),
2221                at: 13,
2222            },
2223        ];
2224        let folded = fold(&records).unwrap();
2225        let pending = folded.pending_batch.expect("latest batch is pending");
2226        assert_eq!(pending.iteration, 1);
2227        assert_eq!(pending.calls.len(), 1);
2228        assert_eq!(pending.calls[0].id, "c2");
2229        assert_eq!(pending.calls[0].result, None, "stale/unknown dones ignored");
2230    }
2231
2232    #[test]
2233    fn fold_clears_a_batch_once_the_iteration_moves_on() {
2234        // A later inference bumped meta.iteration past the batch: the batch was
2235        // applied (even if a sliding window evicted the turn), nothing to replay.
2236        let mut advanced = meta();
2237        advanced.iteration = 1;
2238        let records = vec![
2239            header(),
2240            batch(0, vec![call("c1", Some("done"))]),
2241            RunRecord::Progress {
2242                meta: Box::new(advanced),
2243                delta: ContextDelta {
2244                    stage_name: "plan".to_string(),
2245                    total_tokens: 0,
2246                    max_tokens: 10_000,
2247                    regions: vec![],
2248                },
2249                at: 11,
2250            },
2251        ];
2252        assert_eq!(fold(&records).unwrap().pending_batch, None);
2253    }
2254
2255    #[test]
2256    fn fold_clears_a_batch_whose_turn_already_landed_in_the_window() {
2257        // Same iteration, but the context already holds the batch's assistant
2258        // turn: apply_tool_results ran before the crash, nothing to replay.
2259        let records = vec![
2260            header(),
2261            batch(0, vec![call("c1", Some("done"))]),
2262            RunRecord::ContextCheckpoint {
2263                snapshot: snapshot("plan", vec![region("conv", vec![turn_entry(&["c1"])])]),
2264                at: 11,
2265            },
2266        ];
2267        assert_eq!(fold(&records).unwrap().pending_batch, None);
2268    }
2269
2270    #[test]
2271    fn context_contains_batch_matches_only_the_batch_turn() {
2272        let pending = PendingToolBatch {
2273            stage_index: 0,
2274            iteration: 0,
2275            response: String::new(),
2276            calls: vec![call("c1", None)],
2277        };
2278        // A window with an unrelated turn does not match.
2279        let other = snapshot("plan", vec![region("conv", vec![turn_entry(&["zz"])])]);
2280        assert!(!context_contains_batch(&other, &pending));
2281        // The batch's own turn matches by its first call id.
2282        let own = snapshot(
2283            "plan",
2284            vec![region("conv", vec![turn_entry(&["c1", "c2"])])],
2285        );
2286        assert!(context_contains_batch(&own, &pending));
2287        // A batch with no calls can never match.
2288        let empty = PendingToolBatch {
2289            calls: vec![],
2290            ..pending
2291        };
2292        assert!(!context_contains_batch(&own, &empty));
2293    }
2294
2295    #[test]
2296    fn old_shape_tool_batch_json_still_parses() {
2297        // Archives written before the batch-journal fields existed carry
2298        // ToolBatch records without stage_index/iteration/response (and calls
2299        // without thought_signature); serde defaults fill them in.
2300        let json = br#"{"ToolBatch":{"calls":[{"id":"c1","name":"shell","arguments":"{}","result":"ok"}],"at":9}}"#;
2301        let mut buf = Vec::new();
2302        buf.extend_from_slice(&(json.len() as u64).to_be_bytes());
2303        buf.extend_from_slice(json);
2304        let record = read_record(&mut buf.as_slice()).unwrap().unwrap();
2305        assert_eq!(
2306            record,
2307            RunRecord::ToolBatch {
2308                calls: vec![call("c1", Some("ok"))],
2309                at: 9,
2310                stage_index: 0,
2311                iteration: 0,
2312                response: String::new(),
2313            }
2314        );
2315    }
2316
2317    // ── replay_points (context-window history) ──
2318
2319    /// Three context changes, so a windowing caller has something to page over.
2320    fn three_point_records() -> Vec<RunRecord> {
2321        let mut running = meta();
2322        running.status = RunStatus::Running;
2323        vec![
2324            header(),
2325            RunRecord::ContextCheckpoint {
2326                snapshot: snapshot("plan", vec![region("conv", vec![entry("first", 1)])]),
2327                at: 10,
2328            },
2329            RunRecord::ContextDiff {
2330                delta: ContextDelta {
2331                    stage_name: "plan".to_string(),
2332                    total_tokens: 2,
2333                    max_tokens: 10_000,
2334                    regions: vec![RegionDelta::Append {
2335                        name: "conv".to_string(),
2336                        entries: vec![entry("second", 1)],
2337                        current_tokens: 2,
2338                    }],
2339                },
2340                at: 20,
2341            },
2342            RunRecord::Progress {
2343                meta: Box::new(running),
2344                delta: ContextDelta {
2345                    stage_name: "code".to_string(),
2346                    total_tokens: 3,
2347                    max_tokens: 10_000,
2348                    regions: vec![RegionDelta::Append {
2349                        name: "conv".to_string(),
2350                        entries: vec![entry("third", 1)],
2351                        current_tokens: 3,
2352                    }],
2353                },
2354                at: 30,
2355            },
2356        ]
2357    }
2358
2359    #[test]
2360    fn visit_points_indexes_points_in_order_and_carries_the_running_window() {
2361        let records = three_point_records();
2362        let mut seen: Vec<(usize, i64, usize)> = Vec::new();
2363        visit_points(&records, &mut |point| {
2364            seen.push((
2365                point.index,
2366                point.at,
2367                point.context.regions[0].entries.len(),
2368            ));
2369            ControlFlow::Continue(())
2370        });
2371        // Index counts points, not records - the Header produces none.
2372        assert_eq!(seen, vec![(0, 10, 1), (1, 20, 2), (2, 30, 3)]);
2373    }
2374
2375    /// The reason this function exists: a caller wanting one window, or an
2376    /// answer to "does any point match", must be able to stop.
2377    #[test]
2378    fn visit_points_stops_at_the_first_break() {
2379        let records = three_point_records();
2380        let mut visits = 0;
2381        visit_points(&records, &mut |point| {
2382            visits += 1;
2383            if point.index == 1 {
2384                ControlFlow::Break(())
2385            } else {
2386                ControlFlow::Continue(())
2387            }
2388        });
2389        assert_eq!(
2390            visits, 2,
2391            "stopped at the breaking point, did not run the third"
2392        );
2393    }
2394
2395    #[test]
2396    fn visit_points_without_a_header_visits_nothing() {
2397        let mut visits = 0;
2398        {
2399            let mut count = |_: PointRef<'_>| {
2400                visits += 1;
2401                ControlFlow::Continue(())
2402            };
2403
2404            // A well-formed journal first, with the *same* visitor. Without
2405            // this the test would pass against a visitor that can never run at
2406            // all, which is exactly the reassurance it is not meant to give.
2407            visit_points(&three_point_records(), &mut count);
2408            // Neither of these starts with a Header, so neither is a replayable
2409            // journal and neither may produce a point.
2410            visit_points(&[], &mut count);
2411            visit_points(
2412                &[RunRecord::ContextCheckpoint {
2413                    snapshot: snapshot("plan", vec![]),
2414                    at: 1,
2415                }],
2416                &mut count,
2417            );
2418        }
2419        assert_eq!(visits, 3, "only the well-formed journal produced points");
2420    }
2421
2422    /// `replay_points` is now a thin collector over `visit_points`, so this
2423    /// pins the two together: if the reimplementation ever drifts, the borrowed
2424    /// walk and the materialized one stop agreeing here first.
2425    #[test]
2426    fn visit_points_and_replay_points_agree() {
2427        for records in [
2428            three_point_records(),
2429            vec![header()],
2430            vec![],
2431            vec![RunRecord::Message {
2432                message: MessageRecord {
2433                    role: "user".to_string(),
2434                    content: "x".to_string(),
2435                },
2436                at: 1,
2437            }],
2438        ] {
2439            let collected: Vec<RunPoint> = {
2440                let mut out = Vec::new();
2441                visit_points(&records, &mut |point| {
2442                    out.push(RunPoint {
2443                        meta: point.meta.clone(),
2444                        context: point.context.clone(),
2445                        at: point.at,
2446                    });
2447                    ControlFlow::Continue(())
2448                });
2449                out
2450            };
2451            assert_eq!(collected, replay_points(&records));
2452        }
2453    }
2454
2455    #[test]
2456    fn replay_points_requires_a_header() {
2457        assert!(replay_points(&[]).is_empty());
2458        assert!(
2459            replay_points(&[RunRecord::Message {
2460                message: MessageRecord {
2461                    role: "user".to_string(),
2462                    content: "x".to_string(),
2463                },
2464                at: 1,
2465            }])
2466            .is_empty()
2467        );
2468    }
2469
2470    #[test]
2471    fn replay_points_emits_a_snapshot_per_context_change() {
2472        // Header (no point) → checkpoint (point 1) → status (no point, but tracked)
2473        // → progress diff (point 2). Non-context records don't add points.
2474        let mut running = meta();
2475        running.status = RunStatus::Running;
2476        let records = vec![
2477            header(),
2478            RunRecord::Inference {
2479                stage: "plan".to_string(),
2480                iteration: 0,
2481                request: InferenceRequestRecord {
2482                    model: "m".to_string(),
2483                    system: vec![],
2484                    messages: vec![],
2485                    tool_names: vec![],
2486                    temperature: 0.7,
2487                    max_tokens: 10,
2488                },
2489                response: InferenceResponseRecord {
2490                    content: "ok".to_string(),
2491                    tool_calls: vec![],
2492                    prompt_tokens: 1,
2493                    completion_tokens: 1,
2494                    cached_tokens: 0,
2495                    cache_write_tokens: 0,
2496                },
2497                at: 1,
2498            },
2499            batch(0, vec![call("c1", None)]),
2500            RunRecord::ToolCallDone {
2501                iteration: 0,
2502                call_id: "c1".to_string(),
2503                result: "ran".to_string(),
2504                at: 1,
2505            },
2506            RunRecord::ContextCheckpoint {
2507                snapshot: snapshot("plan", vec![region("conv", vec![entry("hi", 1)])]),
2508                at: 2,
2509            },
2510            RunRecord::StatusChanged {
2511                status: RunStatus::Running,
2512                at: 3,
2513            },
2514            RunRecord::Progress {
2515                meta: Box::new(running),
2516                delta: ContextDelta {
2517                    stage_name: "implement".to_string(),
2518                    total_tokens: 3,
2519                    max_tokens: 10_000,
2520                    regions: vec![RegionDelta::Append {
2521                        name: "conv".to_string(),
2522                        entries: vec![entry("more", 2)],
2523                        current_tokens: 3,
2524                    }],
2525                },
2526                at: 4,
2527            },
2528        ];
2529        let points = replay_points(&records);
2530        assert_eq!(points.len(), 2, "one point per context change");
2531        // First point: the checkpoint window.
2532        assert_eq!(points[0].at, 2);
2533        assert_eq!(points[0].context.regions[0].entries.len(), 1);
2534        // Second point: the progress diff layered on, with the running status
2535        // carried from the StatusChanged + the progress meta.
2536        assert_eq!(points[1].at, 4);
2537        assert_eq!(points[1].context.regions[0].entries.len(), 2);
2538        assert_eq!(points[1].context.stage_name, "implement");
2539        assert_eq!(points[1].meta.status, RunStatus::Running);
2540    }
2541
2542    #[test]
2543    fn replay_points_handles_context_diff_and_a_later_header() {
2544        // A standalone ContextDiff is a point; a second Header refreshes meta
2545        // without adding a point.
2546        let mut relabeled = meta();
2547        relabeled.agent_name = "renamed".to_string();
2548        let records = vec![
2549            header(),
2550            RunRecord::ContextCheckpoint {
2551                snapshot: snapshot("plan", vec![region("conv", vec![entry("hi", 1)])]),
2552                at: 1,
2553            },
2554            RunRecord::Header {
2555                identity: identity(),
2556                meta: Box::new(relabeled),
2557            },
2558            RunRecord::ContextDiff {
2559                delta: ContextDelta {
2560                    stage_name: "plan".to_string(),
2561                    total_tokens: 3,
2562                    max_tokens: 10_000,
2563                    regions: vec![RegionDelta::Append {
2564                        name: "conv".to_string(),
2565                        entries: vec![entry("more", 2)],
2566                        current_tokens: 3,
2567                    }],
2568                },
2569                at: 2,
2570            },
2571        ];
2572        let points = replay_points(&records);
2573        assert_eq!(points.len(), 2); // checkpoint + diff (header adds no point)
2574        assert_eq!(points[1].context.regions[0].entries.len(), 2);
2575        // The later Header's meta is in effect at the diff point.
2576        assert_eq!(points[1].meta.agent_name, "renamed");
2577    }
2578
2579    #[test]
2580    fn replay_points_over_a_full_checkpoint() {
2581        // A `Checkpoint` (full meta+context) is also a point.
2582        let records = vec![
2583            header(),
2584            RunRecord::Checkpoint {
2585                meta: Box::new(meta()),
2586                context: snapshot("review", vec![region("conv", vec![entry("x", 4)])]),
2587                at: 9,
2588            },
2589        ];
2590        let points = replay_points(&records);
2591        assert_eq!(points.len(), 1);
2592        assert_eq!(points[0].context.stage_name, "review");
2593        assert_eq!(points[0].context.regions[0].entries[0].tokens, 4);
2594    }
2595
2596    /// Each kind has to survive the wire under its own name: the label is what
2597    /// a consumer groups a token chart by, so a rename that silently reordered
2598    /// the enum would re-attribute somebody's spend.
2599    #[test]
2600    fn every_inference_kind_has_a_distinct_label_and_serialized_name() {
2601        let all = [
2602            (InferenceKind::Stage, "stage"),
2603            (InferenceKind::Compaction, "compaction"),
2604            (InferenceKind::Title, "title"),
2605            (InferenceKind::Routing, "routing"),
2606        ];
2607        for (kind, label) in all {
2608            assert_eq!(kind.label(), label);
2609            assert_eq!(serde_json::to_value(kind).unwrap(), label);
2610        }
2611        let labels: std::collections::HashSet<_> = all.iter().map(|(k, _)| k.label()).collect();
2612        assert_eq!(labels.len(), all.len(), "labels must not collide");
2613    }
2614
2615    /// Only stage turns are work the agent asked for. The other three are
2616    /// machinery the runtime ran on its behalf, which is the split anything
2617    /// reporting "what did my agent actually do" needs.
2618    #[test]
2619    fn only_a_stage_turn_counts_as_stage_work() {
2620        assert!(InferenceKind::Stage.is_stage_work());
2621        for kind in [
2622            InferenceKind::Compaction,
2623            InferenceKind::Title,
2624            InferenceKind::Routing,
2625        ] {
2626            assert!(
2627                !kind.is_stage_work(),
2628                "{kind:?} is machinery, not stage work"
2629            );
2630        }
2631    }
2632
2633    /// A journal written before the field existed has to read back as stage
2634    /// work rather than failing to parse - every record in one is a stage turn,
2635    /// because nothing else was ever written.
2636    #[test]
2637    fn a_usage_record_without_a_kind_reads_back_as_stage_work() {
2638        let json = serde_json::json!({
2639            "InferenceUsage": {
2640                "stage": "plan",
2641                "iteration": 1,
2642                "provider": "anthropic",
2643                "model": "claude-sonnet-5",
2644                "prompt_tokens": 10,
2645                "completion_tokens": 2,
2646                "cached_tokens": 0,
2647                "cache_write_tokens": 0,
2648                "at": 5,
2649            }
2650        });
2651        // Compared whole rather than destructured: the point is that the
2652        // missing field defaults and every present one still lands, and a
2653        // destructure that pulled out `kind` alone would pass even if the rest
2654        // had been dropped.
2655        let record: RunRecord = serde_json::from_value(json).unwrap();
2656        assert_eq!(
2657            record,
2658            RunRecord::InferenceUsage {
2659                kind: InferenceKind::Stage,
2660                stage: "plan".to_string(),
2661                iteration: 1,
2662                provider: "anthropic".to_string(),
2663                model: "claude-sonnet-5".to_string(),
2664                prompt_tokens: 10,
2665                completion_tokens: 2,
2666                cached_tokens: 0,
2667                cache_write_tokens: 0,
2668                at: 5,
2669            }
2670        );
2671    }
2672
2673    /// The point of the record. Folding keeps every call in order, so a
2674    /// consumer can ask what any single one cost - which the cumulative
2675    /// counters cannot answer, because two calls between two ticks arrive as
2676    /// their sum.
2677    #[test]
2678    fn folding_keeps_each_call_separate_instead_of_summing_them() {
2679        let usage = |kind, prompt, at| RunRecord::InferenceUsage {
2680            kind,
2681            stage: "plan".to_string(),
2682            iteration: 1,
2683            provider: "anthropic".to_string(),
2684            model: "claude-sonnet-5".to_string(),
2685            prompt_tokens: prompt,
2686            completion_tokens: 1,
2687            cached_tokens: 0,
2688            cache_write_tokens: 0,
2689            at,
2690        };
2691        // The shape the issue reported: a compaction call and a stage call
2692        // landing between the same two progress ticks.
2693        let folded = fold(&[
2694            header(),
2695            usage(InferenceKind::Compaction, 7000, 1),
2696            usage(InferenceKind::Stage, 21_000, 2),
2697        ])
2698        .unwrap();
2699
2700        assert_eq!(folded.inference_count, 2);
2701        let seen: Vec<_> = folded
2702            .inference_usage
2703            .iter()
2704            .map(|u| (u.kind, u.prompt_tokens))
2705            .collect();
2706        assert_eq!(
2707            seen,
2708            vec![
2709                (InferenceKind::Compaction, 7000),
2710                (InferenceKind::Stage, 21_000)
2711            ]
2712        );
2713        // The whole reason this exists: their sum is 28k, and a reader of the
2714        // cumulative counter alone would see one 28k request and reasonably ask
2715        // whether a 32k window had been violated. Neither call came close.
2716        assert!(
2717            folded
2718                .inference_usage
2719                .iter()
2720                .all(|u| u.prompt_tokens < 32_000),
2721            "no single call exceeded the window, and the journal can now prove it"
2722        );
2723    }
2724
2725    /// The heavy variant and the light one both name one provider call, so a
2726    /// consumer counting calls should not have to know which the writer chose.
2727    #[test]
2728    fn both_inference_record_kinds_count_as_one_call_each() {
2729        let records = all_record_kinds();
2730        let folded = fold(&records).unwrap();
2731        let written = records
2732            .iter()
2733            .filter(|r| {
2734                matches!(
2735                    r,
2736                    RunRecord::Inference { .. } | RunRecord::InferenceUsage { .. }
2737                )
2738            })
2739            .count();
2740        assert_eq!(folded.inference_count, written);
2741        assert_eq!(folded.inference_usage.len(), 1);
2742    }
2743}