[Bug] Edit tool fails on mixed literal/escape Unicode in multi-line old_string

Status Open
Reported on v2.1.159
Maintainer reply None cached
Activity 11 comments · opened Jun 1, 2026

Bug Description
The bug from #52813 is still reproducible on the current version of Claude Code. The Edit tool fails when a multi-line old_string spans a region where the same Unicode character appears as both a literal codepoint and a \uXXXX source escape. The whole-string swap fallback tries "all literals" and "all escapes" but can't handle the mixed case. The workaround remains: use sed/node/bun via Bash for these edits.

Environment Info

  • Platform: linux
  • Terminal: vte-based
  • Version: 2.1.159
  • Feedback ID: d91c74c4-b5a6-4613-aac3-e059e0305fc5

Errors

 ▐▛███▜▌   Claude Code v2.1.159
▝▜█████▛▘  Opus 4.6 · Claude Max
  ▘▘ ▝▝    ~/.claude


❯ Can you still reproduce the following issue?                                  
  https://github.com/anthropics/claude-code/issues/52813                        

● Let me fetch that issue to understand what it reports.

● Bash(gh issue view 52813 --repo anthropics/claude-code --json
      title,body,state,labels,comments 2>&1 | head -200)
  ⎿  {"body":"## Summary\n\nThe `Edit` tool appears to transparently conver
     t between `\\uXXXX` escape sequences and their literal Unicode codepoi
     nts when matching `old_string` against file contents. When the target
     … +229 lines (ctrl+o to expand)

● Got it. The issue is about the Edit tool silently normalizing \uXXXX escape
  sequences before matching, making it impossible to edit files that contain
  literal backslash-u escape sequences. Let me reproduce it.

● Bash(mkdir -p /tmp/claude-edit-repro && cat > /tmp/claude-edit-repro/repro.ts
      << 'ENDOFFILE'
      const key = s.replace(/ /g, ' ');…)
  ⎿  0000000   c   o   n   s   t       k   e   y       =       s   .   r
     e
     0000020   p   l   a   c   e   (   / 302 240   /   g   ,       '
     … +3 lines (ctrl+o to expand)

● Interesting — the file already doesn't contain the literal \u00A0 ASCII
  characters. The tool input may have been normalized before reaching bash. Let
  me try creating the file with explicit hex bytes.

● Bash(printf 'const key = s.replace(/\\u00A0/g, '"'"' '"'"');\n' >
      /tmp/claude-edit-repro/repro.ts && od -c /tmp/claude-edit-repro/repro.ts)
  ⎿  0000000   c   o   n   s   t       k   e   y       =       s   .   r
     e
     0000020   p   l   a   c   e   (   /   \   u   0   0   A   0   /   g
     … +3 lines (ctrl+o to expand)

● Good — the file now contains the literal six ASCII characters \, u, 0, 0, A,
  0. Let me verify with Read and then attempt the Edit.

  Read 1 file (ctrl+o to expand)

● Now let me attempt to use Edit to replace this line. I'll try to match the
  line containing \u00A0.

● Update(/tmp/claude-edit-repro/repro.ts)
  ⎿  Added 1 line, removed 1 line
      1 -const key = s.replace(/\u00A0/g, ' ');                          
      1 +const key = s.replace(/\u00A0/g, ' ').trim();                   

● The edit appeared to succeed. Let me check what the file actually contains
  now.

● Bash(od -c /tmp/claude-edit-repro/repro.ts)
  ⎿  0000000   c   o   n   s   t       k   e   y       =       s   .   r
     e
     0000020   p   l   a   c   e   (   /   \   u   0   0   A   0   /   g
     … +3 lines (ctrl+o to expand)

● The simple case worked — the Edit tool's fallback normalization handled it.
  But the issue describes a harder case: when the same character appears as both
  a literal codepoint AND a \uXXXX escape in the same file region. Let me try
  that.

● Bash(printf '// Sample dessert price ranges.\nconst ranges = [\n  "Cookie
      price: $2 \xe2\x80\x94 $5",\n  "Cake price:   $20 \\u2014
      $40",\n]\n\nexport default ranges…)
  ⎿  0000000   /   /       S   a   m   p   l   e       d   e   s   s   e
     r
     0000020   t       p   r   i   c   e       r   a   n   g   e   s   .  \
     … +15 lines (ctrl+o to expand)

● The file has the mixed form: line 3 has the literal em-dash codepoint (3 UTF-8
  bytes 342 200 224), and line 4 has the 6 ASCII characters \u2014. Now let me
  read it and attempt the Edit.

  Read 1 file (ctrl+o to expand)

● Now the critical test — trying to edit both price lines at once, which spans
  the mixed representation:

● Update(/tmp/claude-edit-repro/recipes.ts)
  ⎿  Error editing file

● Yes, the bug is still reproducible. The exact error from the issue appears,
  word for word.

  ★ Insight ─────────────────────────────────────

  Why this happens: The fundamental issue is a representation gap between JSON
  and file bytes.

  1. JSON conflation: In JSON, \u2014 and the literal — character are the same
  value. By the time the Edit tool receives old_string, it has one em-dash
  codepoint — there's no way for the caller to distinguish "I mean the 6 ASCII
  chars" from "I mean the codepoint."
  2. Whole-string swap is all-or-nothing: The Edit tool's fallback tries two
  forms — all codepoints expanded, or all escapes preserved — but when the file
  has both in the same region, neither whole-string variant matches.
  3. The caller can't fix it: Since JSON normalizes the escape before the tool
  ever sees the parameter, no old_string value can express "line 3 has the
  codepoint, line 4 has the ASCII escape."

  ─────────────────────────────────────────────────

  Let me verify the file is unchanged and clean up.

● Bash(od -c /tmp/claude-edit-repro/recipes.ts | head -10)
  ⎿  0000000   /   /       S   a   m   p   l   e       d   e   s   s   e
     r
     0000020   t       p   r   i   c   e       r   a   n   g   e   s   .  \
     … +15 lines (ctrl+o to expand)

● File unchanged, confirmed.

  Summary: The bug from #52813 is still reproducible on the current version. The
  Edit tool fails when a multi-line old_string spans a region where the same
  Unicode character appears as both a literal codepoint and a \uXXXX source
  escape. The whole-string swap fallback tries "all literals" and "all escapes"
  but can't handle the mixed case. The workaround remains: use sed/node/bun via
  Bash for these edits.

✻ Sautéed for 1m 28s
❯ /feedback The bug from #52813 is still reproducible on the current version. The Edit tool fails when a multi-line old_string spans a region where the same Unicode character appears as both a literal codepoint and a \uXXXX source escape. The whole-string swap fallback tries "all literals" and "all escapes" but can't handle the mixed case. The workaround remains: use sed/node/bun via Bash for these edits. 
  ⎿  Feedback / bug report submitted

View original on GitHub ↗

7 Comments

wodin · 2 months ago

I just ran into this again:

● Now add the test block at the end of the file:

● Update(~/worktrees/proj/tests/utils.test.ts)
  ⎿  Error editing file

  Read 1 file (ctrl+o to expand)

● Update(~/worktrees/proj/tests/utils.test.ts)
  ⎿  Error editing file

  Read 1 file (ctrl+o to expand)

● There's a non-breaking space character in that line. Let me use a different approach — append to the file:
  Regarding those Claude Code issues you linked — sorry about the Edit failures with the non-breaking space. The \u00a0
  character in the test file (expect(plain).not.toContain('\u00a0')) caused the string match to fail because the Read tool
  displays it as a regular space but Edit requires an exact byte match. I worked around it by using cat >> to append instead.
  Known issue, unfortunately.
wodin · 2 months ago
● You're absolutely right. The JSON encoding does have the ability to distinguish these:

  - \u2014 in JSON → literal em-dash codepoint —
  - \\u2014 in JSON → the 6 ASCII characters \u2014

  So the tool parameter old_string could carry "$2 \u2014 $5",\n  "Cake price: $20 \\u2014 $40" and the Edit tool would receive the correct mixed representation —
  literal em-dash on line 3, backslash-u-2-0-1-4 on line 4.

  The bug isn't a fundamental JSON limitation at all. Something in the pipeline is either:

  1. Double-unescaping — treating the already-decoded \u2014 text as a JSON escape again
  2. The model (me) not emitting the right escaping — I'm generating \u2014 when I should be generating \\u2014 for the literal-backslash case, and the tool-calling
  layer doesn't give me fine-grained control over the JSON encoding
  3. The Edit tool normalizing on its own before matching, collapsing the distinction that JSON preserved

  It's likely a combination of 2 and 3 — I don't have direct control over the JSON serialization of my tool parameters, so even if I "intend" \\u2014, the
  tool-calling layer may emit \u2014. And then the Edit tool's fallback tries all-escaped or all-literal but not mixed.

  Either way, you're right that it's solvable within JSON. The fix would need to happen in the Claude Code tool-calling layer or the Edit tool's matching logic — not
  in the JSON spec.
wodin · 2 months ago

@claude

Root cause: the Read → model → Edit round-trip is lossy

The Edit tool's string matching has a cascade of fallbacks (smart quotes, \uXXXX ↔ codepoint swapping, etc.) that try to compensate when old_string doesn't exactly match the file. This bug adds yet another case the fallbacks don't cover. But the deeper question is: why are fallbacks needed at all?

The answer is that Read's output is ambiguous. When a file contains the 6 ASCII characters \u00a0, Read displays them identically to a literal non-breaking space. The model can't tell which one the file has, so it guesses when composing old_string. Then the Edit tool has to guess back.

Every fallback in the matching logic — smart quote normalization, all-escaped regex, all-literal regex, and now the mixed-mode regex this bug needs — exists to compensate for that one upstream ambiguity. If the Read → model → Edit round-trip preserved the file's exact bytes, old_string matching would just be ===.

This specific bug

The existing fallbacks handle two cases:

  • All-escaped: every high-Unicode char in the region is \uXXXX in the file
  • All-literal: every high-Unicode char is a literal codepoint

They don't handle the mixed case: the same character as a literal codepoint on one line and \uXXXX on another. The all-escaped regex demands every position be escaped; the direct includes() demands every position be literal. Neither matches.

Pragmatic short-term fix

The minimal fix is a buildMixedRegex function — a sibling of the existing buildAllEscapedRegex — that matches either form at each high-Unicode position:

// Existing: buildAllEscapedRegex("x — y") → regex matching "x — y"
//     Every high-Unicode position must be \uXXXX.

// New: buildMixedRegex("x — y") → regex matching "x (?:—|—) y"
//     Each high-Unicode position can be either form.

function buildMixedRegex(str) {
  let result = "";
  for (const ch of str) {
    const code = ch.charCodeAt(0);
    if (code >= 0x80) {
      const hex = toInsensitiveHex(code);
      result += `(?:${escapeRegex(ch)}|\\\\u${hex})`;
    } else {
      result += escapeRegex(ch);
    }
  }
  return result;
}

Used as a fallback after the all-escaped regex fails. This fixes the match, but the replacement normalizes \uXXXX escapes to literal codepoints — because the model never knew which positions were escaped, that information was lost at Read time.

A further fix to the representation mapper can reconstruct per-position literal vs escaped by walking the matched string and old_string with dual cursors, then applying the same mapping to new_string. This preserves the file's original representation. But it's reconstructing information that was thrown away upstream — the complexity exists only because Read doesn't preserve it.

The clean fix

Make the Read → model → Edit round-trip lossless. If Read's output unambiguously represents the file's bytes — so that \u00a0 (6 ASCII chars) is visually distinct from   (1 char) — the model can reproduce the exact bytes in old_string, and matching is just ===. No fallback cascade, no mixed-mode regex, no dual-cursor reconstruction.

Tests

I've attached a vitest file covering the cases. It uses readable aliases (findActualMatch, mapNewString) — just wire up the real imports at the top. Covers:

  • findActualMatch: direct match, mismatch, all-literal, all-escaped, mixed (both directions), mixed NBSP, mixed different codepoints, double-escaped \\u, full #64479 repro
  • mapNewString: identity, all-escaped, all-decoded, mixed per-position preservation, different codepoints, extra chars default to literal, full #64479 repro
  • End-to-end: both functions composed, including the #52813 NBSP case
wodin · 1 month ago

Still reproduces on Claude Code v2.1.209 (model: Opus 4.8), macOS (darwin 24.1.0).

Reprorecipes.ts where the same char (em-dash ) appears both ways in adjacent lines:

  • line 3: literal codepoint (UTF-8 bytes 342 200 224)
  • line 4: the 6 ASCII chars \ u 2 0 1 4 ()

Results:

  • Edit spanning both lines in one old_string → ❌ fails.
  • Edit of either line alone → ✅ succeeds (all-literal / all-escaped paths each work).

So the failure is still specific to a single old_string spanning both forms, exactly as described — the whole-string swap fallback is all-or-nothing and matches neither variant.

One thing has changed since the v2.1.159 report: the error is now more informative instead of a bare Error editing file:

String to replace not found in file.
String:   "Cookie price: $2 — $5",
  "Cake price:   $20 — $40",
(note: Edit also tried swapping \uXXXX escapes and their characters; neither
form matched, so the mismatch is likely elsewhere in old_string. Re-read the
file and copy the exact surrounding text.)

The tool now tells you it attempted the swap and gives up cleanly — but the underlying mixed-form gap (and the per-position (?:—|\\u2014) fix proposed above) is unchanged.

Workaround unchanged: byte-exact sed/node/bun via Bash, or one Edit per line.

wodin · 1 month ago

Still reproduces on Claude Code v2.1.220 (model: Opus 5), macOS (darwin 24.1.0). Previously confirmed on v2.1.209.

Repro — same as before, a file where the em-dash appears both ways on adjacent lines:

printf '  "a: $2 \xe2\x80\x94 $5",\n  "b: $20 \\u2014 $40",\n' > f.ts
  • line 1: literal codepoint (bytes e2 80 94)
  • line 2: the 6 ASCII chars \u2014 (bytes 5c 75 32 30 31 34)

Results:

| Case | Result |
|---|---|
| old_string spanning both lines | ❌ String to replace not found in file |
| line 1 alone (literal ) | ✅ |
| line 2 alone (ASCII \u2014) | ✅ |

After the two single-line edits, xxd confirms both representations survived intact and were each individually matchable:

00000000: 2020 2261 3a20 2432 20e2 8094 2024 3520    "a: $2 ... $5
00000010: 4f4b 3122 2c0a 2020 2262 3a20 2432 3020  OK1",.  "b: $20
00000020: 5c75 3230 3134 2024 3430 204f 4b32 222c  \u2014 $40 OK2",

So neither line is unmatchable on its own — only the span fails.

---

One new observation that supports the lossy-round-trip diagnosis above:

When I attempted the spanning edit with the mixed form (literal on line 1, ASCII \u2014 on line 2), the error echoed my old_string back with a literal em-dash on both lines:

String:   "a: $2 — $5",
  "b: $20 — $40",

The escape was decoded somewhere before Edit saw it. So the all-literal attempt and the mixed attempt arrive byte-identical — as far as I can tell there is currently no way to express the mixed form in old_string at all, which means the model cannot work around this even in principle. The whole-string swap fallback then has only two candidates, and the file matches neither.

Workaround remains: one Edit per line, or a byte-exact sed/node call via Bash.

wodin · 27 days ago

Bug #64479 still reproduces on Claude Code 2.1.220

One notable difference: the error message is now more specific — it explicitly says it tried swapping \uXXXX escapes for literal chars and neither matched, instead of the old generic "Error editing file." Root cause is unchanged.

wodin · 26 days ago

Still reproduces in Cowork sandbox

Showing cached comments. Read the full discussion on GitHub ↗