Edit tool silently normalizes `\uXXXX` escape sequences in old_string, breaking exact matches

Status Closed — not planned
Reported on v2.1.119
Maintainer reply None cached
Activity 8 comments · opened Apr 24, 2026 · closed May 31, 2026

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

  1. Create a file repro.ts containing:

``ts
const key = s.replace(/\u00A0/g, ' ');
`
Verify the file literally contains backslash-u-0-0-A-0 via
od -c repro.ts` — six ASCII chars, not an NBSP codepoint.

  1. Invoke Edit with old_string equal to that same line (same six ASCII chars).
  1. 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 — Read tool rendering \uXXXX inconsistently on Windows. Adjacent symptom (Edit failures after Read) but different root cause: in this report, the file bytes were verified via od -c before the Edit call, so Read was not misleading — the normalization is happening on Edit's old_string input itself.

View original on GitHub ↗

8 Comments

github-actions[bot] · 5 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/50483
  2. https://github.com/anthropics/claude-code/issues/51815
  3. https://github.com/anthropics/claude-code/issues/26141

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

hegner123 · 5 months ago

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 \u0027 inside a JSON-escaped string. Edit's old_string was 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 and od -c verification.

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 \uXXXX in old_string before comparison, with no opt-out. Please don't auto-close.

wodin · 5 months ago
the literal six ASCII characters ' inside a JSON-escaped string.

Did you mean \u0027?

ad-peleng · 5 months ago

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.

Nicholas-Westby · 5 months ago

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 Edit tool's old_string parameter 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.

Write is not affected because it doesn't match anything; only Edit is.

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 by u2014):

// Sample dessert price ranges.
const ranges = [
  "Cookie price: $2 — $5",
  "Cake price:   $20 \u2014 $40",
]

export default ranges

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:

$ awk 'NR==3' /tmp/claude-edit-repro/recipes.ts | od -c | head -1
0000000       "   C   o   o   k   i   e       p   r   i   c   e   :    
       $   2       —  **  **       $   5   "   ,  \n
$ awk 'NR==4' /tmp/claude-edit-repro/recipes.ts | od -c | head -1
0000000       "   C   a   k   e       p   r   i   c   e   :            
   $   2   0       \   u   2   0   1   4       $   4   0   "   ,  \n

(Line 3: — ** ** is od -c's rendering of the 3-byte UTF-8 em-dash. Line 4: \ u 2 0 1 4 is 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 type old_string is 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):

{
  "file_path": "/tmp/claude-edit-repro/recipes.ts",
  "old_string": "  \"Cookie price: $2 — $5\",\n  \"Cake price:   $20 — $40\",",
  "new_string": "  \"Cookie price: $3 — $6\",\n  \"Cake price:   $25 — $45\","
}

3. Observed behavior

The tool returns <tool_use_error> and the file on disk is unchanged:

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

cat /tmp/claude-edit-repro/recipes.ts after the failed call still shows the original prices ($2 — $5 and $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_string alone

JSON has one canonical encoding of U+2014: the codepoint itself. Whether the user writes the em-dash inline as a literal character or types \u2014 in the JSON source, both decode to the same single codepoint by the time the tool receives old_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 Edit and use Bash with a small bun -e / node -e script. String.raw lets the script construct the literal 6-character \u2014 sequence at the JS source level, which is what the file actually contains on line 4.

bun -e '
const fs = require("fs");
const path = "/tmp/claude-edit-repro/recipes.ts";
let s = fs.readFileSync(path, "utf8");
const em = "—";                        // U+2014 codepoint, 3 UTF-8 bytes
const escapeForm = String.raw`\u2014`;  // 6 ASCII chars: \ u 2 0 1 4

const oldBlock = [
  `  "Cookie price: $2 ${em} $5",`,
  `  "Cake price:   $20 ${escapeForm} $40",`,
].join("\n");

const newBlock = [
  `  "Cookie price: $3 ${em} $6",`,
  `  "Cake price:   $25 ${escapeForm} $45",`,
].join("\n");

const i = s.indexOf(oldBlock);
if (i < 0) { console.error("not found"); process.exit(1); }
s = s.slice(0, i) + newBlock + s.slice(i + oldBlock.length);
fs.writeFileSync(path, s);
'

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.ts with the exact byte layout from the repro (line 3 = literal U+2014 codepoint, 3 UTF-8 bytes; line 4 = the 6 ASCII characters \u2014), called Edit with the natural old_string that types the visible em-dash on both lines, and got the same tool_use_error verbatim — 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 \uXXXX source-escape form. Originally encountered in real TypeScript test files where some test names contain the literal em-dash (in describe / it strings) while string-literal payloads use the \u2014 escape 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 identical tool_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 \uXXXX source-escape form coexisting in the same file.

github-actions[bot] · 4 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

Coamithra · 4 months ago

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_string whenever 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-correct old_string copied 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_string as 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 \uXXXX escapes so the corrupting path is bypassed entirely.

github-actions[bot] · 2 months ago

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.