use koda_core::providers::{ToolCall, mock::MockResponse};
use koda_test_utils::{Env, MockProvider};
fn write_call(id: &str, file_path: &str, content: &str) -> ToolCall {
ToolCall {
id: id.into(),
function_name: "Write".into(),
arguments: serde_json::json!({
"file_path": file_path,
"content": content,
"overwrite": true,
})
.to_string(),
thought_signature: None,
}
}
fn undo_depth(env: &Env) -> usize {
env.tools
.undo
.lock()
.expect("undo mutex poisoned — a previous test panicked while holding it")
.depth()
}
fn undo_one(env: &Env) -> String {
env.tools
.undo
.lock()
.expect("undo mutex poisoned")
.undo()
.expect("undo stack should have at least one entry")
}
#[tokio::test]
async fn undo_restores_overwritten_file_through_inference_loop() {
let env = Env::new().await;
let path = env.root.join("greeting.txt");
std::fs::write(&path, "hello, world").unwrap();
env.insert_user_message("rewrite greeting").await;
let provider = MockProvider::new(vec![
MockResponse::tool_call(
"Write",
serde_json::json!({
"file_path": "greeting.txt",
"content": "GOODBYE, WORLD",
"overwrite": true,
}),
),
MockResponse::Text("Done.".into()),
]);
env.run_inference(&provider).await;
assert_eq!(std::fs::read_to_string(&path).unwrap(), "GOODBYE, WORLD");
assert_eq!(
undo_depth(&env),
1,
"exactly one turn happened; expected one undo entry"
);
let summary = undo_one(&env);
assert!(
summary.contains("restored"),
"expected 'restored' in summary, got: {summary}"
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello, world");
}
#[tokio::test]
async fn undo_removes_newly_created_file_through_inference_loop() {
let env = Env::new().await;
let path = env.root.join("new_file.txt");
assert!(!path.exists(), "precondition: file must not exist");
env.insert_user_message("create new_file.txt").await;
let provider = MockProvider::new(vec![
MockResponse::tool_call(
"Write",
serde_json::json!({
"file_path": "new_file.txt",
"content": "fresh content",
}),
),
MockResponse::Text("Created.".into()),
]);
env.run_inference(&provider).await;
assert!(path.exists(), "Write should have created the file");
let summary = undo_one(&env);
assert!(
summary.contains("removed") || summary.contains("newly created"),
"summary should mention removal of newly-created file, got: {summary}"
);
assert!(
!path.exists(),
"undo should have removed the newly-created file"
);
}
#[tokio::test]
async fn undo_after_edit_restores_original_through_inference_loop() {
let env = Env::new().await;
let path = env.root.join("config.toml");
let original = "name = \"alpha\"\nversion = \"1.0\"\n";
std::fs::write(&path, original).unwrap();
env.insert_user_message("rename to beta").await;
let provider = MockProvider::new(vec![
MockResponse::tool_call(
"Edit",
serde_json::json!({
"file_path": "config.toml",
"replacements": [
{"old_str": "alpha", "new_str": "beta"}
],
}),
),
MockResponse::Text("Renamed.".into()),
]);
env.run_inference(&provider).await;
assert!(std::fs::read_to_string(&path).unwrap().contains("beta"));
undo_one(&env);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
original,
"undo must restore the exact pre-edit content (whitespace + all)"
);
}
#[tokio::test]
async fn undo_after_delete_restores_file_through_inference_loop() {
let env = Env::new().await;
let path = env.root.join("doomed.txt");
let content = "important data\nline 2\n";
env.insert_user_message("create doomed.txt").await;
let setup = MockProvider::new(vec![
MockResponse::tool_call(
"Write",
serde_json::json!({
"path": path.to_string_lossy(),
"content": content,
}),
),
MockResponse::Text("Created.".into()),
]);
env.run_inference(&setup).await;
assert!(path.exists(), "setup: Write must have created the file");
assert_eq!(undo_depth(&env), 1, "setup turn = 1 entry");
env.insert_user_message("delete it").await;
let provider = MockProvider::new(vec![
MockResponse::tool_call(
"Delete",
serde_json::json!({"path": path.to_string_lossy()}),
),
MockResponse::Text("Gone.".into()),
]);
env.run_inference(&provider).await;
assert!(!path.exists(), "Delete should have removed the file");
assert_eq!(undo_depth(&env), 2, "setup + delete = 2 entries");
undo_one(&env);
assert!(path.exists(), "undo should have recreated the file");
assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
assert_eq!(undo_depth(&env), 1);
}
#[tokio::test]
async fn multiple_mutations_in_one_turn_share_one_undo_entry() {
let env = Env::new().await;
for name in ["a.txt", "b.txt", "c.txt"] {
std::fs::write(env.root.join(name), format!("orig-{name}")).unwrap();
}
env.insert_user_message("rewrite all three").await;
let provider = MockProvider::new(vec![
MockResponse::tool_call(
"Write",
serde_json::json!({"file_path": "a.txt", "content": "NEW-A", "overwrite": true}),
),
MockResponse::tool_call(
"Write",
serde_json::json!({"file_path": "b.txt", "content": "NEW-B", "overwrite": true}),
),
MockResponse::tool_call(
"Write",
serde_json::json!({"file_path": "c.txt", "content": "NEW-C", "overwrite": true}),
),
MockResponse::Text("All three rewritten.".into()),
]);
env.run_inference(&provider).await;
assert_eq!(
undo_depth(&env),
1,
"three writes in one turn must produce exactly one undo entry"
);
let summary = undo_one(&env);
assert!(
summary.contains("3 file"),
"summary should report 3 files restored, got: {summary}"
);
for name in ["a.txt", "b.txt", "c.txt"] {
assert_eq!(
std::fs::read_to_string(env.root.join(name)).unwrap(),
format!("orig-{name}"),
"{name} should be restored to original"
);
}
assert_eq!(undo_depth(&env), 0);
}
#[tokio::test]
async fn two_turns_create_two_independent_undo_entries() {
let env = Env::new().await;
let path = env.root.join("evolving.txt");
std::fs::write(&path, "v1").unwrap();
env.insert_user_message("upgrade to v2").await;
let provider1 = MockProvider::new(vec![
MockResponse::tool_call(
"Write",
serde_json::json!({"file_path": "evolving.txt", "content": "v2", "overwrite": true}),
),
MockResponse::Text("Upgraded to v2.".into()),
]);
env.run_inference(&provider1).await;
assert_eq!(undo_depth(&env), 1);
env.insert_user_message("upgrade to v3").await;
let provider2 = MockProvider::new(vec![
MockResponse::tool_call(
"Write",
serde_json::json!({"file_path": "evolving.txt", "content": "v3", "overwrite": true}),
),
MockResponse::Text("Upgraded to v3.".into()),
]);
env.run_inference(&provider2).await;
assert_eq!(undo_depth(&env), 2, "second turn must add a second entry");
undo_one(&env);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "v2");
assert_eq!(undo_depth(&env), 1);
undo_one(&env);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "v1");
assert_eq!(undo_depth(&env), 0);
}
#[tokio::test]
async fn read_only_tools_dont_affect_undo_stack() {
let env = Env::new().await;
let src = env.root.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("main.rs"), "fn main() {}").unwrap();
std::fs::write(src.join("lib.rs"), "pub mod foo;").unwrap();
env.insert_user_message("explore the codebase").await;
let provider = MockProvider::new(vec![
MockResponse::tool_call("Glob", serde_json::json!({"pattern": "src/*.rs"})),
MockResponse::tool_call("Grep", serde_json::json!({"pattern": "fn", "path": "."})),
MockResponse::tool_call("Read", serde_json::json!({"file_path": "src/main.rs"})),
MockResponse::Text("Explored.".into()),
]);
env.run_inference(&provider).await;
assert_eq!(
undo_depth(&env),
0,
"read-only tools must not produce any undo entries"
);
}
#[tokio::test]
async fn parallel_writes_in_one_response_share_one_undo_entry() {
let env = Env::new().await;
for name in ["x.txt", "y.txt"] {
std::fs::write(env.root.join(name), format!("original-{name}")).unwrap();
}
env.insert_user_message("rewrite x and y in parallel").await;
let provider = MockProvider::new(vec![
MockResponse::ToolCalls(vec![
write_call("call_x", "x.txt", "AFTER-X"),
write_call("call_y", "y.txt", "AFTER-Y"),
]),
MockResponse::Text("Both rewritten.".into()),
]);
env.run_inference(&provider).await;
assert_eq!(
undo_depth(&env),
1,
"two parallel writes in one response must produce one entry, not two"
);
undo_one(&env);
assert_eq!(
std::fs::read_to_string(env.root.join("x.txt")).unwrap(),
"original-x.txt"
);
assert_eq!(
std::fs::read_to_string(env.root.join("y.txt")).unwrap(),
"original-y.txt"
);
}