Skip to main content

bamboo_tools/tools/
edit.rs

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}