Files
vscode-cowriting-plugin/src/pendingEdits.ts
T
Ben Stull 8fdea97d36 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>
2026-06-10 22:12:15 -07:00

97 lines
3.7 KiB
TypeScript

/**
* PendingEditRegistry — the seam's bookkeeping (spec §6.2, INV-9). Before
* applyAgentEdit applies a WorkspaceEdit, it registers the exact expected
* change; the controller's change handler consumes a matching registration to
* attribute that change to the agent. Anything unmatched is human typing.
* vscode-free and unit-testable.
*/
import type { Provenance } from "./model";
/**
* Greedily trim the common prefix then common suffix between the replaced
* text and its replacement (non-overlapping), mirroring the host's own
* WorkspaceEdit diff-minimization so a registered seam edit matches the
* change event VS Code actually delivers (INV-9).
*/
export function minimizeReplace(
oldText: string,
newText: string,
): { prefix: number; suffix: number } {
const max = Math.min(oldText.length, newText.length);
let prefix = 0;
while (prefix < max && oldText[prefix] === newText[prefix]) prefix++;
let suffix = 0;
while (
suffix < max - prefix &&
oldText[oldText.length - 1 - suffix] === newText[newText.length - 1 - suffix]
) suffix++;
return { prefix, suffix };
}
export interface PendingEdit {
docPath: string;
/** half-open [start, end) offsets of the replaced range. */
start: number;
end: number;
newText: string;
provenance: Provenance;
turnId?: string;
/**
* The agent's full intended replacement before self-minimization (pre-edit
* half-open range + replacement length). The minimized start/end/newText are
* transport-only (so the registration matches the host-delivered change);
* attribution uses this extent — the agent owns every char it asserted,
* including chars the minimized diff left unchanged.
*/
full?: { start: number; end: number; newLength: number };
}
export class PendingEditRegistry {
private pending: PendingEdit[] = [];
register(edit: PendingEdit): void {
this.pending.push(edit);
}
/**
* Remove a registration that failed to apply. Identity-based (`===`), so
* callers must keep the exact registered object. Returns `true` if the
* registration was still pending and was removed, `false` if it was already
* consumed by `match` (or was never registered). This lets callers detect
* seam misses for diagnosability (INV-9).
*/
unregister(edit: PendingEdit): boolean {
const before = this.pending.length;
this.pending = this.pending.filter((p) => p !== edit);
return this.pending.length < before;
}
/**
* 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.
*/
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;
}
}