klieo-runlog 3.5.0

Tier 2 observability — RunLog aggregate + replay engine for klieo agents.
Documentation
//! The [`Capture`] envelope (ADR-046): a single serializable artifact bundling
//! everything needed to replay a run without a live store — the `RunLog`, the
//! parallel `LlmIo` sidecar carrying prompt/completion text, and Option-typed
//! provider-version / seed / memory metadata filled in when available.

use crate::types::{LlmIo, RunLog};
use chrono::{DateTime, Utc};
use klieo_core::RunId;
use serde::{Deserialize, Serialize};

/// A self-contained snapshot of one run's replayable execution context. The
/// `model_version`, `rng_seed`, and `memory_ref` fields are `Option` so a
/// capture assembled before a provider records them deserializes unchanged once
/// they are present — older captures simply carry `null`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Capture {
    /// Equal to `run_log.run_id`; the key under which a persisted capture is
    /// looked up.
    pub run_id: RunId,
    /// The recorded step sequence replay walks; its outputs are what a
    /// `DivergenceReport` compares against.
    pub run_log: RunLog,
    /// Prompt/completion text the `RunLog` itself does not carry, ordered
    /// parallel to the `run_log`'s `LlmCall` steps.
    pub llm_io: Vec<LlmIo>,
    /// Model that served the last LLM call in this run. In heterogeneous-model
    /// runs this captures the most-recent model only. `None` for captures
    /// assembled before model tracking was added (older captures carry `null`).
    pub model_version: Option<String>,
    /// Seed that made the run's LLM deterministic, when one was set — the
    /// signal that replay can expect byte-identical output rather than drift.
    pub rng_seed: Option<u64>,
    /// Opaque handle to a memory snapshot; `None` when no snapshot was taken.
    pub memory_ref: Option<String>,
    /// Capture-assembly time, distinct from the run's own
    /// `started_at`/`finished_at`.
    pub captured_at: DateTime<Utc>,
}

impl Capture {
    /// Stamps `captured_at` from the wall clock at call time — it is the
    /// capture moment, not derived from the run's own `started_at`/`finished_at`.
    ///
    /// `model_version` is derived from the last `LlmIo` entry that carries a
    /// model name; `None` when no entry recorded one.
    pub fn from_runlog(run_log: RunLog, llm_io: Vec<LlmIo>) -> Self {
        let model_version = llm_io.iter().rev().find_map(|io| io.model.clone());
        Self {
            run_id: run_log.run_id,
            run_log,
            llm_io,
            model_version,
            rng_seed: None,
            memory_ref: None,
            captured_at: Utc::now(),
        }
    }
}

#[cfg(test)]
mod capture_tests {
    use super::Capture;
    use crate::types::{LlmIo, RunLog, RunStatus, Usage};
    use chrono::Utc;
    use klieo_core::RunId;

    fn empty_run_log() -> RunLog {
        let now = Utc::now();
        RunLog {
            run_id: RunId::new(),
            agent: "t".into(),
            started_at: now,
            finished_at: Some(now),
            status: RunStatus::Completed,
            steps: vec![],
            tokens: Usage::default(),
            cost_estimate: None,
        }
    }

    fn llm_io() -> LlmIo {
        LlmIo::new("p", "c")
    }

    #[test]
    fn from_runlog_carries_sources_and_leaves_phase2_fields_none() {
        let log = empty_run_log();
        let run_id = log.run_id;
        let before = Utc::now();
        let capture = Capture::from_runlog(log, vec![llm_io()]);
        let after = Utc::now();
        assert_eq!(capture.run_id, run_id);
        assert_eq!(capture.run_log.agent, "t");
        assert_eq!(capture.llm_io.len(), 1);
        assert!(capture.model_version.is_none());
        assert!(capture.rng_seed.is_none());
        assert!(capture.memory_ref.is_none());
        assert!(capture.captured_at >= before && capture.captured_at <= after);
    }

    #[test]
    fn from_runlog_retains_structured_llm_io_for_replay() {
        use klieo_core::llm::{FinishReason, ToolCall};
        let structured = LlmIo::new("p", "c").with_response_shape(
            FinishReason::ToolCalls,
            vec![ToolCall::new("1", "search", serde_json::json!({}))],
        );
        let capture = Capture::from_runlog(empty_run_log(), vec![structured]);
        let io = &capture.llm_io[0];
        assert_eq!(io.finish_reason, Some(FinishReason::ToolCalls));
        assert_eq!(
            io.tool_calls.len(),
            1,
            "structured response survives into Capture.llm_io"
        );
    }

    #[test]
    fn all_none_capture_round_trips_through_json() {
        let capture = Capture::from_runlog(empty_run_log(), vec![]);
        let json = serde_json::to_string(&capture).unwrap();
        assert!(json.contains("\"model_version\":null"));
        let back: Capture = serde_json::from_str(&json).unwrap();
        assert!(back.model_version.is_none());
        assert!(back.llm_io.is_empty());
    }

    #[test]
    fn capture_model_version_from_llm_io() {
        let io = LlmIo::new("p", "c").with_model("ollama:qwen2.5:14b");
        let cap = Capture::from_runlog(empty_run_log(), vec![io]);
        assert_eq!(
            cap.model_version.as_deref(),
            Some("ollama:qwen2.5:14b"),
            "model_version must be derived from the last llm_io entry that carries a model"
        );
    }

    #[test]
    fn capture_model_version_none_when_llm_io_has_no_model() {
        let cap = Capture::from_runlog(empty_run_log(), vec![llm_io()]);
        assert!(
            cap.model_version.is_none(),
            "model_version stays None when no llm_io entry carries a model"
        );
    }

    #[test]
    fn capture_model_version_picks_last_when_multiple_models() {
        let first = LlmIo::new("p1", "c1").with_model("ollama:small");
        let second = LlmIo::new("p2", "c2").with_model("ollama:large");
        let cap = Capture::from_runlog(empty_run_log(), vec![first, second]);
        assert_eq!(
            cap.model_version.as_deref(),
            Some("ollama:large"),
            "model_version must reflect the last (most-recent) model used"
        );
    }
}