Skip to content

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

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.

A Claude agent was adding a shipping note entry to EstadosMapper.md:

  1. Claude read the file earlier in the conversation
  2. Claude called mcp_edit with an old_text fragment derived from its context
  3. 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
  4. Claude re-read the file with mcp_read
  5. Claude called mcp_edit again 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.

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 EstadosMapper

Claude’s old_text (no trailing spaces):

/// <summary>
/// Maps order states to shipment codes.
/// </summary>
public class EstadosMapper

After CRLF normalization, strings.Contains returns false because ·· (trailing spaces) are not present in old_text. validateEditContext rejected immediately.

  • 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)

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) ...
}
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")
}
  • 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, performIntelligentEdit performs 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.

FileChange
core/edit_operations.govalidateEditContext: added Level 2 whitespace-tolerant check
core/edit_operations.goAdded trimTrailingSpacesPerLine helper function
core/edit_operations.goImproved error message with line count and root cause list

go build ./... → OK (no errors)
go test ./tests/... → PASS
go test ./core/... → PASS

  1. Gatekeepers must not be stricter than the workers they guard. validateEditContext was the only entry point to performIntelligentEdit. It rejected cases that performIntelligentEdit would have handled correctly — the strictest check was in the wrong place.

  2. 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.

  3. “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.

  4. 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.