Skip to main content

leviath_runtime/
persistence.rs

1//! Agent-state persistence: turning a live ECS agent into the on-disk snapshot
2//! the dashboard/API read (`meta.json` + `context.json` under the run directory).
3//!
4//! This module holds the **pure** serialization core - components that carry an
5//! agent's run identity and running token totals, plus functions that build the
6//! [`RunMeta`]/[`ContextSnapshot`] value types from an agent's live components.
7//! It does no I/O; the async write lane and the snapshot-dispatch system layer on
8//! top of these.
9
10use bevy_ecs::prelude::*;
11use leviath_core::RegionKind;
12use leviath_core::run_meta::{
13    ContextSnapshot, RegionEntrySnapshot, RegionSnapshot, RunMeta, RunStatus, StageRunStatus,
14};
15
16use crate::components::{AgentState, AgentStatus, ContextWindow};
17
18/// Static per-agent run metadata (the parts of [`RunMeta`] that don't change as
19/// the agent runs). Set once when the agent is spawned; the dynamic fields are
20/// filled from the live components at snapshot time.
21#[derive(Component, Clone)]
22pub struct RunMetadata {
23    /// The run's unique id (its directory name under the runs dir).
24    pub run_id: String,
25    /// The agent/blueprint name.
26    pub agent_name: String,
27    /// Absolute path to the agent manifest directory.
28    pub agent_path: String,
29    /// The task prompt.
30    pub task: String,
31    /// The resolved model label (provider/model), if known.
32    pub model: Option<String>,
33    /// Absolute working directory for tool execution.
34    pub workdir: String,
35    /// Total number of stages in the blueprint.
36    pub num_stages: usize,
37    /// When the run started (unix seconds).
38    pub started_at: i64,
39    /// Parent run id, for sub-agent runs.
40    pub parent_run_id: Option<String>,
41    /// Custom key-value metadata from the spawn request.
42    pub metadata: std::collections::HashMap<String, String>,
43    /// Webhook to POST on completion/error.
44    pub callback_url: Option<String>,
45    /// Optional shared secret for HMAC-SHA256 signing the webhook body.
46    pub callback_secret: Option<String>,
47    /// Short human-readable title (None until generated).
48    pub title: Option<String>,
49    /// Whether this run is unattended (launched with `--yolo`).
50    ///
51    /// Recorded on the agent so anything holding the world can ask. Two things
52    /// need it: the sub-agent and fan-out spawners, which pass it down so a
53    /// child of an unattended run is unattended too, and `meta.json`, so a
54    /// daemon restart resumes the run the way it was launched. Both used to
55    /// hardcode "attended", which stranded unattended runs on prompts no one was
56    /// there to answer.
57    pub unattended: bool,
58    /// How much of the blueprint's `[read_paths]` the config granted, resolved
59    /// once at spawn (see [`ReadPathGrantCounts`]). `None` when the blueprint
60    /// declares none, which is nearly every agent.
61    ///
62    /// [`ReadPathGrantCounts`]: leviath_core::run_meta::ReadPathGrantCounts
63    pub read_paths: Option<leviath_core::run_meta::ReadPathGrantCounts>,
64}
65
66/// Running token + tool-call totals accumulated across an agent's inferences, for
67/// the snapshot. Updated by the inference-collect system.
68#[derive(Component, Clone, Copy, Default, Debug, PartialEq, Eq)]
69pub struct TokenTotals {
70    /// Cumulative prompt tokens.
71    pub prompt_tokens: usize,
72    /// Cumulative completion tokens.
73    pub completion_tokens: usize,
74    /// Cumulative tokens read from provider cache.
75    pub cached_tokens: usize,
76    /// Cumulative tokens written to provider cache.
77    pub cache_write_tokens: usize,
78    /// Cumulative tool calls across all iterations.
79    pub tool_calls: usize,
80}
81
82/// Run-scoped productivity flags, mirrored into `meta.json` so an empty run can
83/// be recognized (and explained) from disk. Unlike [`StageProgress`], this is
84/// never reset on a stage transition - it describes the whole run.
85///
86/// [`StageProgress`]: crate::pipeline::StageProgress
87#[derive(Component, Clone, Default, Debug, PartialEq)]
88pub struct RunOutcomeFlags(pub leviath_core::run_meta::RunFlags);
89
90impl RunOutcomeFlags {
91    /// Seed a fresh run's flags from the blueprint it is about to run.
92    ///
93    /// Every counter starts at zero; the one thing decided here is
94    /// [`no_output_tools`], which is fixed for the run's lifetime and so is
95    /// answered once rather than re-derived on every persist tick.
96    ///
97    /// Judged across *every* stage, not only the ones the run reaches: a run
98    /// cancelled in the first stage of an agent that writes files really did
99    /// produce nothing, and should still say so.
100    ///
101    /// [`no_output_tools`]: leviath_core::run_meta::RunFlags::no_output_tools
102    pub fn for_blueprint(bp: &leviath_core::Blueprint) -> Self {
103        Self(leviath_core::run_meta::RunFlags {
104            no_output_tools: !bp.stages.iter().any(stage_can_modify),
105            ..Default::default()
106        })
107    }
108}
109
110/// Whether `stage` advertises a tool whose writes the framework would record:
111/// a built-in [`MODIFYING_TOOLS`] name, or one that this stage's own outgoing
112/// transition gates name (the declared escape hatch for agents whose writes go
113/// through MCP or script tools).
114///
115/// Deliberately the same test the transition gate applies in `gate_blocks`, so
116/// a gated stage and the run's flags cannot disagree about what "can modify"
117/// means.
118/// `shell` is absent from both: an agent can edit through `sed -i` without the
119/// framework seeing it, so shell capability is real but unverifiable - which
120/// is exactly why such a run should still be reported as empty rather than
121/// excused.
122///
123/// [`MODIFYING_TOOLS`]: leviath_core::blueprint::MODIFYING_TOOLS
124fn stage_can_modify(stage: &leviath_core::Stage) -> bool {
125    stage.available_tools.iter().any(|t| {
126        let canonical = leviath_tools::canonical_tool_name(t);
127        leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical)
128            || stage
129                .transitions
130                .iter()
131                .flat_map(|edges| edges.values())
132                .filter_map(|edge| edge.gate.as_ref())
133                .any(|gate| {
134                    gate.tools
135                        .iter()
136                        .any(|extra| leviath_tools::canonical_tool_name(extra) == canonical)
137                })
138    })
139}
140
141impl TokenTotals {
142    /// Add one inference response's usage to the running totals.
143    pub fn add_usage(&mut self, usage: &leviath_providers::TokenUsage) {
144        self.prompt_tokens += usage.prompt_tokens;
145        self.completion_tokens += usage.completion_tokens;
146        self.cached_tokens += usage.cached_tokens;
147        self.cache_write_tokens += usage.cache_write_tokens;
148    }
149}
150
151/// Whether a run in `status` carrying `flags` stopped with nothing to show for
152/// itself.
153///
154/// Three things have to hold. The run has to have *stopped* - an agent that
155/// hasn't written anything yet is not an empty run, it is a busy one. It has to
156/// have modified nothing. And its blueprint has to have offered a way to modify
157/// something, or the question does not apply to it (see
158/// [`no_output_tools`](leviath_core::run_meta::RunFlags::no_output_tools)).
159///
160/// One definition, called by both `meta.json` and the run listing, so what an
161/// operator reads in `lev ps` and what a harness reads off disk cannot drift
162/// apart.
163pub fn is_empty_output(status: &AgentStatus, flags: &leviath_core::run_meta::RunFlags) -> bool {
164    matches!(
165        run_status_from(status),
166        RunStatus::Complete | RunStatus::Error | RunStatus::Cancelled
167    ) && flags.modified_file_count == 0
168        && !flags.no_output_tools
169}
170
171/// Map an agent's ECS status to the on-disk [`RunStatus`].
172pub fn run_status_from(status: &AgentStatus) -> RunStatus {
173    match status {
174        AgentStatus::Idle | AgentStatus::Active => RunStatus::Running,
175        AgentStatus::Paused => RunStatus::Paused,
176        AgentStatus::Waiting => RunStatus::WaitingInput,
177        AgentStatus::Complete => RunStatus::Complete,
178        AgentStatus::Error { .. } => RunStatus::Error,
179        AgentStatus::Cancelled => RunStatus::Cancelled,
180    }
181}
182
183/// Map an agent's ECS status to the on-disk per-stage [`StageRunStatus`] for the
184/// stage it is currently in. `Cancelled` has no stage-level equivalent, so it
185/// surfaces as `Error` (the stage stopped without completing).
186pub fn stage_status_from(status: &AgentStatus) -> StageRunStatus {
187    match status {
188        // A paused agent's current stage is still mid-flight, not a new stage state.
189        AgentStatus::Idle | AgentStatus::Active | AgentStatus::Paused => StageRunStatus::Active,
190        AgentStatus::Waiting => StageRunStatus::WaitingInput,
191        AgentStatus::Complete => StageRunStatus::Complete,
192        AgentStatus::Error { .. } | AgentStatus::Cancelled => StageRunStatus::Error,
193    }
194}
195
196/// The stringified region kind used in snapshots (matches the dashboard reader).
197fn region_kind_str(kind: &RegionKind) -> &'static str {
198    match kind {
199        RegionKind::Pinned => "pinned",
200        RegionKind::Temporary => "temporary",
201        RegionKind::Clearable => "clearable",
202        RegionKind::SlidingWindow { .. } => "sliding",
203        RegionKind::Compacting { .. } => "compacting",
204        RegionKind::CompactHistory { .. } => "history",
205        RegionKind::HashMap { .. } => "hashmap",
206        RegionKind::Custom { .. } => "custom",
207    }
208}
209
210/// Build the full context snapshot (`context.json`) from a window. Pure over the
211/// window - no engine/entity. (Ported from the CLI's `build_context_snapshot`.)
212pub fn build_context_snapshot(window: &ContextWindow, stage_name: &str) -> ContextSnapshot {
213    let regions = window
214        .regions
215        .iter()
216        .map(|r| RegionSnapshot {
217            name: r.name.clone(),
218            kind: region_kind_str(&r.kind).to_string(),
219            current_tokens: r.current_tokens,
220            max_tokens: r.max_tokens,
221            entries: r
222                .content
223                .iter()
224                .enumerate()
225                .map(|(i, e)| RegionEntrySnapshot {
226                    content: e.content.clone(),
227                    tokens: e.tokens,
228                    kind: e.kind.clone(),
229                    metadata: e.metadata.clone(),
230                    key: e.key.clone(),
231                    // `None` when the region has no taint tracking (it is off,
232                    // or this is an older region): `Public`, which is what a
233                    // restore assumed anyway.
234                    taint: r
235                        .taint
236                        .as_ref()
237                        .and_then(|t| t.entry_taint(i))
238                        .unwrap_or_default(),
239                })
240                .collect(),
241        })
242        .collect();
243    ContextSnapshot {
244        stage_name: stage_name.to_string(),
245        total_tokens: window.current_tokens,
246        max_tokens: window.max_tokens,
247        regions,
248    }
249}
250
251/// Build the run metadata (`meta.json`) from an agent's live components, stamping
252/// `updated_at` with `now_secs`. `stage_index` is the agent's current stage
253/// position within its blueprint.
254///
255/// `last_progress_at` is the caller's separate record of when the run last
256/// actually moved, which is not the same as `now_secs`: this is called on the
257/// heartbeat too, and a heartbeat write must advance `updated_at` while leaving
258/// the progress stamp where it was. Taken as a plain `Option` rather than the
259/// watermark it comes from so this stays a data mapper with no dependency on the
260/// persistence pipeline.
261#[allow(clippy::too_many_arguments)]
262pub fn build_run_meta(
263    md: &RunMetadata,
264    state: &AgentState,
265    totals: &TokenTotals,
266    flags: &RunOutcomeFlags,
267    stage_index: usize,
268    now_secs: i64,
269    last_progress_at: Option<i64>,
270    depth: usize,
271    max_child_depth: usize,
272) -> RunMeta {
273    let status = run_status_from(&state.status);
274    let mut flags = flags.0.clone();
275    flags.empty_output = is_empty_output(&state.status, &flags);
276    RunMeta {
277        run_id: md.run_id.clone(),
278        agent_name: md.agent_name.clone(),
279        agent_path: md.agent_path.clone(),
280        task: md.task.clone(),
281        model: md.model.clone(),
282        pid: 0, // no per-run worker process in the shared world; see RunMeta::pid
283        status,
284        current_stage: state.current_stage.clone(),
285        stage_index,
286        num_stages: md.num_stages,
287        iteration: state.iteration,
288        prompt_tokens: totals.prompt_tokens,
289        completion_tokens: totals.completion_tokens,
290        cached_tokens: totals.cached_tokens,
291        cache_write_tokens: totals.cache_write_tokens,
292        tool_calls: totals.tool_calls,
293        workdir: md.workdir.clone(),
294        started_at: md.started_at,
295        updated_at: now_secs,
296        last_progress_at,
297        error: match &state.status {
298            AgentStatus::Error { message } => Some(message.clone()),
299            _ => None,
300        },
301        title: md.title.clone(),
302        metadata: md.metadata.clone(),
303        callback_url: md.callback_url.clone(),
304        callback_secret: md.callback_secret.clone(),
305        parent_run_id: md.parent_run_id.clone(),
306        // The tree links, so restart can rebuild the exact parent→children graph.
307        children: state.spawned_children_ids.clone(),
308        depth,
309        max_child_depth,
310        flags,
311        yolo: md.unattended,
312        read_paths: md.read_paths,
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use leviath_core::Region;
320    use leviath_providers::TokenUsage;
321
322    fn state(status: AgentStatus) -> AgentState {
323        AgentState {
324            agent_id: "a".to_string(),
325            current_stage: "plan".to_string(),
326            iteration: 4,
327            status,
328            spawned_children_ids: vec![],
329            pending_wait: None,
330            accepts_messages: true,
331        }
332    }
333
334    fn metadata() -> RunMetadata {
335        RunMetadata {
336            run_id: "run-1".to_string(),
337            agent_name: "coder".to_string(),
338            agent_path: "/agents/coder".to_string(),
339            task: "do it".to_string(),
340            model: Some("anthropic/claude".to_string()),
341            workdir: "/work".to_string(),
342            num_stages: 3,
343            started_at: 1000,
344            parent_run_id: Some("parent".to_string()),
345            metadata: std::collections::HashMap::from([("k".to_string(), "v".to_string())]),
346            callback_url: Some("http://cb".to_string()),
347            callback_secret: Some("sekret".to_string()),
348            title: Some("Do It".to_string()),
349            unattended: false,
350            read_paths: None,
351        }
352    }
353
354    /// A stage advertising `tools`, with `gate_tools` named by the gate on its
355    /// single outgoing edge. `gate_tools: None` gives the stage no transitions
356    /// at all, which is the other half of the `Option` the scan walks.
357    fn stage_with(tools: &[&str], gate_tools: Option<&[&str]>) -> leviath_core::Stage {
358        let mut stage = leviath_core::Stage::new(
359            "s".to_string(),
360            leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
361        );
362        stage.available_tools = tools.iter().map(|t| (*t).to_string()).collect();
363        stage.transitions = gate_tools.map(|extra| {
364            let gate = (!extra.is_empty()).then(|| leviath_core::blueprint::TransitionGate {
365                require_modifications: true,
366                tools: extra.iter().map(|t| (*t).to_string()).collect(),
367                ..Default::default()
368            });
369            std::collections::HashMap::from([(
370                "next".to_string(),
371                leviath_core::blueprint::TransitionEdge {
372                    target: "next".to_string(),
373                    condition: leviath_core::blueprint::TransitionCondition::Always,
374                    hint: None,
375                    transform: leviath_core::blueprint::EdgeTransform::Direct,
376                    gate,
377                    stuck: None,
378                },
379            )])
380        });
381        stage
382    }
383
384    fn blueprint_of(stages: Vec<leviath_core::Stage>) -> leviath_core::Blueprint {
385        leviath_core::Blueprint::new(
386            "bp".to_string(),
387            "d".to_string(),
388            stages,
389            leviath_core::ContextLayout::new(vec![], 1000),
390        )
391    }
392
393    fn no_output_tools(stages: Vec<leviath_core::Stage>) -> bool {
394        RunOutcomeFlags::for_blueprint(&blueprint_of(stages))
395            .0
396            .no_output_tools
397    }
398
399    #[test]
400    fn for_blueprint_asks_whether_any_stage_could_have_written() {
401        // A blueprint with no stages at all offers nothing.
402        assert!(no_output_tools(vec![]));
403        // Read-only, and the sub-agent tools a router would use: nothing the
404        // framework tracks as a file change. This is the issue #192 case.
405        assert!(no_output_tools(vec![stage_with(
406            &["read_file", "spawn_agent", "context_write"],
407            None
408        )]));
409        // `shell` confers no tracked write: an agent editing through `sed -i`
410        // leaves no record, so silence from it stays suspicious rather than
411        // excused. The alias resolves, so `bash` is judged as `shell`.
412        assert!(no_output_tools(vec![stage_with(&["bash"], None)]));
413        // A built-in modifying tool, under either name.
414        assert!(!no_output_tools(vec![stage_with(&["write_file"], None)]));
415        assert!(!no_output_tools(vec![stage_with(&["edit_file"], None)]));
416        // Only one stage needs it.
417        assert!(!no_output_tools(vec![
418            stage_with(&["read_file"], None),
419            stage_with(&["write_file"], None),
420        ]));
421    }
422
423    #[test]
424    fn for_blueprint_honors_a_gate_declaring_its_own_write_tool() {
425        // An MCP/script write tool the stage advertises AND a gate names is a
426        // tracked write - the same escape hatch `stage_modifying_tools` gives.
427        assert!(!no_output_tools(vec![stage_with(
428            &["mcp__fs__put"],
429            Some(&["mcp__fs__put"])
430        )]));
431        // Declared by the gate but never advertised: the stage cannot call it.
432        assert!(no_output_tools(vec![stage_with(
433            &["read_file"],
434            Some(&["mcp__fs__put"])
435        )]));
436        // Transitions present, but no gate on the edge.
437        assert!(no_output_tools(vec![stage_with(&["read_file"], Some(&[]))]));
438        // A gate that names a tool unrelated to what the stage advertises.
439        assert!(no_output_tools(vec![stage_with(
440            &["mcp__fs__put"],
441            Some(&["mcp__other__put"])
442        )]));
443    }
444
445    #[test]
446    fn is_empty_output_needs_a_stopped_run_that_could_have_written() {
447        let nothing = leviath_core::run_meta::RunFlags::default();
448        // Running: it has not finished not-writing yet.
449        assert!(!is_empty_output(&AgentStatus::Active, &nothing));
450        assert!(!is_empty_output(&AgentStatus::Idle, &nothing));
451        assert!(!is_empty_output(&AgentStatus::Paused, &nothing));
452        assert!(!is_empty_output(&AgentStatus::Waiting, &nothing));
453        // Every way of stopping counts.
454        for status in [
455            AgentStatus::Complete,
456            AgentStatus::Cancelled,
457            AgentStatus::Error {
458                message: "x".to_string(),
459            },
460        ] {
461            assert!(is_empty_output(&status, &nothing));
462        }
463        // Wrote something.
464        let mut wrote = leviath_core::run_meta::RunFlags::default();
465        wrote.record_modification("src/a.rs");
466        assert!(!is_empty_output(&AgentStatus::Complete, &wrote));
467        // Had nothing to write with.
468        let incapable = leviath_core::run_meta::RunFlags {
469            no_output_tools: true,
470            ..Default::default()
471        };
472        assert!(!is_empty_output(&AgentStatus::Complete, &incapable));
473    }
474
475    #[test]
476    fn status_mapping_covers_all_variants() {
477        assert_eq!(run_status_from(&AgentStatus::Idle), RunStatus::Running);
478        assert_eq!(run_status_from(&AgentStatus::Active), RunStatus::Running);
479        assert_eq!(run_status_from(&AgentStatus::Paused), RunStatus::Paused);
480        assert_eq!(
481            run_status_from(&AgentStatus::Waiting),
482            RunStatus::WaitingInput
483        );
484        assert_eq!(run_status_from(&AgentStatus::Complete), RunStatus::Complete);
485        assert_eq!(
486            run_status_from(&AgentStatus::Error {
487                message: "x".to_string()
488            }),
489            RunStatus::Error
490        );
491        assert_eq!(
492            run_status_from(&AgentStatus::Cancelled),
493            RunStatus::Cancelled
494        );
495    }
496
497    #[test]
498    fn stage_status_mapping_covers_all_variants() {
499        use leviath_core::run_meta::StageRunStatus;
500        assert_eq!(
501            stage_status_from(&AgentStatus::Idle),
502            StageRunStatus::Active
503        );
504        assert_eq!(
505            stage_status_from(&AgentStatus::Active),
506            StageRunStatus::Active
507        );
508        assert_eq!(
509            stage_status_from(&AgentStatus::Paused),
510            StageRunStatus::Active
511        );
512        assert_eq!(
513            stage_status_from(&AgentStatus::Waiting),
514            StageRunStatus::WaitingInput
515        );
516        assert_eq!(
517            stage_status_from(&AgentStatus::Complete),
518            StageRunStatus::Complete
519        );
520        assert_eq!(
521            stage_status_from(&AgentStatus::Error {
522                message: "x".to_string()
523            }),
524            StageRunStatus::Error
525        );
526        assert_eq!(
527            stage_status_from(&AgentStatus::Cancelled),
528            StageRunStatus::Error
529        );
530    }
531
532    #[test]
533    fn token_totals_accumulate() {
534        let mut t = TokenTotals::default();
535        t.add_usage(&TokenUsage {
536            prompt_tokens: 10,
537            completion_tokens: 5,
538            total_tokens: 15,
539            cached_tokens: 2,
540            cache_write_tokens: 1,
541        });
542        t.add_usage(&TokenUsage {
543            prompt_tokens: 3,
544            completion_tokens: 4,
545            total_tokens: 7,
546            cached_tokens: 0,
547            cache_write_tokens: 0,
548        });
549        t.tool_calls = 6;
550        assert_eq!(t.prompt_tokens, 13);
551        assert_eq!(t.completion_tokens, 9);
552        assert_eq!(t.cached_tokens, 2);
553        assert_eq!(t.cache_write_tokens, 1);
554    }
555
556    #[test]
557    fn build_run_meta_fills_dynamic_and_static_fields() {
558        let md = metadata();
559        let totals = TokenTotals {
560            prompt_tokens: 100,
561            completion_tokens: 50,
562            cached_tokens: 10,
563            cache_write_tokens: 5,
564            tool_calls: 7,
565        };
566        let mut st = state(AgentStatus::Active);
567        st.spawned_children_ids = vec!["child-a".to_string(), "child-b".to_string()];
568        let meta = build_run_meta(
569            &md,
570            &st,
571            &totals,
572            &RunOutcomeFlags::default(),
573            1,
574            2000,
575            Some(1900),
576            1,
577            4,
578        );
579
580        assert_eq!(meta.run_id, "run-1");
581        assert_eq!(meta.status, RunStatus::Running);
582        assert_eq!(meta.current_stage, "plan");
583        assert_eq!(meta.stage_index, 1);
584        assert_eq!(meta.iteration, 4);
585        assert_eq!(meta.prompt_tokens, 100);
586        assert_eq!(meta.tool_calls, 7);
587        assert_eq!(meta.updated_at, 2000);
588        // The two stamps are independent: this snapshot was written at 2000, and
589        // the run last moved at 1900. A heartbeat write is exactly that shape.
590        assert_eq!(meta.last_progress_at, Some(1900));
591        assert_eq!(meta.parent_run_id.as_deref(), Some("parent"));
592        assert_eq!(meta.callback_url.as_deref(), Some("http://cb"));
593        assert_eq!(meta.callback_secret.as_deref(), Some("sekret"));
594        assert!(meta.error.is_none());
595        // The tree links are carried through from the agent's live state.
596        assert_eq!(
597            meta.children,
598            vec!["child-a".to_string(), "child-b".to_string()]
599        );
600        assert_eq!(meta.depth, 1);
601        assert_eq!(meta.max_child_depth, 4);
602        // Attended by default, so an ordinary run is never written as unattended.
603        assert!(!meta.yolo);
604    }
605
606    /// The snapshot carries `unattended` through to `meta.json`, which is what a
607    /// daemon restart reads back to resume the run the way it was launched.
608    #[test]
609    fn build_run_meta_records_an_unattended_run() {
610        let mut md = metadata();
611        md.unattended = true;
612        let meta = build_run_meta(
613            &md,
614            &state(AgentStatus::Active),
615            &TokenTotals::default(),
616            &RunOutcomeFlags::default(),
617            1,
618            2000,
619            None,
620            1,
621            4,
622        );
623        assert!(meta.yolo);
624    }
625
626    #[test]
627    fn build_run_meta_flags_empty_output_only_once_the_run_has_stopped() {
628        let mut flags = RunOutcomeFlags::default();
629        flags.0.gates_forced = 2;
630        // Still running with nothing written: not (yet) an empty run.
631        let running = build_run_meta(
632            &metadata(),
633            &state(AgentStatus::Active),
634            &TokenTotals::default(),
635            &flags,
636            0,
637            1000,
638            None,
639            0,
640            0,
641        );
642        assert!(!running.flags.empty_output);
643        assert_eq!(running.flags.gates_forced, 2);
644
645        // Finished with nothing written: that is the #107 signature.
646        for status in [
647            AgentStatus::Complete,
648            AgentStatus::Cancelled,
649            AgentStatus::Error {
650                message: "x".to_string(),
651            },
652        ] {
653            let meta = build_run_meta(
654                &metadata(),
655                &state(status),
656                &TokenTotals::default(),
657                &flags,
658                0,
659                1000,
660                None,
661                0,
662                0,
663            );
664            assert!(meta.flags.empty_output);
665        }
666
667        // Finished having written something: not empty.
668        let mut wrote = RunOutcomeFlags::default();
669        wrote.0.record_modification("src/a.rs");
670        let meta = build_run_meta(
671            &metadata(),
672            &state(AgentStatus::Complete),
673            &TokenTotals::default(),
674            &wrote,
675            0,
676            1000,
677            None,
678            0,
679            0,
680        );
681        assert!(!meta.flags.empty_output);
682        assert_eq!(meta.flags.modified_files, vec!["src/a.rs".to_string()]);
683
684        // Finished having written nothing, with nothing to write *with*: the
685        // framework has no basis to call this empty, so it doesn't (issue #192).
686        let mut incapable = RunOutcomeFlags::default();
687        incapable.0.no_output_tools = true;
688        let meta = build_run_meta(
689            &metadata(),
690            &state(AgentStatus::Complete),
691            &TokenTotals::default(),
692            &incapable,
693            0,
694            1000,
695            None,
696            0,
697            0,
698        );
699        assert!(!meta.flags.empty_output);
700        assert!(meta.flags.no_output_tools);
701    }
702
703    #[test]
704    fn build_run_meta_carries_error_message() {
705        let meta = build_run_meta(
706            &metadata(),
707            &state(AgentStatus::Error {
708                message: "boom".to_string(),
709            }),
710            &TokenTotals::default(),
711            &RunOutcomeFlags::default(),
712            2,
713            3000,
714            None,
715            0,
716            0,
717        );
718        assert_eq!(meta.status, RunStatus::Error);
719        assert_eq!(meta.error.as_deref(), Some("boom"));
720    }
721
722    #[test]
723    fn context_snapshot_captures_all_region_kinds() {
724        let mut w = ContextWindow::new(1000);
725        w.add_region(Region::new("pin".to_string(), RegionKind::Pinned, 100));
726        w.add_region(Region::new("tmp".to_string(), RegionKind::Temporary, 100));
727        w.add_region(Region::new("clr".to_string(), RegionKind::Clearable, 100));
728        w.add_region(Region::new(
729            "slide".to_string(),
730            RegionKind::SlidingWindow {
731                max_items: 5,
732                eviction_strategy: leviath_core::EvictionStrategy::PerItem,
733            },
734            100,
735        ));
736        w.add_region(Region::new(
737            "comp".to_string(),
738            RegionKind::Compacting {
739                threshold_tokens: 5,
740            },
741            100,
742        ));
743        w.add_region(Region::new(
744            "hist".to_string(),
745            RegionKind::CompactHistory {
746                source_region: "comp".to_string(),
747            },
748            100,
749        ));
750        w.add_region(Region::new(
751            "map".to_string(),
752            RegionKind::HashMap { max_entries: None },
753            100,
754        ));
755        w.add_region(Region::new(
756            "brain".to_string(),
757            RegionKind::Custom {
758                script: "b.rhai".to_string(),
759                persistent: false,
760            },
761            100,
762        ));
763        let _ = w.add_to_region("pin", "hello".to_string(), 3);
764        w.current_tokens = w.calculate_tokens();
765
766        let snap = build_context_snapshot(&w, "plan");
767
768        assert_eq!(snap.stage_name, "plan");
769        let kinds: Vec<&str> = snap.regions.iter().map(|r| r.kind.as_str()).collect();
770        assert_eq!(
771            kinds,
772            vec![
773                "pinned",
774                "temporary",
775                "clearable",
776                "sliding",
777                "compacting",
778                "history",
779                "hashmap",
780                "custom"
781            ]
782        );
783        // The pinned region's entry is captured.
784        let pin = snap.regions.iter().find(|r| r.name == "pin").unwrap();
785        assert_eq!(pin.entries.len(), 1);
786        assert_eq!(pin.entries[0].content, "hello");
787    }
788}