| 1 | use super::*; |
| 2 | use serde_json::json; |
| 3 | use tempfile::tempdir; |
| 4 | |
| 5 | fn tool() -> FileTool { |
| 6 | FileTool::with_patch("File") |
| 7 | } |
| 8 | |
| 9 | #[tokio::test] |
| 10 | async fn lowercase_read_returns_a_typed_image_block() { |
| 11 | let tmp = tempdir().expect("tempdir"); |
| 12 | let path = tmp.path().join("shot.png"); |
| 13 | std::fs::write(&path, crate::image_attach::tests::PNG_1X1).expect("write png"); |
| 14 | let context = ToolContext::new(tmp.path().to_path_buf()); |
| 15 | |
| 16 | let rich = ReadTool |
| 17 | .execute_rich(json!({ "path": "shot.png" }), &context) |
| 18 | .await |
| 19 | .expect("read image"); |
| 20 | |
| 21 | assert!(rich.result.success); |
| 22 | assert_eq!(rich.content_blocks.len(), 1); |
| 23 | assert!(matches!( |
| 24 | &rich.content_blocks[0], |
| 25 | codewhale_tools::ToolResultContentBlock::Image { mime_type, data } |
| 26 | if mime_type == "image/png" && !data.is_empty() |
| 27 | )); |
| 28 | } |
| 29 | |
| 30 | async fn err(tool: &FileTool, input: Value) -> String { |
| 31 | let tmp = tempdir().expect("tempdir"); |
| 32 | let ctx = ToolContext::new(tmp.path().to_path_buf()); |
| 33 | tool.execute(input, &ctx) |
| 34 | .await |
| 35 | .expect_err("call must be refused") |
| 36 | .to_string() |
| 37 | } |
| 38 | |
| 39 | /// #5209's shape one level up: `File{path, content}` used to answer an |
| 40 | /// intended write with the file's contents under a success receipt. |
| 41 | #[tokio::test] |
| 42 | async fn missing_action_is_refused_instead_of_silently_reading() { |
| 43 | let message = err(&tool(), json!({"path": "a.rs", "content": "fn main() {}"})).await; |
| 44 | assert!( |
| 45 | message.contains("requires an `action`"), |
| 46 | "must say what is missing: {message}" |
| 47 | ); |
| 48 | assert!( |
| 49 | message.contains("nothing was run"), |
| 50 | "must deny having done work: {message}" |
| 51 | ); |
| 52 | for action in [ |
| 53 | "read", |
| 54 | "list", |
| 55 | "search_name", |
| 56 | "search_content", |
| 57 | "write", |
| 58 | "edit", |
| 59 | "patch", |
| 60 | ] { |
| 61 | assert!(message.contains(action), "must name `{action}`: {message}"); |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | #[tokio::test] |
| 66 | async fn non_string_action_is_refused_with_the_valid_values() { |
| 67 | let message = err(&tool(), json!({"action": 3, "path": "a.rs"})).await; |
| 68 | assert!(message.contains("to be a string"), "{message}"); |
| 69 | assert!(message.contains("read"), "{message}"); |
| 70 | } |
| 71 | |
| 72 | #[tokio::test] |
| 73 | async fn unknown_action_names_the_actions_that_dispatch() { |
| 74 | let message = err(&tool(), json!({"action": "str_replace", "path": "a.rs"})).await; |
| 75 | assert!( |
| 76 | message.contains("str_replace"), |
| 77 | "must quote the bad value: {message}" |
| 78 | ); |
| 79 | assert!(message.contains("nothing was run"), "{message}"); |
| 80 | assert!( |
| 81 | message.contains("edit"), |
| 82 | "must name the real action: {message}" |
| 83 | ); |
| 84 | } |
| 85 | |
| 86 | /// A refusal must never advertise an action this instance cannot run. |
| 87 | #[tokio::test] |
| 88 | async fn read_only_instances_do_not_suggest_write_actions() { |
| 89 | let message = err(&FileTool::read_only("File"), json!({"path": "a.rs"})).await; |
| 90 | assert!(message.contains("read"), "{message}"); |
| 91 | assert!( |
| 92 | !message.contains("write"), |
| 93 | "read-only File must not offer write: {message}" |
| 94 | ); |
| 95 | assert!( |
| 96 | !message.contains("edit"), |
| 97 | "read-only File must not offer edit: {message}" |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | #[tokio::test] |
| 102 | async fn disabled_write_refusal_states_what_is_available() { |
| 103 | let message = err( |
| 104 | &FileTool::read_only("File"), |
| 105 | json!({"action": "write", "path": "a.rs", "content": "x"}), |
| 106 | ) |
| 107 | .await; |
| 108 | assert!(message.contains("nothing was written"), "{message}"); |
| 109 | assert!(message.contains("Available actions here"), "{message}"); |
| 110 | } |
| 111 | |
| 112 | /// The wrapper is the only schema the model reads. Every per-action |
| 113 | /// description must come from the tool that implements the action, so the |
| 114 | /// stale-copy drift that produced "default 200" cannot recur. |
| 115 | #[test] |
| 116 | fn wrapper_borrows_every_inner_description() { |
| 117 | let schema = tool().input_schema(); |
| 118 | let properties = schema["properties"].as_object().expect("properties"); |
| 119 | for (name, property) in properties { |
| 120 | let text = if name == "replace" { |
| 121 | property.to_string() |
| 122 | } else { |
| 123 | property["description"] |
| 124 | .as_str() |
| 125 | .unwrap_or_default() |
| 126 | .to_string() |
| 127 | }; |
| 128 | assert!( |
| 129 | !text.trim().is_empty(), |
| 130 | "`{name}` lost its description — an inner parameter was probably renamed" |
| 131 | ); |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | #[test] |
| 136 | fn read_parameters_state_the_defaults_the_code_actually_uses() { |
| 137 | let schema = tool().input_schema(); |
| 138 | let max_lines = schema["properties"]["max_lines"]["description"] |
| 139 | .as_str() |
| 140 | .expect("max_lines description"); |
| 141 | let inner = ReadFileTool.input_schema()["properties"]["max_lines"]["description"] |
| 142 | .as_str() |
| 143 | .expect("inner max_lines description") |
| 144 | .to_string(); |
| 145 | assert!( |
| 146 | max_lines.contains(inner.trim_end_matches('.')), |
| 147 | "wrapper must quote the implementing tool verbatim: {max_lines}" |
| 148 | ); |
| 149 | assert!( |
| 150 | !max_lines.contains("200"), |
| 151 | "the retired 200-line default must not be advertised: {max_lines}" |
| 152 | ); |
| 153 | assert!( |
| 154 | !max_lines.contains("blame"), |
| 155 | "`File` has no blame action; blame lives on `Git`: {max_lines}" |
| 156 | ); |
| 157 | } |
| 158 | |
| 159 | /// `fuzz` belongs to `patch` alone. `edit` read it into a discarded |
| 160 | /// binding while the schema kept advertising it, and a live model read |
| 161 | /// that as "an optional fuzzy-matching flag for the search" — a |
| 162 | /// capability claim no code honored. The advertisement is gone; what |
| 163 | /// remains must describe only the integer `patch` really uses. |
| 164 | #[test] |
| 165 | fn fuzz_is_advertised_only_for_patch() { |
| 166 | let schema = tool().input_schema(); |
| 167 | assert_eq!(schema["properties"]["fuzz"]["type"], "integer"); |
| 168 | let fuzz = schema["properties"]["fuzz"]["description"] |
| 169 | .as_str() |
| 170 | .expect("fuzz description"); |
| 171 | assert!(fuzz.contains("action=patch"), "{fuzz}"); |
| 172 | assert!( |
| 173 | !fuzz.contains("action=edit"), |
| 174 | "edit no longer accepts fuzz: {fuzz}" |
| 175 | ); |
| 176 | assert!( |
| 177 | EditFileTool.input_schema()["properties"] |
| 178 | .get("fuzz") |
| 179 | .is_none(), |
| 180 | "edit must not advertise a parameter it does not implement" |
| 181 | ); |
| 182 | } |
| 183 | |
| 184 | /// `File` is in the active catalog in every mode, so its schema is re-sent |
| 185 | /// on every turn of every session. Borrowing the implementing tools' |
| 186 | /// descriptions buys accuracy with bytes; this bounds what that costs and |
| 187 | /// prints the per-parameter breakdown when it trips, so the next increase |
| 188 | /// is a decision rather than a drift. |
| 189 | /// |
| 190 | /// Raised 3000 → 3100 in v0.9.7 for the `expected_hash` content-hash guard |
| 191 | /// (#3979) — a new parameter on three actions, kept to instruction-only |
| 192 | /// text. That is a decision, not drift: the budget exists to price schema |
| 193 | /// bytes, not to forbid new capability. |
| 194 | #[test] |
| 195 | fn schema_stays_within_its_catalog_byte_budget() { |
| 196 | const BUDGET_BYTES: usize = 3_100; |
| 197 | |
| 198 | let schema = FileTool::with_patch("File").input_schema(); |
| 199 | let mut rows: Vec<(usize, String)> = schema["properties"] |
| 200 | .as_object() |
| 201 | .expect("properties") |
| 202 | .iter() |
| 203 | .map(|(name, property)| (property.to_string().len(), name.clone())) |
| 204 | .collect(); |
| 205 | rows.sort_by_key(|row| std::cmp::Reverse(row.0)); |
| 206 | let total: usize = rows.iter().map(|(bytes, _)| bytes).sum(); |
| 207 | |
| 208 | assert!( |
| 209 | total <= BUDGET_BYTES, |
| 210 | "File schema is {total} bytes against a {BUDGET_BYTES} budget; \ |
| 211 | trim explanation, keep instruction. Breakdown: {rows:?}" |
| 212 | ); |
| 213 | } |
| 214 | |
| 215 | // === Per-action parameter validation === |
| 216 | // |
| 217 | // #5209 taught `edit` to refuse a parameter it does not implement. Only |
| 218 | // `edit` learned it, so every other action still dropped unknown keys and |
| 219 | // answered anyway: a misspelled `start_line` on `read` returned the head |
| 220 | // of the file with nothing in the response admitting the requested window |
| 221 | // was never honored. These cover the refusal on every action, and — the |
| 222 | // half that keeps a refusal honest — that each action still accepts its |
| 223 | // full legitimate parameter set, optional names and aliases included. |
| 224 | |
| 225 | /// A workspace with one file, already read, so `edit` and `patch` are |
| 226 | /// past their freshness precondition and reach parameter validation. |
| 227 | async fn workspace() -> (tempfile::TempDir, ToolContext) { |
| 228 | let tmp = tempdir().expect("tempdir"); |
| 229 | let ctx = ToolContext::new(tmp.path().to_path_buf()); |
| 230 | std::fs::write(tmp.path().join("doc.txt"), "alpha\nbeta\ngamma\n").expect("write"); |
| 231 | tool() |
| 232 | .execute(json!({"action": "read", "path": "doc.txt"}), &ctx) |
| 233 | .await |
| 234 | .expect("seed read"); |
| 235 | (tmp, ctx) |
| 236 | } |
| 237 | |
| 238 | /// The minimal call that dispatches for each action, in schema order. |
| 239 | fn minimal_calls() -> Vec<(&'static str, Value)> { |
| 240 | vec![ |
| 241 | ("read", json!({"action": "read", "path": "doc.txt"})), |
| 242 | ("list", json!({"action": "list"})), |
| 243 | ( |
| 244 | "search_name", |
| 245 | json!({"action": "search_name", "query": "doc"}), |
| 246 | ), |
| 247 | ( |
| 248 | "search_content", |
| 249 | json!({"action": "search_content", "pattern": "alpha"}), |
| 250 | ), |
| 251 | ( |
| 252 | "write", |
| 253 | json!({"action": "write", "path": "new.txt", "content": "x\n"}), |
| 254 | ), |
| 255 | ( |
| 256 | "edit", |
| 257 | json!({"action": "edit", "path": "doc.txt", "search": "alpha", "replace": "delta"}), |
| 258 | ), |
| 259 | ( |
| 260 | "patch", |
| 261 | json!({"action": "patch", "path": "doc.txt", "patch": "@@ -1,1 +1,1 @@\n-alpha\n+delta\n"}), |
| 262 | ), |
| 263 | ] |
| 264 | } |
| 265 | |
| 266 | fn with_key(mut input: Value, key: &str, value: Value) -> Value { |
| 267 | input |
| 268 | .as_object_mut() |
| 269 | .expect("object") |
| 270 | .insert(key.to_string(), value); |
| 271 | input |
| 272 | } |
| 273 | |
| 274 | /// The gap this closes. A parameter with no known meaning is refused by |
| 275 | /// every action, not just `edit`, and the refusal carries the same four |
| 276 | /// facts everywhere: what was wrong, what is allowed, what is required, |
| 277 | /// and that nothing was done. |
| 278 | #[tokio::test] |
| 279 | async fn every_action_refuses_an_unknown_parameter() { |
| 280 | for (action, call) in minimal_calls() { |
| 281 | let (_tmp, ctx) = workspace().await; |
| 282 | let message = tool() |
| 283 | .execute(with_key(call, "bogus_param", json!(true)), &ctx) |
| 284 | .await |
| 285 | .expect_err("an unknown parameter must be refused") |
| 286 | .to_string(); |
| 287 | assert!( |
| 288 | message.contains("bogus_param"), |
| 289 | "{action} must name the offending parameter: {message}" |
| 290 | ); |
| 291 | assert!( |
| 292 | message.contains(&format!("unexpected File {action} parameter")), |
| 293 | "{action} must name the action it refused: {message}" |
| 294 | ); |
| 295 | assert!( |
| 296 | message.contains("Allowed parameters are"), |
| 297 | "{action} must name the allowed set: {message}" |
| 298 | ); |
| 299 | assert!( |
| 300 | message.contains("Required:"), |
| 301 | "{action} must name the required set: {message}" |
| 302 | ); |
| 303 | assert!( |
| 304 | message.contains(&format!("The {action} was not performed")), |
| 305 | "{action} must deny having done the work: {message}" |
| 306 | ); |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | /// The specific silent wrong answer that motivated this: a misspelled |
| 311 | /// read window used to be dropped, and the head of the file came back |
| 312 | /// under a success receipt as if it were the requested range. |
| 313 | #[tokio::test] |
| 314 | async fn a_misspelled_read_window_is_refused_rather_than_answered_with_the_head() { |
| 315 | let (_tmp, ctx) = workspace().await; |
| 316 | let message = tool() |
| 317 | .execute( |
| 318 | json!({"action": "read", "path": "doc.txt", "start_lien": 2}), |
| 319 | &ctx, |
| 320 | ) |
| 321 | .await |
| 322 | .expect_err("a misspelled window must not silently return the head") |
| 323 | .to_string(); |
| 324 | assert!(message.contains("start_lien"), "{message}"); |
| 325 | assert!(message.contains("`start_line`"), "{message}"); |
| 326 | } |
| 327 | |
| 328 | /// A refusal is only worth having if the legitimate call still lands. |
| 329 | /// Every action's full parameter set — every optional name included — |
| 330 | /// must survive validation. |
| 331 | #[tokio::test] |
| 332 | async fn every_action_accepts_its_full_legitimate_parameter_set() { |
| 333 | let full: Vec<(&str, Value)> = vec![ |
| 334 | ( |
| 335 | "read", |
| 336 | json!({"action": "read", "path": "doc.txt", "start_line": 1, "max_lines": 2, "pages": "1"}), |
| 337 | ), |
| 338 | ("list", json!({"action": "list", "path": "."})), |
| 339 | ( |
| 340 | "search_name", |
| 341 | json!({"action": "search_name", "query": "doc", "path": ".", "limit": 5, |
| 342 | "extensions": ["txt"], "exclude": ["target/**"]}), |
| 343 | ), |
| 344 | ( |
| 345 | "search_content", |
| 346 | json!({"action": "search_content", "pattern": "alpha", "path": ".", |
| 347 | "include": ["*.txt"], "exclude": ["target/**"], "context_lines": 1, |
| 348 | "case_insensitive": true, "max_results": 5}), |
| 349 | ), |
| 350 | ( |
| 351 | "write", |
| 352 | json!({"action": "write", "path": "new.txt", "content": "x\n"}), |
| 353 | ), |
| 354 | ( |
| 355 | "edit", |
| 356 | json!({"action": "edit", "path": "doc.txt", "search": "alpha", "replace": "delta"}), |
| 357 | ), |
| 358 | ( |
| 359 | "patch", |
| 360 | json!({"action": "patch", "path": "doc.txt", |
| 361 | "patch": "@@ -1,1 +1,1 @@\n-alpha\n+delta\n", |
| 362 | "fuzz": 3, "create_if_missing": false}), |
| 363 | ), |
| 364 | ]; |
| 365 | |
| 366 | for (action, call) in full { |
| 367 | let (_tmp, ctx) = workspace().await; |
| 368 | let result = tool() |
| 369 | .execute(call, &ctx) |
| 370 | .await |
| 371 | .unwrap_or_else(|error| panic!("{action} must accept its own parameters: {error}")); |
| 372 | assert!(result.success, "{action}: {}", result.content); |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | /// Validation runs *after* alias translation, so every cross-harness |
| 377 | /// spelling the alias lane folds must still reach the action it names. |
| 378 | /// A refusal that fired first would undo #5209's fix. |
| 379 | #[tokio::test] |
| 380 | async fn every_alias_survives_validation() { |
| 381 | let aliased: Vec<(&str, Value)> = vec![ |
| 382 | // Path spellings, on every action that takes a path. |
| 383 | ("read", json!({"action": "read", "file_path": "doc.txt"})), |
| 384 | ("read", json!({"action": "read", "filePath": "doc.txt"})), |
| 385 | ("list", json!({"action": "list", "file_path": "."})), |
| 386 | ( |
| 387 | "search_name", |
| 388 | json!({"action": "search_name", "query": "doc", "file_path": "."}), |
| 389 | ), |
| 390 | ( |
| 391 | "search_content", |
| 392 | json!({"action": "search_content", "pattern": "alpha", "file_path": "."}), |
| 393 | ), |
| 394 | ( |
| 395 | "write", |
| 396 | json!({"action": "write", "file_path": "new.txt", "content": "x\n"}), |
| 397 | ), |
| 398 | // Read-window spellings. |
| 399 | ( |
| 400 | "read", |
| 401 | json!({"action": "read", "path": "doc.txt", "offset": 2, "limit": 1}), |
| 402 | ), |
| 403 | ( |
| 404 | "read", |
| 405 | json!({"action": "read", "path": "doc.txt", "line_offset": 2, "n_lines": 1}), |
| 406 | ), |
| 407 | ( |
| 408 | "read", |
| 409 | json!({"action": "read", "path": "doc.txt", "num_lines": 1}), |
| 410 | ), |
| 411 | // Search spellings the wrapper advertises across both actions. |
| 412 | ( |
| 413 | "search_name", |
| 414 | json!({"action": "search_name", "query": "doc", "max_results": 5}), |
| 415 | ), |
| 416 | ( |
| 417 | "search_content", |
| 418 | json!({"action": "search_content", "query": "alpha", "limit": 5}), |
| 419 | ), |
| 420 | ]; |
| 421 | |
| 422 | for (action, call) in aliased { |
| 423 | let (_tmp, ctx) = workspace().await; |
| 424 | let result = tool() |
| 425 | .execute(call.clone(), &ctx) |
| 426 | .await |
| 427 | .unwrap_or_else(|error| panic!("{action} must accept {call}: {error}")); |
| 428 | assert!(result.success, "{action} / {call}: {}", result.content); |
| 429 | } |
| 430 | |
| 431 | // Edit spellings need their own loop: each one mutates the file. |
| 432 | for (search, replace) in [ |
| 433 | ("old_string", "new_string"), |
| 434 | ("old_str", "new_str"), |
| 435 | ("oldText", "newText"), |
| 436 | ("old_text", "new_text"), |
| 437 | ] { |
| 438 | let (_tmp, ctx) = workspace().await; |
| 439 | let result = tool() |
| 440 | .execute( |
| 441 | json!({"action": "edit", "path": "doc.txt", |
| 442 | search: "alpha", replace: "delta"}), |
| 443 | &ctx, |
| 444 | ) |
| 445 | .await |
| 446 | .unwrap_or_else(|error| panic!("edit must accept {search}/{replace}: {error}")); |
| 447 | assert!(result.success, "{search}/{replace}: {}", result.content); |
| 448 | } |
| 449 | let (_tmp, ctx) = workspace().await; |
| 450 | let result = tool() |
| 451 | .execute( |
| 452 | json!({"action": "edit", "path": "doc.txt", "search": "alpha", "replacement": "delta"}), |
| 453 | &ctx, |
| 454 | ) |
| 455 | .await |
| 456 | .expect("edit must accept `replacement`"); |
| 457 | assert!(result.success, "{}", result.content); |
| 458 | } |
| 459 | |
| 460 | /// A parameter that belongs to a *different* action is still unknown to |
| 461 | /// this one. Silently dropping it is how a model learns a call worked |
| 462 | /// when the argument it cared about was discarded. |
| 463 | #[tokio::test] |
| 464 | async fn parameters_do_not_leak_between_actions() { |
| 465 | for (action, call) in [ |
| 466 | ( |
| 467 | "read", |
| 468 | json!({"action": "read", "path": "doc.txt", "case_insensitive": true}), |
| 469 | ), |
| 470 | ( |
| 471 | "write", |
| 472 | json!({"action": "write", "path": "new.txt", "content": "x\n", "start_line": 2}), |
| 473 | ), |
| 474 | ( |
| 475 | "list", |
| 476 | json!({"action": "list", "path": ".", "context_lines": 3}), |
| 477 | ), |
| 478 | ( |
| 479 | "search_name", |
| 480 | json!({"action": "search_name", "query": "doc", "context_lines": 3}), |
| 481 | ), |
| 482 | ( |
| 483 | "search_content", |
| 484 | json!({"action": "search_content", "pattern": "alpha", "extensions": ["txt"]}), |
| 485 | ), |
| 486 | ] { |
| 487 | let (_tmp, ctx) = workspace().await; |
| 488 | let message = tool() |
| 489 | .execute(call, &ctx) |
| 490 | .await |
| 491 | .expect_err("another action's parameter must be refused") |
| 492 | .to_string(); |
| 493 | assert!( |
| 494 | message.contains(&format!("The {action} was not performed")), |
| 495 | "{action}: {message}" |
| 496 | ); |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | /// Every action's required names must be names it also allows, or a |
| 501 | /// refusal would tell the model to pass something the action rejects. |
| 502 | #[test] |
| 503 | fn every_required_parameter_is_also_an_allowed_one() { |
| 504 | use crate::tools::file::{ |
| 505 | EDIT_PARAMS, LIST_PARAMS, PATCH_PARAMS, READ_PARAMS, SEARCH_CONTENT_PARAMS, |
| 506 | SEARCH_NAME_PARAMS, WRITE_PARAMS, |
| 507 | }; |
| 508 | |
| 509 | for params in [ |
| 510 | READ_PARAMS, |
| 511 | WRITE_PARAMS, |
| 512 | EDIT_PARAMS, |
| 513 | LIST_PARAMS, |
| 514 | SEARCH_NAME_PARAMS, |
| 515 | SEARCH_CONTENT_PARAMS, |
| 516 | PATCH_PARAMS, |
| 517 | ] { |
| 518 | params.assert_required_is_allowed(); |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | #[test] |
| 523 | fn advertised_actions_match_the_actions_that_dispatch() { |
| 524 | for (tool, expected) in [ |
| 525 | (FileTool::with_patch("File"), 7), |
| 526 | (FileTool::new("File"), 6), |
| 527 | (FileTool::read_only("File"), 4), |
| 528 | ] { |
| 529 | let schema = tool.input_schema(); |
| 530 | let advertised = schema["properties"]["action"]["enum"] |
| 531 | .as_array() |
| 532 | .expect("action enum") |
| 533 | .len(); |
| 534 | assert_eq!(advertised, expected); |
| 535 | assert_eq!(advertised, tool.available_actions().len()); |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | #[test] |
| 540 | fn primitive_schemas_are_separate_and_small_contract_shaped() { |
| 541 | assert_eq!(ReadTool.name(), "read"); |
| 542 | assert_eq!(WriteTool.name(), "write"); |
| 543 | assert_eq!(EditTool.name(), "edit"); |
| 544 | assert_eq!( |
| 545 | ReadTool.input_schema()["properties"] |
| 546 | .as_object() |
| 547 | .expect("read properties") |
| 548 | .keys() |
| 549 | .cloned() |
| 550 | .collect::<std::collections::BTreeSet<_>>(), |
| 551 | // `max_bytes` is the model-requested per-call output budget (C05). |
| 552 | ["limit", "max_bytes", "offset", "path"] |
| 553 | .into_iter() |
| 554 | .map(str::to_string) |
| 555 | .collect() |
| 556 | ); |
| 557 | assert_eq!( |
| 558 | WriteTool.input_schema()["properties"] |
| 559 | .as_object() |
| 560 | .expect("write properties") |
| 561 | .keys() |
| 562 | .cloned() |
| 563 | .collect::<std::collections::BTreeSet<_>>(), |
| 564 | ["content", "path"] |
| 565 | .into_iter() |
| 566 | .map(str::to_string) |
| 567 | .collect() |
| 568 | ); |
| 569 | assert_eq!( |
| 570 | EditTool.input_schema()["required"], |
| 571 | json!(["path", "edits"]) |
| 572 | ); |
| 573 | assert!(!tool().model_visible(), "legacy File must stay hidden"); |
| 574 | } |
| 575 | |
| 576 | #[tokio::test] |
| 577 | async fn primitive_edit_applies_disjoint_matches_against_original() { |
| 578 | let (tmp, ctx) = workspace().await; |
| 579 | let result = EditTool |
| 580 | .execute( |
| 581 | json!({ |
| 582 | "path": "doc.txt", |
| 583 | "edits": [ |
| 584 | {"oldText": "alpha", "newText": "ALPHA-LONG"}, |
| 585 | {"oldText": "gamma", "newText": "g"} |
| 586 | ] |
| 587 | }), |
| 588 | &ctx, |
| 589 | ) |
| 590 | .await |
| 591 | .expect("multi-edit"); |
| 592 | |
| 593 | assert!(result.success, "{}", result.content); |
| 594 | assert_eq!( |
| 595 | std::fs::read_to_string(tmp.path().join("doc.txt")).expect("read"), |
| 596 | "ALPHA-LONG\nbeta\ng\n" |
| 597 | ); |
| 598 | assert_eq!( |
| 599 | result.content, |
| 600 | "Successfully replaced 2 block(s) in doc.txt." |
| 601 | ); |
| 602 | } |
| 603 | |
| 604 | #[tokio::test] |
| 605 | async fn primitive_edit_rejects_overlap_without_writing() { |
| 606 | let (tmp, ctx) = workspace().await; |
| 607 | let before = std::fs::read_to_string(tmp.path().join("doc.txt")).expect("before"); |
| 608 | let error = EditTool |
| 609 | .execute( |
| 610 | json!({ |
| 611 | "path": "doc.txt", |
| 612 | "edits": [ |
| 613 | {"oldText": "alpha\nbeta", "newText": "one"}, |
| 614 | {"oldText": "beta\ngamma", "newText": "two"} |
| 615 | ] |
| 616 | }), |
| 617 | &ctx, |
| 618 | ) |
| 619 | .await |
| 620 | .expect_err("overlap must fail"); |
| 621 | |
| 622 | assert!(error.to_string().contains("overlap")); |
| 623 | assert_eq!( |
| 624 | std::fs::read_to_string(tmp.path().join("doc.txt")).expect("after"), |
| 625 | before |
| 626 | ); |
| 627 | } |
| 628 | |
| 629 | #[tokio::test] |
| 630 | async fn primitive_edit_does_not_require_a_prior_read() { |
| 631 | let tmp = tempdir().expect("tempdir"); |
| 632 | std::fs::write(tmp.path().join("doc.txt"), "alpha\n").expect("write"); |
| 633 | let ctx = ToolContext::new(tmp.path().to_path_buf()); |
| 634 | let result = EditTool |
| 635 | .execute( |
| 636 | json!({ |
| 637 | "path": "doc.txt", |
| 638 | "edits": [{"oldText": "alpha", "newText": "beta"}] |
| 639 | }), |
| 640 | &ctx, |
| 641 | ) |
| 642 | .await |
| 643 | .expect("lowercase edit must be Pi-simple"); |
| 644 | assert!(result.success, "{}", result.content); |
| 645 | assert_eq!( |
| 646 | std::fs::read_to_string(tmp.path().join("doc.txt")).expect("updated"), |
| 647 | "beta\n" |
| 648 | ); |
| 649 | } |
| 650 |