From 8d48b19b5d8e41b651656d7f91ab6a5405467381 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:37:33 -0500 Subject: [PATCH] test: add regression coverage for edit_file fuzz omission (#2138) - Test that edit_file accepts calls with fuzz omitted, fuzz=false, and fuzz=true - Verify fuzz is excluded from schema required fields but present as optional boolean - Add agent-mode catalog test confirming edit_file is loaded and fuzz-less calls execute - Update existing required-fields assertions to check for exactly path/search/replace --- crates/tui/src/core/engine/tests.rs | 64 +++++++++++++++++++++++++++++ crates/tui/src/tools/file.rs | 43 ++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 422fdc11..f138726e 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -472,6 +472,70 @@ fn model_tool_catalog_applies_native_and_mcp_deferral() { assert_eq!(defer_loading("mcp_server_write"), Some(true)); } +#[test] +fn agent_catalog_keeps_edit_file_loaded_when_fuzz_is_omitted() { + let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + let registry = engine + .build_turn_tool_registry_builder( + AppMode::Agent, + engine.config.todos.clone(), + engine.config.plan_state.clone(), + ) + .build(engine.build_tool_context(AppMode::Agent, false)); + let always_load = HashSet::new(); + let catalog = build_model_tool_catalog( + registry.to_api_tools_with_cache(true), + vec![], + AppMode::Agent, + &always_load, + ); + let edit = catalog + .iter() + .find(|tool| tool.name == "edit_file") + .expect("edit_file registered"); + + assert_eq!(edit.defer_loading, Some(false)); + let required = edit.input_schema["required"] + .as_array() + .expect("edit_file schema should include required fields"); + assert!(required.iter().any(|field| field.as_str() == Some("path"))); + assert!( + required + .iter() + .any(|field| field.as_str() == Some("search")) + ); + assert!( + required + .iter() + .any(|field| field.as_str() == Some("replace")) + ); + assert!(!required.iter().any(|field| field.as_str() == Some("fuzz"))); + assert_eq!( + edit.input_schema["properties"]["fuzz"]["type"].as_str(), + Some("boolean") + ); + + let active_at_batch_start = initial_active_tools(&catalog); + assert!(active_at_batch_start.contains("edit_file")); + let mut hydrated_this_batch = HashSet::new(); + assert!( + maybe_hydrate_requested_deferred_tool( + "edit_file", + &json!({ + "path": "src/foo.rs", + "search": "before", + "replace": "after" + }), + &catalog, + &active_at_batch_start, + &mut hydrated_this_batch, + ) + .is_none(), + "loaded edit_file calls without fuzz should execute instead of hydrating the schema" + ); + assert!(hydrated_this_batch.is_empty()); +} + #[test] fn tools_always_load_overrides_default_native_deferral() { let always_load = HashSet::from(["git_show".to_string()]); diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index 97ebadd8..068c2030 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -1475,6 +1475,41 @@ mod tests { assert_eq!(edited, "hi world hi"); } + #[tokio::test] + async fn test_edit_file_accepts_omitted_and_explicit_fuzz() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let tool = EditFileTool; + + for (file_name, fuzz) in [ + ("fuzz_omitted.txt", None), + ("fuzz_false.txt", Some(false)), + ("fuzz_true.txt", Some(true)), + ] { + let test_file = tmp.path().join(file_name); + fs::write(&test_file, "hello world").expect("write"); + + let mut input = serde_json::Map::from_iter([ + ("path".to_string(), json!(file_name)), + ("search".to_string(), json!("hello")), + ("replace".to_string(), json!("hi")), + ]); + if let Some(fuzz) = fuzz { + input.insert("fuzz".to_string(), json!(fuzz)); + } + + let result = tool + .execute(Value::Object(input), &ctx) + .await + .expect("execute"); + + assert!(result.success, "{file_name}: {}", result.content); + assert!(result.content.contains("Replaced 1 occurrence")); + let edited = fs::read_to_string(&test_file).expect("read"); + assert_eq!(edited, "hi world"); + } + } + #[tokio::test] async fn test_edit_file_single_match_has_no_multi_match_warning() { let tmp = tempdir().expect("tempdir"); @@ -1827,7 +1862,13 @@ mod tests { .get("required") .and_then(|value| value.as_array()) .expect("edit schema should include required array"); - assert_eq!(required.len(), 3); + let required_fields: Vec<_> = required.iter().filter_map(|value| value.as_str()).collect(); + assert_eq!(required_fields, vec!["path", "search", "replace"]); + assert!(!required_fields.contains(&"fuzz")); + assert_eq!( + edit_schema["properties"]["fuzz"]["type"].as_str(), + Some("boolean") + ); let search_desc = edit_schema["properties"]["search"]["description"] .as_str() .expect("search description");