File Edit
Feature Definition
Section titled “Feature Definition”The file edit tool allows the model to make targeted changes to existing files. Unlike writing a complete file from scratch, editing applies a diff or patch — specifying what to remove and what to add while leaving the rest of the file untouched. This is more token-efficient for small changes and more predictable for multi-file operations where only specific regions change.
The key tension is between expressiveness and precision. A text-based patch format is easy for the model to emit but fragile under fuzzy matching. A structured format (line numbers plus old/new content) is precise but requires the model to have read the exact current file content first. Real implementations use a layered approach: a compact text-based format with a fuzzy matching fallback.
This page covers the tool API surface — parameter schema, execution phases, permission gating, and output format. The underlying patch format grammars and fuzzy matching strategies are documented in detail in Edit Formats and Streaming Edits.
See Also
Section titled “See Also”- File Read for the read-side contract that patch generation depends on.
- File Write for full-file overwrite semantics (separate from targeted patch edits).
- Approval Flow and LSP Diagnostics for post-patch safety and feedback layers.
Aider Implementation
Section titled “Aider Implementation”Aider has no file edit tool in the JSON tool-call sense. All file modifications flow through text-based format parsing inside the agent loop.
The model produces a complete response containing one or more edit blocks in the chosen format (SEARCH/REPLACE, unified diff, whole file, etc.). The coder class parses the response text after the stream completes and applies edits by invoking format-specific appliers like do_replace() in aider/coders/editblock_coder.py or apply_edits() in aider/coders/udiff_coder.py.
Because there is no tool call:
- There is no per-edit permission gate; the entire set of edits in a response either runs or is rejected
- There is no structured metadata (diff text, file paths) returned to the model in tool result format
- Fuzzy matching failures produce text messages inserted into the next user turn, not structured error objects
The architecture is simpler but offers less per-operation control.
Codex Implementation
Section titled “Codex Implementation”Codex exposes apply_patch defined in codex-rs/core/src/tools/spec.rs with the handler in codex-rs/core/src/tools/handlers/apply_patch.rs (commit 4ab44e2c5).
Parameter Schema
Section titled “Parameter Schema”struct ApplyPatchToolArgs { /// The full patch text in the Codex patch format. input: String,}A single input field containing the complete patch text. The model emits the entire patch — potentially touching multiple files — in one tool call.
Patch Format
Section titled “Patch Format”Codex uses its own text grammar (defined in codex-rs/core/src/tools/tool_apply_patch.lark):
start: ("***" "Begin" "Patch") (file_operation)+ ("***" "End" "Patch")
file_operation: add_file | update_file | delete_file
add_file: "***" "Add File:" path NEWLINE content_linesupdate_file: "***" "Update File:" path NEWLINE move_line? hunksdelete_file: "***" "Delete File:" path NEWLINE
move_line: "***" "Move to:" path NEWLINE
hunks: hunk+hunk: context_line* change_line+ context_line*context_line: " " REST_OF_LINE NEWLINEchange_line: ("+" | "-") REST_OF_LINE NEWLINEExample patch:
*** Begin Patch*** Update File: src/main.rs@@ fn main() {- println!("Hello");+ println!("Hello, world!"); }*** End Patch@@ introduces a hunk. Context lines (space-prefixed) anchor the location. Removed lines are prefixed with -, added lines with +.
Dual Execution Paths
Section titled “Dual Execution Paths”apply_patch.rs:80–183 contains two code paths for applying patches:
Path 1: Direct parsing (apply_patch.rs:107–115):
match parse_patch(&args.input) { Ok(patch) => { let result = apply_patch::apply_patch(&patch, &working_dir)?; return Ok(ToolOutput::text(result)); } Err(_) => { /* fall through to shell path */ }}If the Lark grammar parses cleanly, the patch is applied in-process without spawning a subprocess. This is the fast path.
Path 2: Shell delegation (apply_patch.rs:117–163):
let req = ApplyPatchRequest { action: apply.action, file_paths, // Vec<AbsolutePathBuf> for approval tracking changes, // protocol-serialized change list exec_approval_requirement, timeout_ms: None, codex_exe, // path to the codex binary for subprocess invocation};orchestrator.run(&mut runtime, &req, &tool_ctx, &turn, approval_policy).awaitIf direct parsing fails (unusual patch syntax, escaping issues), Codex delegates to ApplyPatchRuntime, which invokes the codex-rs/apply-patch crate as a subprocess. This enables streaming output and routes through the full approval overlay.
File Path Resolution
Section titled “File Path Resolution”apply_patch.rs:39–61:
fn file_paths_for_action(action: &ApplyPatchAction) -> Vec<AbsolutePathBuf> { // Extracts all paths touched by the patch: // - source path (for update/delete) // - destination path (for move/rename: move_path field) // Returns Vec<AbsolutePathBuf> used for approval tracking}Both the source and destination paths of renames are included so the approval system can show the full scope of the change.
Permission/Approval Gating
Section titled “Permission/Approval Gating”The file_paths extracted above feed into the ApprovalOverlay (documented in Approval Flow). The overlay shows the user a diff summary per file and offers [Y]es / [N]o / [A]lways / [P]revious / [C]ancel options. For patch application, Always grants auto-approval for future patches to the same paths.
Output Format
Section titled “Output Format”The tool result is a plain text string. Success:
Patch applied successfully.Updated: src/main.rsAdded: src/lib.rsDeleted: src/old.rsFailure returns an error description identifying which hunk failed and at what file/line.
OpenCode Implementation
Section titled “OpenCode Implementation”OpenCode exposes apply_patch in packages/opencode/src/tool/apply_patch.ts (commit 7ed44997).
Parameter Schema (Zod)
Section titled “Parameter Schema (Zod)”parameters: z.object({ patchText: z.string() .describe( "The full patch text that describes all changes to be made. " + "Must begin with '*** Begin Patch' and end with '*** End Patch'." ),})Same single-field design as Codex. The entire multi-file patch is one tool call.
Patch Format
Section titled “Patch Format”OpenCode uses the same *** Begin Patch / *** End Patch text grammar as Codex. The format is documented in packages/opencode/src/tool/apply_patch.txt and supports:
| Operation | Syntax |
|---|---|
| Add file | *** Add File: <path> |
| Update file | *** Update File: <path> |
| Rename during update | *** Move to: <new_path> |
| Delete file | *** Delete File: <path> |
| Hunk anchor | @@ |
| Context line | <space><content> |
| Addition | +<content> |
| Deletion | -<content> |
Five-Phase Execution
Section titled “Five-Phase Execution”OpenCode’s apply pipeline is more structured than Codex’s. apply_patch.ts executes in five sequential phases:
Phase 1: Parse (apply_patch.ts:29–90)
// Parse patchText into hunks:// [{type: "add"|"update"|"delete", path, chunks, movePath?}]const hunks = parsePatch(params.patchText);Each hunk identifies the file type (add/update/delete), the target path, the list of change chunks, and an optional rename destination.
Phase 2: Compute New Contents (apply_patch.ts:91–160)
for (const hunk of hunks) { const original = await fs.readFile(hunk.path, "utf8"); const newContent = applyChunks(original, hunk.chunks); // fuzzy match + apply const diff = createUnifiedDiff(original, newContent, hunk.path); results.push({ hunk, original, newContent, diff, additions, deletions });}applyChunks() uses a 4-pass fuzzy-seeking strategy (exact match → stripped match → Unicode-normalized match → partial match) identical to OpenCode’s general patch applier. This happens before any permission check, so the permission dialog can show an accurate diff.
Phase 3: Permission Request (apply_patch.ts:174–185)
await ctx.ask({ permission: "edit", patterns: relativePaths, always: ["*"], // auto-approve all edits once permission granted once metadata: { filepath: relativePaths.join(", "), diff: totalDiff, // full unified diff across all files files: results.map(r => ({ filePath: r.hunk.path, relativePath: r.hunk.relativePath, type: r.hunk.type, diff: r.diff, before: r.original, after: r.newContent, additions: r.additions, deletions: r.deletions, movePath: r.hunk.movePath, })), },});The metadata is rendered in the TUI’s permission dialog: per-file before/after diffs, addition/deletion counts, move destinations. The user sees the full impact before approving.
Phase 4: Apply Changes (apply_patch.ts:187–227)
for (const { hunk, newContent } of results) { if (hunk.type === "add" || hunk.type === "update") { await fs.mkdir(path.dirname(hunk.path), { recursive: true }); await fs.writeFile(hunk.path, newContent); if (hunk.movePath) { await fs.unlink(hunk.path); // delete old path on rename } } else if (hunk.type === "delete") { await fs.unlink(hunk.path); } // Publish FileWatcher.Updated event for each changed path FileWatcher.publish({ type: "updated", path: hunk.path });}Writes are atomic at the file level (single writeFile call). There is no rollback on partial failure — if the third file in a five-file patch fails, the first two are already written. The model receives an error message identifying the failure point and must decide how to recover.
Phase 5: LSP Diagnostics (apply_patch.ts:234–269)
const MAX_DIAGNOSTICS_PER_FILE = 20;
for (const path of modifiedPaths) { await LSP.touchFile(path, true); // signal LSP server to re-check file}
const diagnostics = await collectDiagnostics(modifiedPaths, { severity: DiagnosticSeverity.Error, // errors only, not warnings timeout: 3000, // 3s debounce wait maxPerFile: MAX_DIAGNOSTICS_PER_FILE,});
if (diagnostics.length > 0) { output += formatDiagnosticsXml(diagnostics);}After writing, OpenCode pings each LSP server to re-check the modified files. This is the same mechanism used in LSP Diagnostics. The tool result includes the error output, so the model can self-correct without a separate read-then-lint cycle.
Output Format
Section titled “Output Format”Success. Updated the following files:[M] src/main.rs[A] src/new_feature.rs[D] src/old_feature.rs[M→M] src/renamed.rs → src/new_name.rs
<diagnostics> <file path="src/main.rs"> <error line="42" col="5">expected `;`</error> </file></diagnostics>[A] = added, [M] = modified, [D] = deleted. Rename is shown as [M→M]. Diagnostics block is omitted if there are no errors.
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”No Rollback on Partial Failure
Section titled “No Rollback on Partial Failure”Both Codex and OpenCode apply changes file by file with no transactional guarantee. If a five-file patch fails on file three, files one and two are already written. The model must re-read the affected files to understand the current state and decide whether to continue or revert. OpenOxide should consider writing all files to a staging area first and committing atomically, or at minimum creating a ghost snapshot before applying (as documented in Ghost Commits).
Fuzzy Matching False Positives
Section titled “Fuzzy Matching False Positives”The 4-pass fuzzy seek in OpenCode can match the wrong location if the context lines appear multiple times in a file (e.g., a file with repeated } / } / } closings). Applying an edit to the wrong occurrence silently corrupts the file. The model does not know this happened unless it reads the file again. Uniqueness validation — requiring that the context lines are unique in the file — prevents this at the cost of more read calls before editing.
LSP Timing Creates False Negatives
Section titled “LSP Timing Creates False Negatives”OpenCode waits 3 seconds for LSP diagnostics after applying. Some language servers (TypeScript’s tsserver in particular) need longer to typecheck large projects. If the tool returns before typecheck completes, the model sees “no errors” and proceeds, even though errors would appear a few seconds later. The touchFile mechanism and 150ms debounce help, but there is no guarantee the LSP has caught up.
Rename Ordering Matters
Section titled “Rename Ordering Matters”When a patch renames A.rs to B.rs, the sequence must be: write new content to B.rs, then delete A.rs. Writing B.rs first is safe even if B.rs already exists (it will be overwritten). But if the tool deletes A.rs first and the write to B.rs fails, the file is gone. Always write before deleting on rename operations.
The Permission Compute-Before-Ask Pattern
Section titled “The Permission Compute-Before-Ask Pattern”OpenCode computes the full diff before asking for permission (Phase 2 runs before Phase 3). This is deliberate — the permission dialog shows the user an accurate before/after preview. The cost is that if the user denies the patch, the computation work (fuzzy matching, diffing) was wasted. For large patches, this computation can take hundreds of milliseconds. Accept this cost; showing users accurate diffs is worth it.
Context Lines Must Match Exactly
Section titled “Context Lines Must Match Exactly”The @@ hunk anchor followed by context lines must match the actual file content. If the model read a stale version of the file (e.g., a previous tool call modified it), the context lines will not match and the patch will fail. The model must always re-read files before constructing patches if there is any chance the file was modified since the last read.
Deletion Without Permission Recovery
Section titled “Deletion Without Permission Recovery”Codex’s shell or apply_patch tools can delete files. If the model deletes the wrong file, the only recovery is the ghost commit mechanism (documented in Ghost Commits). There is no built-in “undo” at the tool level — deletion is immediate and permanent unless a snapshot exists. The approval flow is the last line of defense.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”Architecture: Two-Phase Apply with Staging
Section titled “Architecture: Two-Phase Apply with Staging”#[derive(Deserialize, JsonSchema)]pub struct ApplyPatchParams { /// The full patch text. Must begin with '*** Begin Patch'. pub patch_text: String,}
pub async fn execute(params: ApplyPatchParams, ctx: &ToolContext) -> ToolResult { // Phase 1: Parse let patch = parse_patch(¶ms.patch_text)?;
// Phase 2: Compute new contents and diffs (before permission) let plan = compute_plan(&patch, ctx).await?;
// Phase 3: Permission ctx.ask(Permission::Edit { files: plan.file_summaries(), diff: plan.total_diff(), }).await?;
// Phase 4: Apply with staging let snapshot = Snapshot::create(ctx.git_repo(), &plan.paths()).await?; apply_with_rollback(&plan, snapshot).await?;
// Phase 5: LSP diagnostics let diagnostics = collect_diagnostics(&plan.paths(), ctx.lsp()).await;
Ok(ToolOutput::new(plan.summary(), diagnostics))}Staged Apply with Rollback
Section titled “Staged Apply with Rollback”Unlike OpenCode’s write-then-hope approach, OpenOxide should write to a staging directory first:
async fn apply_with_rollback(plan: &ApplyPlan, snapshot: Snapshot) -> Result<()> { let staged: Vec<(PathBuf, Vec<u8>)> = plan.compute_staged_writes()?;
// Atomic-ish: write all files, then commit for (path, content) in &staged { tokio::fs::write(path, content).await?; }
// If any write failed, restore from snapshot // (the snapshot is a git ghost commit per [Ghost Commits] architecture) Ok(())}In practice, filesystem writes are not transactional. The best approximation is: collect all (path, new_content) pairs first, then write in a loop, tracking which writes succeeded. On any failure, restore succeeded-so-far paths from the ghost snapshot.
Uniqueness Validation
Section titled “Uniqueness Validation”Before accepting a patch, verify that each hunk’s context lines match exactly one location in the target file:
fn validate_uniqueness(original: &str, context_lines: &[&str]) -> Result<usize> { let matches: Vec<usize> = find_all_occurrences(original, context_lines); match matches.len() { 0 => Err(PatchError::ContextNotFound), 1 => Ok(matches[0]), n => Err(PatchError::AmbiguousContext { count: n }), }}Return a structured error with the count of matches so the model can add more context lines to disambiguate.
Output Format
Section titled “Output Format”Patch applied.[M] src/main.rs (+12 -3)[A] src/lib.rs (+48)[D] src/old.rs
<diagnostics> <file path="src/main.rs"> <error line="15">mismatched types: expected i32, found &str</error> </file></diagnostics>Crates
Section titled “Crates”[dependencies]similar = "2" # diff computation (unified diff generation)git2 = "0.18" # ghost snapshot creation before applytokio = { features = ["fs"] }serde = { features = ["derive"] }schemars = "0.8"