Repository navigation
Description
Description
edit's model-facing description tells the model to "preserve the exact indentation and line endings (tabs vs. spaces)", but in v2.0.22 the implementation decides the file's line ending from the whole file and rewrites every \n the model supplied, and matches through three undocumented fallback tiers. A model that follows the tool contract exactly can get edits that fail with a misleading error, or content that changes in a way it never asked for.
Parts of this are already reported elsewhere, so this issue is mostly about what is not yet covered. I am happy to close this in favor of #45880 / #44996 if that is preferred.
- edit: oldString never matches on files with mixed line endings (read strips \r, edit converts whole oldString to CRLF) #45880 already covers the matching failure and the misleading not-found message on files with mixed line endings, so I do not re-report that. One correction to the chain as I first sketched it, verified against
v2.0.22: the\ris not lost on the way in. edit: oldString never matches on files with mixed line endings (read strips \r, edit converts whole oldString to CRLF) #45880 citesread-filesystem.tsandedit.ts:42-45withdetectLineEnding/convertToLineEnding; atv2.0.22that layout no longer exists (packages/core/src/tool/plugin/),readsplits on\nonly (toModelContent,text.split("\n")), andedit'ssourcestill contains\r— anoldStringcontaining\r\nmatches a CRLF file exactly. Onv2.0.22the failure therefore comes from the rewrite below plus the whole-line-only third tier, not from a\rthatreadhid. edit: not-found error claims exact whitespace matching, but matching normalizes whitespace #44996 already covers the not-found message contradicting real matching behavior, but against1.18.21's replacer chain (LineTrimmedReplacer/WhitespaceNormalizedReplacer).v2.0.22no longer contains those; the tiers below are a different mechanism and behave differently.
What this issue adds:
1. replacements counts matches, not edits applied (clarity, not a behavior bug). In the apply path:
const replacements = matches.length
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)The name reads as "how many edits were made", but it is matches.length, and replaced is the slice that is actually written. This is harmless in practice: the replacements > 1 && input.replaceAll !== true guard fails the edit before any write, so the two values can never disagree about what lands on disk. Noting it only because the name invites the wrong reading, and because replaced is computed eagerly even when it is about to be discarded.
2. The v2.0.22 fallback chain, none of which is documented — so "exact text" is not what the tool does:
const exact = findOccurrences(source, oldString)
// These one-to-one mappings preserve offsets into the original source.
const unicode =
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailingconst normalizeForMatch = (value: string) =>
value
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")(The first three classes are literal characters in the source, written here as \u escapes. Same code points.)
An edit can therefore match smart quotes, en/em dashes, special spaces and trailing whitespace that the model never asked to match. findLineOccurrences applies trimEnd() but never trimStart(), so an oldString that is not aligned to whole lines, or whose leading indentation differs, is not rescued by that tier.
3. Corroborating #45880 with byte-level evidence — the silent content change. packages/core/src/tool/plugin/edit.ts, inside execute:
const source = original.text
const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)source.includes(crlf) is true as soon as a single CRLF exists anywhere in the file. From that point every \n the model supplied becomes \r\n, in both oldString and newString. Nothing in the tool output indicates it.
Suggested fix — either make the contract match the behavior, or the behavior match the contract:
- Line endings: stop rewriting what the model sent, or document the rewrite and report which ending was used.
- When
replacements === 0, report which matchers ran and what was normalized, or explicitly mention line endings. - Document the fallback tiers, or remove them, so that "exact text" means what it says.
Plugins
None — reproduction uses the built-in edit tool only.
OpenCode version
2.0.22 (commit 527f0b931d1f9b3ebd34e106c51b31ce5db5b075), file packages/core/src/tool/plugin/edit.ts (blob b736c45beb31e5389e09656a0162b70d4786b52c).
Steps to reproduce
- Create a file that is LF except for a single CRLF line — an ordinary LF file that happens to contain one CRLF, e.g.
printf 'head\r\nAAA OLD BBB\ntail\n' > mixed.txt. This is CR = 1, LF = 3. - Call
editwitholdString = "AAA OLD BBB"andnewString = "AAA NEW\nLINE2". - Inspect the bytes afterwards:
before: 68 65 61 64 0D 0A 41 41 41 20 4F 4C 44 20 42 42 42 0A 74 61 69 6C 0A
h e a d CR LF A A A O L D B B B LF t a i l LF
after: 68 65 61 64 0D 0A 41 41 41 20 4E 45 57 0D 0A 4C 49 4E 45 32 0A 74 61 69 6C 0A
h e a d CR LF A A A N E W CR LF L I N E 2 LF t a i l LF
CR went 1 -> 2. The \n supplied in newString was written as CR LF, and the tool output does not mention it.
-
For the matching failure, use an
oldStringthat spans a line break and is not aligned to whole lines, e.g.oldString = "OLD BBB\ntail". It is converted to CRLF, no longer exists in the file, andfindLineOccurrencesdoes not apply because the match does not start at a line boundary. The tool returns:Could not find oldString in
<path>. It must match exactly, including whitespace and indentation.The message never mentions line endings, so a model is sent to debug whitespace it did not get wrong.
-
For the undocumented normalization, target text containing a smart quote, an en dash, or a non-breaking space and supply the plain ASCII equivalent in
oldString; the edit succeeds through theunicodetier.
Screenshot and/or share link
None — the reproduction is file-level and the evidence above is byte-level, so there is no UI state to capture.
Operating System
Windows (win32).
Terminal
Windows PowerShell 5.1 — powershell.exe at C:\Windows\System32\WindowsPowerShell\v1.0\powershell.EXE, which is the shell opencode spawns on this machine. WT_SESSION, TERM_PROGRAM and ConEmuANSI are all absent from the environment, so the host is not Windows Terminal, VS Code or ConEmu; TERM=xterm-256color.
Activity
github-actions commentedon Oct 4, 2026
Thanks for updating the issue with the required bug report details and reproduction context!