Skip to main content

agent_types/
checkpoint.rs

1//! Checkpoint-related pure types: CheckpointData, CheckpointStep, ToolResultData.
2
3use 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                // ChatMessage has no PartialEq, so compare canonical JSON.
113                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            // CheckpointStep does not derive PartialEq, so compare variant
189            // discriminants for the step round-trip.
190            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        // Unknown variant tag.
200        assert!(serde_json::from_str::<CheckpointStep>(r#"{"NotAStep":{}}"#).is_err());
201        // Wrong field type on a known variant.
202        assert!(
203            serde_json::from_str::<CheckpointStep>(
204                r#"{"BeforeLlm":{"messages":"not-a-list","tools":[]}}"#,
205            )
206            .is_err()
207        );
208        // Truncated JSON.
209        assert!(serde_json::from_str::<CheckpointStep>(r#"{"AfterUserInput""#).is_err());
210        // Empty input.
211        assert!(serde_json::from_str::<CheckpointStep>("").is_err());
212    }
213
214    #[test]
215    fn checkpoint_data_deserialization_fails_gracefully_on_malformed_input() {
216        // Missing required fields.
217        assert!(
218            serde_json::from_str::<CheckpointData>(r#"{"session_id":{"id":1},"user_input":"hi"}"#,)
219                .is_err()
220        );
221        // Wrong session_id shape.
222        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        // turn_count is not a number.
229        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}