Skip to main content

leviath_cli/commands/
context.rs

1//! `lev context <run-id>` - show a run's context-window history.
2//!
3//! Replays the run's portable archive (`run.lvr`) into the sequence of context
4//! windows over time (one per recorded checkpoint/step) and prints them, so you
5//! can inspect what the agent's memory looked like at each stage and point -
6//! for debugging or auditing. Read-only; sources everything from disk.
7
8use clap::Args;
9use leviath_core::run_archive::RunPoint;
10
11/// Arguments for `lev context`.
12#[derive(Args, Debug)]
13pub struct ContextArgs {
14    /// The run id whose context-window history to show.
15    pub run_id: String,
16    /// Print the full history as JSON instead of a human-readable summary.
17    #[arg(long)]
18    pub json: bool,
19    /// Include each region's entry contents (not just per-region summaries).
20    #[arg(long)]
21    pub full: bool,
22}
23
24/// Execute `lev context`.
25pub async fn execute(args: ContextArgs) -> anyhow::Result<()> {
26    let history = crate::runstate::context_history(&args.run_id);
27    if history.is_empty() {
28        anyhow::bail!(
29            "no context history for run '{}' (no readable run.lvr archive)",
30            args.run_id
31        );
32    }
33    let out = render(&args.run_id, &history, args.json, args.full);
34    print!("{out}");
35    Ok(())
36}
37
38/// Render the history to a string (pure, so it's directly testable).
39fn render(run_id: &str, history: &[RunPoint], json: bool, full: bool) -> String {
40    if json {
41        // RunPoint is Serialize; a plain array is the machine-readable form.
42        return format!(
43            "{}\n",
44            serde_json::to_string_pretty(history).expect("RunPoint history always serializes")
45        );
46    }
47    let mut out = String::new();
48    out.push_str(&format!(
49        "Context history for run '{run_id}' ({} point{}):\n\n",
50        history.len(),
51        if history.len() == 1 { "" } else { "s" }
52    ));
53    for (i, point) in history.iter().enumerate() {
54        out.push_str(&format!(
55            "[{}] {}  stage={}  iter={}  status={}  tokens={}/{}\n",
56            i + 1,
57            format_time(point.at),
58            point.context.stage_name,
59            point.meta.iteration,
60            point.meta.status,
61            point.context.total_tokens,
62            point.context.max_tokens,
63        ));
64        for region in &point.context.regions {
65            out.push_str(&format!(
66                "      region {} ({}) - {} tok, {} entr{}\n",
67                region.name,
68                region.kind,
69                region.current_tokens,
70                region.entries.len(),
71                if region.entries.len() == 1 {
72                    "y"
73                } else {
74                    "ies"
75                },
76            ));
77            if full {
78                for entry in &region.entries {
79                    for line in entry.content.lines() {
80                        out.push_str(&format!("          {line}\n"));
81                    }
82                }
83            }
84        }
85        out.push('\n');
86    }
87    out
88}
89
90/// Format a unix timestamp as a local `YYYY-MM-DD HH:MM:SS`, or the raw seconds
91/// if it's out of range.
92fn format_time(secs: i64) -> String {
93    match chrono::DateTime::from_timestamp(secs, 0) {
94        Some(dt) => dt
95            .with_timezone(&chrono::Local)
96            .format("%Y-%m-%d %H:%M:%S")
97            .to_string(),
98        None => secs.to_string(),
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use leviath_core::run_meta::{ContextSnapshot, RegionEntrySnapshot, RegionSnapshot, RunMeta};
106
107    fn point(stage: &str, tokens: usize, entries: Vec<&str>) -> RunPoint {
108        RunPoint {
109            meta: RunMeta::new(
110                "run-x".to_string(),
111                "coder".to_string(),
112                "/agents/coder".to_string(),
113                "task".to_string(),
114                None,
115                "/work".to_string(),
116                2,
117            ),
118            context: ContextSnapshot {
119                stage_name: stage.to_string(),
120                total_tokens: tokens,
121                max_tokens: 1000,
122                regions: vec![RegionSnapshot {
123                    name: "conv".to_string(),
124                    kind: "clearable".to_string(),
125                    current_tokens: tokens,
126                    max_tokens: 1000,
127                    entries: entries
128                        .into_iter()
129                        .map(|c| RegionEntrySnapshot {
130                            content: c.to_string(),
131                            tokens: 1,
132                            kind: leviath_core::region::EntryKind::Text,
133                            metadata: None,
134                            key: None,
135                            taint: Default::default(),
136                        })
137                        .collect(),
138                }],
139            },
140            at: 0,
141        }
142    }
143
144    #[test]
145    fn render_human_lists_each_point_and_region() {
146        let history = vec![
147            point("plan", 1, vec!["hi"]),
148            point("implement", 3, vec!["a", "b"]),
149        ];
150        let out = render("run-x", &history, false, false);
151        assert!(out.contains("Context history for run 'run-x' (2 points)"));
152        assert!(out.contains("[1]") && out.contains("stage=plan"));
153        assert!(out.contains("[2]") && out.contains("stage=implement"));
154        assert!(out.contains("region conv (clearable)"));
155        // Summary mode doesn't dump entry contents.
156        assert!(!out.contains("          hi"));
157    }
158
159    #[test]
160    fn render_full_includes_entry_contents() {
161        let history = vec![point("plan", 1, vec!["secret line"])];
162        let out = render("run-x", &history, false, true);
163        assert!(out.contains("secret line"));
164        // Singular "point" / "entry" wording.
165        assert!(out.contains("(1 point)"));
166        assert!(out.contains("1 entry"));
167    }
168
169    #[test]
170    fn render_json_is_a_parseable_array() {
171        let history = vec![point("plan", 1, vec!["hi"])];
172        let out = render("run-x", &history, true, false);
173        let parsed: Vec<RunPoint> = serde_json::from_str(&out).unwrap();
174        assert_eq!(parsed.len(), 1);
175        assert_eq!(parsed[0].context.stage_name, "plan");
176    }
177
178    #[test]
179    fn format_time_handles_valid_and_out_of_range() {
180        // A fixed timestamp (2023-11-14 UTC) formats as a date-time string.
181        let formatted = format_time(1_700_000_000);
182        assert!(formatted.contains('-') && formatted.contains(':'));
183        // i64::MAX is out of DateTime range → falls back to the raw number.
184        assert_eq!(format_time(i64::MAX), i64::MAX.to_string());
185    }
186
187    #[test]
188    fn execute_prints_history_for_a_run_with_an_archive() {
189        crate::runstate::with_isolated_runs_dir("context-execute-ok", |_d| {
190            use leviath_core::run_archive::{self, RunIdentity, RunRecord};
191            let run_id = "ctx-exec-run";
192            std::fs::create_dir_all(crate::runstate::run_dir(run_id)).unwrap();
193            let mut buf = Vec::new();
194            run_archive::write_archive_start(&mut buf, run_archive::RUN_ARCHIVE_VERSION).unwrap();
195            run_archive::write_record(
196                &mut buf,
197                &RunRecord::Header {
198                    identity: RunIdentity {
199                        run_id: run_id.to_string(),
200                        machine_id: "m".to_string(),
201                        world_id: "w".to_string(),
202                        created_at: 0,
203                    },
204                    meta: Box::new(RunMeta::new(
205                        run_id.to_string(),
206                        "a".to_string(),
207                        "/p".to_string(),
208                        "t".to_string(),
209                        None,
210                        "/w".to_string(),
211                        1,
212                    )),
213                },
214            )
215            .unwrap();
216            run_archive::write_record(
217                &mut buf,
218                &RunRecord::ContextCheckpoint {
219                    snapshot: ContextSnapshot {
220                        stage_name: "plan".to_string(),
221                        total_tokens: 1,
222                        max_tokens: 100,
223                        regions: vec![],
224                    },
225                    at: 1,
226                },
227            )
228            .unwrap();
229            std::fs::write(crate::runstate::run_dir(run_id).join("run.lvr"), &buf).unwrap();
230
231            // Present archive → the success path (render + print) runs and returns Ok.
232            let args = ContextArgs {
233                run_id: run_id.to_string(),
234                json: true,
235                full: false,
236            };
237            let rt = tokio::runtime::Runtime::new().unwrap();
238            assert!(rt.block_on(execute(args)).is_ok());
239            // Missing archive → the error path.
240            let missing = ContextArgs {
241                run_id: "no-archive-run".to_string(),
242                json: false,
243                full: false,
244            };
245            assert!(rt.block_on(execute(missing)).is_err());
246        });
247    }
248}