Skip to content

fix(core): preserve mixed line endings in edit tool - #53166

Merged
neriousy merged 3 commits into
devfrom
edit-line-endings
Oct 12, 2026
Merged

neriousy merged 3 commits into
devfrom
edit-line-endings

Conversation

@neriousy

@neriousy neriousy commented Oct 4, 2026 •

Copy link
Copy Markdown
Member
pr-53166.mp4

Fixes #53149
Fixes #53151
Refs #45927

Note

Not ready to merge yet. Eval results are below.

Problem

The edit tool picked one line ending for the whole file: source.includes("\r\n") ? "\r\n" : "\n". If a file contained a single CRLF, every \n in both oldString and newString was rewritten to CRLF. In mixed-ending files this:

  • wrote CRLF into regions that used LF (one CRLF in the file became two after an edit), and
  • made multi-line oldString values fail with the misleading "must match exactly, including whitespace and indentation" error.

The read tool strips \r, so the model never sees CRLF and had no way to work around it.

Fix

  • Matching (exact, then Unicode-normalized, then trailing-whitespace) runs against an LF view of the source, which is the same text the read tool shows. Match offsets are mapped back to source offsets, so untouched bytes keep their original endings.
  • Inside a match, replacement lines are aligned with the matched lines (longest common subsequence). A line that is kept or changed in place reuses the ending of its source line, so unchanged context keeps its bytes even in a mixed-ending file. Only added lines take the dominant ending of the matched region, or the file's dominant ending for a single-line match or a tie.
  • If oldString and newString differ only in \r, the edit is treated as a line-ending change and applied to the exact bytes. Explicit CRLF↔LF conversion still works.
  • An edit that would leave the file unchanged now fails instead of reporting success (edit reports success for a CRLF-only line-ending change that does nothing #45927). The error says how to change line endings explicitly.
  • The tool description now says to write line breaks as \n, that existing endings are preserved, how to change endings explicitly, and the lenient fallback tiers that were previously undocumented.

Testing

Eval results

The runs come from two dumber evals: line-endings and line-endings-realistic. 20 models (DeepSeek, Grok, Muse, Gemini, GLM, MiniMax, Claude, Kimi, Qwen; GPT is left out because it gets patch instead of edit) × cases × 3 repetitions. Trials only get read, glob, grep and edit, so every byte on disk went through the edit tool. Baseline is 0bef0ea (v2), v1 is 64eb0f6 (this PR's first commit), v2 is d38a53b (this PR's current head). The variants ran at the same time. The baseline and v1 were each run twice to measure noise.

line-endings-realistic: multi-file feature work in a mixed C#/TS/Python/Rust repo (360 trials per run)

The repo has CRLF, LF and mixed-ending files. No prompt mentions line endings. Pass = every requested change is present and no line ending was damaged.

baseline v1 v2
pass rate 32.5% / 30.8% 82.8% / 80.8% 98.3%
trials with clean line endings 120 / 116 303 / 292 359 / 359
damaged line endings, total 1380 / 1331 154 / 192 0
failed edit calls 445 / 547 278 / 290 255
mean trial time 87.5s / 89.1s 76.8s / 73.7s 71.0s
tokens 142M / 154M 120M / 133M 107M
cost $57.4 / $58.5 $51.0 / $51.8 $48.5

Pass rate per case (out of 60):

case baseline v1 v2
add-discount 0, 0 60, 60 60
category-two-turns 0, 0 54, 54 59
add-delete 1, 1 45, 47 59
rename-sku 16, 19 14, 19 56
move-item-value 34, 39 59, 60 60
seed-and-docs 60, 58 59, 58 60

v1 still damaged line endings: unchanged context inside a multi-line match took the region's dominant ending. v2's per-line alignment removes all of it. The remaining v2 failures are missing requirements (model mistakes) plus one Gemini MALFORMED_FUNCTION_CALL, not line endings. Fewer failed edits also mean fewer retries, which is where the drops in time (about −20%), tokens (about −28%) and cost (about −16%) come from.

line-endings: single-file edits with known bytes (780 trials per run)

baseline v1 v2
pass rate 90.0% / 90.0% 94.1% / 93.3% 96.2%
mixed-single-crlf (#53149) 19/60, 20/60 60/60, 57/60 60/60
crlf-convert-to-lf 30/60, 23/60 15/60, 11/60 33/60
tool reported success, file unchanged 12 / 14 19 / 28 9
mean trial time, excluding conversion 16.7s / 16.1s 12.6s / 13.2s 13.7s

11 of the 13 cases are at or near 100% in every variant.

  • v1 regressed explicit conversion. Because every \n was normalized, crlf-convert-to-lf could no longer be done with edit, and the tool reported success on a no-op. v2 fixes both. The 9 remaining "unchanged" trials are models that changed the file and then reverted it themselves; the tool now rejects true no-ops.
  • Known gap: crlf-convert-to-lf with only edit still passes about half the time, and about a third of trials hit the 300s timeout. Many models (Claude included) write \r as a literal backslash escape instead of a real carriage return, so their oldString never matches. With a shell available this case is trivial.

Full tool set (shell, write, subagents, …)

Same evals with OpenCode's full tool set, baseline 0bef0ea vs v2 d38a53b, run at the same time. The LINE_ENDINGS_TOOLS=all switch and the raw runs are in anomalyco/evals#15.

baseline v2
line-endings-realistic pass rate 34.2% 91.7%
trials with clean line endings 132 / 358 343 / 360
line-endings pass rate 95.8% 99.5%
mixed-single-crlf 36/60 60/60
line-endings mean trial time / tokens / cost 17.3s / 28.5M / $18.9 15.1s / 23.1M / $15.7
line-endings-realistic mean trial time / tokens / cost 91.4s / 186M / $66.1 100.9s / 185M / $64.9

Realistic pass rate per case (out of 60): add-delete 5 → 55, add-discount 4 → 56, category-two-turns 2 → 52, move-item-value 44 → 60, rename-sku 11 → 47, seed-and-docs 57 → 60.

None of the 17 damaged v2 realistic trials was damaged by edit:

  • 12 used write to rewrite a whole CRLF file with LF (DeepSeek V4 Pro accounts for 11 of them, MiniMax M3 for 1). The same trials are damaged on the baseline too.
  • 5 rewrote a file with a Python one-liner through shell (text-mode read/write turns CRLF into LF).
  • 2 are Claude Opus deliberately normalizing a mixed-ending .cs file to CRLF with perl, as .gitattributes asks. The scorer counts that as damage, but it is a reasonable choice.

The realistic time difference comes from provider stalls that hit the 600s timeout: MiniMax M3 and Gemini 3.8 Flash account for 9 timeouts vs 7 on the baseline, with idle sessions and almost no edits. Median trial time is 52s vs 50s, and tokens and cost are flat.

Not covered yet

  • write does not preserve line endings when it overwrites an existing file. It is now the main remaining source of damage with the full tool set. A follow-up could keep the overwritten file's ending.
  • patch (used for GPT models) does not touch line endings and is not covered by these evals.
  • With only edit, explicit CRLF→LF conversion still fails about half the time because models write \r as a literal escape.

Added by the opencode bot (AI-generated).

Fixes #45927
Fixes #45880

Related: #54605, #45926

neriousy and others added 2 commits October 4, 2026 18:29
Match oldString against the LF view the read tool shows the model and map
offsets back to the source, instead of rewriting every newline to CRLF as
soon as the file contains a single CRLF. Replacement text takes the dominant
line ending of the matched region, falling back to the file's dominant
ending. The tool description now documents line-ending handling and the
lenient fallback matching tiers.
… edits

Lines a replacement keeps or changes in place reuse the matched region's own endings, so context inside a multi-line match no longer takes the region's dominant ending in mixed files. Edits whose oldString and newString differ only in \r apply to the exact bytes, which restores explicit CRLF/LF conversion. An edit that would leave the file unchanged now fails instead of reporting success (#45927).
@neriousy
neriousy marked this pull request as draft October 5, 2026 10:55
@neriousy
neriousy marked this pull request as ready for review October 5, 2026 11:54
@neriousy
neriousy marked this pull request as draft October 5, 2026 11:59
@thdxr
thdxr changed the base branch from v2 to dev October 10, 2026 19:31
@neriousy
neriousy marked this pull request as ready for review October 12, 2026 13:57

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. I reproduced both reported cases on the base with the simulated model: the lone-LF region in head\r\nAAA OLD BBB\ntail\n gained a CRLF, and a mostly-CRLF file's lone LF line was rewritten to CRLF. With this PR, both edits keep every original line ending byte-for-byte. tool-edit.test.ts passes (20; the 4 new tests fail on the base), and packages/core type-checks.

Public API is unchanged: only the edit tool's description text and internal matching and replacement behaviour change.

No blocking concerns. The new tests are focused and deterministic.

Fixes #45927
Fixes #45880
Related: #54605, #45926

@neriousy
neriousy merged commit 0cb4347 into dev Oct 12, 2026
11 checks passed
@neriousy
neriousy deleted the edit-line-endings branch October 12, 2026 14:23
teknium1 added a commit to jonpol01/hermes-agent that referenced this pull request Oct 16, 2026
…rence

Port from anomalyco/opencode#53166 (preserve mixed line endings in edit
tool): their fix class, replayed against hermes, reproduced the same bug on
both patch modes. The fix itself is @jonpol01's NousResearch#125519 (cherry-picked
above); this records the new contract on write_file's docstring.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant