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:
@@ -8,12 +8,13 @@
|
|||||||
* FileSystemWatcher live in CoauthorStore / extension.ts (the sidecar is
|
* FileSystemWatcher live in CoauthorStore / extension.ts (the sidecar is
|
||||||
* co-owned with ThreadController).
|
* co-owned with ThreadController).
|
||||||
*/
|
*/
|
||||||
|
import * as fs from "node:fs";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { CoauthorStore } from "./store";
|
import { CoauthorStore } from "./store";
|
||||||
import { newId, type AttributionRecord, type Provenance } from "./model";
|
import { newId, type AttributionRecord, type Provenance } from "./model";
|
||||||
import { buildFingerprint, resolve, type OffsetRange } from "./anchorer";
|
import { buildFingerprint, resolve, type OffsetRange } from "./anchorer";
|
||||||
import { applyChange, coalesce, type LiveSpan } from "./attributionTracker";
|
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. */
|
/** Test-facing snapshot of live attribution state for a document. */
|
||||||
export interface RenderedSpan {
|
export interface RenderedSpan {
|
||||||
@@ -136,8 +137,11 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||||
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
||||||
const docPath = this.docPathOf(e.document.uri);
|
const docPath = this.docPathOf(e.document.uri);
|
||||||
if (!e.document.isDirty) {
|
if (!e.document.isDirty && this.matchesDisk(e.document)) {
|
||||||
// Disk sync (revert / reload): re-resolve, never attribute (PUC-4).
|
// 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);
|
this.loadAll(e.document);
|
||||||
return;
|
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 hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text });
|
||||||
const author = hit ? hit.provenance : this.currentAuthor();
|
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"),
|
newId: () => newId("at"),
|
||||||
now: () => new Date().toISOString(),
|
now: () => new Date().toISOString(),
|
||||||
turnId: hit?.turnId,
|
turnId: hit?.turnId,
|
||||||
@@ -162,6 +169,17 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
this.render(e.document);
|
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) ---------------------------------------------------------------
|
// ---- 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).
|
* document version or a rejected edit — never partial-applies (spec §6.9).
|
||||||
* The pending edit is unregistered unconditionally afterwards (a no-op when
|
* The pending edit is unregistered unconditionally afterwards (a no-op when
|
||||||
* `match` already consumed it), so a no-op edit never leaks a registration.
|
* `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(
|
async applyAgentEdit(
|
||||||
document: vscode.TextDocument,
|
document: vscode.TextDocument,
|
||||||
@@ -181,17 +201,29 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
if (!this.isTracked(document)) return false;
|
if (!this.isTracked(document)) return false;
|
||||||
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
|
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
|
||||||
const docPath = this.docPathOf(document.uri);
|
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 = {
|
const pendingEdit = {
|
||||||
docPath,
|
docPath,
|
||||||
start: document.offsetAt(range.start),
|
start: minStart,
|
||||||
end: document.offsetAt(range.end),
|
end: minEnd,
|
||||||
newText,
|
newText: minText,
|
||||||
provenance,
|
provenance,
|
||||||
turnId: opts?.turnId,
|
turnId: opts?.turnId,
|
||||||
|
full: { start: startOffset, end: endOffset, newLength: newText.length },
|
||||||
};
|
};
|
||||||
this.pending.register(pendingEdit);
|
this.pending.register(pendingEdit);
|
||||||
const we = new vscode.WorkspaceEdit();
|
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);
|
const ok = await vscode.workspace.applyEdit(we);
|
||||||
this.pending.unregister(pendingEdit);
|
this.pending.unregister(pendingEdit);
|
||||||
return ok;
|
return ok;
|
||||||
|
|||||||
@@ -7,6 +7,27 @@
|
|||||||
*/
|
*/
|
||||||
import type { Provenance } from "./model";
|
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 {
|
export interface PendingEdit {
|
||||||
docPath: string;
|
docPath: string;
|
||||||
/** half-open [start, end) offsets of the replaced range. */
|
/** half-open [start, end) offsets of the replaced range. */
|
||||||
@@ -15,6 +36,14 @@ export interface PendingEdit {
|
|||||||
newText: string;
|
newText: string;
|
||||||
provenance: Provenance;
|
provenance: Provenance;
|
||||||
turnId?: string;
|
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 {
|
export class PendingEditRegistry {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { PendingEditRegistry } from "../src/pendingEdits";
|
import { minimizeReplace, PendingEditRegistry } from "../src/pendingEdits";
|
||||||
import type { Provenance } from "../src/model";
|
import type { Provenance } from "../src/model";
|
||||||
|
|
||||||
const AGENT: Provenance = {
|
const AGENT: Provenance = {
|
||||||
@@ -30,3 +30,22 @@ describe("PendingEditRegistry (INV-9)", () => {
|
|||||||
expect(reg.match("d.md", { start: 0, end: 0, text: "x" })).toBeNull();
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user