返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tools / file_tool / tests.rs
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
650 lines RUST