1use llm_trait::message::ChatMessage;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::session::SessionId;
8
9#[derive(Clone, Debug, Serialize, Deserialize)]
10pub struct CheckpointData {
11 pub session_id: SessionId,
12 pub user_input: String,
13 pub step: CheckpointStep,
14 pub turn_count: u32,
15}
16
17#[derive(Clone, Debug, Serialize, Deserialize)]
18pub enum CheckpointStep {
19 AfterUserInput,
20 BeforeLlm {
21 messages: Vec<ChatMessage>,
22 tools: Vec<Value>,
23 },
24 BeforeToolCalls {
25 tool_calls: Vec<(String, String, String)>,
26 },
27 AfterToolCalls {
28 tool_calls: Vec<(String, String, String)>,
29 results: Vec<ToolResultData>,
30 },
31}
32
33#[derive(Clone, Debug, Serialize, Deserialize)]
34pub struct ToolResultData {
35 pub tool_call_id: String,
36 pub tool_name: String,
37 pub summary: String,
38}
39
40#[cfg(test)]
41mod tests {
42 use super::*;
43
44 fn sample_messages() -> Vec<ChatMessage> {
45 vec![
46 ChatMessage::system("You are a helpful agent."),
47 ChatMessage::user("What is the weather in Tokyo?"),
48 ]
49 }
50
51 fn sample_tools() -> Vec<Value> {
52 vec![serde_json::json!({"name": "weather", "args": {"city": "Tokyo"}})]
53 }
54
55 fn sample_tool_calls() -> Vec<(String, String, String)> {
56 vec![
57 (
58 "call_1".to_string(),
59 "weather".to_string(),
60 "{\"city\": \"Tokyo\"}".to_string(),
61 ),
62 (
63 "call_2".to_string(),
64 "time".to_string(),
65 "{\"tz\": \"Asia/Tokyo\"}".to_string(),
66 ),
67 ]
68 }
69
70 fn sample_results() -> Vec<ToolResultData> {
71 vec![
72 ToolResultData {
73 tool_call_id: "call_1".to_string(),
74 tool_name: "weather".to_string(),
75 summary: "20C, clear".to_string(),
76 },
77 ToolResultData {
78 tool_call_id: "call_2".to_string(),
79 tool_name: "time".to_string(),
80 summary: "09:41 JST".to_string(),
81 },
82 ]
83 }
84
85 fn checkpoint_data(step: CheckpointStep) -> CheckpointData {
86 CheckpointData {
87 session_id: SessionId::with_external_id(42, "test-session"),
88 user_input: "What is the weather in Tokyo?".to_string(),
89 step,
90 turn_count: 3,
91 }
92 }
93
94 #[test]
95 fn checkpoint_step_after_user_input_round_trips() {
96 let step = CheckpointStep::AfterUserInput;
97 let json = serde_json::to_string(&step).unwrap();
98 let decoded: CheckpointStep = serde_json::from_str(&json).unwrap();
99 assert!(matches!(decoded, CheckpointStep::AfterUserInput));
100 }
101
102 #[test]
103 fn checkpoint_step_before_llm_round_trips() {
104 let step = CheckpointStep::BeforeLlm {
105 messages: sample_messages(),
106 tools: sample_tools(),
107 };
108 let json = serde_json::to_string(&step).unwrap();
109 let decoded: CheckpointStep = serde_json::from_str(&json).unwrap();
110 match decoded {
111 CheckpointStep::BeforeLlm { messages, tools } => {
112 assert_eq!(
114 serde_json::to_string(&messages).unwrap(),
115 serde_json::to_string(&sample_messages()).unwrap()
116 );
117 assert_eq!(tools, sample_tools());
118 }
119 other => panic!("expected BeforeLlm, got {other:?}"),
120 }
121 }
122
123 #[test]
124 fn checkpoint_step_before_tool_calls_round_trips() {
125 let step = CheckpointStep::BeforeToolCalls {
126 tool_calls: sample_tool_calls(),
127 };
128 let json = serde_json::to_string(&step).unwrap();
129 let decoded: CheckpointStep = serde_json::from_str(&json).unwrap();
130 match decoded {
131 CheckpointStep::BeforeToolCalls { tool_calls } => {
132 assert_eq!(tool_calls, sample_tool_calls());
133 }
134 other => panic!("expected BeforeToolCalls, got {other:?}"),
135 }
136 }
137
138 #[test]
139 fn checkpoint_step_after_tool_calls_round_trips() {
140 let step = CheckpointStep::AfterToolCalls {
141 tool_calls: sample_tool_calls(),
142 results: sample_results(),
143 };
144 let json = serde_json::to_string(&step).unwrap();
145 let decoded: CheckpointStep = serde_json::from_str(&json).unwrap();
146 match decoded {
147 CheckpointStep::AfterToolCalls {
148 tool_calls,
149 results,
150 } => {
151 assert_eq!(tool_calls, sample_tool_calls());
152 assert_eq!(results.len(), 2);
153 assert_eq!(results[0].tool_call_id, "call_1");
154 assert_eq!(results[0].tool_name, "weather");
155 assert_eq!(results[0].summary, "20C, clear");
156 assert_eq!(results[1].tool_call_id, "call_2");
157 assert_eq!(results[1].tool_name, "time");
158 assert_eq!(results[1].summary, "09:41 JST");
159 }
160 other => panic!("expected AfterToolCalls, got {other:?}"),
161 }
162 }
163
164 #[test]
165 fn checkpoint_data_round_trips_for_every_step_variant() {
166 let variants = [
167 CheckpointStep::AfterUserInput,
168 CheckpointStep::BeforeLlm {
169 messages: sample_messages(),
170 tools: sample_tools(),
171 },
172 CheckpointStep::BeforeToolCalls {
173 tool_calls: sample_tool_calls(),
174 },
175 CheckpointStep::AfterToolCalls {
176 tool_calls: sample_tool_calls(),
177 results: sample_results(),
178 },
179 ];
180
181 for step in variants {
182 let data = checkpoint_data(step);
183 let json = serde_json::to_string(&data).unwrap();
184 let decoded: CheckpointData = serde_json::from_str(&json).unwrap();
185 assert_eq!(decoded.session_id, data.session_id);
186 assert_eq!(decoded.user_input, data.user_input);
187 assert_eq!(decoded.turn_count, data.turn_count);
188 assert_eq!(
191 std::mem::discriminant(&data.step),
192 std::mem::discriminant(&decoded.step)
193 );
194 }
195 }
196
197 #[test]
198 fn checkpoint_step_deserialization_fails_gracefully_on_malformed_input() {
199 assert!(serde_json::from_str::<CheckpointStep>(r#"{"NotAStep":{}}"#).is_err());
201 assert!(
203 serde_json::from_str::<CheckpointStep>(
204 r#"{"BeforeLlm":{"messages":"not-a-list","tools":[]}}"#,
205 )
206 .is_err()
207 );
208 assert!(serde_json::from_str::<CheckpointStep>(r#"{"AfterUserInput""#).is_err());
210 assert!(serde_json::from_str::<CheckpointStep>("").is_err());
212 }
213
214 #[test]
215 fn checkpoint_data_deserialization_fails_gracefully_on_malformed_input() {
216 assert!(
218 serde_json::from_str::<CheckpointData>(r#"{"session_id":{"id":1},"user_input":"hi"}"#,)
219 .is_err()
220 );
221 assert!(
223 serde_json::from_str::<CheckpointData>(
224 r#"{"session_id":"oops","user_input":"hi","step":"AfterUserInput","turn_count":1}"#,
225 )
226 .is_err()
227 );
228 assert!(
230 serde_json::from_str::<CheckpointData>(
231 r#"{"session_id":{"id":1},"user_input":"hi","step":"AfterUserInput","turn_count":"three"}"#,
232 )
233 .is_err()
234 );
235 }
236}