This document analyzes how file editing works in three AI coding assistants, highlighting the unique approaches and tricks each uses.
| Feature | Codex | Gemini-CLI | OpenCode |
|---|---|---|---|
| Language | Rust | TypeScript | TypeScript |
| Edit Tool Name | apply_patch |
replace |
edit |
| Write Tool | None (use patch) | write_file |
write |
| Patch Format | Custom AI-friendly | Literal string replacement | Custom patch + literal replacement |
| Fuzzy Matching | 4-tier (exact → unicode normalized) | 3-tier (exact → flexible → regex) | 9 replacer strategies |
| Self-Healing | No (validation only) | LLM-based correction | No (falls back through strategies) |
| Conflict Detection | Validation before apply | SHA256 content hashing | File mtime + lock chains |
| Path Format | Relative only | Absolute | Absolute |
| Post-Edit Feedback | Success/error only | Diff stats | LSP diagnostics |
Location: codex-rs/apply-patch/
Codex uses a custom declarative patch format designed for AI reliability:
*** Begin Patch
*** Add File: path/to/new.txt
+line1
+line2
*** Update File: path/to/existing.txt
@@ context_marker
context_line
-old_line
+new_line
*** Delete File: path/to/remove.txt
*** End Patch
Why custom? Traditional unified diffs have ambiguous line numbers that LLMs often get wrong. This format is position-independent—it finds context lines wherever they appear.
When locating where to apply a patch, Codex tries increasingly lenient matching:
| Tier | Strategy | Handles |
|---|---|---|
| 1 | Exact byte match | Perfect input |
| 2 | trim_end() |
Trailing whitespace differences |
| 3 | trim() |
Leading/trailing whitespace |
| 4 | Unicode normalization | Smart quotes, em-dashes, non-breaking spaces |
The Unicode trick is clever—it converts fancy punctuation to ASCII:
fn normalise(s: &str) -> String {
s.trim().chars().map(|c| match c {
'\u{2010}'..='\u{2015}' | '\u{2212}' => '-', // dashes
'\u{2018}'..='\u{201F}' => '"', // smart quotes
'\u{00A0}' | '\u{2002}'..='\u{200A}' => ' ', // special spaces
other => other,
}).collect()
}This handles copy-pasted code from rich text editors that silently converts " to ".
When a patch has multiple hunks, Codex applies them in reverse line order:
for (start_idx, old_len, new_segment) in replacements.iter().rev() {
lines.remove(start_idx);
lines.insert(start_idx, new_line);
}Why? If you apply from top-to-bottom, each insertion shifts subsequent line numbers. Reverse order means earlier replacements don't invalidate later positions.
Each hunk can include a change_context line (e.g., def function_name():):
pub struct UpdateFileChunk {
pub change_context: Option<String>, // Narrows search to this location
pub old_lines: Vec<String>,
pub new_lines: Vec<String>,
}Instead of searching the entire file, Codex first finds the context marker, then searches nearby. This prevents matching identical lines in the wrong function.
The parser can extract patches from bash heredocs:
cd /project && apply_patch <<'EOF'
*** Begin Patch
...
*** End Patch
EOFUses Tree-sitter for robust bash parsing—handles GPT's tendency to wrap patches in shell commands.
Strengths:
- Very robust against LLM quirks (unicode, whitespace, shell wrapping)
- Declarative format prevents line-number errors
- Fast Rust implementation
Weaknesses:
- No self-correction—fails if context can't be found
- Non-standard format requires LLM training
Location: packages/core/src/tools/
Gemini uses literal string replacement as the primary mechanism:
interface EditToolParams {
file_path: string;
old_string: string; // Exact text to find
new_string: string; // Replacement text
expected_replacements?: number; // Validate occurrence count
}No patch format—the LLM specifies exactly what to find and replace.
If exact match fails, Gemini tries progressively fuzzier matching:
| Strategy | How It Works |
|---|---|
| Exact | content.indexOf(oldString) |
| Flexible | Line-by-line matching ignoring indent, applies original indentation to replacement |
| Regex | Tokenizes both strings, builds regex with \s* between tokens |
The flexible replacer is particularly clever:
// Original file has: " const x = 1;"
// LLM sends old_string: "const x = 1;" (no indent)
// Flexible matcher finds it, preserves " " prefix in replacementThe killer feature. When edits fail, Gemini asks the LLM to fix them:
// llm-edit-fixer.ts
const prompt = `Your task is to analyze a failed edit attempt and provide
a corrected search string that will match the text in the file precisely.
The correction should be as minimal as possible...`;
const result = await generateJson({
schema: { search: string, replace: string, noChangesRequired: boolean },
prompt: `File content:\n${fileContent}\n\nFailed search: "${oldString}"`
});This handles cases where the LLM hallucinated slightly wrong code—the fixer finds the actual matching text.
Caching: Uses LRU cache (50 entries) to avoid re-calling the LLM for the same failed edit.
JavaScript's string.replace() treats $1, $& etc. as special:
"foo".replace("foo", "$1") // Throws or produces garbageGemini escapes these:
function safeLiteralReplace(str: string, oldString: string, newString: string): string {
if (!newString.includes('$')) {
return str.replaceAll(oldString, newString);
}
const escapedNewString = newString.replaceAll('$', '$$$$');
return str.replaceAll(oldString, escapedNewString);
}Essential for editing JavaScript/TypeScript code with template literals.
Before writing, Gemini checks if the file changed since it was read:
const originalHash = sha256(originalContent);
// ... time passes ...
const currentHash = sha256(fs.readFileSync(filePath));
if (originalHash !== currentHash) {
throw new Error("File modified externally");
}Prevents clobbering changes from other editors or parallel LLM requests.
Strengths:
- Self-healing via LLM correction handles hallucinated code
- Simple find/replace is easy for LLMs to generate
- Good caching reduces redundant LLM calls
Weaknesses:
- No native multi-hunk support (each edit is independent)
- Self-correction adds latency (40s timeout)
- Requires LLM call even for simple whitespace mismatches
Location: packages/opencode/src/tool/ and packages/opencode/src/patch/
OpenCode supports both literal replacement AND a custom patch format similar to Codex.
The most comprehensive fuzzy matching of the three:
const replacers = [
SimpleReplacer, // Exact match
LineTrimmedReplacer, // Ignore per-line leading whitespace
BlockAnchorReplacer, // First/last line anchor + Levenshtein on middle
WhitespaceNormalizedReplacer, // All whitespace → single space
IndentationFlexibleReplacer, // Remove all common indent
EscapeNormalizedReplacer, // Handle \\n, \\t escape sequences
TrimmedBoundaryReplacer, // Trim outer boundaries
ContextAwareReplacer, // 50% similarity threshold on context
MultiOccurrenceReplacer, // Replace ALL matches when requested
];Each yields candidates; first match wins. This covers edge cases the other engines miss.
The BlockAnchorReplacer uses edit distance:
function levenshtein(a: string, b: string): number { /* standard impl */ }
const similarity = 1 - (levenshtein(searchMiddle, candidateMiddle) /
Math.max(searchMiddle.length, candidateMiddle.length));
if (candidates.length === 1) {
threshold = 0.0; // Accept any anchor match
} else {
threshold = 0.3; // Require 30% similarity for disambiguation
}Clever: For single candidates, it's very permissive (trust the anchors). For multiple candidates, it requires similarity to disambiguate.
Serializes writes to the same file:
const locks = new Map<string, Promise<void>>();
async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
const currentLock = locks.get(filepath) ?? Promise.resolve();
const { promise, resolve } = Promise.withResolvers<void>();
locks.set(filepath, currentLock.then(() => promise));
await currentLock; // Wait for previous write
try {
return await fn();
} finally {
resolve(); // Allow next writer
}
}No external lock files—uses JavaScript's event loop to serialize. Elegant for a single-process tool.
Combines with locking for full protection:
const lastReadTime = fileTimeMap.get(filepath);
const currentMtime = (await fs.stat(filepath)).mtime;
if (currentMtime > lastReadTime) {
throw new Error("File modified since last read. Please re-read file.");
}Forces the LLM to re-read after external changes, ensuring it works with current content.
After every edit, OpenCode queries the Language Server:
const diagnostics = await lsp.getDiagnostics(filePath);
return {
...result,
errors: diagnostics.slice(0, 20) // Return up to 20 errors
};Immediate syntax/type error feedback helps the LLM self-correct in the next turn.
Strengths:
- Most comprehensive fuzzy matching (9 strategies)
- Proper file locking for concurrent safety
- LSP integration catches errors immediately
Weaknesses:
- No LLM self-correction (relies on strategy fallback)
- Complex codebase with two parallel edit systems
- Levenshtein on every potential match could be slow on large files
| Engine | Approach | Trade-off |
|---|---|---|
| Codex | Custom declarative format | Robust but requires format training |
| Gemini | Pure find/replace | Simple but no multi-hunk atomicity |
| OpenCode | Both supported | Flexible but complex |
| Engine | Strategy |
|---|---|
| Codex | Fail with clear error message |
| Gemini | LLM self-correction (expensive but works) |
| OpenCode | Fall through 9 strategies (fast but limited) |
| Engine | Mechanism |
|---|---|
| Codex | None (single-threaded assumption) |
| Gemini | SHA256 content hashing |
| OpenCode | Promise chains + mtime checks |
This section details the actual tools exposed to LLMs and their parameters.
Tool Name: apply_patch
Type: Freeform tool (not JSON) - the patch is passed as raw text
Codex has no write_file or edit_file tool. All file modifications go through apply_patch.
How it's called:
{
"command": ["apply_patch", "*** Begin Patch\n*** Update File: path/to/file.py\n@@ def example():\n- pass\n+ return 123\n*** End Patch"]
}Grammar (Lark format):
Patch := Begin { FileOp } End
Begin := "*** Begin Patch" NEWLINE
End := "*** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "*** Delete File: " path NEWLINE
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "*** Move to: " newPath NEWLINE
Hunk := "@@" [ context_header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE
Key System Prompt Instructions:
- "Use
apply_patchtool to edit files (NEVERapplypatchorapply-patch)" - "Requires 3 lines of context before/after changes"
- "Files must be relative paths (never absolute)"
Shell Tool (for completeness):
{
"name": "shell",
"parameters": {
"command": { "type": "array", "items": { "type": "string" } },
"workdir": { "type": "string", "optional": true },
"timeout_ms": { "type": "number", "optional": true }
}
}Gemini exposes three separate tools for file operations:
{
"name": "read_file",
"description": "Reads and returns the content of a specified file...",
"parameters": {
"file_path": { "type": "string", "required": true },
"offset": { "type": "number", "description": "0-based line to start from" },
"limit": { "type": "number", "description": "Max lines to read" }
}
}{
"name": "replace",
"description": "Replaces text within a file. By default, replaces a single occurrence...",
"parameters": {
"file_path": {
"type": "string",
"required": true,
"description": "The path to the file to modify."
},
"old_string": {
"type": "string",
"required": true,
"description": "The exact literal text to replace. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely."
},
"new_string": {
"type": "string",
"required": true,
"description": "The exact literal text to replace old_string with."
},
"expected_replacements": {
"type": "number",
"minimum": 1,
"description": "Number of replacements expected. Defaults to 1."
}
}
}Key instruction in description: "NEVER escape old_string or new_string, that would break the exact literal text requirement."
{
"name": "write_file",
"description": "Writes content to a specified file in the local filesystem.",
"parameters": {
"file_path": { "type": "string", "required": true },
"content": { "type": "string", "required": true }
}
}Hidden parameters (not in schema, used internally):
modified_by_user: boolean - set if user edited the proposed changeai_proposed_content: string - original AI proposal before user edits
Smart Edit variant (when enabled) adds:
instruction: string (required) - semantic description of the change intent
OpenCode uses Zod schemas converted to JSON Schema via z.toJSONSchema().
z.object({
filePath: z.string().describe("The absolute path to the file to read"),
offset: z.number().optional().describe("Line number to start from"),
limit: z.number().optional().describe("Number of lines to read"),
})z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
})Tool description (from edit.txt):
- "Must use Read tool at least once before editing"
- "Preserves exact indentation from line number prefix"
- "Edit will FAIL if old_string is not unique—provide more context or use replace_all"
z.object({
filePath: z.string().describe("The absolute path to the file to write (must be absolute)"),
content: z.string().describe("The content to write to the file"),
})z.object({
patchText: z.string().describe("The full patch text that describes all changes"),
})Uses same format as Codex (*** Begin Patch etc.)
| Aspect | Codex | Gemini | OpenCode |
|---|---|---|---|
| Edit tool name | apply_patch |
replace |
edit |
| Write tool | None (use patch) | write_file |
write |
| Path parameter | Embedded in patch | file_path |
filePath |
| Path format | Relative only | Absolute | Absolute |
| Multi-occurrence | Via multiple hunks | expected_replacements |
replaceAll: true |
| Context requirement | 3 lines in patch | "at least 3 lines" in description | Implicit via uniqueness |
| Schema format | Lark grammar | JSON Schema | Zod → JSON Schema |
Codex: Tools defined in Rust (spec.rs), converted to OpenAI function-calling format
pub fn to_function_tool(&self) -> ChatCompletionTool {
ChatCompletionTool {
r#type: ChatCompletionToolType::Function,
function: FunctionObject {
name: self.name.clone(),
description: Some(self.description.clone()),
parameters: Some(self.parameters.clone()),
strict: Some(true),
},
}
}Gemini: Tools registered via ToolRegistry, converted to FunctionDeclaration[]
class ToolRegistry {
getFunctionDeclarations(): FunctionDeclaration[] {
return this.tools.map(tool => ({
name: tool.name,
description: tool.description,
parameters: tool.parametersJsonSchema
}));
}
}OpenCode: Tools wrapped with Vercel AI SDK's tool() helper
const tools = {};
for (const [id, item] of Object.entries(registry)) {
tools[id] = tool({
description: item.description,
parameters: jsonSchema(z.toJSONSchema(item.parameters)),
execute: async (params) => item.execute(params),
});
}Codex (from prompt.md):
Use the `apply_patch` tool to edit files. This is the ONLY way to modify files.
- Always use relative paths
- Include 3 lines of context before and after changes
- Use @@ headers with function/class names for disambiguation
Gemini (from prompts.ts):
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file',
'run_shell_command'...) to act on the plan...
- Use `read_file` to examine current content before editing
- Use `replace` for targeted text replacements with context
- Use `write_file` for creating new files or complete rewrites
OpenCode (from anthropic.txt, beast.txt):
- You must use your Read tool at least once before editing
- The edit will FAIL if old_string is not unique in the file
- ALWAYS prefer editing existing files. NEVER write new files unless required.
What gets returned to the LLM after an edit:
Codex:
Applied patch successfully.
Files modified: path/to/file.py
On failure:
Error applying patch: Could not locate context lines in file.
Expected to find:
def example():
pass
Gemini:
{
"llmContent": "Successfully replaced 1 occurrence in path/to/file.ts",
"returnDisplay": "<diff view for UI>",
"diffStats": { "added_lines": 3, "removed_lines": 1 }
}On failure (before LLM correction):
{
"error": {
"type": "EDIT_NO_OCCURRENCE_FOUND",
"display": "Could not find the specified text in the file.",
"raw": "old_string not found in file content"
}
}OpenCode:
{
result: "Successfully edited path/to/file.ts",
diff: "--- a/file.ts\n+++ b/file.ts\n@@ -1,3 +1,4 @@...",
diagnostics: [
{ line: 15, message: "Property 'foo' does not exist on type 'Bar'" }
]
}The LSP diagnostics in OpenCode's response is a key differentiator—immediate type errors help the LLM self-correct.
CODEX:
LLM generates patch text
→ Parse grammar (Lark)
→ Validate all hunks exist in files
→ Apply in reverse order
→ Return success/failure
GEMINI:
LLM calls replace(file_path, old_string, new_string)
→ Try exact match
→ Try flexible match (whitespace-tolerant)
→ Try regex match (tokenized)
→ If all fail AND smart-edit enabled:
→ Call LLM to fix old_string
→ Retry with corrected string
→ Return result with diff stats
OPENCODE:
LLM calls edit(filePath, oldString, newString)
→ Check file was previously read (mtime tracking)
→ Acquire promise-chain lock
→ Try 9 replacer strategies in order
→ Write file
→ Query LSP for diagnostics
→ Return result with errors
Based on this audit:
- Adopt Gemini's safe dollar-sign replacement—essential for JS/TS editing
- Consider OpenCode's mtime checking—prevents overwriting external changes
- Codex's unicode normalization is valuable—handles smart quotes from copy-paste
- Skip LLM self-correction initially—adds complexity and latency; try fuzzy matching first
- Use reverse-order application for any multi-hunk patch support
The ideal hybrid would use:
- Literal find/replace as primary (like Gemini)
- 4-5 fuzzy matching tiers (subset of OpenCode's 9)
- Codex-style unicode normalization as a fallback tier
- File mtime checking before writes