/** * 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; } }