use super::*;
async fn model_and_display(
name: &str,
args: serde_json::Value,
ws: &std::path::Path,
caveats: &Caveats,
collab: ToolCollaborators<'_, '_>,
) -> (String, String) {
let mut display = ToolDisplay::new(Vec::new(), false, 4096, 0, false);
let model = execute_tool_with_display_cancellable(
&mut display,
name,
&args,
&ws.to_string_lossy(),
false,
20,
caveats,
&mut NoMcp,
collab,
false,
PromptDisposition::Act,
None,
)
.await
.unwrap()
.unwrap();
(model, String::from_utf8(display.into_inner()).unwrap())
}
#[tokio::test]
async fn a_successful_mutation_gives_the_model_a_headline_and_the_operator_the_diff() {
let ws = tempfile::TempDir::new().unwrap();
let path = "state.rs";
for (name, args, kind) in [
(
"write_file",
serde_json::json!({"path": path, "content": "let new = 2;\n"}),
"Modified",
),
(
"edit_file",
serde_json::json!({"path": path, "old_string": "old", "new_string": "new"}),
"Modified",
),
("delete_file", serde_json::json!({"path": path}), "Deleted"),
] {
std::fs::write(ws.path().join(path), "let old = 1;\n").unwrap();
let mut display = ToolDisplay::new(Vec::new(), true, 100, 0, false);
let raw = execute_tool_with_display_cancellable(
&mut display,
name,
&args,
&ws.path().to_string_lossy(),
true,
20,
&caveats_rw(ws.path()),
&mut NoMcp,
ToolCollaborators::default(),
false,
PromptDisposition::Act,
None,
)
.await
.unwrap()
.unwrap();
assert!(
!raw.contains("```diff"),
"the model gets a headline, not the patch: {raw}"
);
assert!(raw.contains(&format!("{kind} (+")), "{raw}");
let visible = String::from_utf8(display.into_inner()).unwrap();
assert!(
visible.contains(&format!("{kind} \"state.rs\"")),
"{name} did not project actual captured model: {visible:?}"
);
assert!(
visible.contains("\x1b[48;"),
"source changes have semantic backgrounds"
);
assert!(
!visible.contains("```diff"),
"the display consumes the receipt once"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn nonregular_file_errors_escape_path_controls_in_the_production_display() {
let ws = tempfile::TempDir::new().unwrap();
let path = "directory-\u{1b}[31m\tname";
std::fs::create_dir(ws.path().join(path)).unwrap();
for (name, args) in [
(
"write_file",
serde_json::json!({"path": path, "content": "new\n"}),
),
(
"edit_file",
serde_json::json!({"path": path, "old_string": "old", "new_string": "new"}),
),
] {
let mut display = ToolDisplay::new(Vec::new(), false, 4096, 0, false);
let raw = execute_tool_with_display_cancellable(
&mut display,
name,
&args,
&ws.path().to_string_lossy(),
false,
20,
&caveats_rw(ws.path()),
&mut NoMcp,
ToolCollaborators::default(),
false,
PromptDisposition::Act,
None,
)
.await
.unwrap()
.unwrap();
assert!(raw.starts_with("error:"), "{raw:?}");
assert!(raw.contains("nonregular target"), "{raw:?}");
assert!(raw.contains(path), "raw result retains the actual path");
let visible = String::from_utf8(display.into_inner()).unwrap();
assert!(
visible.chars().all(|ch| !ch.is_control() || ch == '\n'),
"{name} early error leaked pathname controls: {visible:?}"
);
assert!(visible.contains("directory-<U+001B>[31m<U+0009>name"));
assert!(visible.contains("display escapes control characters"));
assert!(ws.path().join(path).is_dir());
}
}
#[cfg(unix)]
#[tokio::test]
async fn pathname_controls_are_safe_in_file_headers_and_result_prefixes() {
let ws = tempfile::TempDir::new().unwrap();
let path = "file-\u{1b}[31m\tname.rs";
for (name, args) in [
(
"write_file",
serde_json::json!({"path": path, "content": "new\n"}),
),
(
"edit_file",
serde_json::json!({"path": path, "old_string": "old", "new_string": "new"}),
),
("delete_file", serde_json::json!({"path": path})),
] {
std::fs::write(ws.path().join(path), "old\n").unwrap();
let mut display = ToolDisplay::new(Vec::new(), false, 4096, 0, false);
let raw = execute_tool_with_display_cancellable(
&mut display,
name,
&args,
&ws.path().to_string_lossy(),
false,
20,
&caveats_rw(ws.path()),
&mut NoMcp,
ToolCollaborators::default(),
false,
PromptDisposition::Act,
None,
)
.await
.unwrap()
.unwrap();
assert!(raw.contains(path), "raw result retains the actual path");
let visible = String::from_utf8(display.into_inner()).unwrap();
assert!(
visible.chars().all(|ch| !ch.is_control() || ch == '\n'),
"{name} leaked pathname controls: {visible:?}"
);
assert!(
visible.contains("file-<U+001B>[31m<U+0009>name.rs"),
"{visible}"
);
assert!(
visible.contains("display escapes control characters"),
"{visible}"
);
}
}
#[tokio::test]
async fn file_receipts_keep_raw_source_but_neutralize_terminal_controls() {
let ws = tempfile::TempDir::new().unwrap();
let original = "start\u{1b}]8;;https://example.test/\u{7}link\u{1b}]8;;\u{7}\u{1b}]52;c;c2VjcmV0\u{1b}\\\r\tend\n";
let replacement = "\tnew\u{1b}[31m\r\n";
for (name, args, after) in [
(
"write_file",
serde_json::json!({"path": "state.txt", "content": replacement}),
Some(replacement.to_string()),
),
(
"edit_file",
serde_json::json!({"path": "state.txt", "old_string": "start", "new_string": "changed"}),
Some(original.replacen("start", "changed", 1)),
),
(
"delete_file",
serde_json::json!({"path": "state.txt"}),
None,
),
] {
std::fs::write(ws.path().join("state.txt"), original).unwrap();
let mut display = ToolDisplay::new(Vec::new(), false, 4096, 0, false);
let raw = execute_tool_with_display_cancellable(
&mut display,
name,
&args,
&ws.path().to_string_lossy(),
false,
20,
&caveats_rw(ws.path()),
&mut NoMcp,
ToolCollaborators::default(),
false,
PromptDisposition::Act,
None,
)
.await
.unwrap()
.unwrap();
let expected_receipt = crate::agentic::tools::file_change::receipt(
"state.txt",
Some(original),
after.as_deref(),
)
.unwrap();
let headline = expected_receipt.lines().next().unwrap();
assert!(raw.contains(headline), "{name} headline missing: {raw:?}");
assert!(
!raw.contains("\u{1b}]52;"),
"the model gets no file bytes on success: {raw:?}"
);
let visible = String::from_utf8(display.into_inner()).unwrap();
assert!(
visible.chars().all(|ch| !ch.is_control() || ch == '\n'),
"{name} terminal bytes still contain source controls: {visible:?}"
);
assert!(visible.contains("<U+001B>]8;"), "{visible}");
assert!(visible.contains("<U+001B>]52;"), "{visible}");
assert!(visible.contains("<U+000D>"), "{visible}");
assert!(visible.contains("<U+0009>"), "{visible}");
assert!(
visible.contains("display escapes control characters"),
"{visible}"
);
assert!(visible.contains("not a raw patch"), "{visible}");
assert!(!raw.contains("display escapes control characters"));
}
}
#[cfg(unix)]
#[test]
fn file_mutations_refuse_a_fifo_before_the_legacy_reads_can_block() {
const CHILD: &str = "NEWT_RECEIPT_FIFO_CHILD";
if let Some(done) = std::env::var_os(CHILD) {
let ws = tempfile::TempDir::new().unwrap();
let path = ws.path().join("fifo");
let path_c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(path_c.as_ptr(), 0o600) }, 0);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
for (tool, args) in [
(
"write_file",
serde_json::json!({"path": "fifo", "content": "text"}),
),
(
"edit_file",
serde_json::json!({"path": "fifo", "old_string": "a", "new_string": "b"}),
),
] {
let output = runtime.block_on(run_tool(
tool,
args,
ws.path(),
&caveats_rw(ws.path()),
None,
));
assert!(output.starts_with("error:"), "{output}");
assert!(output.contains("nonregular target"), "{output}");
}
assert!(matches!(
crate::agentic::tools::file_capture::capture(&Scope::All, &path),
crate::agentic::tools::file_capture::TextSnapshot::Unavailable(_)
));
std::fs::write(done, "passed").unwrap();
return;
}
let parent = tempfile::TempDir::new().unwrap();
let done = parent.path().join("child-passed");
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"agentic::tools::execute_tool_branch_tests::file_receipts::file_mutations_refuse_a_fifo_before_the_legacy_reads_can_block",
"--nocapture",
])
.env(CHILD, &done)
.spawn()
.unwrap();
let pid = child.id();
let (exited_tx, exited_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = exited_tx.send(child.wait().unwrap());
});
let status = match exited_rx.recv_timeout(crate::test_guard::HANG_GUARD) {
Ok(status) => status,
Err(_) => {
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
panic!(
"a file mutation blocked on a FIFO with no writer: the child ran past the {}s hang guard",
crate::test_guard::HANG_GUARD.as_secs()
);
}
};
assert!(status.success(), "FIFO tool child failed: {status}");
assert!(done.exists(), "the exact FIFO child test did not run");
}
struct ChangeWhileConfirming {
path: std::path::PathBuf,
}
impl PermissionGate for ChangeWhileConfirming {
fn ask(&mut self, _: &[PermissionRequest]) -> PermissionDecision {
panic!("the configured scope already permits this write")
}
fn ask_question(&mut self, _: &str) -> HumanQuestionOutcome {
std::fs::write(&self.path, "changed while confirming\n").unwrap();
HumanQuestionOutcome::Answer("y".into())
}
}
#[tokio::test]
async fn a_write_captures_its_preimage_after_confirmation() {
use super::super::disable_ocap_tests::{env_lock, EnvVar};
let _lock = env_lock().await;
let _normal_mode = EnvVar::set("NEWT_DISABLE_OCAP", "0");
let ws = tempfile::TempDir::new().unwrap();
let path = ws.path().join("state.txt");
std::fs::write(&path, "before confirmation\n").unwrap();
let mut gate = ChangeWhileConfirming { path };
let caveats = Caveats {
fs_write: Scope::All,
..caveats_rw(ws.path())
};
let (model, seen) = model_and_display(
"write_file",
serde_json::json!({"path": "state.txt", "content": "written\n"}),
ws.path(),
&caveats,
ToolCollaborators {
permission_gate: Some(&mut gate),
..ToolCollaborators::default()
},
)
.await;
assert!(model.contains("Modified (+1 -1)"), "{model}");
assert!(seen.contains("-changed while confirming"), "{seen}");
assert!(seen.contains("+written"), "{seen}");
assert!(!seen.contains("-before confirmation"), "{seen}");
}
#[tokio::test]
async fn sequential_file_tools_show_the_immediate_change_without_an_artifact_sink() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let (added, seen) = model_and_display(
"write_file",
serde_json::json!({"path": "state.txt", "content": "one\n"}),
ws.path(),
&caveats,
ToolCollaborators::default(),
)
.await;
assert!(added.starts_with("wrote state.txt"), "{added}");
assert!(added.contains("Added (+1 -0)"), "{added}");
assert!(
!added.contains("--- /dev/null"),
"no patch to the model: {added}"
);
assert!(seen.contains("--- /dev/null"), "{seen}");
assert!(seen.contains("+one"), "{seen}");
let (edited, seen) = model_and_display(
"edit_file",
serde_json::json!({"path": "state.txt", "old_string": "one", "new_string": "two"}),
ws.path(),
&caveats,
ToolCollaborators::default(),
)
.await;
assert!(edited.starts_with("edited state.txt"), "{edited}");
assert!(edited.contains("Modified (+1 -1)"), "{edited}");
assert!(seen.contains("-one") && seen.contains("+two"), "{seen}");
assert!(!seen.contains("--- /dev/null"), "{seen}");
let (deleted, seen) = model_and_display(
"delete_file",
serde_json::json!({"path": "state.txt"}),
ws.path(),
&caveats,
ToolCollaborators::default(),
)
.await;
assert!(deleted.starts_with("deleted state.txt"), "{deleted}");
assert!(deleted.contains("Deleted (+0 -1)"), "{deleted}");
assert!(seen.contains("-two"), "{seen}");
assert!(!ws.path().join("state.txt").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn a_failed_build_check_keeps_the_verified_tool_change() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("state.txt"), "old\n").unwrap();
let caveats = Caveats {
#[cfg(target_os = "macos")]
net: Scope::All,
..caveats_rw(ws.path())
};
let (output, seen) = model_and_display(
"write_file",
serde_json::json!({"path": "state.txt", "content": "tool bytes\n"}),
ws.path(),
&caveats,
ToolCollaborators {
build_check_cmd: Some("printf 'build bytes\\n' > state.txt; exit 1"),
..ToolCollaborators::default()
},
)
.await;
assert!(output.contains("Modified (+1 -1)"), "{output}");
assert!(output.contains("build check failed"), "{output}");
assert!(
seen.contains("-old") && seen.contains("+tool bytes"),
"{seen}"
);
assert!(!seen.contains("+build bytes"), "{seen}");
assert_eq!(
std::fs::read_to_string(ws.path().join("state.txt")).unwrap(),
"build bytes\n"
);
}
#[tokio::test]
async fn unavailable_preimages_do_not_become_added_file_receipts() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("state.txt"), "private old bytes\n").unwrap();
let caveats = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let output = run_tool(
"write_file",
serde_json::json!({"path": "state.txt", "content": "public result\n"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(output.starts_with("wrote state.txt"), "{output}");
assert!(
output.contains("File operation succeeded; optional change preview unavailable"),
"{output}"
);
assert!(!output.contains("private old bytes"), "{output}");
assert!(!output.contains("```diff"), "{output}");
std::fs::write(ws.path().join("binary.txt"), [0xff, 0xfe]).unwrap();
let binary = run_tool(
"write_file",
serde_json::json!({"path": "binary.txt", "content": "text\n"}),
ws.path(),
&caveats_rw(ws.path()),
None,
)
.await;
assert!(
binary.contains("File operation succeeded; optional change preview unavailable"),
"{binary}"
);
assert!(!binary.contains("Added (+"), "{binary}");
}
#[tokio::test]
async fn unavailable_receipt_preview_preserves_mutation_outcome() {
let ws = tempfile::TempDir::new().unwrap();
let content = "x".repeat(crate::agentic::tools::file_change::MAX_VERSION_BYTES + 1);
let (model, display) = model_and_display(
"write_file",
serde_json::json!({"path": "large.txt", "content": content}),
ws.path(),
&caveats_rw(ws.path()),
ToolCollaborators::default(),
)
.await;
assert_eq!(
std::fs::read_to_string(ws.path().join("large.txt")).unwrap(),
content
);
assert!(tool_result_ok(&model), "{model}");
assert!(model.starts_with("wrote large.txt"), "{model}");
assert!(
model.contains("File operation succeeded; optional change preview unavailable"),
"{model}"
);
assert!(!model.contains("receipt capture limit"), "{model}");
assert!(
display.contains(
"file-change receipt unavailable: receipt capture limit exceeded (256 KiB per version)"
),
"{display}"
);
std::fs::write(ws.path().join("blocked"), "keep these bytes").unwrap();
let (failed, display) = model_and_display(
"write_file",
serde_json::json!({"path": "blocked/child.txt", "content": "not written"}),
ws.path(),
&caveats_rw(ws.path()),
ToolCollaborators::default(),
)
.await;
assert!(!tool_result_ok(&failed), "{failed}");
assert!(!failed.contains("File operation succeeded"), "{failed}");
assert!(
failed.contains("file-change receipt unavailable"),
"{failed}"
);
assert!(
display.contains("file-change receipt unavailable"),
"{display}"
);
assert_eq!(
std::fs::read_to_string(ws.path().join("blocked")).unwrap(),
"keep these bytes"
);
}
#[cfg(windows)]
#[tokio::test]
async fn exact_file_grant_verifies_its_deleted_leaf() {
let ws = tempfile::TempDir::new().unwrap();
let file = ws.path().join("state.txt");
std::fs::write(&file, "before\n").unwrap();
let granted = file.to_string_lossy().into_owned();
let caveats = Caveats {
fs_read: Scope::only([granted.clone()]),
fs_write: Scope::only([granted]),
..Caveats::top()
};
let output = run_tool(
"delete_file",
serde_json::json!({"path": "state.txt"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(output.starts_with("deleted state.txt"), "{output}");
assert!(output.contains("Deleted (+0 -1)"), "{output}");
assert!(!file.exists());
}
#[cfg(windows)]
#[test]
fn windows_not_found_receipts_require_a_missing_leaf_below_a_directory() {
let ws = tempfile::TempDir::new().unwrap();
let blocked = ws.path().join("blocked");
std::fs::write(&blocked, "regular file").unwrap();
let workspace_scope = Scope::only([ws.path().to_string_lossy().into_owned()]);
let through_regular_file = blocked.join("child.txt");
assert!(matches!(
crate::agentic::tools::file_capture::capture(&workspace_scope, &through_regular_file),
crate::agentic::tools::file_capture::TextSnapshot::Unavailable(_)
));
assert!(!crate::agentic::tools::file_capture::absent(
&workspace_scope,
&through_regular_file
));
let through_missing_parent = ws.path().join("missing-parent/child.txt");
assert!(matches!(
crate::agentic::tools::file_capture::capture(&workspace_scope, &through_missing_parent),
crate::agentic::tools::file_capture::TextSnapshot::Unavailable(_)
));
assert!(!crate::agentic::tools::file_capture::absent(
&workspace_scope,
&through_missing_parent
));
let directory = ws.path().join("directory");
std::fs::create_dir(&directory).unwrap();
let exact_directory_scope = Scope::only([directory.to_string_lossy().into_owned()]);
assert!(matches!(
crate::agentic::tools::file_capture::capture(&exact_directory_scope, &directory),
crate::agentic::tools::file_capture::TextSnapshot::Unavailable(_)
));
assert!(!crate::agentic::tools::file_capture::absent(
&exact_directory_scope,
&directory
));
}
#[cfg(unix)]
#[tokio::test]
async fn deleting_a_final_symlink_does_not_report_its_target_contents_as_deleted() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("target.txt"), "target remains\n").unwrap();
std::os::unix::fs::symlink("target.txt", ws.path().join("link.txt")).unwrap();
let output = run_tool(
"delete_file",
serde_json::json!({"path": "link.txt"}),
ws.path(),
&caveats_rw(ws.path()),
None,
)
.await;
assert!(output.starts_with("deleted link.txt"), "{output}");
assert!(
output.contains("File operation succeeded; optional change preview unavailable"),
"{output}"
);
assert!(!output.contains("-target remains"), "{output}");
assert!(!output.contains("```diff"), "{output}");
assert_eq!(
std::fs::read_to_string(ws.path().join("target.txt")).unwrap(),
"target remains\n"
);
}
#[tokio::test]
async fn copy_from_moves_a_line_range_byte_exact_after_a_typed_header() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(
ws.path().join("src.rs"),
"l1\nfn moved() {\n body();\n}\nl5\n",
)
.unwrap();
let (model, _) = model_and_display(
"write_file",
serde_json::json!({"path": "dst.rs", "content": "use x;\n",
"copy_from": {"path": "src.rs", "start_line": 2, "end_line": 4}}),
ws.path(),
&caveats_rw(ws.path()),
ToolCollaborators::default(),
)
.await;
assert!(model.contains("Added (+4 -0)"), "{model}");
assert_eq!(
std::fs::read_to_string(ws.path().join("dst.rs")).unwrap(),
"use x;\nfn moved() {\n body();\n}\n"
);
}
#[tokio::test]
async fn copy_from_a_source_outside_the_read_scope_is_denied_and_writes_nothing() {
let ws = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
let secret = outside.path().join("secret.txt");
std::fs::write(&secret, "private\n").unwrap();
let caveats = Caveats {
fs_read: Scope::only([ws.path().to_string_lossy().into_owned()]),
..caveats_rw(ws.path())
};
let (model, _) = model_and_display(
"write_file",
serde_json::json!({"path": "dst.txt", "content": "",
"copy_from": {"path": secret.to_string_lossy(), "start_line": 1, "end_line": 1}}),
ws.path(),
&caveats,
ToolCollaborators::default(),
)
.await;
assert!(
model.contains("fs_read"),
"refusal names the denied axis: {model}"
);
assert!(!model.contains("private"), "{model}");
assert!(!ws.path().join("dst.txt").exists(), "nothing written");
}
const EDIT_FIXTURE: &str = "line a\nline b\nline c\nline d\nline e\n";
async fn edit_outcome(args: serde_json::Value) -> (String, String) {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("fixture.rs"), EDIT_FIXTURE).unwrap();
let (model, _) = model_and_display(
"edit_file",
args,
ws.path(),
&caveats_rw(ws.path()),
ToolCollaborators::default(),
)
.await;
let after = std::fs::read_to_string(ws.path().join("fixture.rs")).unwrap();
(model, after)
}
#[tokio::test]
async fn an_edit_without_a_string_new_string_fails_and_changes_nothing() {
for args in [
serde_json::json!({"path": "fixture.rs", "start_line": 2, "end_line": 4}),
serde_json::json!({"path": "fixture.rs", "old_string": "line b"}),
serde_json::json!({"path": "fixture.rs", "old_string": "line b", "new_string": null}),
serde_json::json!({"path": "fixture.rs", "old_string": "line b", "new_string": 7}),
serde_json::json!({"path": "fixture.rs", "old_string": "line b", "new_string": false}),
serde_json::json!({"path": "fixture.rs", "old_string": "line b", "new_string": ["x"]}),
serde_json::json!({"path": "fixture.rs", "old_string": "line b", "new_string": {"x": 1}}),
] {
let (model, after) = edit_outcome(args.clone()).await;
assert!(
!tool_result_ok(&model),
"must be a FAILED call: {args} -> {model}"
);
assert!(
model.contains("new_string"),
"names the missing field: {model}"
);
assert_eq!(after, EDIT_FIXTURE, "bytes unchanged for {args}");
}
}
#[tokio::test]
async fn a_line_range_selector_is_refused_and_changes_nothing() {
for args in [
serde_json::json!({"path": "fixture.rs", "start_line": 2, "end_line": 4, "new_string": "x"}),
serde_json::json!({"path": "fixture.rs", "start_line": 2, "new_string": ""}),
serde_json::json!({"path": "fixture.rs", "old_string": "line b", "end_line": 4, "new_string": "x"}),
] {
let (model, after) = edit_outcome(args.clone()).await;
assert!(
!tool_result_ok(&model),
"must be a FAILED call: {args} -> {model}"
);
assert!(
model.contains("line range"),
"says ranges are not supported: {model}"
);
assert_eq!(after, EDIT_FIXTURE, "bytes unchanged for {args}");
}
}
#[tokio::test]
async fn an_explicit_empty_new_string_still_deletes_on_purpose() {
let (model, after) = edit_outcome(
serde_json::json!({"path": "fixture.rs", "old_string": "line b\n", "new_string": ""}),
)
.await;
assert!(tool_result_ok(&model), "{model}");
assert_eq!(after, "line a\nline c\nline d\nline e\n");
}
#[tokio::test]
async fn a_write_without_a_string_content_fails_and_changes_nothing() {
for args in [
serde_json::json!({"path": "fixture.rs"}),
serde_json::json!({"path": "fixture.rs", "content": null}),
serde_json::json!({"path": "fixture.rs", "content": 7}),
serde_json::json!({"path": "new.rs"}),
] {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("fixture.rs"), EDIT_FIXTURE).unwrap();
let (model, _) = model_and_display(
"write_file",
args.clone(),
ws.path(),
&caveats_rw(ws.path()),
ToolCollaborators::default(),
)
.await;
assert!(
!tool_result_ok(&model),
"must be a FAILED call: {args} -> {model}"
);
assert!(
model.contains("content"),
"names the missing field: {model}"
);
let after = std::fs::read_to_string(ws.path().join("fixture.rs")).unwrap();
assert_eq!(after, EDIT_FIXTURE, "bytes unchanged for {args}");
assert!(
!ws.path().join("new.rs").exists(),
"no empty file created for {args}"
);
}
}
struct NeverAsked;
impl PermissionGate for NeverAsked {
fn ask(&mut self, requests: &[PermissionRequest]) -> PermissionDecision {
panic!("a malformed call reached the permission gate: {requests:?}")
}
fn ask_question(&mut self, question: &str) -> HumanQuestionOutcome {
panic!("a malformed call reached the confirm prompt: {question}")
}
}
#[tokio::test]
async fn malformed_mutations_are_refused_before_the_permission_gate() {
for (tool, args, names) in [
(
"edit_file",
serde_json::json!({"path": "fixture.rs", "old_string": "", "new_string": "x"}),
"old_string",
),
(
"edit_file",
serde_json::json!({"path": "fixture.rs", "new_string": "x"}),
"old_string",
),
(
"edit_file",
serde_json::json!({"old_string": "line b", "new_string": "x"}),
"path",
),
("write_file", serde_json::json!({"content": "x"}), "path"),
(
"write_file",
serde_json::json!({"path": "fixture.rs"}),
"content",
),
] {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("fixture.rs"), EDIT_FIXTURE).unwrap();
let caveats = Caveats {
fs_write: Scope::none(),
..caveats_rw(ws.path())
};
let mut gate = NeverAsked;
let (model, _) = model_and_display(
tool,
args.clone(),
ws.path(),
&caveats,
ToolCollaborators {
permission_gate: Some(&mut gate),
..ToolCollaborators::default()
},
)
.await;
assert!(
!tool_result_ok(&model),
"must be a FAILED call: {tool} {args} -> {model}"
);
assert!(model.contains(names), "names `{names}`: {model}");
let after = std::fs::read_to_string(ws.path().join("fixture.rs")).unwrap();
assert_eq!(after, EDIT_FIXTURE, "bytes unchanged for {tool} {args}");
}
}
#[tokio::test]
async fn move_from_rejects_duplicate_items_before_mutation() {
let ws = tempfile::tempdir().unwrap();
let source = "fn helper() -> u8 { 7 }\n";
std::fs::write(ws.path().join("lib.rs"), source).unwrap();
std::fs::write(
ws.path().join("Cargo.toml"),
"[package]\nname=\"move_fixture\"\nversion=\"0.1.0\"\n[lib]\npath=\"lib.rs\"\n",
)
.unwrap();
let (output, _) = model_and_display(
"write_file",
serde_json::json!({"path":"child.rs", "content":"", "move_from":{
"path":"lib.rs", "items":["helper", "helper"]
}}),
ws.path(),
&Caveats::top(),
ToolCollaborators::default(),
)
.await;
assert!(output.starts_with("error:"), "{output}");
#[cfg(feature = "ast")]
assert!(output.contains("unique, nonempty item names"), "{output}");
assert_eq!(
std::fs::read_to_string(ws.path().join("lib.rs")).unwrap(),
source
);
assert!(!ws.path().join("child.rs").exists());
}