1use crate::types::{LlmIo, RunLog};
7use chrono::{DateTime, Utc};
8use klieo_core::RunId;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct Capture {
18 pub run_id: RunId,
21 pub run_log: RunLog,
24 pub llm_io: Vec<LlmIo>,
27 pub model_version: Option<String>,
31 pub rng_seed: Option<u64>,
34 pub memory_ref: Option<String>,
36 pub captured_at: DateTime<Utc>,
39}
40
41impl Capture {
42 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}