Skip to main content

klieo_runlog/
capture.rs

1//! The [`Capture`] envelope (ADR-046): a single serializable artifact bundling
2//! everything needed to replay a run without a live store — the `RunLog`, the
3//! parallel `LlmIo` sidecar carrying prompt/completion text, and Option-typed
4//! provider-version / seed / memory metadata filled in when available.
5
6use crate::types::{LlmIo, RunLog};
7use chrono::{DateTime, Utc};
8use klieo_core::RunId;
9use serde::{Deserialize, Serialize};
10
11/// A self-contained snapshot of one run's replayable execution context. The
12/// `model_version`, `rng_seed`, and `memory_ref` fields are `Option` so a
13/// capture assembled before a provider records them deserializes unchanged once
14/// they are present — older captures simply carry `null`.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct Capture {
18    /// Equal to `run_log.run_id`; the key under which a persisted capture is
19    /// looked up.
20    pub run_id: RunId,
21    /// The recorded step sequence replay walks; its outputs are what a
22    /// `DivergenceReport` compares against.
23    pub run_log: RunLog,
24    /// Prompt/completion text the `RunLog` itself does not carry, ordered
25    /// parallel to the `run_log`'s `LlmCall` steps.
26    pub llm_io: Vec<LlmIo>,
27    /// Model that served the last LLM call in this run. In heterogeneous-model
28    /// runs this captures the most-recent model only. `None` for captures
29    /// assembled before model tracking was added (older captures carry `null`).
30    pub model_version: Option<String>,
31    /// Seed that made the run's LLM deterministic, when one was set — the
32    /// signal that replay can expect byte-identical output rather than drift.
33    pub rng_seed: Option<u64>,
34    /// Opaque handle to a memory snapshot; `None` when no snapshot was taken.
35    pub memory_ref: Option<String>,
36    /// Capture-assembly time, distinct from the run's own
37    /// `started_at`/`finished_at`.
38    pub captured_at: DateTime<Utc>,
39}
40
41impl Capture {
42    /// Stamps `captured_at` from the wall clock at call time — it is the
43    /// capture moment, not derived from the run's own `started_at`/`finished_at`.
44    ///
45    /// `model_version` is derived from the last `LlmIo` entry that carries a
46    /// model name; `None` when no entry recorded one.
47    pub fn from_runlog(run_log: RunLog, llm_io: Vec<LlmIo>) -> Self {
48        let model_version = llm_io.iter().rev().find_map(|io| io.model.clone());
49        Self {
50            run_id: run_log.run_id,
51            run_log,
52            llm_io,
53            model_version,
54            rng_seed: None,
55            memory_ref: None,
56            captured_at: Utc::now(),
57        }
58    }
59}
60
61#[cfg(test)]
62mod capture_tests {
63    use super::Capture;
64    use crate::types::{LlmIo, RunLog, RunStatus, Usage};
65    use chrono::Utc;
66    use klieo_core::RunId;
67
68    fn empty_run_log() -> RunLog {
69        let now = Utc::now();
70        RunLog {
71            run_id: RunId::new(),
72            agent: "t".into(),
73            started_at: now,
74            finished_at: Some(now),
75            status: RunStatus::Completed,
76            steps: vec![],
77            tokens: Usage::default(),
78            cost_estimate: None,
79        }
80    }
81
82    fn llm_io() -> LlmIo {
83        LlmIo::new("p", "c")
84    }
85
86    #[test]
87    fn from_runlog_carries_sources_and_leaves_phase2_fields_none() {
88        let log = empty_run_log();
89        let run_id = log.run_id;
90        let before = Utc::now();
91        let capture = Capture::from_runlog(log, vec![llm_io()]);
92        let after = Utc::now();
93        assert_eq!(capture.run_id, run_id);
94        assert_eq!(capture.run_log.agent, "t");
95        assert_eq!(capture.llm_io.len(), 1);
96        assert!(capture.model_version.is_none());
97        assert!(capture.rng_seed.is_none());
98        assert!(capture.memory_ref.is_none());
99        assert!(capture.captured_at >= before && capture.captured_at <= after);
100    }
101
102    #[test]
103    fn from_runlog_retains_structured_llm_io_for_replay() {
104        use klieo_core::llm::{FinishReason, ToolCall};
105        let structured = LlmIo::new("p", "c").with_response_shape(
106            FinishReason::ToolCalls,
107            vec![ToolCall::new("1", "search", serde_json::json!({}))],
108        );
109        let capture = Capture::from_runlog(empty_run_log(), vec![structured]);
110        let io = &capture.llm_io[0];
111        assert_eq!(io.finish_reason, Some(FinishReason::ToolCalls));
112        assert_eq!(
113            io.tool_calls.len(),
114            1,
115            "structured response survives into Capture.llm_io"
116        );
117    }
118
119    #[test]
120    fn all_none_capture_round_trips_through_json() {
121        let capture = Capture::from_runlog(empty_run_log(), vec![]);
122        let json = serde_json::to_string(&capture).unwrap();
123        assert!(json.contains("\"model_version\":null"));
124        let back: Capture = serde_json::from_str(&json).unwrap();
125        assert!(back.model_version.is_none());
126        assert!(back.llm_io.is_empty());
127    }
128
129    #[test]
130    fn capture_model_version_from_llm_io() {
131        let io = LlmIo::new("p", "c").with_model("ollama:qwen2.5:14b");
132        let cap = Capture::from_runlog(empty_run_log(), vec![io]);
133        assert_eq!(
134            cap.model_version.as_deref(),
135            Some("ollama:qwen2.5:14b"),
136            "model_version must be derived from the last llm_io entry that carries a model"
137        );
138    }
139
140    #[test]
141    fn capture_model_version_none_when_llm_io_has_no_model() {
142        let cap = Capture::from_runlog(empty_run_log(), vec![llm_io()]);
143        assert!(
144            cap.model_version.is_none(),
145            "model_version stays None when no llm_io entry carries a model"
146        );
147    }
148
149    #[test]
150    fn capture_model_version_picks_last_when_multiple_models() {
151        let first = LlmIo::new("p1", "c1").with_model("ollama:small");
152        let second = LlmIo::new("p2", "c2").with_model("ollama:large");
153        let cap = Capture::from_runlog(empty_run_log(), vec![first, second]);
154        assert_eq!(
155            cap.model_version.as_deref(),
156            Some("ollama:large"),
157            "model_version must reflect the last (most-recent) model used"
158        );
159    }
160}