recursive/tools/
checkpoint.rs1use async_trait::async_trait;
9use serde_json::{json, Value};
10use std::sync::{Arc, Mutex};
11
12use super::Tool;
13use crate::checkpoint::{CheckpointId, ShadowRepo};
14use crate::error::{Error, Result};
15use crate::llm::ToolSpec;
16
17#[derive(Clone)]
22pub struct CheckpointToolCtx {
23 pub repo: Arc<Mutex<ShadowRepo>>,
24 pub session_id: String,
25}
26
27pub struct CheckpointList {
30 ctx: CheckpointToolCtx,
31}
32
33impl CheckpointList {
34 pub fn new(ctx: CheckpointToolCtx) -> Self {
35 Self { ctx }
36 }
37}
38
39#[async_trait]
40impl Tool for CheckpointList {
41 fn spec(&self) -> ToolSpec {
42 ToolSpec {
43 name: "checkpoint_list".into(),
44 description:
45 "List checkpoints (turn-level workspace snapshots) for the current session, \
46 newest first. Snapshots are created automatically by the runtime around each \
47 turn — agents do not create or restore them; that's a CLI/runtime operation."
48 .into(),
49 parameters: json!({"type": "object", "properties": {}}),
50 }
51 }
52
53 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
54 crate::tools::ToolSideEffect::ReadOnly
55 }
56
57 async fn execute(&self, _args: Value) -> Result<String> {
58 let infos = {
59 let repo = self.ctx.repo.lock().map_err(|_| Error::Tool {
60 name: "checkpoint_list".into(),
61 message: "lock poisoned".into(),
62 })?;
63 repo.list_for_session(&self.ctx.session_id)?
64 };
65 if infos.is_empty() {
66 return Ok("no checkpoints in this session yet".to_string());
67 }
68 let mut out = String::new();
69 for i in &infos {
70 out.push_str(&format!(
71 "[{}] {} ({} file(s) changed) ts={}\n",
72 i.id, i.message, i.files_changed, i.timestamp
73 ));
74 }
75 Ok(out.trim_end().to_string())
76 }
77}
78
79pub struct CheckpointDiff {
82 ctx: CheckpointToolCtx,
83}
84
85impl CheckpointDiff {
86 pub fn new(ctx: CheckpointToolCtx) -> Self {
87 Self { ctx }
88 }
89}
90
91#[async_trait]
92impl Tool for CheckpointDiff {
93 fn spec(&self) -> ToolSpec {
94 ToolSpec {
95 name: "checkpoint_diff".into(),
96 description: "Show a unified diff between two checkpoints in this session. \
97 If `b` is omitted, diffs `a` against the current workspace state."
98 .into(),
99 parameters: json!({
100 "type": "object",
101 "properties": {
102 "a": {"type": "string", "description": "Older checkpoint id."},
103 "b": {"type": "string", "description": "Optional newer checkpoint id; omit for current workspace."}
104 },
105 "required": ["a"]
106 }),
107 }
108 }
109
110 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
111 crate::tools::ToolSideEffect::ReadOnly
112 }
113
114 async fn execute(&self, args: Value) -> Result<String> {
115 let a = args["a"].as_str().ok_or_else(|| Error::BadToolArgs {
116 name: "checkpoint_diff".into(),
117 message: "missing `a`".into(),
118 })?;
119 let b = args["b"].as_str();
120 let aid = CheckpointId(a.to_string());
121 let bid = b.map(|s| CheckpointId(s.to_string()));
122 let diff = {
123 let repo = self.ctx.repo.lock().map_err(|_| Error::Tool {
124 name: "checkpoint_diff".into(),
125 message: "lock poisoned".into(),
126 })?;
127 repo.diff(&aid, bid.as_ref(), &[])?
128 };
129 if diff.trim().is_empty() {
130 Ok("no differences".to_string())
131 } else {
132 Ok(diff)
133 }
134 }
135}
136
137pub fn build_checkpoint_tools(
142 workspace: impl Into<std::path::PathBuf>,
143 session_id: impl Into<String>,
144) -> Option<(CheckpointList, CheckpointDiff, Arc<Mutex<ShadowRepo>>)> {
145 let repo = match ShadowRepo::open(workspace) {
146 Ok(r) => Arc::new(Mutex::new(r)),
147 Err(e) => {
148 tracing::warn!("checkpoint tools unavailable: {e}");
149 return None;
150 }
151 };
152 build_checkpoint_tools_inner(repo, session_id.into())
153}
154
155pub fn build_checkpoint_tools_at(
158 workspace: impl Into<std::path::PathBuf>,
159 shadow_dir: impl Into<std::path::PathBuf>,
160 session_id: impl Into<String>,
161) -> Option<(CheckpointList, CheckpointDiff, Arc<Mutex<ShadowRepo>>)> {
162 let repo = match ShadowRepo::open_at(workspace, shadow_dir) {
163 Ok(r) => Arc::new(Mutex::new(r)),
164 Err(e) => {
165 tracing::warn!("checkpoint tools unavailable: {e}");
166 return None;
167 }
168 };
169 build_checkpoint_tools_inner(repo, session_id.into())
170}
171
172fn build_checkpoint_tools_inner(
173 repo: Arc<Mutex<ShadowRepo>>,
174 session_id: String,
175) -> Option<(CheckpointList, CheckpointDiff, Arc<Mutex<ShadowRepo>>)> {
176 let ctx = CheckpointToolCtx {
177 repo: Arc::clone(&repo),
178 session_id,
179 };
180 Some((
181 CheckpointList::new(ctx.clone()),
182 CheckpointDiff::new(ctx),
183 repo,
184 ))
185}
186
187#[cfg(test)]
190mod tests {
191 use super::*;
192 use std::fs;
193 use std::process::Command;
194 use tempfile::TempDir;
195
196 fn has_git() -> bool {
197 Command::new("git").arg("--version").output().is_ok()
198 }
199
200 struct TestWs {
205 workspace: TempDir,
206 shadow: TempDir,
207 }
208
209 impl TestWs {
210 fn path(&self) -> &std::path::Path {
211 self.workspace.path()
212 }
213 fn shadow_dir(&self) -> std::path::PathBuf {
214 self.shadow.path().join("shadow-git")
215 }
216 }
217
218 fn ws() -> TestWs {
219 TestWs {
220 workspace: tempfile::tempdir().expect("workspace tempdir"),
221 shadow: tempfile::tempdir().expect("shadow tempdir"),
222 }
223 }
224
225 #[tokio::test]
226 async fn list_tool_shows_session_checkpoints() {
227 if !has_git() {
228 return;
229 }
230 let w = ws();
231 fs::write(w.path().join("a.txt"), "hi").unwrap();
232 let (list, _, repo) = build_checkpoint_tools_at(w.path(), w.shadow_dir(), "alpha").unwrap();
233 repo.lock()
234 .unwrap()
235 .snapshot_for_session("alpha", "t0")
236 .unwrap();
237 let out = list.execute(json!({})).await.unwrap();
238 assert!(out.contains("t0"));
239 }
240
241 #[tokio::test]
242 async fn list_tool_only_shows_own_session() {
243 if !has_git() {
244 return;
245 }
246 let w = ws();
247 fs::write(w.path().join("a.txt"), "hi").unwrap();
248 let (list_a, _, repo) =
249 build_checkpoint_tools_at(w.path(), w.shadow_dir(), "alpha").unwrap();
250 repo.lock()
251 .unwrap()
252 .snapshot_for_session("alpha", "tA")
253 .unwrap();
254 repo.lock()
255 .unwrap()
256 .snapshot_for_session("beta", "tB")
257 .unwrap();
258 let out_a = list_a.execute(json!({})).await.unwrap();
259 assert!(out_a.contains("tA"));
260 assert!(
261 !out_a.contains("tB"),
262 "alpha must not see beta's checkpoints"
263 );
264 }
265
266 #[tokio::test]
267 async fn diff_tool_returns_empty_for_no_change() {
268 if !has_git() {
269 return;
270 }
271 let w = ws();
272 fs::write(w.path().join("a.txt"), "x").unwrap();
273 let (_, diff, repo) = build_checkpoint_tools_at(w.path(), w.shadow_dir(), "s").unwrap();
274 let id = repo
275 .lock()
276 .unwrap()
277 .snapshot_for_session("s", "snap")
278 .unwrap();
279 let out = diff.execute(json!({"a": id.0})).await.unwrap();
280 assert_eq!(out, "no differences");
281 }
282}