1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5use std::collections::HashSet;
6use std::path::Path;
7
8use super::read_tracker::{BaselineAdvance, ReadState};
9use super::{content_diagnostics, file_change, read_tracker};
10
11const MAX_PATCH_BYTES: usize = 256 * 1024;
12const MAX_PATCH_BLOCKS: usize = 128;
13const MAX_PATCH_BLOCK_BYTES: usize = 64 * 1024;
14const MAX_SAFE_EDIT_SCOPE_LINES: usize = 120;
15const MAX_SAFE_REPLACE_ALL_OCCURRENCES: usize = 8;
16const MAX_SAFE_REPLACE_ALL_SCOPE_LINES: usize = 80;
17const MIN_REPLACE_ALL_NON_WHITESPACE_CHARS: usize = 2;
18const MIN_REPLACE_ALL_LINES: usize = 1;
19
20#[derive(Debug, Deserialize)]
21struct EditArgs {
22 file_path: String,
23 #[serde(default)]
24 old_string: Option<String>,
25 #[serde(default)]
26 new_string: Option<String>,
27 #[serde(default)]
28 replace_all: Option<bool>,
29 #[serde(default)]
30 patch: Option<String>,
31 #[serde(default)]
32 line_number: Option<usize>,
33}
34
35pub struct EditTool;
36
37#[derive(Debug, Clone)]
38struct ReplacementCandidate {
39 start: usize,
40 matched_len: usize,
41 replacement: String,
42 start_line: usize,
43 end_line: usize,
44}
45
46#[derive(Debug, Clone)]
47struct AppliedEdit {
48 updated: String,
49 replacements: usize,
50}
51
52impl EditTool {
53 pub fn new() -> Self {
54 Self
55 }
56
57 fn to_lf(value: &str) -> String {
58 value.replace("\r\n", "\n")
59 }
60
61 fn to_crlf(value: &str) -> String {
62 Self::to_lf(value).replace('\n', "\r\n")
63 }
64
65 fn has_meaningful_optional_text(value: Option<&str>) -> bool {
66 value.is_some_and(|text| !text.is_empty())
67 }
68
69 fn line_starts(content: &str) -> Vec<usize> {
70 let mut starts = vec![0usize];
71 for (idx, byte) in content.bytes().enumerate() {
72 if byte == b'\n' && idx + 1 < content.len() {
73 starts.push(idx + 1);
74 }
75 }
76 starts
77 }
78
79 fn line_for_offset(line_starts: &[usize], offset: usize) -> usize {
80 line_starts.partition_point(|line_start| *line_start <= offset)
81 }
82
83 fn has_candidate_containing_line(
84 candidates: &[ReplacementCandidate],
85 line_number: usize,
86 ) -> bool {
87 candidates.iter().any(|candidate| {
88 candidate.start_line <= line_number && line_number <= candidate.end_line
89 })
90 }
91
92 fn validate_replace_all_scope(old_string: &str) -> Result<(), ToolError> {
93 let non_whitespace_chars = old_string.chars().filter(|ch| !ch.is_whitespace()).count();
94 if non_whitespace_chars < MIN_REPLACE_ALL_NON_WHITESPACE_CHARS {
95 return Err(ToolError::InvalidArguments(format!(
96 "replace_all requires old_string to contain at least {} non-whitespace characters",
97 MIN_REPLACE_ALL_NON_WHITESPACE_CHARS
98 )));
99 }
100
101 let non_empty_lines = old_string
102 .lines()
103 .filter(|line| !line.trim().is_empty())
104 .count();
105 if non_empty_lines < MIN_REPLACE_ALL_LINES {
106 return Err(ToolError::InvalidArguments(
107 "replace_all requires old_string to contain at least one non-empty line"
108 .to_string(),
109 ));
110 }
111
112 Ok(())
113 }
114
115 fn ensure_safe_scope(
116 replace_all: bool,
117 replacements: usize,
118 touched_lines: usize,
119 ) -> Result<(), ToolError> {
120 if replace_all && replacements > MAX_SAFE_REPLACE_ALL_OCCURRENCES {
121 return Err(ToolError::Execution(format!(
122 "replace_all would modify {} occurrences, exceeding the safe limit of {}; provide a more specific old_string or use patch mode",
123 replacements, MAX_SAFE_REPLACE_ALL_OCCURRENCES
124 )));
125 }
126
127 let max_scope = if replace_all {
128 MAX_SAFE_REPLACE_ALL_SCOPE_LINES
129 } else {
130 MAX_SAFE_EDIT_SCOPE_LINES
131 };
132
133 if touched_lines > max_scope {
134 let guidance = if replace_all {
135 "provide a more specific old_string or use patch mode"
136 } else {
137 "split the change into smaller patches or use Write for intentional full-file rewrites"
138 };
139 return Err(ToolError::Execution(format!(
140 "Edit would touch {} diff lines, exceeding the safe limit of {}; {}",
141 touched_lines, max_scope, guidance
142 )));
143 }
144
145 Ok(())
146 }
147
148 fn replacement_variants(
149 content: &str,
150 old_text: &str,
151 new_text: &str,
152 ) -> Vec<(String, String)> {
153 let mut variants: Vec<(String, String)> = Vec::new();
154 let mut seen_variants: HashSet<(String, String)> = HashSet::new();
155 let mut push_variant = |search: String, replace: String| {
156 if seen_variants.insert((search.clone(), replace.clone())) {
157 variants.push((search, replace));
158 }
159 };
160
161 push_variant(old_text.to_string(), new_text.to_string());
162 push_variant(Self::to_lf(old_text), Self::to_lf(new_text));
163 if content.contains("\r\n") {
164 push_variant(Self::to_crlf(old_text), Self::to_crlf(new_text));
165 }
166
167 variants
168 }
169
170 fn collect_candidates(
171 content: &str,
172 old_text: &str,
173 new_text: &str,
174 ) -> Vec<ReplacementCandidate> {
175 let variants = Self::replacement_variants(content, old_text, new_text);
176 let line_starts = Self::line_starts(content);
177 let mut out: Vec<ReplacementCandidate> = Vec::new();
178 let mut seen_matches: HashSet<(usize, usize, String)> = HashSet::new();
179
180 for (search, replacement) in variants {
181 if search.is_empty() {
182 continue;
183 }
184 for (start, _) in content.match_indices(&search) {
185 let matched_len = search.len();
186 let end = start + matched_len - 1;
187 let candidate = ReplacementCandidate {
188 start,
189 matched_len,
190 replacement: replacement.clone(),
191 start_line: Self::line_for_offset(&line_starts, start),
192 end_line: Self::line_for_offset(&line_starts, end),
193 };
194 if seen_matches.insert((start, matched_len, candidate.replacement.clone())) {
195 out.push(candidate);
196 }
197 }
198 }
199
200 out.sort_by_key(|candidate| candidate.start);
201 out
202 }
203
204 fn candidate_line_summary(candidates: &[ReplacementCandidate]) -> String {
205 let mut lines = candidates
206 .iter()
207 .map(|candidate| candidate.start_line.to_string())
208 .collect::<Vec<_>>();
209 lines.sort();
210 lines.dedup();
211 lines.join(", ")
212 }
213
214 fn choose_candidate_with_line_hint(
215 candidates: &[ReplacementCandidate],
216 line_number: usize,
217 ) -> Option<ReplacementCandidate> {
218 let containing = candidates
219 .iter()
220 .filter(|candidate| {
221 candidate.start_line <= line_number && line_number <= candidate.end_line
222 })
223 .cloned()
224 .collect::<Vec<_>>();
225
226 if containing.is_empty() {
227 return None;
228 }
229
230 let mut best: Option<ReplacementCandidate> = None;
231 let mut best_distance = usize::MAX;
232 let mut tie = false;
233
234 for candidate in containing {
235 let distance = candidate.start_line.abs_diff(line_number);
236 if distance < best_distance {
237 best_distance = distance;
238 best = Some(candidate);
239 tie = false;
240 } else if distance == best_distance {
241 tie = true;
242 }
243 }
244
245 if tie {
246 None
247 } else {
248 best
249 }
250 }
251
252 fn apply_single_replacement(
253 content: &str,
254 old_string: &str,
255 new_string: &str,
256 replace_all: bool,
257 line_number: Option<usize>,
258 ) -> Result<AppliedEdit, ToolError> {
259 if old_string == new_string {
260 return Err(ToolError::InvalidArguments(
261 "new_string must be different from old_string".to_string(),
262 ));
263 }
264 if old_string.is_empty() {
265 return Err(ToolError::InvalidArguments(
266 "old_string must be non-empty".to_string(),
267 ));
268 }
269
270 if let Some(line) = line_number {
271 if line == 0 {
272 return Err(ToolError::InvalidArguments(
273 "line_number must be >= 1".to_string(),
274 ));
275 }
276 if replace_all {
277 return Err(ToolError::InvalidArguments(
278 "line_number cannot be combined with replace_all=true".to_string(),
279 ));
280 }
281 }
282
283 let candidates = Self::collect_candidates(content, old_string, new_string);
284
285 if candidates.is_empty() {
286 return Err(ToolError::Execution(
287 "old_string not found in target file".to_string(),
288 ));
289 }
290
291 if !replace_all && candidates.len() != 1 && line_number.is_none() {
292 return Err(ToolError::Execution(format!(
293 "old_string matched {} times; provide a more specific old_string, set line_number, or use patch mode with additional context",
294 candidates.len()
295 )));
296 }
297
298 if replace_all {
299 Self::validate_replace_all_scope(old_string)?;
300 let variants = Self::replacement_variants(content, old_string, new_string);
301 for (search, replacement) in variants {
302 let matches = content.match_indices(&search).count();
303 if matches > 0 {
304 return Ok(AppliedEdit {
305 updated: content.replace(&search, &replacement),
306 replacements: matches,
307 });
308 }
309 }
310
311 return Ok(AppliedEdit {
312 updated: content.to_string(),
313 replacements: 0,
314 });
315 }
316
317 let chosen = if let Some(line) = line_number {
318 match Self::choose_candidate_with_line_hint(&candidates, line) {
319 Some(candidate) => candidate,
320 None if Self::has_candidate_containing_line(&candidates, line) => {
321 return Err(ToolError::Execution(format!(
322 "old_string matched {} times and line_number={} was not unique among candidates containing that line; candidate start lines: {}. Provide a more specific old_string or patch context",
323 candidates.len(),
324 line,
325 Self::candidate_line_summary(&candidates),
326 )));
327 }
328 None => {
329 return Err(ToolError::Execution(format!(
330 "line_number={} did not match any old_string candidate; candidate start lines: {}. Provide a line_number within the target match or use patch context",
331 line,
332 Self::candidate_line_summary(&candidates),
333 )));
334 }
335 }
336 } else {
337 candidates[0].clone()
338 };
339
340 let mut next = String::with_capacity(
341 content.len().saturating_sub(chosen.matched_len) + chosen.replacement.len(),
342 );
343 next.push_str(&content[..chosen.start]);
344 next.push_str(&chosen.replacement);
345 next.push_str(&content[chosen.start + chosen.matched_len..]);
346
347 Ok(AppliedEdit {
348 updated: next,
349 replacements: 1,
350 })
351 }
352
353 fn parse_patch_blocks(patch: &str) -> Result<Vec<(String, String)>, ToolError> {
354 const SEARCH: &str = "<<<<<<< SEARCH\n";
355 const SEP: &str = "\n=======\n";
356 const REPLACE: &str = "\n>>>>>>> REPLACE";
357
358 let normalized = patch.replace("\r\n", "\n");
359 if normalized.trim().is_empty() {
360 return Err(ToolError::InvalidArguments(
361 "patch must be non-empty".to_string(),
362 ));
363 }
364 if normalized.len() > MAX_PATCH_BYTES {
365 return Err(ToolError::InvalidArguments(format!(
366 "patch exceeds max size of {} bytes",
367 MAX_PATCH_BYTES
368 )));
369 }
370
371 let mut cursor = 0usize;
372 let mut blocks = Vec::new();
373 while let Some(start_rel) = normalized[cursor..].find(SEARCH) {
374 if blocks.len() >= MAX_PATCH_BLOCKS {
375 return Err(ToolError::InvalidArguments(format!(
376 "patch exceeds max block count of {}",
377 MAX_PATCH_BLOCKS
378 )));
379 }
380 let search_start = cursor + start_rel + SEARCH.len();
381 let sep_rel = normalized[search_start..].find(SEP).ok_or_else(|| {
382 ToolError::InvalidArguments("Malformed patch block: missing =======".to_string())
383 })?;
384 let sep_idx = search_start + sep_rel;
385 let replace_start = sep_idx + SEP.len();
386 let replace_rel = normalized[replace_start..].find(REPLACE).ok_or_else(|| {
387 ToolError::InvalidArguments(
388 "Malformed patch block: missing >>>>>>> REPLACE".to_string(),
389 )
390 })?;
391 let replace_idx = replace_start + replace_rel;
392
393 let old_block = normalized[search_start..sep_idx].to_string();
394 let new_block = normalized[replace_start..replace_idx].to_string();
395 if old_block.is_empty() {
396 return Err(ToolError::InvalidArguments(
397 "Patch SEARCH block must be non-empty".to_string(),
398 ));
399 }
400 if old_block.len() > MAX_PATCH_BLOCK_BYTES || new_block.len() > MAX_PATCH_BLOCK_BYTES {
401 return Err(ToolError::InvalidArguments(format!(
402 "Patch block exceeds max block size of {} bytes",
403 MAX_PATCH_BLOCK_BYTES
404 )));
405 }
406 blocks.push((old_block, new_block));
407
408 cursor = replace_idx + REPLACE.len();
409 if normalized[cursor..].starts_with('\n') {
410 cursor += 1;
411 }
412 }
413
414 if blocks.is_empty() {
415 return Err(ToolError::InvalidArguments(
416 "patch must contain at least one SEARCH/REPLACE block".to_string(),
417 ));
418 }
419
420 Ok(blocks)
421 }
422
423 fn apply_patch_mode(
424 content: &str,
425 patch: &str,
426 line_number: Option<usize>,
427 ) -> Result<AppliedEdit, ToolError> {
428 if let Some(line) = line_number {
429 if line == 0 {
430 return Err(ToolError::InvalidArguments(
431 "line_number must be >= 1".to_string(),
432 ));
433 }
434 }
435 let blocks = Self::parse_patch_blocks(patch)?;
436 let mut updated = content.to_string();
437 let mut replacements = 0usize;
438
439 for (idx, (old_block, new_block)) in blocks.iter().enumerate() {
440 let candidates = Self::collect_candidates(&updated, old_block, new_block);
441
442 if candidates.is_empty() {
443 return Err(ToolError::Execution(format!(
444 "Patch block {} SEARCH content not found in target file",
445 idx + 1
446 )));
447 }
448
449 let chosen = if candidates.len() == 1 {
450 candidates[0].clone()
451 } else if let Some(line) = line_number {
452 match Self::choose_candidate_with_line_hint(&candidates, line) {
453 Some(candidate) => candidate,
454 None if Self::has_candidate_containing_line(&candidates, line) => {
455 return Err(ToolError::Execution(format!(
456 "Patch block {} SEARCH content matched {} times and line_number={} was not unique among candidates containing that line; candidate start lines: {}. Add more context to make it unique",
457 idx + 1,
458 candidates.len(),
459 line,
460 Self::candidate_line_summary(&candidates),
461 )));
462 }
463 None => {
464 return Err(ToolError::Execution(format!(
465 "Patch block {} line_number={} did not match any SEARCH candidate; candidate start lines: {}. Add more context or use a line within the target block",
466 idx + 1,
467 line,
468 Self::candidate_line_summary(&candidates),
469 )));
470 }
471 }
472 } else {
473 return Err(ToolError::Execution(format!(
474 "Patch block {} SEARCH content matched {} times; set line_number or add more context to make it unique",
475 idx + 1,
476 candidates.len()
477 )));
478 };
479
480 let mut next = String::with_capacity(
481 updated.len().saturating_sub(chosen.matched_len) + chosen.replacement.len(),
482 );
483 next.push_str(&updated[..chosen.start]);
484 next.push_str(&chosen.replacement);
485 next.push_str(&updated[chosen.start + chosen.matched_len..]);
486 updated = next;
487 replacements += 1;
488 }
489
490 Ok(AppliedEdit {
491 updated,
492 replacements,
493 })
494 }
495}
496
497impl Default for EditTool {
498 fn default() -> Self {
499 Self::new()
500 }
501}
502
503#[async_trait]
504impl Tool for EditTool {
505 fn name(&self) -> &str {
506 "Edit"
507 }
508
509 fn description(&self) -> &str {
510 "Edit existing files via exact replacements or SEARCH/REPLACE patch blocks. IMPORTANT: call Read first in this session or Edit will fail."
511 }
512
513 fn parameters_schema(&self) -> serde_json::Value {
514 json!({
515 "type": "object",
516 "properties": {
517 "file_path": {
518 "type": "string",
519 "description": "The absolute path to the file to modify"
520 },
521 "old_string": {
522 "type": "string",
523 "description": "Legacy mode only: exact text to replace. Do not send with patch mode."
524 },
525 "new_string": {
526 "type": "string",
527 "description": "Legacy mode only: replacement text. Do not send with patch mode."
528 },
529 "replace_all": {
530 "type": "boolean",
531 "default": false,
532 "description": "Legacy mode only: replace all occurrences. Do not send with patch mode."
533 },
534 "patch": {
535 "type": "string",
536 "description": "Patch mode: one or more blocks using <<<<<<< SEARCH / ======= / >>>>>>> REPLACE. Preferred mode. Do not combine with non-empty old_string/new_string or replace_all=true."
537 },
538 "line_number": {
539 "type": "integer",
540 "minimum": 1,
541 "description": "Optional 1-based line hint to disambiguate duplicate matches"
542 }
543 },
544 "required": ["file_path"],
545 "additionalProperties": false
546 })
547 }
548
549 async fn invoke(
550 &self,
551 args: serde_json::Value,
552 ctx: ToolCtx,
553 ) -> Result<ToolOutcome, ToolError> {
554 let parsed: EditArgs = serde_json::from_value(args)
555 .map_err(|e| ToolError::InvalidArguments(format!("Invalid Edit args: {}", e)))?;
556
557 let file_path = parsed.file_path.trim();
558 let path = Path::new(file_path);
559 if !path.is_absolute() {
560 return Err(ToolError::InvalidArguments(
561 "file_path must be an absolute path".to_string(),
562 ));
563 }
564
565 let session_id = ctx.session_id().map(str::to_owned);
566 let validated_read = if let Some(session_id) = session_id.as_deref() {
567 match read_tracker::read_if_fresh(session_id, file_path).await {
568 Ok(validated) => Some(validated),
569 Err(ReadState::Unread) => {
570 return Err(ToolError::Execution(
571 "Edit requires reading the target file first via Read".to_string(),
572 ));
573 }
574 Err(ReadState::Stale) => {
575 return Err(ToolError::Execution(
576 "Target file changed after last Read; call Read again before Edit"
577 .to_string(),
578 ));
579 }
580 Err(ReadState::Fresh) => unreachable!("Fresh is returned as a validated read"),
581 }
582 } else {
583 None
584 };
585
586 let content = if let Some(validated) = validated_read.as_ref() {
587 String::from_utf8(validated.bytes().to_vec())
588 .map_err(|e| ToolError::Execution(format!("Failed to read file: {}", e)))?
589 } else {
590 tokio::fs::read_to_string(path)
591 .await
592 .map_err(|e| ToolError::Execution(format!("Failed to read file: {}", e)))?
593 };
594
595 let patch = parsed
596 .patch
597 .as_deref()
598 .map(str::trim)
599 .filter(|value| !value.is_empty());
600 let old_string = parsed.old_string.as_deref();
601 let new_string = parsed.new_string.as_deref();
602
603 let requested_replace_all = parsed.replace_all.unwrap_or(false);
604 let line_number_hint = parsed.line_number;
605 let used_patch_mode = patch.is_some();
606
607 let AppliedEdit {
608 updated,
609 replacements,
610 } = if let Some(patch_text) = patch {
611 if Self::has_meaningful_optional_text(old_string)
612 || Self::has_meaningful_optional_text(new_string)
613 || requested_replace_all
614 {
615 return Err(ToolError::InvalidArguments(
616 "patch mode cannot be combined with old_string/new_string/replace_all"
617 .to_string(),
618 ));
619 }
620 Self::apply_patch_mode(&content, patch_text, parsed.line_number)?
621 } else {
622 let old = old_string.ok_or_else(|| {
623 ToolError::InvalidArguments(
624 "old_string is required unless patch mode is used".to_string(),
625 )
626 })?;
627 let new = new_string.ok_or_else(|| {
628 ToolError::InvalidArguments(
629 "new_string is required unless patch mode is used".to_string(),
630 )
631 })?;
632 Self::apply_single_replacement(
633 &content,
634 old,
635 new,
636 requested_replace_all,
637 parsed.line_number,
638 )?
639 };
640 let mode_label = if used_patch_mode { "patch" } else { "legacy" };
641 let touched_lines = file_change::touched_line_count(&content, &updated);
642
643 Self::ensure_safe_scope(requested_replace_all, replacements, touched_lines)?;
644
645 let checkpoint = file_change::create_checkpoint(path, Some(content.as_bytes())).await?;
646
647 let write_expectation = validated_read.as_ref().map_or(
648 file_change::AtomicWriteExpectation::Unchecked,
649 |validated| file_change::AtomicWriteExpectation::Exact(validated.bytes()),
650 );
651 file_change::atomic_write_text_with_expectation(path, &updated, write_expectation).await?;
652
653 if session_id.is_some() {
654 let validated = validated_read
655 .as_ref()
656 .expect("a session-scoped Edit always has a validated Read");
657 if read_tracker::advance_after_verified_write(
658 file_path,
659 validated.slot(),
660 updated.as_bytes(),
661 )
662 .await
663 == BaselineAdvance::Conflict
664 {
665 return Err(ToolError::Execution(
666 "Edit committed, but the target changed before it could be verified; call Read again"
667 .to_string(),
668 ));
669 }
670 }
671
672 let changed_bytes = updated.len().abs_diff(content.len());
673 let changed_lines = updated.lines().count().abs_diff(content.lines().count());
674
675 let mut payload = file_change::build_file_change_payload_value(
676 "Edit",
677 path,
678 format!(
679 "Edited file: {} (mode: {}, replacements: {})",
680 file_path, mode_label, replacements
681 ),
682 checkpoint,
683 &content,
684 &updated,
685 );
686 if let Some(obj) = payload.as_object_mut() {
687 obj.insert("edit_mode".to_string(), json!(mode_label));
688 obj.insert("replacements".to_string(), json!(replacements));
689 obj.insert(
690 "requested_replace_all".to_string(),
691 json!(requested_replace_all),
692 );
693 obj.insert("used_patch_mode".to_string(), json!(used_patch_mode));
694 obj.insert("line_number_hint".to_string(), json!(line_number_hint));
695 obj.insert("changed_bytes".to_string(), json!(changed_bytes));
696 obj.insert("changed_lines".to_string(), json!(changed_lines));
697 obj.insert("touched_lines".to_string(), json!(touched_lines));
698 }
699 content_diagnostics::attach_file_diagnostics(&mut payload, path, &updated);
700
701 Ok(ToolOutcome::Completed(ToolResult {
702 success: true,
703 result: payload.to_string(),
704 display_preference: Some("Default".to_string()),
705 images: Vec::new(),
706 }))
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713 use crate::tools::ReadTool;
714 use serde_json::json;
715
716 fn ctx(session_id: &str) -> ToolCtx {
717 ToolCtx {
718 executing_caller: None,
719 executing_supervisor: None,
720 session_id: Some(std::sync::Arc::from(session_id)),
721 tool_call_id: std::sync::Arc::from("call_1"),
722 event_tx: None,
723 available_tool_schemas: std::sync::Arc::from(Vec::new()),
724 bypass_permissions: false,
725 auto_approve_permissions: false,
726 plan_read_only: false,
727 can_async_resume: false,
728 async_completion_sink: None,
729 bash_completion_sink: None,
730 }
731 }
732
733 async fn run(tool: &EditTool, args: serde_json::Value) -> Result<ToolResult, ToolError> {
734 match tool.invoke(args, ToolCtx::none("t")).await? {
735 ToolOutcome::Completed(r) => Ok(r),
736 _ => panic!("expected Completed"),
737 }
738 }
739
740 #[tokio::test]
741 async fn edit_requires_unique_match_without_replace_all() {
742 let file = tempfile::NamedTempFile::new().unwrap();
743 tokio::fs::write(file.path(), "foo\nfoo\n").await.unwrap();
744
745 let tool = EditTool::new();
746 let result = run(
747 &tool,
748 json!({
749 "file_path": file.path(),
750 "old_string": "foo",
751 "new_string": "bar"
752 }),
753 )
754 .await;
755
756 assert!(result.is_err());
757 }
758
759 #[tokio::test]
760 async fn edit_supports_replace_all() {
761 let file = tempfile::NamedTempFile::new().unwrap();
762 tokio::fs::write(file.path(), "foo\nfoo\n").await.unwrap();
763
764 let tool = EditTool::new();
765 let result = run(
766 &tool,
767 json!({
768 "file_path": file.path(),
769 "old_string": "foo",
770 "new_string": "bar",
771 "replace_all": true
772 }),
773 )
774 .await
775 .unwrap();
776
777 assert!(result.success);
778 let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
779 assert_eq!(updated, "bar\nbar\n");
780 }
781
782 #[tokio::test]
783 async fn edit_replace_all_does_not_reprocess_newly_inserted_matches() {
784 let file = tempfile::NamedTempFile::new().unwrap();
785 tokio::fs::write(file.path(), "a\n").await.unwrap();
786
787 let tool = EditTool::new();
788 let result = run(
789 &tool,
790 json!({
791 "file_path": file.path(),
792 "old_string": "aa",
793 "new_string": "bb",
794 "replace_all": true
795 }),
796 )
797 .await;
798
799 assert!(matches!(
800 result,
801 Err(ToolError::Execution(_)) | Err(ToolError::InvalidArguments(_))
802 ));
803 }
804
805 #[tokio::test]
806 async fn edit_replace_all_rejects_excessive_occurrence_count() {
807 let file = tempfile::NamedTempFile::new().unwrap();
808 let content = (0..=MAX_SAFE_REPLACE_ALL_OCCURRENCES)
809 .map(|_| "foo")
810 .collect::<Vec<_>>()
811 .join("\n");
812 tokio::fs::write(file.path(), format!("{content}\n"))
813 .await
814 .unwrap();
815
816 let tool = EditTool::new();
817 let result = run(
818 &tool,
819 json!({
820 "file_path": file.path(),
821 "old_string": "foo",
822 "new_string": "bar",
823 "replace_all": true
824 }),
825 )
826 .await;
827
828 assert!(
829 matches!(result, Err(ToolError::Execution(msg)) if msg.contains("replace_all would modify"))
830 );
831 }
832
833 #[tokio::test]
834 async fn edit_replace_all_rejects_too_short_old_string() {
835 let file = tempfile::NamedTempFile::new().unwrap();
836 tokio::fs::write(file.path(), "a\na\n").await.unwrap();
837
838 let tool = EditTool::new();
839 let result = run(
840 &tool,
841 json!({
842 "file_path": file.path(),
843 "old_string": "a",
844 "new_string": "b",
845 "replace_all": true
846 }),
847 )
848 .await;
849
850 assert!(
851 matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("non-whitespace characters"))
852 );
853 }
854
855 #[tokio::test]
856 async fn edit_replace_all_rejects_whitespace_only_old_string() {
857 let file = tempfile::NamedTempFile::new().unwrap();
858 tokio::fs::write(file.path(), " \n \n").await.unwrap();
859
860 let tool = EditTool::new();
861 let result = run(
862 &tool,
863 json!({
864 "file_path": file.path(),
865 "old_string": " ",
866 "new_string": "x",
867 "replace_all": true
868 }),
869 )
870 .await;
871
872 assert!(
873 matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("non-whitespace characters") || msg.contains("non-empty line"))
874 );
875 }
876
877 #[tokio::test]
878 async fn edit_requires_read_first_when_session_context_exists() {
879 let file = tempfile::NamedTempFile::new().unwrap();
880 tokio::fs::write(file.path(), "hello world\n")
881 .await
882 .unwrap();
883 let call_id = "call_1";
884
885 let edit_tool = EditTool::new();
886 let read_tool = ReadTool::new();
887
888 let denied = edit_tool
889 .invoke(
890 json!({
891 "file_path": file.path(),
892 "old_string": "world",
893 "new_string": "rust"
894 }),
895 ToolCtx {
896 executing_caller: None,
897 executing_supervisor: None,
898 session_id: Some(std::sync::Arc::from("session_1")),
899 tool_call_id: std::sync::Arc::from(call_id),
900 event_tx: None,
901 available_tool_schemas: std::sync::Arc::from(Vec::new()),
902 bypass_permissions: false,
903 auto_approve_permissions: false,
904 plan_read_only: false,
905 can_async_resume: false,
906 async_completion_sink: None,
907 bash_completion_sink: None,
908 },
909 )
910 .await;
911 assert!(denied.is_err());
912
913 let _ = read_tool
914 .invoke(
915 json!({"file_path": file.path()}),
916 ToolCtx {
917 executing_caller: None,
918 executing_supervisor: None,
919 session_id: Some(std::sync::Arc::from("session_1")),
920 tool_call_id: std::sync::Arc::from(call_id),
921 event_tx: None,
922 available_tool_schemas: std::sync::Arc::from(Vec::new()),
923 bypass_permissions: false,
924 auto_approve_permissions: false,
925 plan_read_only: false,
926 can_async_resume: false,
927 async_completion_sink: None,
928 bash_completion_sink: None,
929 },
930 )
931 .await
932 .unwrap();
933
934 let allowed = edit_tool
935 .invoke(
936 json!({
937 "file_path": file.path(),
938 "old_string": "world",
939 "new_string": "rust"
940 }),
941 ToolCtx {
942 executing_caller: None,
943 executing_supervisor: None,
944 session_id: Some(std::sync::Arc::from("session_1")),
945 tool_call_id: std::sync::Arc::from(call_id),
946 event_tx: None,
947 available_tool_schemas: std::sync::Arc::from(Vec::new()),
948 bypass_permissions: false,
949 auto_approve_permissions: false,
950 plan_read_only: false,
951 can_async_resume: false,
952 async_completion_sink: None,
953 bash_completion_sink: None,
954 },
955 )
956 .await
957 .unwrap();
958 let ToolOutcome::Completed(allowed) = allowed else {
959 panic!("expected Completed")
960 };
961
962 assert!(allowed.success);
963 }
964
965 #[tokio::test]
966 async fn read_edit_edit_succeeds_without_an_external_change() {
967 let file = tempfile::NamedTempFile::new().unwrap();
968 tokio::fs::write(file.path(), "alpha\nbeta\ngamma\n")
969 .await
970 .unwrap();
971 let session = format!("edit-twice-{}", uuid::Uuid::new_v4());
972 let read_tool = ReadTool::new();
973 let edit_tool = EditTool::new();
974
975 read_tool
976 .invoke(json!({"file_path": file.path()}), ctx(&session))
977 .await
978 .unwrap();
979 edit_tool
980 .invoke(
981 json!({
982 "file_path": file.path(),
983 "old_string": "alpha",
984 "new_string": "alpha-one"
985 }),
986 ctx(&session),
987 )
988 .await
989 .unwrap();
990 edit_tool
991 .invoke(
992 json!({
993 "file_path": file.path(),
994 "old_string": "beta",
995 "new_string": "beta-two"
996 }),
997 ctx(&session),
998 )
999 .await
1000 .unwrap();
1001
1002 assert_eq!(
1003 tokio::fs::read_to_string(file.path()).await.unwrap(),
1004 "alpha-one\nbeta-two\ngamma\n"
1005 );
1006 }
1007
1008 #[tokio::test]
1009 async fn concurrent_read_of_edited_version_is_idempotent() {
1010 let file = tempfile::NamedTempFile::new().unwrap();
1011 tokio::fs::write(file.path(), "alpha\nbeta\n")
1012 .await
1013 .unwrap();
1014 let path = file.path().to_path_buf();
1015 let path_str = path.to_string_lossy().into_owned();
1016 let session = format!("edit-concurrent-read-{}", uuid::Uuid::new_v4());
1017
1018 ReadTool::new()
1019 .invoke(json!({"file_path": path}), ctx(&session))
1020 .await
1021 .unwrap();
1022 let (advance_reached, resume_advance) =
1023 read_tracker::pause_next_advance_for_test(&session, &path_str).await;
1024
1025 let edit_path = path.clone();
1026 let edit_session = session.clone();
1027 let editor = tokio::spawn(async move {
1028 EditTool::new()
1029 .invoke(
1030 json!({
1031 "file_path": edit_path,
1032 "old_string": "alpha",
1033 "new_string": "ALPHA"
1034 }),
1035 ctx(&edit_session),
1036 )
1037 .await
1038 });
1039
1040 tokio::time::timeout(
1041 std::time::Duration::from_secs(5),
1042 advance_reached.notified(),
1043 )
1044 .await
1045 .expect("Edit did not reach post-write baseline advancement");
1046 ReadTool::new()
1047 .invoke(json!({"file_path": path}), ctx(&session))
1048 .await
1049 .unwrap();
1050 resume_advance.notify_one();
1051
1052 let first = tokio::time::timeout(std::time::Duration::from_secs(5), editor)
1053 .await
1054 .expect("Edit did not resume")
1055 .unwrap()
1056 .unwrap();
1057 assert!(matches!(first, ToolOutcome::Completed(result) if result.success));
1058
1059 EditTool::new()
1060 .invoke(
1061 json!({
1062 "file_path": path,
1063 "old_string": "beta",
1064 "new_string": "BETA"
1065 }),
1066 ctx(&session),
1067 )
1068 .await
1069 .unwrap();
1070 assert_eq!(
1071 tokio::fs::read_to_string(path).await.unwrap(),
1072 "ALPHA\nBETA\n"
1073 );
1074 }
1075
1076 #[tokio::test]
1077 async fn edit_rejects_external_change_after_a_successful_edit() {
1078 let file = tempfile::NamedTempFile::new().unwrap();
1079 tokio::fs::write(file.path(), "alpha\nbeta\n")
1080 .await
1081 .unwrap();
1082 let session = format!("edit-external-{}", uuid::Uuid::new_v4());
1083 let read_tool = ReadTool::new();
1084 let edit_tool = EditTool::new();
1085
1086 read_tool
1087 .invoke(json!({"file_path": file.path()}), ctx(&session))
1088 .await
1089 .unwrap();
1090 edit_tool
1091 .invoke(
1092 json!({
1093 "file_path": file.path(),
1094 "old_string": "alpha",
1095 "new_string": "ALPHA"
1096 }),
1097 ctx(&session),
1098 )
1099 .await
1100 .unwrap();
1101
1102 tokio::fs::write(file.path(), "ALPHA\nBETA\n")
1103 .await
1104 .unwrap();
1105 let stale = edit_tool
1106 .invoke(
1107 json!({
1108 "file_path": file.path(),
1109 "old_string": "ALPHA",
1110 "new_string": "alpha-two"
1111 }),
1112 ctx(&session),
1113 )
1114 .await;
1115
1116 assert!(matches!(stale, Err(ToolError::Execution(message)) if message.contains("changed")));
1117 assert_eq!(
1118 tokio::fs::read_to_string(file.path()).await.unwrap(),
1119 "ALPHA\nBETA\n"
1120 );
1121 }
1122
1123 #[tokio::test]
1124 async fn failed_edit_does_not_break_the_existing_fresh_baseline() {
1125 let file = tempfile::NamedTempFile::new().unwrap();
1126 tokio::fs::write(file.path(), "alpha\nbeta\n")
1127 .await
1128 .unwrap();
1129 let session = format!("edit-failure-{}", uuid::Uuid::new_v4());
1130 let read_tool = ReadTool::new();
1131 let edit_tool = EditTool::new();
1132
1133 read_tool
1134 .invoke(json!({"file_path": file.path()}), ctx(&session))
1135 .await
1136 .unwrap();
1137 let failed = edit_tool
1138 .invoke(
1139 json!({
1140 "file_path": file.path(),
1141 "old_string": "not-present",
1142 "new_string": "replacement"
1143 }),
1144 ctx(&session),
1145 )
1146 .await;
1147 assert!(failed.is_err());
1148
1149 edit_tool
1150 .invoke(
1151 json!({
1152 "file_path": file.path(),
1153 "old_string": "beta",
1154 "new_string": "beta-two"
1155 }),
1156 ctx(&session),
1157 )
1158 .await
1159 .unwrap();
1160 assert_eq!(
1161 tokio::fs::read_to_string(file.path()).await.unwrap(),
1162 "alpha\nbeta-two\n"
1163 );
1164 }
1165
1166 #[tokio::test]
1167 async fn edit_rejects_empty_old_string() {
1168 let file = tempfile::NamedTempFile::new().unwrap();
1169 tokio::fs::write(file.path(), "hello").await.unwrap();
1170
1171 let tool = EditTool::new();
1172 let result = run(
1173 &tool,
1174 json!({
1175 "file_path": file.path(),
1176 "old_string": "",
1177 "new_string": "x",
1178 "replace_all": true
1179 }),
1180 )
1181 .await;
1182
1183 assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
1184 }
1185
1186 #[tokio::test]
1187 async fn edit_legacy_mode_handles_crlf_when_old_string_uses_lf() {
1188 let file = tempfile::NamedTempFile::new().unwrap();
1189 tokio::fs::write(file.path(), "alpha\r\nbeta\r\n")
1190 .await
1191 .unwrap();
1192
1193 let tool = EditTool::new();
1194 let result = run(
1195 &tool,
1196 json!({
1197 "file_path": file.path(),
1198 "old_string": "alpha\nbeta\n",
1199 "new_string": "gamma\ndelta\n"
1200 }),
1201 )
1202 .await
1203 .unwrap();
1204
1205 assert!(result.success);
1206 let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
1207 assert_eq!(updated, "gamma\r\ndelta\r\n");
1208 }
1209
1210 #[tokio::test]
1211 async fn edit_legacy_mode_line_number_disambiguates_duplicates() {
1212 let file = tempfile::NamedTempFile::new().unwrap();
1213 tokio::fs::write(file.path(), "foo\nbar\nfoo\n")
1214 .await
1215 .unwrap();
1216
1217 let tool = EditTool::new();
1218 let result = run(
1219 &tool,
1220 json!({
1221 "file_path": file.path(),
1222 "old_string": "foo",
1223 "new_string": "baz",
1224 "line_number": 3
1225 }),
1226 )
1227 .await
1228 .unwrap();
1229 assert!(result.success);
1230
1231 let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
1232 assert_eq!(updated, "foo\nbar\nbaz\n");
1233 }
1234
1235 #[tokio::test]
1236 async fn edit_legacy_mode_rejects_line_number_when_no_candidate_contains_it() {
1237 let file = tempfile::NamedTempFile::new().unwrap();
1238 tokio::fs::write(file.path(), "foo\nbar\nfoo\n")
1239 .await
1240 .unwrap();
1241
1242 let tool = EditTool::new();
1243 let result = run(
1244 &tool,
1245 json!({
1246 "file_path": file.path(),
1247 "old_string": "foo",
1248 "new_string": "baz",
1249 "line_number": 2
1250 }),
1251 )
1252 .await;
1253
1254 assert!(
1255 matches!(result, Err(ToolError::Execution(msg)) if msg.contains("did not match any old_string candidate"))
1256 );
1257 }
1258
1259 #[tokio::test]
1260 async fn edit_legacy_mode_rejects_line_number_with_replace_all() {
1261 let file = tempfile::NamedTempFile::new().unwrap();
1262 tokio::fs::write(file.path(), "foo\nfoo\n").await.unwrap();
1263
1264 let tool = EditTool::new();
1265 let result = run(
1266 &tool,
1267 json!({
1268 "file_path": file.path(),
1269 "old_string": "foo",
1270 "new_string": "bar",
1271 "replace_all": true,
1272 "line_number": 1
1273 }),
1274 )
1275 .await;
1276
1277 assert!(
1278 matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("line_number cannot be combined"))
1279 );
1280 }
1281
1282 #[tokio::test]
1283 async fn edit_patch_mode_can_target_second_duplicate_with_context() {
1284 let file = tempfile::NamedTempFile::new().unwrap();
1285 tokio::fs::write(
1286 file.path(),
1287 "fn a() {\n let v = 1;\n}\n\nfn b() {\n let v = 1;\n}\n",
1288 )
1289 .await
1290 .unwrap();
1291
1292 let tool = EditTool::new();
1293 let result = run(&tool,json!({
1294 "file_path": file.path(),
1295 "patch": "<<<<<<< SEARCH\nfn b() {\n let v = 1;\n}\n=======\nfn b() {\n let v = 2;\n}\n>>>>>>> REPLACE"
1296 }))
1297 .await
1298 .unwrap();
1299 assert!(result.success);
1300
1301 let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
1302 assert!(updated.contains("fn a() {\n let v = 1;\n}"));
1303 assert!(updated.contains("fn b() {\n let v = 2;\n}"));
1304 }
1305
1306 #[tokio::test]
1307 async fn edit_patch_mode_handles_crlf_when_patch_uses_lf() {
1308 let file = tempfile::NamedTempFile::new().unwrap();
1309 tokio::fs::write(file.path(), "fn b() {\r\n let v = 1;\r\n}\r\n")
1310 .await
1311 .unwrap();
1312
1313 let tool = EditTool::new();
1314 let result = run(&tool,json!({
1315 "file_path": file.path(),
1316 "patch": "<<<<<<< SEARCH\nfn b() {\n let v = 1;\n}\n=======\nfn b() {\n let v = 2;\n}\n>>>>>>> REPLACE"
1317 }))
1318 .await
1319 .unwrap();
1320 assert!(result.success);
1321
1322 let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
1323 assert_eq!(updated, "fn b() {\r\n let v = 2;\r\n}\r\n");
1324 }
1325
1326 #[tokio::test]
1327 async fn edit_patch_mode_line_number_disambiguates_duplicates() {
1328 let file = tempfile::NamedTempFile::new().unwrap();
1329 tokio::fs::write(file.path(), "x = 1;\nx = 1;\n")
1330 .await
1331 .unwrap();
1332
1333 let tool = EditTool::new();
1334 let result = run(
1335 &tool,
1336 json!({
1337 "file_path": file.path(),
1338 "line_number": 2,
1339 "patch": "<<<<<<< SEARCH\nx = 1;\n=======\nx = 2;\n>>>>>>> REPLACE"
1340 }),
1341 )
1342 .await
1343 .unwrap();
1344 assert!(result.success);
1345
1346 let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
1347 assert_eq!(updated, "x = 1;\nx = 2;\n");
1348 }
1349
1350 #[tokio::test]
1351 async fn edit_patch_mode_rejects_line_number_when_no_candidate_contains_it() {
1352 let file = tempfile::NamedTempFile::new().unwrap();
1353 tokio::fs::write(file.path(), "x = 1;\ny = 0;\nx = 1;\n")
1354 .await
1355 .unwrap();
1356
1357 let tool = EditTool::new();
1358 let result = run(
1359 &tool,
1360 json!({
1361 "file_path": file.path(),
1362 "line_number": 2,
1363 "patch": "<<<<<<< SEARCH\nx = 1;\n=======\nx = 2;\n>>>>>>> REPLACE"
1364 }),
1365 )
1366 .await;
1367
1368 assert!(
1369 matches!(result, Err(ToolError::Execution(msg)) if msg.contains("did not match any SEARCH candidate"))
1370 );
1371 }
1372
1373 #[tokio::test]
1374 async fn edit_patch_mode_rejects_ambiguous_search_block() {
1375 let file = tempfile::NamedTempFile::new().unwrap();
1376 tokio::fs::write(file.path(), "x = 1;\nx = 1;\n")
1377 .await
1378 .unwrap();
1379
1380 let tool = EditTool::new();
1381 let result = run(
1382 &tool,
1383 json!({
1384 "file_path": file.path(),
1385 "patch": "<<<<<<< SEARCH\nx = 1;\n=======\nx = 2;\n>>>>>>> REPLACE"
1386 }),
1387 )
1388 .await;
1389
1390 assert!(
1391 matches!(result, Err(ToolError::Execution(msg)) if msg.contains("matched 2 times"))
1392 );
1393 }
1394
1395 #[tokio::test]
1396 async fn edit_patch_mode_rejects_large_scope_edits() {
1397 let file = tempfile::NamedTempFile::new().unwrap();
1398 let old_block = (0..70)
1399 .map(|idx| format!("line {idx}"))
1400 .collect::<Vec<_>>()
1401 .join("\n");
1402 let new_block = (0..70)
1403 .map(|idx| format!("updated {idx}"))
1404 .collect::<Vec<_>>()
1405 .join("\n");
1406 tokio::fs::write(file.path(), format!("{old_block}\n"))
1407 .await
1408 .unwrap();
1409
1410 let patch = format!("<<<<<<< SEARCH\n{old_block}\n=======\n{new_block}\n>>>>>>> REPLACE");
1411
1412 let tool = EditTool::new();
1413 let result = run(
1414 &tool,
1415 json!({
1416 "file_path": file.path(),
1417 "patch": patch
1418 }),
1419 )
1420 .await;
1421
1422 assert!(
1423 matches!(result, Err(ToolError::Execution(msg)) if msg.contains("exceeding the safe limit"))
1424 );
1425 }
1426
1427 #[tokio::test]
1428 async fn edit_rejects_mixed_patch_and_legacy_args() {
1429 let file = tempfile::NamedTempFile::new().unwrap();
1430 tokio::fs::write(file.path(), "hello").await.unwrap();
1431
1432 let tool = EditTool::new();
1433 let result = run(
1434 &tool,
1435 json!({
1436 "file_path": file.path(),
1437 "old_string": "hello",
1438 "new_string": "world",
1439 "patch": "<<<<<<< SEARCH\nhello\n=======\nworld\n>>>>>>> REPLACE"
1440 }),
1441 )
1442 .await;
1443
1444 assert!(
1445 matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("cannot be combined"))
1446 );
1447 }
1448
1449 #[tokio::test]
1450 async fn edit_patch_mode_ignores_empty_legacy_placeholders() {
1451 let file = tempfile::NamedTempFile::new().unwrap();
1452 tokio::fs::write(file.path(), "hello").await.unwrap();
1453
1454 let tool = EditTool::new();
1455 let result = run(
1456 &tool,
1457 json!({
1458 "file_path": file.path(),
1459 "old_string": "",
1460 "new_string": "",
1461 "replace_all": false,
1462 "patch": "<<<<<<< SEARCH\nhello\n=======\nworld\n>>>>>>> REPLACE"
1463 }),
1464 )
1465 .await
1466 .unwrap();
1467
1468 assert!(result.success);
1469 let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
1470 assert_eq!(updated, "world");
1471 }
1472
1473 #[tokio::test]
1474 async fn edit_patch_rejects_oversized_patch_payload() {
1475 let file = tempfile::NamedTempFile::new().unwrap();
1476 tokio::fs::write(file.path(), "hello world").await.unwrap();
1477 let huge = "a".repeat(MAX_PATCH_BYTES + 1);
1478
1479 let tool = EditTool::new();
1480 let result = run(
1481 &tool,
1482 json!({
1483 "file_path": file.path(),
1484 "patch": huge
1485 }),
1486 )
1487 .await;
1488
1489 assert!(
1490 matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("max size"))
1491 );
1492 }
1493
1494 #[tokio::test]
1495 async fn edit_patch_rejects_excessive_block_count() {
1496 let file = tempfile::NamedTempFile::new().unwrap();
1497 tokio::fs::write(file.path(), "hello world").await.unwrap();
1498 let mut patch = String::new();
1499 for _ in 0..=MAX_PATCH_BLOCKS {
1500 patch.push_str("<<<<<<< SEARCH\nx\n=======\ny\n>>>>>>> REPLACE\n");
1501 }
1502
1503 let tool = EditTool::new();
1504 let result = run(
1505 &tool,
1506 json!({
1507 "file_path": file.path(),
1508 "patch": patch
1509 }),
1510 )
1511 .await;
1512
1513 assert!(
1514 matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("max block count"))
1515 );
1516 }
1517
1518 #[tokio::test]
1519 async fn edit_includes_json_diagnostics_after_change() {
1520 let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
1521 tokio::fs::write(file.path(), r#"{"ok":true}"#)
1522 .await
1523 .unwrap();
1524
1525 let read_tool = ReadTool::new();
1526 let _ = read_tool
1527 .invoke(
1528 json!({ "file_path": file.path() }),
1529 ToolCtx {
1530 executing_caller: None,
1531 executing_supervisor: None,
1532 session_id: Some(std::sync::Arc::from("session_edit_diag")),
1533 tool_call_id: std::sync::Arc::from("call_1"),
1534 event_tx: None,
1535 available_tool_schemas: std::sync::Arc::from(Vec::new()),
1536 bypass_permissions: false,
1537 auto_approve_permissions: false,
1538 plan_read_only: false,
1539 can_async_resume: false,
1540 async_completion_sink: None,
1541 bash_completion_sink: None,
1542 },
1543 )
1544 .await
1545 .unwrap();
1546
1547 let tool = EditTool::new();
1548 let result = tool
1549 .invoke(
1550 json!({
1551 "file_path": file.path(),
1552 "old_string": r#"{"ok":true}"#,
1553 "new_string": "{"
1554 }),
1555 ToolCtx {
1556 executing_caller: None,
1557 executing_supervisor: None,
1558 session_id: Some(std::sync::Arc::from("session_edit_diag")),
1559 tool_call_id: std::sync::Arc::from("call_2"),
1560 event_tx: None,
1561 available_tool_schemas: std::sync::Arc::from(Vec::new()),
1562 bypass_permissions: false,
1563 auto_approve_permissions: false,
1564 plan_read_only: false,
1565 can_async_resume: false,
1566 async_completion_sink: None,
1567 bash_completion_sink: None,
1568 },
1569 )
1570 .await
1571 .unwrap();
1572 let ToolOutcome::Completed(result) = result else {
1573 panic!("expected Completed")
1574 };
1575
1576 assert!(result.success);
1577 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
1578 assert_eq!(payload["diagnostics"]["format"], "json");
1579 assert_eq!(payload["diagnostics"]["valid"], false);
1580 assert_eq!(payload["touched_lines"], 2);
1581 }
1582}