Bug #14: edit_file Rejected Valid Edits (Trailing Whitespace)
Status: RESOLVED in v3.14.4 Category: Correctness / False Rejection Severity: Medium (avoidable token waste, degraded Claude UX on Windows projects) Resolution Date: 2026-02-27
Problem
Section titled “Problem”edit_file failed with context validation failed: old_text not found in current file on the first attempt, even though the file had not changed since it was last read. The edit succeeded on the second attempt, after Claude re-read the file and copied the content byte-for-byte.
What Happened
Section titled “What Happened”A Claude agent was adding a shipping note entry to EstadosMapper.md:
- Claude read the file earlier in the conversation
- Claude called
mcp_editwith anold_textfragment derived from its context - The tool returned:
Error: context validation failed: old_text not found in current file - file has likely changed - file may have been modified. Please re-read the file with smart_search + read_file_range - Claude re-read the file with
mcp_read - Claude called
mcp_editagain with the same region — this time it succeeded
The file had not changed between steps 2 and 4. The extra round-trip consumed ~400–800 tokens and added latency to every affected edit.
Root Cause
Section titled “Root Cause”EditFile calls validateEditContext as a gatekeeper before performIntelligentEdit. The two functions use different matching levels:
validateEditContext performIntelligentEdit───────────────────────────────────── ─────────────────────────────────────Level 1: strings.Contains after CRLF OPT 1: strings.Contains after CRLF normalization only OPT 2: strings.TrimSpace(oldText) ↓ FAIL → return error immediately OPT 3: line-by-line TrimSpace OPT 4: line-by-line Contains OPT 5: multiline Contains OPT 6: flexible regex (\s*\n\s*)validateEditContext had a single check at Level 1. If it failed, the function returned an error and performIntelligentEdit was never called — even though OPTIMIZATION 6’s flexible regex (\s*\n\s* for newlines, \s+ for spaces) would have matched and performed the replacement correctly.
Why Trailing Whitespace Caused the Mismatch
Section titled “Why Trailing Whitespace Caused the Mismatch”Windows files saved by Visual Studio, Rider, or Notepad++ commonly have trailing spaces on blank or comment lines. When Claude regenerates old_text from its context window, it often omits trailing whitespace — the LLM’s tokenizer and generation pipeline normalize such characters away.
Example file on disk (. = trailing space):
/// <summary>··/// Maps order states to shipment codes.··/// </summary>··public class EstadosMapperClaude’s old_text (no trailing spaces):
/// <summary>/// Maps order states to shipment codes./// </summary>public class EstadosMapperAfter CRLF normalization, strings.Contains returns false because ·· (trailing spaces) are not present in old_text. validateEditContext rejected immediately.
Impact
Section titled “Impact”- Every edit to a Windows file with trailing whitespace failed on the first attempt
- Claude’s error message said “file has likely changed”, misleading the agent into thinking a concurrent modification occurred
- The re-read + retry pattern wasted 400–800 tokens per affected edit
- On long editing sessions this degraded measurably (multiple failed edits × extra tokens each)
Solution
Section titled “Solution”Fix 1: Two-level validation in validateEditContext
Section titled “Fix 1: Two-level validation in validateEditContext”Added a trimTrailingSpacesPerLine helper and a Level 2 check:
// BEFORE (broken)func (e *UltraFastEngine) validateEditContext(currentContent, oldText string) (bool, string) { normalizedContent := normalizeLineEndings(currentContent) normalizedOldText := normalizeLineEndings(oldText)
if !strings.Contains(normalizedContent, normalizedOldText) { return false, "old_text not found in current file - file has likely changed" } // ... context check ...}// AFTER (fixed)func (e *UltraFastEngine) validateEditContext(currentContent, oldText string) (bool, string) { normalizedContent := normalizeLineEndings(currentContent) normalizedOldText := normalizeLineEndings(oldText)
// Level 1: exact normalized match (fastest, most common case) exactMatch := strings.Contains(normalizedContent, normalizedOldText)
if !exactMatch { // Level 2: trailing-whitespace-tolerant match. // Editors and LLMs often disagree on trailing spaces/tabs per line. trimmedContent := trimTrailingSpacesPerLine(normalizedContent) trimmedOld := trimTrailingSpacesPerLine(normalizedOldText) if !strings.Contains(trimmedContent, trimmedOld) { lineCount := strings.Count(normalizedOldText, "\n") + 1 return false, fmt.Sprintf( "old_text not found in current file (%d line(s)) - "+ "file has likely changed or old_text has encoding differences "+ "(BOM, non-breaking spaces, Unicode normalization). "+ "Re-read with read_file_range to get exact current content", lineCount, ) } // Level 2 matched — proceed. performIntelligentEdit handles replacement. } // ... context check (unchanged) ...}Fix 2: trimTrailingSpacesPerLine helper
Section titled “Fix 2: trimTrailingSpacesPerLine helper”func trimTrailingSpacesPerLine(s string) string { lines := strings.Split(s, "\n") for i, line := range lines { lines[i] = strings.TrimRight(line, " \t") } return strings.Join(lines, "\n")}Why This Is Safe
Section titled “Why This Is Safe”- Security unchanged: the file must still contain the specified text (modulo trailing whitespace). The validation does not weaken the stale-edit protection — it only stops rejecting edits where the content is genuinely present.
- Replacement is correct: when Level 2 passes,
performIntelligentEditperforms the replacement. Its OPTIMIZATION 6 (flexible regex:\s*\n\s*for newlines) correctly finds and replaces the matching region in the original content, preserving the file’s existing whitespace style. - No false positives: the trimmed match requires the full
old_text(minus trailing whitespace per line) to be present as a contiguous block. It does not relax the check for unrelated text.
Files Changed
Section titled “Files Changed”| File | Change |
|---|---|
core/edit_operations.go | validateEditContext: added Level 2 whitespace-tolerant check |
core/edit_operations.go | Added trimTrailingSpacesPerLine helper function |
core/edit_operations.go | Improved error message with line count and root cause list |
Verification
Section titled “Verification”go build ./... → OK (no errors)go test ./tests/... → PASSgo test ./core/... → PASSLessons Learned
Section titled “Lessons Learned”-
Gatekeepers must not be stricter than the workers they guard.
validateEditContextwas the only entry point toperformIntelligentEdit. It rejected cases thatperformIntelligentEditwould have handled correctly — the strictest check was in the wrong place. -
LLMs do not reproduce trailing whitespace faithfully. The tokenizer and sampling process treat trailing spaces as noise. Any validation that compares LLM-generated text to file content byte-for-byte must account for this.
-
“File has likely changed” is the wrong error for a whitespace mismatch. The misleading message caused Claude to assume a concurrency issue and issue a redundant re-read, amplifying the token waste. Error messages should describe what was actually detected, not infer intent.
-
Test with real Windows project files. Files with trailing whitespace are extremely common on Windows (Visual Studio, Rider, most .NET tooling). A test suite based on hand-crafted strings will never expose this class of bug.
Related
Section titled “Related”- Bug #12: batch_operations Edit Placeholder — similar class of silent replacement failure
- Bug #13: smart_search Walk Performance — performance issues on Windows projects