feat(snapshot): #137 add /restore command and revert_turn tool

Two user-facing entry points to the snapshot side-repo:

- `/restore [N]` (slash command) — `/restore` with no arg lists the
  10 most recent snapshots so the user can see what's available.
  `/restore N` restores the N-th most recent snapshot. Outside YOLO
  or `/trust on`, the command refuses to mutate files and tells the
  user how to opt in (no in-flow modal-confirm path inside slash
  commands today; trust mode is the explicit gate).

- `revert_turn` (agent-callable tool) — `turn_offset` (default 1)
  counts in `pre-turn:*` snapshots, so the model can say "undo my
  last edit" without having to enumerate the history. Approval-gated
  (`ApprovalRequirement::Required`) since it mutates the workspace,
  and registered through `with_full_agent_surface` so children
  inherit it just like every other agent-mode tool.

Tests for both surfaces use the process-wide env mutex
(`crate::test_support::lock_test_env`) plus an RAII `HOME` guard so
tempdir-based snapshot resolution stays inside the per-test sandbox
even when the runner threads multiple tests in parallel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-28 00:31:47 -05:00
parent 8ff4f66b95
commit fb12e331ab
5 changed files with 480 additions and 0 deletions
+8
View File
@@ -12,6 +12,7 @@ mod init;
mod note;
mod provider;
mod queue;
mod restore;
mod review;
mod session;
mod skills;
@@ -337,6 +338,12 @@ pub const COMMANDS: &[CommandInfo] = &[
description: "Run a structured code review on a file, diff, or PR",
usage: "/review <target>",
},
CommandInfo {
name: "restore",
aliases: &[],
description: "Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots.",
usage: "/restore [N]",
},
// RLM command
CommandInfo {
name: "rlm",
@@ -412,6 +419,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"skills" => skills::list_skills(app),
"skill" => skills::run_skill(app, arg),
"review" => review::review(app, arg),
"restore" => restore::restore(app, arg),
// RLM command
"rlm" | "recursive" => rlm(app, arg),
+255
View File
@@ -0,0 +1,255 @@
//! `/restore` slash command — roll back the workspace to a prior snapshot.
//!
//! `/restore` (no arg) lists the most recent snapshots so the user can
//! see what's available. `/restore <N>` restores the *N*th-most-recent
//! snapshot, where `N=1` is the newest. In non-YOLO mode we refuse to
//! mutate files unless the user has explicitly trusted the workspace
//! (`/trust on` or YOLO) — the user can always view the list, just not
//! one-shot revert without a safety net.
use super::CommandResult;
use crate::snapshot::SnapshotRepo;
use crate::tui::app::App;
const LIST_LIMIT: usize = 10;
/// Entry point for `/restore [N]`.
pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult {
let workspace = app.workspace.clone();
let repo = match SnapshotRepo::open_or_init(&workspace) {
Ok(r) => r,
Err(e) => {
return CommandResult::error(format!(
"Snapshot repo unavailable for {}: {e}",
workspace.display(),
));
}
};
let snapshots = match repo.list(LIST_LIMIT) {
Ok(s) => s,
Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")),
};
if snapshots.is_empty() {
return CommandResult::message(
"No snapshots yet. Send a message to create the first pre-turn snapshot.",
);
}
let Some(arg) = arg.map(str::trim).filter(|s| !s.is_empty()) else {
return CommandResult::message(format_listing(&snapshots));
};
let n: usize = match arg.parse() {
Ok(n) if n >= 1 => n,
_ => {
return CommandResult::error(format!(
"Usage: /restore <N> (N is 1-based; got '{arg}')",
));
}
};
if n > snapshots.len() {
return CommandResult::error(format!(
"Only {} snapshot(s) available; asked for #{n}.",
snapshots.len(),
));
}
// Non-YOLO sessions get a confirmation gate. We don't have a true
// modal-confirmation path inside slash commands today, so the gate
// is "require trust mode" — `/trust on` or YOLO. Users in plain
// Agent mode get a clear message explaining how to proceed.
if !(app.yolo || app.trust_mode) {
return CommandResult::message(format!(
"Refusing to restore snapshot #{n} ('{}') outside trusted mode.\n\
Run `/trust on` or `/yolo` first, then re-run `/restore {n}`.",
snapshots[n - 1].label,
));
}
let target = &snapshots[n - 1];
if let Err(e) = repo.restore(&target.id) {
return CommandResult::error(format!("Restore failed: {e}"));
}
CommandResult::message(format!(
"Restored snapshot #{n} ('{}', {}). Workspace files have been reverted; conversation history is unchanged.",
target.label,
short_sha(target.id.as_str()),
))
}
fn format_listing(snapshots: &[crate::snapshot::Snapshot]) -> String {
let mut out = String::from("Recent snapshots (newest first; pass /restore <N> to revert):\n");
for (i, s) in snapshots.iter().enumerate() {
out.push_str(&format!(
" #{:<2} {} {}\n",
i + 1,
short_sha(s.id.as_str()),
s.label,
));
}
out
}
fn short_sha(sha: &str) -> &str {
&sha[..sha.len().min(8)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::test_support::lock_test_env;
use crate::tui::app::TuiOptions;
use std::sync::MutexGuard;
use tempfile::TempDir;
fn make_app(tmp: &TempDir, yolo: bool) -> App {
let workspace = tmp.path().to_path_buf();
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: tmp.path().join("skills"),
memory_path: tmp.path().join("memory.md"),
notes_path: tmp.path().join("notes.txt"),
mcp_config_path: tmp.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo,
resume_session_id: None,
};
App::new(options, &Config::default())
}
/// Pins HOME to a tempdir for the duration of the test under the
/// crate-wide env mutex.
struct ScopedHome {
prev: Option<std::ffi::OsString>,
_guard: MutexGuard<'static, ()>,
}
impl Drop for ScopedHome {
fn drop(&mut self) {
// SAFETY: process-wide lock still held.
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
fn scoped_home(tmp: &TempDir) -> ScopedHome {
let guard = lock_test_env();
let prev = std::env::var_os("HOME");
// SAFETY: serialised by the global env lock.
unsafe {
std::env::set_var("HOME", tmp.path());
}
ScopedHome {
prev,
_guard: guard,
}
}
#[test]
fn restore_with_no_snapshots_shows_empty_message() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let result = restore(&mut app, None);
let msg = result.message.expect("expected message");
assert!(msg.contains("No snapshots"));
}
#[test]
fn restore_lists_when_no_arg_provided() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v2").unwrap();
repo.snapshot("post-turn:1").unwrap();
let result = restore(&mut app, None);
let msg = result.message.expect("expected message");
assert!(msg.contains("post-turn:1"));
assert!(msg.contains("pre-turn:1"));
assert!(msg.contains("#1"));
assert!(msg.contains("#2"));
}
#[test]
fn restore_in_yolo_reverts_workspace() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
let f = app.workspace.join("a.txt");
std::fs::write(&f, b"original").unwrap();
repo.snapshot("pre-turn:1").unwrap();
std::fs::write(&f, b"clobbered").unwrap();
repo.snapshot("post-turn:1").unwrap();
let result = restore(&mut app, Some("2"));
assert!(result.message.unwrap().contains("Restored"));
let after = std::fs::read_to_string(&f).unwrap();
assert_eq!(after, "original");
}
#[test]
fn restore_outside_trust_mode_refuses() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, false);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
let result = restore(&mut app, Some("1"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Refusing"));
assert!(msg.contains("/trust on"));
}
#[test]
fn restore_invalid_index_returns_error() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
let result = restore(&mut app, Some("99"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Only 1 snapshot"));
}
#[test]
fn restore_zero_index_returns_error() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
// Need at least one snapshot so we exercise the parse-index
// branch instead of the "no snapshots" early return.
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
let result = restore(&mut app, Some("0"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Usage:"));
}
}
+1
View File
@@ -15,6 +15,7 @@ pub mod plan;
pub mod project;
pub mod recall_archive;
pub mod registry;
pub mod revert_turn;
pub mod review;
pub mod rlm;
pub mod search;
+11
View File
@@ -381,6 +381,16 @@ impl ToolRegistryBuilder {
self.with_tool(Arc::new(ApplyPatchTool))
}
/// Include the `revert_turn` tool. Approval-gated since it mutates
/// the workspace; the model uses it when the user asks to "undo my
/// last edit". Backed by the per-workspace snapshot side-repo
/// (`crate::snapshot`).
#[must_use]
pub fn with_revert_turn_tool(self) -> Self {
use super::revert_turn::RevertTurnTool;
self.with_tool(Arc::new(RevertTurnTool))
}
/// Include the RLM tool (`rlm`). Runs the full recursive language-model
/// loop on a long input (file or inline content); the long input never
/// enters the calling model's context window. The Python REPL exposes
@@ -495,6 +505,7 @@ impl ToolRegistryBuilder {
.with_review_tool(client.clone(), model.clone())
.with_rlm_tool(client, model)
.with_recall_archive_tool()
.with_revert_turn_tool()
.with_subagent_tools(manager, runtime)
}
+205
View File
@@ -0,0 +1,205 @@
//! `revert_turn` — agent-callable tool that rewinds the workspace to a
//! prior pre-turn snapshot.
//!
//! The model invokes this when the user says something like "undo the
//! last edit" or "roll back". It mirrors `/restore` but speaks JSON and
//! takes a turn-offset (default 1 = previous turn) instead of a list
//! index, so the model doesn't have to count entries.
//!
//! Approval is `Required` because this mutates the workspace.
use async_trait::async_trait;
use serde_json::{Value, json};
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
};
use crate::snapshot::SnapshotRepo;
/// Default offset: revert the most-recent turn (i.e. the last `pre-turn:*`
/// snapshot in history).
const DEFAULT_OFFSET: u64 = 1;
/// Hard cap so the model can't ask to roll back to the dawn of time.
const MAX_OFFSET: u64 = 50;
pub struct RevertTurnTool;
#[async_trait]
impl ToolSpec for RevertTurnTool {
fn name(&self) -> &str {
"revert_turn"
}
fn description(&self) -> &str {
"Roll back the workspace files to the snapshot taken before a recent turn. \
Use when the user explicitly asks to undo, revert, or roll back the most recent edits. \
`turn_offset` is 1-based: 1 reverts the most recent turn, 2 reverts the previous one, \
and so on (max 50). Conversation history is NOT modified only working-tree files are \
restored from the side-git snapshot repo."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"turn_offset": {
"type": "integer",
"minimum": 1,
"maximum": MAX_OFFSET,
"description": "How many turns back to revert (default 1)."
}
},
"additionalProperties": false
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![
ToolCapability::WritesFiles,
ToolCapability::RequiresApproval,
]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Required
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let offset = optional_u64(&input, "turn_offset", DEFAULT_OFFSET);
if offset == 0 || offset > MAX_OFFSET {
return Err(ToolError::invalid_input(format!(
"turn_offset must be between 1 and {MAX_OFFSET}; got {offset}",
)));
}
let workspace = context.workspace.clone();
let label = format!("revert_turn(offset={offset})");
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
let repo = SnapshotRepo::open_or_init(&workspace)
.map_err(|e| format!("Snapshot repo init failed: {e}"))?;
// Find pre-turn:* snapshots only — those mark the start of
// each turn, which is the right rollback target. We pull a
// generous list and filter so the model's `turn_offset` is
// counted in turns, not raw snapshots.
let snapshots = repo
.list((MAX_OFFSET as usize).saturating_mul(2) + 16)
.map_err(|e| format!("Snapshot list failed: {e}"))?;
let pre_turns: Vec<_> = snapshots
.into_iter()
.filter(|s| s.label.starts_with("pre-turn:"))
.collect();
let target = pre_turns
.get((offset - 1) as usize)
.ok_or_else(|| {
format!(
"Only {} pre-turn snapshot(s) exist; turn_offset={offset} is out of range.",
pre_turns.len(),
)
})?
.clone();
repo.restore(&target.id)
.map_err(|e| format!("Restore failed: {e}"))?;
Ok(format!(
"{label}: restored '{}' ({}). Workspace files reverted; conversation unchanged.",
target.label,
short_sha(target.id.as_str()),
))
})
.await
.map_err(|e| ToolError::execution_failed(format!("revert_turn join failed: {e}")))?;
match result {
Ok(msg) => Ok(ToolResult::success(msg)),
Err(e) => Ok(ToolResult::error(e)),
}
}
}
fn short_sha(sha: &str) -> &str {
&sha[..sha.len().min(8)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::lock_test_env;
use std::sync::MutexGuard;
use tempfile::tempdir;
/// Pins HOME to a tempdir for the duration of the test under the
/// process-wide env mutex (`crate::test_support::lock_test_env`).
struct HomeGuard {
prev: Option<std::ffi::OsString>,
_lock: MutexGuard<'static, ()>,
}
impl Drop for HomeGuard {
fn drop(&mut self) {
// SAFETY: process-wide lock still held.
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
fn scoped_home(home: &std::path::Path) -> HomeGuard {
let lock = lock_test_env();
let prev = std::env::var_os("HOME");
// SAFETY: serialised by the global env lock.
unsafe {
std::env::set_var("HOME", home);
}
HomeGuard { prev, _lock: lock }
}
#[tokio::test]
async fn revert_turn_default_offset_restores_pre_turn_one() {
let tmp = tempdir().unwrap();
let workspace = tmp.path().join("ws");
std::fs::create_dir_all(&workspace).unwrap();
let _guard = scoped_home(tmp.path());
// Setup: create pre-turn:1, post-turn:1 with file modifications.
let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
std::fs::write(workspace.join("a.txt"), b"original").unwrap();
repo.snapshot("pre-turn:1").unwrap();
std::fs::write(workspace.join("a.txt"), b"modified").unwrap();
repo.snapshot("post-turn:1").unwrap();
let tool = RevertTurnTool;
let ctx = ToolContext::new(workspace.clone());
let r = tool.execute(json!({}), &ctx).await.expect("execute");
assert!(r.success, "expected success: {r:?}");
let content = std::fs::read_to_string(workspace.join("a.txt")).unwrap();
assert_eq!(content, "original");
}
#[tokio::test]
async fn revert_turn_invalid_offset_rejected() {
let tmp = tempdir().unwrap();
let workspace = tmp.path().join("ws");
std::fs::create_dir_all(&workspace).unwrap();
let _guard = scoped_home(tmp.path());
let tool = RevertTurnTool;
let ctx = ToolContext::new(workspace);
let r = tool.execute(json!({"turn_offset": 0}), &ctx).await;
assert!(r.is_err());
}
#[tokio::test]
async fn revert_turn_no_snapshots_returns_error_result() {
let tmp = tempdir().unwrap();
let workspace = tmp.path().join("ws");
std::fs::create_dir_all(&workspace).unwrap();
let _guard = scoped_home(tmp.path());
let tool = RevertTurnTool;
let ctx = ToolContext::new(workspace);
let r = tool.execute(json!({}), &ctx).await.expect("execute");
assert!(!r.success);
assert!(r.content.contains("out of range"));
}
}