[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
Workaround ✓ Mentioned in thread ↓
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 submittedShowing cached comments. Read the full discussion on GitHub ↗
7 Comments
I just ran into this again:
@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 whenold_stringdoesn'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 composingold_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_stringmatching would just be===.This specific bug
The existing fallbacks handle two cases:
\uXXXXin the fileThey don't handle the mixed case: the same character as a literal codepoint on one line and
\uXXXXon another. The all-escaped regex demands every position be escaped; the directincludes()demands every position be literal. Neither matches.Pragmatic short-term fix
The minimal fix is a
buildMixedRegexfunction — a sibling of the existingbuildAllEscapedRegex— that matches either form at each high-Unicode position:Used as a fallback after the all-escaped regex fails. This fixes the match, but the replacement normalizes
\uXXXXescapes 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_stringwith dual cursors, then applying the same mapping tonew_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 inold_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 repromapNewString: identity, all-escaped, all-decoded, mixed per-position preservation, different codepoints, extra chars default to literal, full #64479 reproStill reproduces on Claude Code v2.1.209 (model: Opus 4.8), macOS (darwin 24.1.0).
Repro —
recipes.tswhere the same char (em-dash—) appears both ways in adjacent lines:342 200 224)\ u 2 0 1 4(—)Results:
old_string→ ❌ fails.So the failure is still specific to a single
old_stringspanning 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: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/bunvia Bash, or one Edit per line.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:
e2 80 94)\u2014(bytes5c 75 32 30 31 34)Results:
| Case | Result |
|---|---|
|
old_stringspanning both lines | ❌String to replace not found in file|| line 1 alone (literal
—) | ✅ || line 2 alone (ASCII
\u2014) | ✅ |After the two single-line edits,
xxdconfirms both representations survived intact and were each individually matchable: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\u2014on line 2), the error echoed myold_stringback with a literal em-dash on both lines: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_stringat 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/nodecall via Bash.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.
Still reproduces in Cowork sandbox