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
+30 -15
View File
@@ -146,24 +146,39 @@ export class AttributionController implements vscode.Disposable {
return;
}
const s = this.state(docPath);
// Sort descending by offset so earlier changes don't invalidate later offsets
// (VS Code's order is undocumented; defensive sort is the safe guarantee).
for (const change of [...e.contentChanges].sort((a, b) => b.rangeOffset - a.rangeOffset)) {
const edit = {
start: change.rangeOffset,
end: change.rangeOffset + change.rangeLength,
newLength: change.text.length,
};
const hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text });
const author = hit ? hit.provenance : this.currentAuthor();
// On a seam hit, attribute the agent's FULL intended replacement (the
// registered edit is diff-minimized for transport only): same delta,
// wider span — the agent owns every char it asserted (INV-9).
s.spans = applyChange(s.spans, hit?.full ?? edit, author, {
// One applyEdit = one change event, but the host may deliver a seam edit
// as SEVERAL minimal hunks (word-level diffing). Match the EVENT's net
// effect against the registry; on a hit the agent owns its FULL intended
// replacement (INV-9) — apply it as ONE algebra edit, not per hunk.
const hit = this.pending.matchEvent(
docPath,
e.contentChanges.map((c) => ({
start: c.rangeOffset,
end: c.rangeOffset + c.rangeLength,
newLength: c.text.length,
})),
);
if (hit) {
const full = hit.full ?? { start: hit.start, end: hit.end, newLength: hit.newText.length };
s.spans = applyChange(s.spans, full, hit.provenance, {
newId: () => newId("at"),
now: () => new Date().toISOString(),
turnId: hit?.turnId,
turnId: hit.turnId,
});
} else {
// Sort descending by offset so earlier changes don't invalidate later offsets
// (VS Code's order is undocumented; defensive sort is the safe guarantee).
for (const change of [...e.contentChanges].sort((a, b) => b.rangeOffset - a.rangeOffset)) {
const edit = {
start: change.rangeOffset,
end: change.rangeOffset + change.rangeLength,
newLength: change.text.length,
};
s.spans = applyChange(s.spans, edit, this.currentAuthor(), {
newId: () => newId("at"),
now: () => new Date().toISOString(),
});
}
}
if (s.spans.length > 0) s.hadAttributions = true;
this.render(e.document);