diff --git a/src/attributionController.ts b/src/attributionController.ts index c419ed5..5e2b2bb 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -8,12 +8,13 @@ * FileSystemWatcher live in CoauthorStore / extension.ts (the sidecar is * co-owned with ThreadController). */ +import * as fs from "node:fs"; import * as vscode from "vscode"; import { CoauthorStore } from "./store"; import { newId, type AttributionRecord, type Provenance } from "./model"; import { buildFingerprint, resolve, type OffsetRange } from "./anchorer"; import { applyChange, coalesce, type LiveSpan } from "./attributionTracker"; -import { PendingEditRegistry } from "./pendingEdits"; +import { minimizeReplace, PendingEditRegistry } from "./pendingEdits"; /** Test-facing snapshot of live attribution state for a document. */ export interface RenderedSpan { @@ -136,8 +137,11 @@ export class AttributionController implements vscode.Disposable { private onDidChange(e: vscode.TextDocumentChangeEvent): void { if (!this.isTracked(e.document) || e.contentChanges.length === 0) return; const docPath = this.docPathOf(e.document.uri); - if (!e.document.isDirty) { - // Disk sync (revert / reload): re-resolve, never attribute (PUC-4). + if (!e.document.isDirty && this.matchesDisk(e.document)) { + // Disk sync (revert / external reload): buffer now equals the file on + // disk — re-resolve, never attribute (PUC-4). A real edit can also + // arrive with isDirty still false (VS Code flips the flag after the + // change event), but then the buffer no longer matches the disk. this.loadAll(e.document); return; } @@ -152,7 +156,10 @@ export class AttributionController implements vscode.Disposable { }; const hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text }); const author = hit ? hit.provenance : this.currentAuthor(); - s.spans = applyChange(s.spans, edit, author, { + // 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, { newId: () => newId("at"), now: () => new Date().toISOString(), turnId: hit?.turnId, @@ -162,6 +169,17 @@ export class AttributionController implements vscode.Disposable { this.render(e.document); } + /** True when the document buffer is byte-identical to the file on disk. */ + private matchesDisk(document: vscode.TextDocument): boolean { + try { + return fs.readFileSync(document.uri.fsPath, "utf8") === document.getText(); + } catch { + // Unreadable/missing file: treat as a real edit (attribute), per + // fail-open honesty — misclassifying a sync as an edit is recoverable. + return false; + } + } + // ---- the seam (INV-9) --------------------------------------------------------------- /** @@ -170,6 +188,8 @@ export class AttributionController implements vscode.Disposable { * document version or a rejected edit — never partial-applies (spec §6.9). * The pending edit is unregistered unconditionally afterwards (a no-op when * `match` already consumed it), so a no-op edit never leaks a registration. + * The replace is self-minimized (common prefix/suffix trimmed) to mirror the + * host's WorkspaceEdit diff-minimization, so registered == applied == delivered. */ async applyAgentEdit( document: vscode.TextDocument, @@ -181,17 +201,29 @@ export class AttributionController implements vscode.Disposable { if (!this.isTracked(document)) return false; if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false; const docPath = this.docPathOf(document.uri); + const startOffset = document.offsetAt(range.start); + const endOffset = document.offsetAt(range.end); + const oldText = document.getText(range); + const { prefix, suffix } = minimizeReplace(oldText, newText); + const minStart = startOffset + prefix; + const minEnd = endOffset - suffix; + const minText = newText.slice(prefix, newText.length - suffix); + if (minStart === minEnd && minText.length === 0) { + // Replacement equals the existing text — a no-op; nothing to attribute. + return true; + } const pendingEdit = { docPath, - start: document.offsetAt(range.start), - end: document.offsetAt(range.end), - newText, + start: minStart, + end: minEnd, + newText: minText, provenance, turnId: opts?.turnId, + full: { start: startOffset, end: endOffset, newLength: newText.length }, }; this.pending.register(pendingEdit); const we = new vscode.WorkspaceEdit(); - we.replace(document.uri, range, newText); + we.replace(document.uri, new vscode.Range(document.positionAt(minStart), document.positionAt(minEnd)), minText); const ok = await vscode.workspace.applyEdit(we); this.pending.unregister(pendingEdit); return ok; diff --git a/src/pendingEdits.ts b/src/pendingEdits.ts index e48bd32..7318f83 100644 --- a/src/pendingEdits.ts +++ b/src/pendingEdits.ts @@ -7,6 +7,27 @@ */ 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. */ @@ -15,6 +36,14 @@ export interface PendingEdit { 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 { diff --git a/test/pendingEdits.test.ts b/test/pendingEdits.test.ts index 622941f..77c3c88 100644 --- a/test/pendingEdits.test.ts +++ b/test/pendingEdits.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { PendingEditRegistry } from "../src/pendingEdits"; +import { minimizeReplace, PendingEditRegistry } from "../src/pendingEdits"; import type { Provenance } from "../src/model"; const AGENT: Provenance = { @@ -30,3 +30,22 @@ describe("PendingEditRegistry (INV-9)", () => { expect(reg.match("d.md", { start: 0, end: 0, text: "x" })).toBeNull(); }); }); + +describe("minimizeReplace (host diff-minimization mirror)", () => { + it("trims a common suffix (the E2E-observed case)", () => { + expect(minimizeReplace("target sentence", "REWRITTEN-BY-CLAUDE sentence")).toEqual({ prefix: 0, suffix: 9 }); + }); + it("trims a common prefix", () => { + expect(minimizeReplace("Hello world", "Hello there")).toEqual({ prefix: 6, suffix: 0 }); + }); + it("trims both, never overlapping", () => { + expect(minimizeReplace("aba", "aa")).toEqual({ prefix: 1, suffix: 1 }); + expect(minimizeReplace("aaaa", "aa")).toEqual({ prefix: 2, suffix: 0 }); + }); + it("identical texts minimize to nothing", () => { + expect(minimizeReplace("same", "same")).toEqual({ prefix: 4, suffix: 0 }); + }); + it("disjoint texts trim nothing", () => { + expect(minimizeReplace("abc", "xyz")).toEqual({ prefix: 0, suffix: 0 }); + }); +});