Skip to main content

cookie_agent_tools/
edit.rs

1use std::path::PathBuf;
2
3use async_trait::async_trait;
4use cookie_agent_engine::{
5    PreparedExecutor, PreparedSerializationKey, PreparedTool, SessionToolContext, ToolCall,
6    ToolError, ToolExecutionContext, ToolPreparationContext, ToolProvider, ToolSpec,
7};
8use cookie_agent_protocol::{PermissionAction, PersistedToolResult as ToolResult, Sha256Digest};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::{fs_cap, parse_args, prepared_operation, prepared_path_resources, schema};
13
14#[derive(Debug)]
15pub struct EditTool {
16    workspace: PathBuf,
17}
18
19#[derive(Debug, Deserialize, JsonSchema, Serialize)]
20struct EditArgs {
21    #[serde(
22        rename = "filePath",
23        deserialize_with = "crate::path_args::deserialize"
24    )]
25    /// Path to the file. Relative paths resolve against the session working directory.
26    file_path: String,
27    #[serde(rename = "oldString")]
28    old_string: String,
29    #[serde(rename = "newString")]
30    new_string: String,
31    #[serde(rename = "replaceAll", default)]
32    replace_all: bool,
33}
34
35struct EditExecutor {
36    target: EditTarget,
37    new_bytes: Vec<u8>,
38}
39
40enum EditTarget {
41    Existing(fs_cap::PreparedExisting),
42    Chained(fs_cap::PreparedChained),
43}
44
45impl EditTool {
46    #[must_use]
47    pub fn new(workspace: impl Into<PathBuf>) -> Self {
48        Self {
49            workspace: workspace.into(),
50        }
51    }
52
53    /// The per-call front half of preparation: parse arguments and resolve
54    /// the target. Shared by `prepare` and `prepare_parallel`.
55    fn prepare_target(
56        &self,
57        ctx: &ToolPreparationContext,
58        call: ToolCall,
59    ) -> Result<PendingEdit, ToolError> {
60        let args: EditArgs = parse_args("edit", call.arguments)?;
61        fs_cap::ensure_atomic_write_supported()?;
62        if args.old_string.is_empty() {
63            return Err(ToolError::execution("oldString must not be empty"));
64        }
65        let target = fs_cap::prepare_existing(&ctx.cwd, std::path::Path::new(&args.file_path))?;
66        if target.directory {
67            return Err(ToolError::unsupported_security(
68                "edit target is a directory",
69            ));
70        }
71        let bytes = target.read_bytes()?;
72        let text =
73            String::from_utf8(bytes).map_err(|_| ToolError::execution("edit requires UTF-8"))?;
74        Ok(PendingEdit { args, target, text })
75    }
76
77    /// Match `oldString` against `text` and build the prepared call. When
78    /// `predecessor` is set, the call belongs to a same-target chain: the
79    /// fingerprint binds the predecessor's output digest (the content this
80    /// edit matched against) and the executor validates that digest instead
81    /// of the original target identity, which an earlier call in the chain
82    /// atomically replaces. Returns the replacement text for chain
83    /// continuation.
84    fn prepare_edit(
85        &self,
86        ctx: &ToolPreparationContext,
87        args: EditArgs,
88        target: fs_cap::PreparedExisting,
89        text: &str,
90        predecessor: Option<Sha256Digest>,
91    ) -> Result<(PreparedTool, String), ToolError> {
92        let count = text.matches(&args.old_string).count();
93        if count == 0 {
94            return Err(ToolError::execution("oldString was not found"));
95        }
96        if !args.replace_all && count != 1 {
97            return Err(ToolError::execution(format!(
98                "oldString matched {count} times"
99            )));
100        }
101        let replaced = if args.replace_all {
102            text.replace(&args.old_string, &args.new_string)
103        } else {
104            text.replacen(&args.old_string, &args.new_string, 1)
105        };
106        let new_bytes = replaced.clone().into_bytes();
107        let input_digest = predecessor
108            .clone()
109            .unwrap_or_else(|| target.content_digest.clone());
110        let mut binding = target.identity.canonical_bytes();
111        binding.extend_from_slice(input_digest.as_str().as_bytes());
112        binding.extend_from_slice(
113            Sha256Digest::of_bytes(args.old_string.as_bytes())
114                .as_str()
115                .as_bytes(),
116        );
117        binding.extend_from_slice(
118            Sha256Digest::of_bytes(args.new_string.as_bytes())
119                .as_str()
120                .as_bytes(),
121        );
122        binding.extend_from_slice(&(count as u64).to_be_bytes());
123        binding.extend_from_slice(Sha256Digest::of_bytes(&new_bytes).as_str().as_bytes());
124        binding.extend_from_slice(&target.manifest_bytes()?);
125        let (resources, policy_labels) = prepared_path_resources(
126            PermissionAction::Write,
127            "file",
128            &target.display_path,
129            &self.workspace,
130            &binding,
131        )?;
132        let context = fs_cap::cwd_context_bytes(&ctx.cwd)?;
133        let operation = prepared_operation(
134            "edit",
135            &args,
136            vec![(PermissionAction::Write, "edit")],
137            resources,
138            &context,
139        )?;
140        let normalized_arguments = serde_json::json!({
141            "filePath": target.display_path,
142            "oldString": args.old_string,
143            "newString": args.new_string,
144            "replaceAll": args.replace_all,
145        });
146        let mut serialization_key = target.identity.device.to_be_bytes().to_vec();
147        serialization_key.extend_from_slice(&target.identity.inode.to_be_bytes());
148        let executor_target = match predecessor {
149            Some(predecessor_digest) => EditTarget::Chained(fs_cap::PreparedChained {
150                original_digest: target.content_digest.clone(),
151                predecessor_digest,
152                target,
153            }),
154            None => EditTarget::Existing(target),
155        };
156        let prepared = PreparedTool::new(
157            operation,
158            normalized_arguments,
159            Some(PreparedSerializationKey::new(serialization_key)),
160            Box::new(EditExecutor {
161                target: executor_target,
162                new_bytes,
163            }),
164        )?
165        .with_policy_labels(policy_labels)?;
166        Ok((prepared, replaced))
167    }
168}
169
170struct PendingEdit {
171    args: EditArgs,
172    target: fs_cap::PreparedExisting,
173    text: String,
174}
175impl Default for EditTool {
176    fn default() -> Self {
177        Self::new(std::env::current_dir().expect("current directory"))
178    }
179}
180
181#[async_trait]
182impl ToolProvider for EditTool {
183    fn provider_id(&self) -> &'static str {
184        "builtin.edit"
185    }
186
187    fn tools_for_session(&self, _: &SessionToolContext) -> Result<Vec<ToolSpec>, ToolError> {
188        Ok(vec![ToolSpec {
189            output: Default::default(),
190            concurrency: cookie_agent_engine::ToolConcurrency::Parallel,
191            result_truncation: Default::default(),
192            name: "edit".into(),
193            permission_name: Self::get_permission_name("edit")?.into(),
194            description: "Apply a precomputed semantic replacement atomically.".into(),
195            parameters: schema::<EditArgs>(),
196        }])
197    }
198
199    fn get_permission_name(tool_name: &str) -> Result<&'static str, ToolError> {
200        match tool_name {
201            "edit" => Ok("write"),
202            _ => Err(ToolError::execution("edit provider received another tool")),
203        }
204    }
205
206    fn get_permission_resource(
207        &self,
208        name: &str,
209        arguments: &serde_json::Value,
210    ) -> Result<(&'static str, Option<String>), ToolError> {
211        let permission_name = Self::get_permission_name(name)?;
212        let args: EditArgs = parse_args("edit", arguments.clone())?;
213        if args.file_path.is_empty() {
214            return Err(ToolError::execution("filePath must not be empty"));
215        }
216        Ok((
217            permission_name,
218            Some(crate::permission_path_label(
219                &args.file_path,
220                &self.workspace,
221            )),
222        ))
223    }
224
225    fn get_display_argument(
226        &self,
227        name: &str,
228        arguments: &serde_json::Value,
229    ) -> Result<String, ToolError> {
230        let (_, Some(path)) = self.get_permission_resource(name, arguments)? else {
231            return Err(ToolError::execution("edit permission resource is missing"));
232        };
233        Ok(crate::abbreviated_display_path(&path, &self.workspace))
234    }
235
236    async fn prepare(
237        &self,
238        ctx: ToolPreparationContext,
239        call: ToolCall,
240    ) -> Result<PreparedTool, ToolError> {
241        let args: EditArgs = parse_args("edit", call.arguments)?;
242        fs_cap::ensure_atomic_write_supported()?;
243        if args.old_string.is_empty() {
244            return Err(ToolError::execution("oldString must not be empty"));
245        }
246        let target = fs_cap::prepare_existing(&ctx.cwd, std::path::Path::new(&args.file_path))?;
247        if target.directory {
248            return Err(ToolError::unsupported_security(
249                "edit target is a directory",
250            ));
251        }
252        let bytes = target.read_bytes()?;
253        let text =
254            String::from_utf8(bytes).map_err(|_| ToolError::execution("edit requires UTF-8"))?;
255        let (prepared, _) = self.prepare_edit(&ctx, args, target, &text, None)?;
256        Ok(prepared)
257    }
258
259    async fn prepare_parallel(
260        &self,
261        ctx: ToolPreparationContext,
262        calls: Vec<ToolCall>,
263    ) -> Vec<Result<PreparedTool, ToolError>> {
264        if calls.len() == 1 {
265            let call = calls.into_iter().next().expect("single call batch");
266            return vec![self.prepare(ctx, call).await];
267        }
268        // Prepare each call's target individually, then group by target
269        // identity: same-file calls chain in call order, with every edit
270        // matching against the content produced by the previous edit.
271        let mut pending = Vec::with_capacity(calls.len());
272        for call in calls {
273            pending.push(Some(self.prepare_target(&ctx, call)));
274        }
275        let mut results: Vec<Option<Result<PreparedTool, ToolError>>> =
276            (0..pending.len()).map(|_| None).collect();
277        let mut groups: Vec<((u64, u64), Vec<usize>)> = Vec::new();
278        for (index, entry) in pending.iter_mut().enumerate() {
279            match entry {
280                Some(Ok(pending_edit)) => {
281                    let key = (
282                        pending_edit.target.identity.device,
283                        pending_edit.target.identity.inode,
284                    );
285                    if let Some((_, indexes)) =
286                        groups.iter_mut().find(|(candidate, _)| *candidate == key)
287                    {
288                        indexes.push(index);
289                    } else {
290                        groups.push((key, vec![index]));
291                    }
292                }
293                // Early per-call failures (invalid arguments, missing target,
294                // directory, non-UTF-8) stay isolated in their own slot.
295                Some(Err(_)) => {
296                    let Err(error) = entry.take().expect("each edit call is visited once") else {
297                        unreachable!("error entry");
298                    };
299                    results[index] = Some(Err(error));
300                }
301                None => unreachable!("each edit call is visited once"),
302            }
303        }
304        for (_, indexes) in groups {
305            let base_digest = pending[indexes[0]]
306                .as_ref()
307                .and_then(|entry| entry.as_ref().ok())
308                .expect("grouped edit prepared its target")
309                .target
310                .content_digest
311                .clone();
312            // Current chained content, advanced by every successful edit. A
313            // failed edit is skipped: later edits continue from the last good
314            // state. The first successful edit prepares standalone (it
315            // validates the original target identity); later edits validate
316            // their predecessor's output digest.
317            let mut chain_text: Option<String> = None;
318            let mut chain_started = false;
319            for index in indexes {
320                let pending_edit = pending[index]
321                    .take()
322                    .expect("grouped edit pending")
323                    .expect("grouped edit prepared");
324                // A member that observed different content than the chain base
325                // (external writer between preparations) prepares standalone.
326                let chained = pending_edit.target.content_digest == base_digest;
327                let (input_text, predecessor) = if chained {
328                    let predecessor = if chain_started {
329                        chain_text.as_deref().map(|text| {
330                            cookie_agent_protocol::Sha256Digest::of_bytes(text.as_bytes())
331                        })
332                    } else {
333                        None
334                    };
335                    let text = chain_text
336                        .clone()
337                        .unwrap_or_else(|| pending_edit.text.clone());
338                    (text, predecessor)
339                } else {
340                    (pending_edit.text.clone(), None)
341                };
342                match self.prepare_edit(
343                    &ctx,
344                    pending_edit.args,
345                    pending_edit.target,
346                    &input_text,
347                    predecessor,
348                ) {
349                    Ok((prepared, new_text)) => {
350                        if chained {
351                            chain_text = Some(new_text);
352                            chain_started = true;
353                        }
354                        results[index] = Some(Ok(prepared));
355                    }
356                    Err(error) => {
357                        results[index] = Some(Err(error));
358                    }
359                }
360            }
361        }
362        results
363            .into_iter()
364            .map(|result| result.expect("every edit call is grouped"))
365            .collect()
366    }
367}
368
369#[async_trait]
370impl PreparedExecutor for EditExecutor {
371    async fn revalidate(&self) -> Result<(), ToolError> {
372        match &self.target {
373            EditTarget::Existing(target) => target.revalidate(),
374            EditTarget::Chained(target) => target.revalidate(),
375        }
376    }
377    async fn revalidate_for_execution(&self) -> Result<(), ToolError> {
378        match &self.target {
379            EditTarget::Existing(target) => target.revalidate(),
380            EditTarget::Chained(target) => target.revalidate_for_execution(),
381        }
382    }
383
384    async fn execute(
385        self: Box<Self>,
386        context: ToolExecutionContext,
387    ) -> Result<cookie_agent_engine::ToolCompletion, ToolError> {
388        let result: Result<ToolResult, ToolError> = async move {
389        if context.cancellation.is_cancelled() {
390            return Err(ToolError::execution(
391                "prepared edit cancelled before commit",
392            ));
393        }
394        let path = match &self.target {
395            EditTarget::Existing(target) => target.display_path.clone(),
396            EditTarget::Chained(target) => target.target.display_path.clone(),
397        };
398        let outcome = match &self.target {
399            EditTarget::Existing(target) => target.replace_atomically(&self.new_bytes)?,
400            EditTarget::Chained(target) => {
401                // Direct executions (tests) bypass the engine's serialization
402                // lock; the strict predecessor check must hold regardless.
403                target.revalidate_for_execution()?;
404                target.replace_atomically(&self.new_bytes)?
405            }
406        };
407        Ok(ToolResult {
408display: None,
409retained_output: None,
410            title: crate::safe_title(format!("Edited {}", path.display())),
411            output: "Edit applied atomically".into(),
412            metadata: serde_json::json!({"new_sha256":Sha256Digest::of_bytes(&self.new_bytes),"cleanup_warning":outcome.cleanup_warning}),
413            truncation: None,
414            attachments: Vec::new(),
415            additional_messages: Vec::new(),
416        })
417}.await;
418        result.map(cookie_agent_engine::ToolCompletion::single)
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use std::fs;
425
426    #[cfg(any(unix, windows))]
427    use cookie_agent_engine::ToolExecutionContext;
428    use cookie_agent_engine::{ToolCall, ToolError, ToolPreparationContext, ToolProvider};
429    #[cfg(any(unix, windows))]
430    use cookie_agent_protocol::OperationFingerprint;
431    use cookie_agent_protocol::{RunId, SessionId, ToolCallId};
432
433    use super::EditTool;
434
435    #[test]
436    fn permission_resource_is_the_file_path() {
437        let tool = EditTool::new("/tmp");
438        assert_eq!(
439            tool.get_permission_resource(
440                "edit",
441                &serde_json::json!({
442                    "filePath":"value.txt",
443                    "oldString":"a",
444                    "newString":"b"
445                })
446            )
447            .expect("permission resource"),
448            ("write", Some("value.txt".into()))
449        );
450        assert!(matches!(
451            tool.get_permission_resource(
452                "edit",
453                &serde_json::json!({"oldString":"a","newString":"b"})
454            ),
455            Err(ToolError::Failed(_))
456        ));
457    }
458
459    #[test]
460    fn display_argument_abbreviates_workspace_and_home_paths() {
461        let workspace = tempfile::tempdir().expect("workspace");
462        let tool = EditTool::new(workspace.path());
463        let args = serde_json::json!({
464            "filePath":workspace.path().join("value.txt"),
465            "oldString":"a",
466            "newString":"b"
467        });
468        assert_eq!(
469            tool.get_display_argument("edit", &args).expect("workspace"),
470            "value.txt"
471        );
472        let home = cookie_agent_protocol::paths::home_dir().expect("home directory");
473        assert_eq!(
474            tool.get_display_argument(
475                "edit",
476                &serde_json::json!({
477                    "filePath": home.join("value.txt"),
478                    "oldString":"a",
479                    "newString":"b"
480                })
481            )
482            .expect("home"),
483            "~/value.txt"
484        );
485    }
486
487    #[tokio::test]
488    async fn ambiguous_single_replacement_is_rejected_before_approval() {
489        let root = tempfile::tempdir().expect("root");
490        fs::write(root.path().join("value.txt"), "same same").expect("fixture");
491        let result = EditTool::new(root.path())
492            .prepare(
493                ToolPreparationContext {
494                    session: SessionId::new_v7(),
495                    run: RunId::new_v7(),
496                    cwd: root.path().to_owned(),
497                    workspace_root: root.path().to_owned(),
498                    turn_context: crate::test_turn_context(),
499                },
500                ToolCall {
501                    id: ToolCallId::new_v7(),
502                    name: "edit".into(),
503                    arguments: serde_json::json!({
504                        "filePath":"value.txt",
505                        "oldString":"same",
506                        "newString":"new"
507                    }),
508                },
509            )
510            .await;
511        assert!(matches!(result, Err(ToolError::Failed(_))));
512    }
513
514    #[cfg(any(unix, windows))]
515    fn context(root: &std::path::Path) -> ToolPreparationContext {
516        ToolPreparationContext {
517            session: SessionId::new_v7(),
518            run: RunId::new_v7(),
519            cwd: root.to_owned(),
520            workspace_root: root.to_owned(),
521            turn_context: crate::test_turn_context(),
522        }
523    }
524
525    #[cfg(unix)]
526    #[tokio::test]
527    async fn symlink_routes_keep_lexical_edit_contract_and_preserve_links() {
528        use std::os::unix::fs::symlink;
529
530        let root = tempfile::tempdir().expect("root");
531        let workspace = root.path().join("workspace");
532        let external = root.path().join("external");
533        fs::create_dir(&workspace).expect("workspace");
534        fs::create_dir(workspace.join("routes")).expect("routes");
535        fs::create_dir(&external).expect("external");
536        for name in [
537            "relative-leaf.txt",
538            "absolute-leaf.txt",
539            "relative-ancestor.txt",
540            "absolute-ancestor.txt",
541        ] {
542            fs::write(external.join(name), "old value").expect("fixture");
543        }
544        symlink(
545            "../../external/relative-leaf.txt",
546            workspace.join("routes/relative-leaf"),
547        )
548        .expect("relative leaf");
549        symlink(
550            external.join("absolute-leaf.txt"),
551            workspace.join("routes/absolute-leaf"),
552        )
553        .expect("absolute leaf");
554        symlink(
555            "absolute-ancestor",
556            workspace.join("routes/relative-ancestor"),
557        )
558        .expect("relative ancestor");
559        symlink(&external, workspace.join("routes/absolute-ancestor")).expect("absolute ancestor");
560
561        let tool = EditTool::new(&workspace);
562        for (requested, destination, link) in [
563            (
564                "routes/relative-leaf",
565                external.join("relative-leaf.txt"),
566                workspace.join("routes/relative-leaf"),
567            ),
568            (
569                "routes/absolute-leaf",
570                external.join("absolute-leaf.txt"),
571                workspace.join("routes/absolute-leaf"),
572            ),
573            (
574                "routes/relative-ancestor/relative-ancestor.txt",
575                external.join("relative-ancestor.txt"),
576                workspace.join("routes/relative-ancestor"),
577            ),
578            (
579                "routes/absolute-ancestor/absolute-ancestor.txt",
580                external.join("absolute-ancestor.txt"),
581                workspace.join("routes/absolute-ancestor"),
582            ),
583        ] {
584            let original_link = fs::read_link(&link).expect("link target");
585            let prepared = tool
586                .prepare(
587                    context(&workspace),
588                    ToolCall {
589                        id: ToolCallId::new_v7(),
590                        name: "edit".into(),
591                        arguments: serde_json::json!({
592                            "filePath":requested,
593                            "oldString":"old",
594                            "newString":"new"
595                        }),
596                    },
597                )
598                .await
599                .expect("prepare symlink edit");
600            assert_eq!(prepared.operation().resources().len(), 1);
601            assert_eq!(prepared.policy_labels(), [Some(requested.to_owned())]);
602            assert_eq!(
603                prepared.normalized_arguments()["filePath"],
604                workspace.join(requested).to_string_lossy().as_ref()
605            );
606            assert_eq!(
607                tool.get_permission_resource("edit", prepared.normalized_arguments())
608                    .expect("normalized permission resource"),
609                ("write", Some(requested.to_owned()))
610            );
611            crate::assert_workspace_rule_allows(
612                &prepared,
613                &workspace,
614                cookie_agent_protocol::PermissionAction::Write,
615                "routes/*",
616            );
617            prepared
618                .execute_for_test(
619                    ToolExecutionContext::for_test(
620                        root.path().join("artifacts"),
621                        crate::test_turn_context(),
622                    )
623                    .expect("execution context"),
624                )
625                .await
626                .expect("execute symlink edit");
627            assert_eq!(fs::read_to_string(destination).unwrap(), "new value");
628            assert_eq!(fs::read_link(link).unwrap(), original_link);
629        }
630    }
631
632    #[cfg(unix)]
633    #[tokio::test]
634    async fn dangling_edit_errors_and_ancestor_swap_is_operation_changed() {
635        use std::os::unix::fs::symlink;
636
637        let root = tempfile::tempdir().expect("root");
638        fs::create_dir(root.path().join("destination")).expect("destination");
639        fs::write(root.path().join("destination/value.txt"), "old").expect("fixture");
640        symlink("missing.txt", root.path().join("dangling")).expect("dangling route");
641        let dangling = EditTool::new(root.path())
642            .prepare(
643                context(root.path()),
644                ToolCall {
645                    id: ToolCallId::new_v7(),
646                    name: "edit".into(),
647                    arguments: serde_json::json!({
648                        "filePath":"dangling",
649                        "oldString":"old",
650                        "newString":"new"
651                    }),
652                },
653            )
654            .await;
655        assert!(matches!(dangling, Err(ToolError::Failed(_))));
656
657        symlink("destination", root.path().join("ancestor")).expect("ancestor route");
658        let prepared = EditTool::new(root.path())
659            .prepare(
660                context(root.path()),
661                ToolCall {
662                    id: ToolCallId::new_v7(),
663                    name: "edit".into(),
664                    arguments: serde_json::json!({
665                        "filePath":"ancestor/value.txt",
666                        "oldString":"old",
667                        "newString":"new"
668                    }),
669                },
670            )
671            .await
672            .expect("prepare edit");
673        fs::remove_file(root.path().join("ancestor")).expect("remove ancestor");
674        symlink("destination", root.path().join("ancestor")).expect("replace ancestor");
675        let error = prepared
676            .execute_for_test(
677                ToolExecutionContext::for_test(
678                    root.path().join("artifacts"),
679                    crate::test_turn_context(),
680                )
681                .expect("execution context"),
682            )
683            .await
684            .expect_err("ancestor swap must fail");
685        assert!(matches!(error, ToolError::OperationChanged(_)));
686    }
687
688    #[cfg(unix)]
689    #[tokio::test]
690    async fn edit_routes_to_the_same_target_have_distinct_fingerprints() {
691        use std::os::unix::fs::symlink;
692
693        let root = tempfile::tempdir().expect("root");
694        fs::write(root.path().join("value.txt"), "old").expect("fixture");
695        symlink("value.txt", root.path().join("left")).expect("left route");
696        symlink(root.path().join("value.txt"), root.path().join("right")).expect("right route");
697        let tool = EditTool::new(root.path());
698        let prepare = |path: &str| {
699            tool.prepare(
700                context(root.path()),
701                ToolCall {
702                    id: ToolCallId::new_v7(),
703                    name: "edit".into(),
704                    arguments: serde_json::json!({
705                        "filePath":path,
706                        "oldString":"old",
707                        "newString":"new"
708                    }),
709                },
710            )
711        };
712        let left = prepare("left").await.expect("left");
713        let right = prepare("right").await.expect("right");
714        assert_ne!(
715            left.operation().resources()[0].binding_digest,
716            right.operation().resources()[0].binding_digest
717        );
718        assert_ne!(
719            OperationFingerprint::from_prepared_operation(left.operation()),
720            OperationFingerprint::from_prepared_operation(right.operation())
721        );
722    }
723
724    #[cfg(any(unix, windows))]
725    fn edit_call(path: &str, old: &str, new: &str) -> ToolCall {
726        ToolCall {
727            id: ToolCallId::new_v7(),
728            name: "edit".into(),
729            arguments: serde_json::json!({
730                "filePath": path,
731                "oldString": old,
732                "newString": new
733            }),
734        }
735    }
736
737    #[cfg(any(unix, windows))]
738    fn execution_context(root: &std::path::Path) -> ToolExecutionContext {
739        ToolExecutionContext::for_test(root.join("artifacts"), crate::test_turn_context())
740            .expect("execution context")
741    }
742
743    #[cfg(any(unix, windows))]
744    #[tokio::test]
745    async fn parallel_same_file_edits_chain_in_call_order() {
746        let root = tempfile::tempdir().expect("root");
747        fs::write(root.path().join("value.txt"), "alpha beta gamma").expect("fixture");
748        let prepared = EditTool::new(root.path())
749            .prepare_parallel(
750                context(root.path()),
751                vec![
752                    edit_call("value.txt", "alpha", "ALPHA"),
753                    edit_call("value.txt", "beta", "BETA"),
754                ],
755            )
756            .await;
757        let mut prepared = prepared.into_iter();
758        let first = prepared.next().expect("first").expect("first edit");
759        let second = prepared.next().expect("second").expect("second edit");
760        assert!(prepared.next().is_none());
761        assert_ne!(
762            OperationFingerprint::from_prepared_operation(first.operation()),
763            OperationFingerprint::from_prepared_operation(second.operation())
764        );
765        first
766            .execute_for_test(execution_context(root.path()))
767            .await
768            .expect("first edit");
769        second
770            .execute_for_test(execution_context(root.path()))
771            .await
772            .expect("chained edit");
773        assert_eq!(
774            fs::read_to_string(root.path().join("value.txt")).unwrap(),
775            "ALPHA BETA gamma"
776        );
777    }
778
779    #[cfg(any(unix, windows))]
780    #[tokio::test]
781    async fn chained_edit_matches_predecessor_output() {
782        let root = tempfile::tempdir().expect("root");
783        fs::write(root.path().join("value.txt"), "a").expect("fixture");
784        let prepared = EditTool::new(root.path())
785            .prepare_parallel(
786                context(root.path()),
787                vec![
788                    edit_call("value.txt", "a", "b"),
789                    edit_call("value.txt", "b", "c"),
790                ],
791            )
792            .await;
793        let mut prepared = prepared.into_iter();
794        let first = prepared.next().expect("first").expect("first edit");
795        // The second edit's oldString only exists after the first edit ran.
796        let second = prepared.next().expect("second").expect("chained edit");
797        first
798            .execute_for_test(execution_context(root.path()))
799            .await
800            .expect("first edit");
801        second
802            .execute_for_test(execution_context(root.path()))
803            .await
804            .expect("chained edit");
805        assert_eq!(
806            fs::read_to_string(root.path().join("value.txt")).unwrap(),
807            "c"
808        );
809    }
810
811    #[cfg(any(unix, windows))]
812    #[tokio::test]
813    async fn failed_chain_member_skips_without_breaking_the_chain() {
814        let root = tempfile::tempdir().expect("root");
815        fs::write(root.path().join("value.txt"), "a b c").expect("fixture");
816        let prepared = EditTool::new(root.path())
817            .prepare_parallel(
818                context(root.path()),
819                vec![
820                    edit_call("value.txt", "a", "A"),
821                    edit_call("value.txt", "missing", "X"),
822                    edit_call("value.txt", "c", "C"),
823                ],
824            )
825            .await;
826        let mut prepared = prepared.into_iter();
827        let first = prepared.next().expect("first").expect("first edit");
828        let middle = prepared.next().expect("middle");
829        assert!(matches!(middle, Err(ToolError::Failed(_))));
830        // The third edit chains from the last good state (after the first).
831        let third = prepared.next().expect("third").expect("third edit");
832        first
833            .execute_for_test(execution_context(root.path()))
834            .await
835            .expect("first edit");
836        third
837            .execute_for_test(execution_context(root.path()))
838            .await
839            .expect("third edit");
840        assert_eq!(
841            fs::read_to_string(root.path().join("value.txt")).unwrap(),
842            "A b C"
843        );
844    }
845
846    #[cfg(any(unix, windows))]
847    #[tokio::test]
848    async fn early_preparation_errors_stay_isolated_in_a_batch() {
849        let root = tempfile::tempdir().expect("root");
850        fs::write(root.path().join("value.txt"), "alpha beta").expect("fixture");
851        let prepared = EditTool::new(root.path())
852            .prepare_parallel(
853                context(root.path()),
854                vec![
855                    edit_call("missing.txt", "alpha", "ALPHA"),
856                    edit_call("value.txt", "beta", "BETA"),
857                ],
858            )
859            .await;
860        let mut prepared = prepared.into_iter();
861        assert!(matches!(
862            prepared.next().expect("first"),
863            Err(ToolError::Failed(_))
864        ));
865        let second = prepared.next().expect("second").expect("second edit");
866        second
867            .execute_for_test(execution_context(root.path()))
868            .await
869            .expect("valid edit");
870        assert_eq!(
871            fs::read_to_string(root.path().join("value.txt")).unwrap(),
872            "alpha BETA"
873        );
874    }
875
876    #[cfg(any(unix, windows))]
877    #[tokio::test]
878    async fn chained_edit_requires_predecessor_and_detects_external_changes() {
879        let root = tempfile::tempdir().expect("root");
880        fs::write(root.path().join("value.txt"), "a b").expect("fixture");
881        let tool = EditTool::new(root.path());
882        let prepared = tool
883            .prepare_parallel(
884                context(root.path()),
885                vec![
886                    edit_call("value.txt", "a", "A"),
887                    edit_call("value.txt", "b", "B"),
888                ],
889            )
890            .await;
891        let mut prepared = prepared.into_iter();
892        let first = prepared.next().expect("first").expect("first edit");
893        let second = prepared.next().expect("second").expect("second edit");
894
895        // Executing the chained edit before its predecessor must fail.
896        let error = second
897            .revalidate_for_execution_for_test()
898            .await
899            .expect_err("predecessor has not run yet");
900        assert!(matches!(error, ToolError::OperationChanged(_)));
901
902        first
903            .execute_for_test(execution_context(root.path()))
904            .await
905            .expect("first edit");
906
907        // An external change after the predecessor must fail the chain.
908        fs::write(root.path().join("value.txt"), "tampered").expect("external write");
909        let error = second
910            .execute_for_test(execution_context(root.path()))
911            .await
912            .expect_err("external change must fail the chained edit");
913        assert!(matches!(error, ToolError::OperationChanged(_)));
914    }
915}