8fdea97d36
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>
97 lines
3.7 KiB
TypeScript
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;
|
|
}
|
|
}
|