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