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