Skip to main content

recursive/tools/
checkpoint.rs

1//! Read-only agent tools that surface the session's checkpoint chain.
2//!
3//! Only `checkpoint_list` and `checkpoint_diff` are exposed to agents.
4//! Snapshot creation and restoration are runtime-driven (see
5//! `AgentRuntime` and the `recursive sessions rewind` CLI), not
6//! agent-driven.
7
8use 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/// Shared state for the read-only checkpoint tools.
18///
19/// `session_id` is fixed at registration time so the agent can only
20/// inspect its own checkpoint chain — never another session's.
21#[derive(Clone)]
22pub struct CheckpointToolCtx {
23    pub repo: Arc<Mutex<ShadowRepo>>,
24    pub session_id: String,
25}
26
27// ── checkpoint_list ───────────────────────────────────────────────────────────
28
29pub 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
79// ── checkpoint_diff ───────────────────────────────────────────────────────────
80
81pub 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
137// ── helper ────────────────────────────────────────────────────────────────────
138
139/// Build the read-only checkpoint tools for a given session.
140/// Returns `None` if the shadow repo cannot be opened (e.g. git missing).
141pub 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
155/// Like [`build_checkpoint_tools`], but with an explicit `shadow_dir`,
156/// for tests that want to bypass `paths::user_data_dir()` resolution.
157pub 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// ── tests ─────────────────────────────────────────────────────────────────────
188
189#[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    /// Test workspace bundle: workspace tempdir + sibling shadow tempdir.
201    /// Tests use `build_checkpoint_tools_at` to bypass `paths::user_data_dir()`,
202    /// avoiding any need to mutate `RECURSIVE_HOME` (and thus the global
203    /// env lock). This lets the suite run fully in parallel.
204    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}