Edit tool silently normalizes `\uXXXX` escape sequences in old_string, breaking exact matches
Summary
The Edit tool appears to transparently convert between \uXXXX escape sequences and their literal Unicode codepoints when matching old_string against file contents. When the target file legitimately contains the six ASCII characters \, u, 0, 0, A, 0 (e.g., inside a JS/TS regex), no form of old_string matches — and there's no way to opt out.
Steps to reproduce
- Create a file
repro.tscontaining:
``ts`
const key = s.replace(/\u00A0/g, ' ');
od -c repro.ts` — six ASCII chars, not an NBSP codepoint.
Verify the file literally contains backslash-u-0-0-A-0 via
- Invoke
Editwithold_stringequal to that same line (same six ASCII chars).
- Error returned:
````
String to replace not found in file.
(note: Edit also tried swapping \uXXXX escapes and their characters;
neither form matched, so the mismatch is likely elsewhere in old_string.)
The error message confirms the harness tried both the escape form and the literal-NBSP form — neither matched, because the input was presumably rewritten to the NBSP form before comparison while the file contains the ASCII-escape form.
Expected
old_string is matched as a raw byte sequence. If the caller passes six ASCII chars, the tool looks for six ASCII chars.
Actual
Something in the input-processing path rewrites \u00A0 (and presumably any \uXXXX) to the actual codepoint before comparison, making it impossible to match source code that contains the escape sequence as literal text. Workaround required shelling out to python3 / sed, or git checkout <path> when the edit was effectively a revert of uncommitted changes.
Impact
Common in real codebases — any regex over NBSP / ZWSP / control chars, unicode-escape tests, tokenizer fixtures, JSON-encoded strings in source. Rare enough to not bite often, painful and opaque when it does.
Suggested fix
Treat old_string as an opaque byte sequence. If "helpful" normalization is desired as a fallback, only apply it when the literal match fails, and surface _which_ form matched so the caller understands what happened.
Environment
- Claude Code CLI -
2.1.119 (Claude Code) - Platform: Linux
- Model: Opus 4.7
Related
- #50483 —
Readtool rendering\uXXXXinconsistently on Windows. Adjacent symptom (Edit failures after Read) but different root cause: in this report, the file bytes were verified viaod -cbefore the Edit call, so Read was not misleading — the normalization is happening on Edit'sold_stringinput itself.
8 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Hit this independently on macOS, Claude Code 2.1.119, Opus 4.7 (1M context). File was an Umbraco UDA artifact containing the literal six ASCII characters
\u0027inside a JSON-escaped string. Edit'sold_stringwas normalized to'before matching, so no form of the input matched the on-disk bytes.Read rendered the escape as
'consistently in this case, so the failure is not downstream of #50483 (Read inconsistency). The normalization is on Edit's input path, matching the OP's description andod -cverification.Not a duplicate of #50483 (Read rendering inconsistency) or #26141 (Edit corrupting curly quotes/em dashes on write). This one is specifically silent normalization of
\uXXXXinold_stringbefore comparison, with no opt-out. Please don't auto-close.Did you mean
\u0027?Ran into this exact issue with a JavaScript file containing \u2026 and \u2013 as literal escape sequences in string literals (not as unicode characters). The Edit tool consistently normalizes them to the actual unicode characters before matching, so the old_string never matches and the edit fails with "String to replace not found."
The only workaround was to fall back to sed via the Bash tool, which defeats the purpose of having a diff-previewing Edit tool. The user never gets to see and approve the change before it's applied.
I found a very similar issue that I was able to reproduce on the the latest Claude Code (2.1.123):
<img width="1577" height="1253" alt="Image" src="https://github.com/user-attachments/assets/249a3374-c7f1-40c5-ae28-69f4a498414a" />
Note the red error when it failed to perform the edit.
I ran into this issue with dashes, but it applies to other characters too. What follows is the bug report I was going to make before finding this very similar issue.
Summary
The
Edittool'sold_stringparameter cannot match a region of a file that contains both the literal Unicode codepoint of a character (e.g.,—, U+2014, 3 UTF-8 bytes) and the 6-character source escape\u2014(six ASCII characters: backslash, lowercase u, 2, 0, 1, 4) for the same character. The tool's existing auto-swap, mentioned in the failure note, only tries two whole-string variants — "all literal codepoint" or "all\u2014-escape" — neither of which fits when the file genuinely contains both forms in different positions.Writeis not affected because it doesn't match anything; onlyEditis.The error message is also misleading. It tells the user "the mismatch is likely elsewhere in old_string", which sends them looking for invisible whitespace differences when the real issue is the mixed Unicode representation.
Minimal reproduction
1. Create the input file
Save this 7-line file at
/tmp/claude-edit-repro/recipes.ts. Line 3 contains the literal em-dash codepoint (U+2014, 3 UTF-8 bytes); line 4 contains the 6-character source escape\u2014(a literal backslash followed byu2014):Line 3 has the em-dash typed inline (one codepoint, three UTF-8 bytes). Line 4 has the literal six-character source escape
\u2014(backslash +u2014) in the source code — both forms compile to the same string at runtime, but the bytes on disk differ.Confirm the bytes:
(Line 3:
— ** **isod -c's rendering of the 3-byte UTF-8 em-dash. Line 4:\ u 2 0 1 4is six ASCII characters — the backslash is part of the file, not a shell escape.)2. Trigger
Ask Claude to update both prices via
Edit. The natural way to typeold_stringis with the visible em-dash character on both lines (because JSON has only one canonical encoding of U+2014, and that's the codepoint itself):3. Observed behavior
The tool returns
<tool_use_error>and the file on disk is unchanged:cat /tmp/claude-edit-repro/recipes.tsafter the failed call still shows the original prices ($2 — $5and$20 \u2014 $40), confirming the tool aborted without writing.4. Expected behavior
The block should match. Both lines describe the same character — an em-dash — in the user's mental model. The tool should succeed and produce the new content.
Why the user can't fix it from
old_stringaloneJSON has one canonical encoding of U+2014: the codepoint itself. Whether the user writes the em-dash inline as a literal character or types
\u2014in the JSON source, both decode to the same single codepoint by the time the tool receivesold_string. The user cannot distinguish "this position in the file has the literal codepoint, that position has the 6-character escape" through any JSON parameter value.So the matching logic has to live in the tool, not in user input.
Workaround used
Bypass
Editand useBashwith a smallbun -e/node -escript.String.rawlets the script construct the literal 6-character\u2014sequence at the JS source level, which is what the file actually contains on line 4.Environment
| | |
|---|---|
| Claude Code version | 2.1.121 (originally reported); also reproduces on 2.1.123 |
| OS | macOS 26.3 (build 25D125) — original report; macOS 26.4.1 (build 25E253) on retest |
| Kernel | Darwin 25.3.0 arm64 — original; Darwin 25.4.0 arm64 on retest |
| Shell | zsh |
| Terminal |
TERM=xterm-256color||
bun(used in workaround) | 1.3.13 |Retest — 2026-04-29 (Claude Code 2.1.123)
Still reproduces. Recreated
/tmp/claude-edit-repro/recipes.tswith the exact byte layout from the repro (line 3 = literal U+2014 codepoint, 3 UTF-8 bytes; line 4 = the 6 ASCII characters\u2014), calledEditwith the naturalold_stringthat types the visible em-dash on both lines, and got the sametool_use_errorverbatim — same auto-swap note ("tried swapping \uXXXX escapes and their characters; neither form matched") and the same misleading "mismatch is likely elsewhere in old_string" guidance. File on disk unchanged. Re-verified with smart apostrophe (’/\u2019) on a fresh 2-line mixed-form fixture: identical failure, identical error text. The whole-string auto-swap behavior described in the original report has not changed.Affects any file where the same character appears in both literal-codepoint form and
\uXXXXsource-escape form. Originally encountered in real TypeScript test files where some test names contain the literal em-dash (indescribe/itstrings) while string-literal payloads use the\u2014escape to keep diffs ASCII-clean. Same failure confirmed (tested) for other characters: en-dash (codepoint–/ source escape\u2013), smart apostrophe (’/\u2019), ellipsis (…/\u2026). Each was tested with a 2-line mixed-form file in the same shape as the em-dash repro and produced an identicaltool_use_error. The auto-swap is whole-string and codepoint-agnostic, so the bug likely affects any BMP character that has both a literal-codepoint form and a\uXXXXsource-escape form coexisting in the same file.Closing for now — inactive for too long. Please open a new issue if this is still relevant.
Claude says:
Still reproduces on current Claude Code (mid-2026), and worth reopening — this normalization is the root cause of a whole cluster of "String to replace not found in file" reports, not just the
\uXXXX-as-literal case.The same pre-match normalization breaks
old_stringwhenever it contains→(U+2192),—(U+2014),’(U+2019),“/”(U+201C/U+201D) or U+00A0: the matcher conflates/rewrites the codepoint before comparing, so a byte-correctold_stringcopied straight from Read still fails to match (and sometimes silently swaps the glyph on write). Same family: #15920, #38765, #26141, and on Windows it stacks with the cp1252 encoding bug #7134.The fix suggested here is exactly right and would resolve all of them: treat
old_stringas an opaque byte sequence; apply any "helpful" normalization only as a fallback after a literal match fails, and surface which form matched. The error text already hints it's doing hidden work (note: Edit also tried swapping \uXXXX escapes and their characters), so the matcher clearly knows when it fell back — it just needs to prefer the literal match and not corrupt the file on write.Reliable workaround in the meantime: shell out to Python with explicit
encoding=and pass the codepoints as ASCII\uXXXXescapes so the corrupting path is bypassed entirely.This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.