F3 SLICE-3 host fixes: disk-compare sync detection + self-minimized seam edits (INV-9) (#6)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-10 10:50:07 -07:00
parent fe23ffa100
commit 2604ab4925
3 changed files with 89 additions and 9 deletions
+40 -8
View File
@@ -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;
+29
View File
@@ -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 {
+20 -1
View File
@@ -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 });
});
});