Patch tool escape sequence handling in new_string
Cluster 33733 · agent. Data as of
2 open items. Fix merged in #33799 on 28 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 7 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #33799. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #33799. That is separate from the fix event. - Still open
- 1 issue and 1 PR.
Next step: read #33799, then check whether your report is the one still open.
Read #33799Pick up a Lab taskThese issues and pull requests concern a bug where escape sequences like \t, \r, and \n in the patch tool's new_string field are written as literal characters instead of being interpreted as control bytes, particularly when the escape-normalized matching strategy is used. Multiple fix attempts and related bug reports address unescaping behavior during file writes.
Open issues1
- Issue#59188[Bug]: Escape-normalized fuzzy replacement writes literal \n into multiline files
Open PRs1
- PR#59192fix(patch): normalize newline escapes in fuzzy replacements
Fixes on record
-
#33799 fix(patch): unescape \t/\r in new_string when file region uses real control bytes (#33733)
files:
tests/tools/test_fuzzy_match.py,tools/fuzzy_match.pyShow the diff
-
tests/tools/test_fuzzy_match.py@@ -429,3 +429,118 @@ def test_silent_when_no_similar_content(self): ) assert result == "" + +class TestEscapeNormalizedNewString: + """Regression tests for unescaping common sequences in new_string when + the matched region of the file contains real control characters. + + Issue #33733: LLMs overwhelmingly represent tabs as the two-character + sequence ``\\t`` (backslash + t) in JSON tool-call arguments. When the + file already contains real tab bytes (0x09), writing new_string + verbatim leaves literal ``\\t`` characters and corrupts the file. + + The fix unescapes ``\\t`` -> tab and ``\\r`` -> CR in new_string when + the matched file region actually contains those control characters, + regardless of which match strategy fired. ``\\n`` is excluded because + newlines serialize correctly through JSON. + """ + + def test_tab_in_new_string_unescaped_under_escape_normalized(self): + """File has real tab, model sends literal \\t in BOTH old and new. + + Match strategy is ``escape_normalized``. + """ + content = "def hello():\n\tprint(\"before\")\n" + old_string = "def hello():\n\\tprint(\"before\")\n" + new_string = "def hello():\n\\tprint(\"after\")\n" + new, count, strategy, err = fuzzy_find_and_replace(content, old_string, new_string) + assert err is None, f"Unexpected error: {err}" + assert count == 1 + assert strategy == "escape_normalized" + assert "\tprint(\"after\")" in new + assert "\\t" not in new + + def test_tab_in_new_string_unescaped_under_exact(self): + """File has real tab, old_string has real tab too (matches via + ``exact``), but new_string still arrives with literal ``\\t``. + + This is the issue's headline reproduction — the previous fix that + gated on ``strategy_name == "escape_normalized"`` missed this case. + """ + content = "def hello():\n\tprint(\"before\")\n" + old_string = "\tprint(\"before\")" # real tab + new_string = "\\tprint(\"after\")" # literal backslash + t + new, count, strategy, err = fuzzy_find_and_replace(content, old_string, new_string) + assert err is None, f"Unexpected error: {err}" + assert count == 1 + assert strategy == "exact" + assert "\tprint(\"after\")" in new + assert "\\t" not in new + + def test_carriage_return_in_new_string_unescaped(self): + """File has real CR, model sends literal \\r in new_string.""" + content = "line1\r\nline2\r\n" + old_string = "line1\\r\\nline2\\r\\n" + new_string = "replaced\\r\\n" + new, count, strategy, err = fuzzy_find_and_replace(content, old_string, new_string) + assert err is None, f"Unexpected error: {err}" + assert count == 1 + assert strategy == "escape_normalized" + assert "replaced\r" in new + + def test_newline_in_new_string_NOT_unescaped(self): + """``\\n`` is intentionally left alone — newlines serialize correctly + through JSON, and unescaping would corrupt source-code escape + sequences far more often than help. + """ + content = "line1\nline2\n" + old_string = "line1\nline2" + new_string = "alpha\\nbeta" # literal backslash + n + new, count, _, err = fuzzy_find_and_replace(content, old_string, new_string) + assert err is None, f"Unexpected error: {err}" + assert count == 1 + # The literal two-character sequence ``\n`` must survive verbatim. + assert "alpha\\nbeta" in new + # And there should be no real newline added where ``\\n`` sat. + assert "alpha\nbeta" not in new + + def test_mixed_tab_and_newline_only_tab_unescaped(self): + """When new_string contains both \\t and \\n, only \\t is converted.""" + content = "def foo():\n\tpass\n" + old_string = "def foo():\n\tpass\n" + new_string = "def bar():\\n\\treturn 1\\n" + new, count, _, err = fuzzy_find_and_replace(content, old_string, new_string) + assert err is None, f"Unexpected error: {err}" + assert count == 1 + # \t -> real tab + assert "\treturn 1" in new + assert "\\t" not in new + # \n preserved as literal backslash-n + assert "\\n" in new + + def test_exact_match_preserves_literal_backslash_t_in_string_literal(self): + """If the matched region of the file does NOT contain a real tab, + new_string's literal ``\\t`` is preserved — the file genuinely uses + a backslash-t sequence (e.g. a Python source line ``sep = "\\t"``). + """ + content = 'sep = "\\t"\n' # source contains backslash + t + old_string = 'sep = "\\t"\n' + new_string = 'sep = "\\tab"\n' # still backslash + t literal + new, count, strategy, err = fuzzy_find_and_replace(content, old_string, new_string) + assert err is None, f"Unexpected error: {err}" + assert count == 1 + assert strategy == "exact" + # File still has the literal two-char ``\t`` — no tab byte injected. + assert 'sep = "\\tab"' in new + assert "\t" not in new + + def test_no_escape_sequences_passthrough(self): + """When new_string has no \\t or \\r, the helper is a no-op.""" + content = "def foo():\n return 1\n" + old_string = "def foo():\n return 1\n" + new_string = "def foo():\n return 2\n" + new, count, _, err = fuzzy_find_and_replace(content, old_string, new_string) + assert err is None + assert count == 1 + assert "return 2" in new + -
tools/fuzzy_match.py@@ -113,8 +113,29 @@ def fuzzy_find_and_replace(content: str, old_string: str, new_string: str, # old_string/new_string — e.g. LLM used 2-space indent but the # file is 4-space. Shift new_string by the indentation delta so # the replacement matches the file's actual indent pattern. + # LLMs frequently serialize tabs / carriage returns in JSON + # tool-call arguments as the two-character sequences ``\t`` and + # ``\r`` (backslash + letter) instead of the real control bytes. + # If we write new_string verbatim, the file ends up with literal + # backslash sequences where the surrounding code uses real tabs. + # + # Strategy: only unescape when the matched region of the file + # *actually contains* the corresponding real control character. + # That mirrors the region-based heuristic in + # ``_detect_escape_drift`` and keeps legitimate writes of the + # literal two-character string ``"\t"`` (e.g. patching Python + # source that contains a tab string literal in source text) + # untouched — those files have a backslash+t in the matched + # region, not a real tab, so we leave new_string alone. + # + # ``\n`` is intentionally excluded: newlines serialize correctly + # through JSON, and rewriting backslash-n would mangle escape + # sequences in source code constants far more often than help. + effective_new = _maybe_unescape_new_string( + new_string, content, matches, + ) new_content = _apply_replacements( - content, matches, new_string, + content, matches, effective_new, old_string=old_string if strategy_name != "exact" else None, ) return new_content, len(matches), strategy_name, None @@ -247,6 +268,42 @@ def _reindent_replacement(file_region: str, old_string: str, new_string: str) -> return "\n".join(out_lines) +def _maybe_unescape_new_string(new_string: str, + content: str, + matches: List[Tuple[int, int]]) -> str: + """Conditionally unescape ``\\t``/``\\r`` in new_string. + + LLMs frequently send the two-character sequences ``\\t`` (backslash + t) + and ``\\r`` (backslash + r) inside JSON tool-call arguments where they + meant a real tab or carriage-return byte. Writing the string verbatim + corrupts tab-indented files with literal backslash-letter pairs. + + The unescape is only applied per-sequence when the *matched region of + the file* actually contains the corresponding control character — that + is, we only convert ``\\t`` -> tab when the file region we're replacing + contains a real tab byte. Files that legitimately contain the literal + two-character string ``"\\t"`` (e.g. a Python source line that defines + ``sep = "\\t"``) get a backslash+t in the matched region instead of a + tab, so we leave new_string alone. + + ``\\n`` is intentionally excluded: newlines serialize correctly through + JSON and rewriting backslash-n would corrupt escape sequences in + string literals far more often than it would help. + """ + # Cheap pre-check — bail out unless new_string actually contains one of + # the suspect sequences. Keeps the common case free. + if "\\t" not in new_string and "\\r" not in new_string: + return new_string + + matched_regions = "".join(content[start:end] for start, end in matches) + out = new_string + if "\\t" in out and "\t" in matched_regions: + out = out.replace("\\t", "\t") + if "\\r" in out and "\r" in matched_regions: + out = out.replace("\\r", "\r") + return out + + def _apply_replacements(content: str, matches: List[Tuple[int, int]], new_string: str, old_string: Optional[str] = None) -> str: """
-
3 PRs closed without merging
From the close comments: duplicate 2 · merged via another change 1
-
#33737 fix(tools): unescape common sequences in new_string when escape_normalized matches · merged via another change →
#33799 -
#33745 fix: unescape \t and \r in new_string before writing to file (#33733) · duplicate →
#33737,#33733 -
#59569 fix(tools): unescape \\n in replacement when escape_normalized strategy matched (#59188) · duplicate →
#59192
Closed issues · 1
- Closed#33733patch tool: new_string escape sequences (\t) get written literally
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.26: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #33799
- Closed issues
- 1
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 28 May 2026