F4 SLICE-5: host E2E (propose/accept/reject/persist/stale/coexist) + seam fix: event-level net-effect matching survives host word-diff splitting (INV-9) (#12)

The accept E2E exposed a latent F3 limitation: VS Code word-diffs one
applied WorkspaceEdit into several minimal hunks when old/new share
interior tokens, so the registry's per-hunk exact match missed and seam
edits fell through as fragmented human spans. PendingEditRegistry.match
is replaced by matchEvent (all hunks inside the registered full range +
equal net delta — one applyEdit is one change event). Plan AMENDMENT 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-10 22:12:15 -07:00
parent 452c071efb
commit 8fdea97d36
7 changed files with 278 additions and 35 deletions
+21 -10
View File
@@ -67,17 +67,28 @@ export class PendingEditRegistry {
}
/**
* Find-and-consume the registration exactly matching a change event
* (same doc, same replaced range, same inserted text). Null → human edit.
* Find-and-consume the registration matching a change EVENT's net effect.
* The host may deliver one applied WorkspaceEdit as SEVERAL minimal hunks
* (word-level diffing — observed on the F4 accept path), so per-hunk
* equality misses real seam edits and they fall through as "human typing".
* A pending edit matches an event when EVERY hunk lies inside its full
* pre-edit range and the event's net length delta equals the edit's
* (one applyEdit = one change event, so a seam event is never mixed with
* human hunks). Null → human edit.
*/
match(docPath: string, change: { start: number; end: number; text: string }): PendingEdit | null {
const i = this.pending.findIndex(
(p) =>
p.docPath === docPath &&
p.start === change.start &&
p.end === change.end &&
p.newText === change.text,
);
matchEvent(
docPath: string,
changes: ReadonlyArray<{ start: number; end: number; newLength: number }>,
): PendingEdit | null {
if (changes.length === 0) return null;
const i = this.pending.findIndex((p) => {
if (p.docPath !== docPath) return false;
const full = p.full ?? { start: p.start, end: p.end, newLength: p.newText.length };
const delta = full.newLength - (full.end - full.start);
const eventDelta = changes.reduce((d, c) => d + (c.newLength - (c.end - c.start)), 0);
if (delta !== eventDelta) return false;
return changes.every((c) => c.start >= full.start && c.end <= full.end);
});
if (i === -1) return null;
const [hit] = this.pending.splice(i, 1);
return hit;