Skip to content

edit tool silently rewrites line endings; tool description contradicts implementation #53149

Closed

Description

@xvcezrqpbxtpzblrt

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.

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 : trailing
const 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:

  1. Line endings: stop rewriting what the model sent, or document the rewrite and report which ending was used.
  2. When replacements === 0, report which matchers ran and what was normalized, or explicitly mention line endings.
  3. 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

  1. 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.
  2. Call edit with oldString = "AAA OLD BBB" and newString = "AAA NEW\nLINE2".
  3. 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.

  1. For the matching failure, use an oldString that 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, and findLineOccurrences does 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.

  2. 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 the unicode tier.

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

  1. added
    needs:complianceThis means the issue will auto-close after 2 hours.
    and removed
    needs:complianceThis means the issue will auto-close after 2 hours.
    on Oct 4, 2026
  2. github-actions commented on Oct 4, 2026

    @github-actions
    Contributor

    Thanks for updating the issue with the required bug report details and reproduction context!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions