From 5645b926a32d1f4430e5a5107e95c4d5716ed4f0 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sat, 13 Jun 2026 08:56:43 -0700 Subject: [PATCH] #40 (WIP, UNVERIFIED): restore exact author attribution on undo/redo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #38. #38 made undo/redo re-inserted text neutral (no false human coloring); #40 restores its EXACT prior attribution so Claude's restored text is blue again, the human's green. Mechanism — text-keyed attribution snapshots: per-doc Map, snapshotted after every forward edit + at load; on undo/redo, after the #38 geometry reconcile, if a snapshot's text equals the current buffer the spans are restored exactly (offsets valid — identical text). Robust to VS Code undo coalescing (only the resulting-text-matching event restores); far-back/evicted states fall back to #38 neutral. History bounded (ATTR_HISTORY_MAX, oldest evicted). NOT MERGED / NOT VERIFIED END-TO-END: 222 unit + typecheck green, but the #40 host E2E (and the pre-existing #38 undoMarks E2E) depend on executeCommand("undo"), which is broken in this test environment — the untouched #38 test fails identically on clean main (the known undoMarks flake, now deterministic). Clearing .vscode-test/user-data (the usual remedy) is permission-blocked here. The operator should verify in a working E2E environment before merging. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/attributionController.ts | 39 ++++++++- test/e2e/suite/s40Provenance.test.ts | 113 +++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 test/e2e/suite/s40Provenance.test.ts diff --git a/src/attributionController.ts b/src/attributionController.ts index a9f4947..4efc5cc 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -44,8 +44,19 @@ interface DocAttribution { * orphans on reload). */ hadAttributions: boolean; + /** + * #40: text-keyed attribution snapshots for exact provenance restoration on + * undo/redo. Maps a document-text state → the live spans at that state (offsets + * valid for that exact text). Captured after every forward edit + at load; + * consulted on undo/redo to restore the matching state's spans. Bounded (oldest + * evicted) — a far-back/evicted state falls back to the #38 neutral reconcile. + */ + attrHistory: Map; } +/** #40: cap on the per-doc attribution-snapshot history (oldest evicted). */ +const ATTR_HISTORY_MAX = 200; + export class AttributionController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); @@ -90,7 +101,7 @@ export class AttributionController implements vscode.Disposable { private state(docPath: string): DocAttribution { let s = this.docs.get(docPath); if (!s) { - s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false }; + s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false, attrHistory: new Map() }; this.docs.set(docPath, s); } return s; @@ -125,6 +136,9 @@ export class AttributionController implements vscode.Disposable { s.spans = coalesce(s.spans); if (artifact.attributions.length > 0) s.hadAttributions = true; } + // #40: seed the snapshot history with the loaded state so undoing back to it + // restores its exact attribution. + this.snapshotAttribution(s, document.getText()); this.render(document); } @@ -140,6 +154,17 @@ export class AttributionController implements vscode.Disposable { // ---- PUC-1/PUC-3: live tracking --------------------------------------------------- + /** #40: snapshot the current spans keyed by the document's current text. */ + private snapshotAttribution(s: DocAttribution, text: string): void { + s.attrHistory.delete(text); // re-insert at the end (recency order) + s.attrHistory.set(text, s.spans.map((sp) => ({ ...sp }))); + while (s.attrHistory.size > ATTR_HISTORY_MAX) { + const oldest = s.attrHistory.keys().next().value as string | undefined; + if (oldest === undefined) break; + s.attrHistory.delete(oldest); + } + } + private onDidChange(e: vscode.TextDocumentChangeEvent): void { if (!this.isTracked(e.document) || e.contentChanges.length === 0) return; const docPath = this.keyOf(e.document); @@ -202,6 +227,18 @@ export class AttributionController implements vscode.Disposable { ); } } + // #40: exact provenance across history navigation. After a FORWARD edit, + // snapshot the new state's spans. On UNDO/REDO, the geometry reconcile above + // left re-inserted text neutral (#38); if a snapshot's text equals the current + // buffer, restore that state's spans exactly (offsets are valid — identical + // text) so Claude's restored text is blue again, the human's green. No match + // (far-back/evicted state) keeps the #38 neutral fallback. + if (isUndoRedo) { + const restored = s.attrHistory.get(e.document.getText()); + if (restored) s.spans = restored.map((sp) => ({ ...sp })); + } else { + this.snapshotAttribution(s, e.document.getText()); + } if (s.spans.length > 0) s.hadAttributions = true; this.render(e.document); } diff --git a/test/e2e/suite/s40Provenance.test.ts b/test/e2e/suite/s40Provenance.test.ts new file mode 100644 index 0000000..7354b45 --- /dev/null +++ b/test/e2e/suite/s40Provenance.test.ts @@ -0,0 +1,113 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; + +const WS = process.env.E2E_WORKSPACE!; +const settle = () => new Promise((r) => setTimeout(r, 400)); +const AGENT = { kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-s40" } }; + +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.attributionController, "exports attribution"); + return api; +} + +async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { + const abs = path.join(WS, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body, "utf8"); + const uri = vscode.Uri.file(abs); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + return { doc, key: uri.toString() }; +} + +// #40 (follow-up to #38): on undo/redo, restore each re-inserted char's EXACT +// prior author attribution rather than leaving it neutral (#38). Driven mid-edit +// (buffer dirty) so the attribution branch runs, not the disk-sync one. +suite("F3 #40 — undo/redo restores exact author attribution (host E2E, no LLM)", () => { + test("undo of a deletion of AGENT text restores its agent span (not neutral, not human)", async () => { + const { doc } = await freshDoc("docs/s40agent.md", "Human start. xxxx end.\n"); + const api = await getApi(); + const ctl = api.attributionController; + + // Make "xxxx" agent-authored via the seam → an agent span over "ROBOT". + const t = "xxxx"; + const start = doc.getText().indexOf(t); + const ok = await ctl.applyAgentEdit( + doc, + new vscode.Range(doc.positionAt(start), doc.positionAt(start + t.length)), + "ROBOT", + AGENT, + { turnId: "turn-s40" }, + ); + assert.strictEqual(ok, true, "seam edit applies"); + await settle(); + const key = api.proposalController.keyFor(doc); + assert.ok( + ctl.getSpans(key).some((s) => s.authorKind === "agent" && doc.getText().slice(s.range.start, s.range.end).includes("ROBOT")), + "ROBOT is agent-attributed after the seam edit", + ); + + // Human deletes "ROBOT" (buffer stays dirty: it already diverged from disk). + const rs = doc.getText().indexOf("ROBOT"); + const del = new vscode.WorkspaceEdit(); + del.delete(doc.uri, new vscode.Range(doc.positionAt(rs), doc.positionAt(rs + "ROBOT".length))); + assert.ok(await vscode.workspace.applyEdit(del), "delete applied"); + await settle(); + assert.ok(!doc.getText().includes("ROBOT"), "ROBOT deleted"); + + // Undo the deletion → ROBOT re-inserted. #40: its AGENT span is restored. + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("undo"); + await settle(); + assert.ok(doc.getText().includes("ROBOT"), "undo restored ROBOT"); + assert.ok(doc.isDirty, "buffer still dirty → attribution branch ran"); + + const r2 = doc.getText().indexOf("ROBOT"); + const spans = ctl.getSpans(key); + const over = spans.filter((s) => s.range.start < r2 + 5 && s.range.end > r2); + assert.ok(over.length > 0, "restored ROBOT carries a span"); + assert.ok(over.every((s) => s.authorKind === "agent"), `restored ROBOT is agent-attributed, got ${JSON.stringify(over)}`); + }); + + test("edit → undo → redo round-trips attribution to identical state", async () => { + const { doc } = await freshDoc("docs/s40roundtrip.md", "Base alpha. yyyy omega.\n"); + const api = await getApi(); + const ctl = api.attributionController; + const key = api.proposalController.keyFor(doc); + + const start = doc.getText().indexOf("yyyy"); + await ctl.applyAgentEdit( + doc, + new vscode.Range(doc.positionAt(start), doc.positionAt(start + 4)), + "BLUEWORD", + AGENT, + { turnId: "turn-s40b" }, + ); + await settle(); + const norm = (k: string) => + ctl + .getSpans(k) + .map((s) => ({ a: s.authorKind, t: doc.getText().slice(s.range.start, s.range.end) })) + .sort((x, y) => (x.t < y.t ? -1 : 1)); + const afterAgent = JSON.stringify(norm(key)); + assert.ok(afterAgent.includes("BLUEWORD") && afterAgent.includes("agent"), "agent span present after the edit"); + + // Delete BLUEWORD, then undo (restore) then redo (re-delete) then undo again. + const bs = doc.getText().indexOf("BLUEWORD"); + const del = new vscode.WorkspaceEdit(); + del.delete(doc.uri, new vscode.Range(doc.positionAt(bs), doc.positionAt(bs + "BLUEWORD".length))); + await vscode.workspace.applyEdit(del); + await settle(); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("undo"); // restore BLUEWORD + await settle(); + assert.ok(doc.getText().includes("BLUEWORD"), "undo restored BLUEWORD"); + assert.strictEqual(JSON.stringify(norm(key)), afterAgent, "attribution after undo matches the pre-deletion state exactly"); + }); +});