Skip to content

fix(tools): preserve unmatched text in partial-line legacy edits - #1420

Merged
vastsa merged 4 commits into
vastsa:mainfrom
GalaxyXieyu:fix/legacy-edit-partial-line
Oct 5, 2026
Merged

vastsa merged 4 commits into
vastsa:mainfrom
GalaxyXieyu:fix/legacy-edit-partial-line

Conversation

@GalaxyXieyu

Copy link
Copy Markdown
Contributor

Summary

The legacy Edit shape (old_string / new_string without tag / ops, adapted in #1269 for #1217) treated a substring match as a whole-line replacement. When old_string covered only part of a line, the rest of that line was silently deleted and the tool still returned success with a fresh tag. The file is corrupted and the model gets no signal:

file old_string → new_string expected main (c1afefea7)
let x = foo; = foo; → = bar; let x = bar; = bar;
value = 1; // keep me value = 1; → value = 2; value = 2; // keep me value = 2;
call(alpha, beta); alpha → gamma call(gamma, beta); gamma
fn a() { one();⏎ two(); } // end one();⏎ two(); → uno(); fn a() { uno(); } // end uno();
a⏎foo⏎b⏎ foo⏎ → `` a⏎b⏎ a⏎⏎ (neighbour line b deleted)

This PR covers only the legacy-edit sub-item of the #1106 umbrella (Agent Harness tool feedback); the other points in that issue are out of scope.

Root cause

tool_edit (crates/host-core/src/tools/mod.rs:1547-1555 on c1afefea7) used the unique match offset only to compute the first and last touched line numbers, then emitted PUT first.=last: whose body was new_string split on \n. PUT N.=M replaces whole lines (hashline/apply.rs:129-137), so the unmatched prefix of the first line and suffix of the last line were dropped. The line count came from old.split('\n').count(), which overshoots by one when old_string ends with a newline, so a whole-line delete such as foo\n → `` also wiped the following line.

Change

  • New private helper legacy_replace_ops next to tool_edit:
    • builds the exact substring result text[..start] + new_string + text[start + old.len()..];
    • trims the common leading and trailing lines;
    • lowers the remaining difference to one line-anchored op: PUT a.=b:, CUT a.=b, or a PUT >N: / PUT <1: insertion.
  • The op still goes through hashline::apply_edit with the live tag, so EOL, BOM, and trailing-newline handling, tag minting, and the provenance gate are the same code path as before. Only lines that actually change are anchored.
  • The legacy match arm shrinks to one call. An identical replacement returns EDIT_NO_CHANGE directly; the not-found and ambiguous paths (EDIT_LEGACY_MATCH_FAILED) are untouched.

Production diff: +51 / −9 in tools/mod.rs; the rest is tests and docs.

Evidence

  • Tested commit: af701dea5 (fix/legacy-edit-partial-line). Base main: c1afefea7 (fix(mcp): bound the session/get compaction history before truncating #1418), an ancestor of HEAD; pnpm check:pr-base passes.
  • New table test edit_legacy_replacement_preserves_unmatched_bytes (11 cases) plus edit_legacy_identical_replacement_leaves_file_unchanged:
    • Added before the fix: cases 1–8 fail on c1afefea7 (see the table above, plus CRLF). The whole-line case 9 passes on both.
    • With the fix, all cases pass. Cases 10 (pure insertion) and 11 (line join) were added together with the fix.
  • cargo test -p host-core --locked: 757 passed, 0 failed (main c1afefea7: 755 passed; +2 new tests). The existing edit_accepts_legacy_old_string_new_string_shape is unchanged and passes.
  • cargo fmt --check: clean. cargo clippy -p host-core --all-targets: no warnings.
  • Protocol-level probe against the real pi-desktop-host-core binary over stdio. Each case runs tools.execute Read followed by a legacy Edit in an agent session, and the file on disk is compared byte for byte:
    • fixed binary (af701dea5): 7/7 byte-exact;
    • main binary (c1afefea7): 1/7 (only the exact whole-line case);
    • the same 7/7 result without a prior Read.
  • E2E (R7): pnpm test:e2e on af701dea5: 23/23 passed, 2 skipped (E2E-008-live-model and E2E-009-stream need PI_DESKTOP_TEST_API_KEY). The identical result on main c1afefea7.
    • The smoke suite does not exercise the legacy Edit shape; the new scenario is covered by the host-core tests and the stdio probe above.
    • Environment: Linux x64, Node 22, Rust 1.99, isolated HOME and data dir. No Electron UI suite applies to this host-only change.
  • pnpm docs:check: pass. git diff --check: clean.

Specs / E2E docs

  • docs/spec/03-runtime/18-line-anchored-edit-contract.md §11: one paragraph stating the legacy compatibility semantics. A unique match is required, exactly the matched text is replaced, and the result is lowered to one op over the changed lines.
  • docs/spec/06-delivery/04-e2e-test-plan.md: new E2E-EDIT-legacy-replacement-preserves-unmatched-text, placed after E2E-156 (Status: Automated via the host-core tests).

Compatibility / risk

  • Only the legacy old_string / new_string branch changes. The tag + ops contract, the parser, and apply_edit are untouched. Exact whole-line legacy matches write the same file as before.
  • For a legacy call, the ops echoed in the result now name only the lines that changed, which can be a narrower range than before.
  • The file's trailing-newline state is still owned by hashline. A legacy edit whose only effect would be adding or removing the file's final newline is reported as EDIT_NO_CHANGE; the ops path cannot express that change either.
  • The helper splits the normalized text into lines once more per legacy call, which is O(file size). Negligible against the existing normalize, read, and write.
  • No migration, protocol, or security surface change.

Refs #1106

GalaxyXieyu and others added 4 commits October 5, 2026 12:30
The legacy Edit shape (`old_string` / `new_string` without `tag` / `ops`)
located the unique match by byte offset but then emitted a whole-line
`PUT first.=last:` with `new_string` as the body. `PUT N.=M` replaces whole
lines, so when `old_string` matched only part of a line the unmatched prefix
of the first line and suffix of the last line were silently deleted while the
tool still reported success (`let x = foo;` with `= foo;` -> `= bar;` became
`= bar;`). Counting `old_string.split('\n')` lines also overshot by one when
`old_string` ended in a newline, so deleting `foo\n` wiped the next line too.

Build the exact substring result instead, trim the unchanged leading and
trailing lines, and lower the difference to a single line-anchored op
(`PUT a.=b:`, `CUT a.=b`, or a `PUT >N:` insertion) that still goes through
`hashline::apply_edit` with the live tag. Only changed lines are anchored, so
the provenance gate sees exactly what changed. Exact whole-line matches produce
the same file as before; an identical replacement still fails with
`EDIT_NO_CHANGE`, and not-found / ambiguous matches still fail with
`EDIT_LEGACY_MATCH_FAILED`.

Refs vastsa#1106
The line-anchored edit format preserves terminal newline state, so the
legacy substring adapter cannot express a terminal-newline-only change.
Pin the result in a regression test and make the compatibility contract
explicit.
@vastsa
vastsa merged commit ea87db1 into vastsa:main Oct 5, 2026
4 checks passed
@vastsa

vastsa commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Thanks for the focused fix. I added a small landing completion for the terminal-newline-only edge case: the regression test now records the current no-change behavior, and the line-edit contract documents that limitation. The PR is merged.

@GalaxyXieyu
GalaxyXieyu deleted the fix/legacy-edit-partial-line branch October 6, 2026 08:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants