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:
@@ -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);
|
||||
|
||||
+21
-10
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user