use crate::types::{LlmIo, RunLog};
use chrono::{DateTime, Utc};
use klieo_core::RunId;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Capture {
pub run_id: RunId,
pub run_log: RunLog,
pub llm_io: Vec<LlmIo>,
pub model_version: Option<String>,
pub rng_seed: Option<u64>,
pub memory_ref: Option<String>,
pub captured_at: DateTime<Utc>,
}
impl Capture {
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"
);
}
}