From fc5fde1cc9533b0702f1318fc054d907afb19bbe Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 10:50:58 -0700 Subject: [PATCH] =?UTF-8?q?F3=20SLICE-6:=20host=20E2E=20=E2=80=94=20type/s?= =?UTF-8?q?eam/persist/re-anchor/orphan,=20no=20LLM=20in=20CI=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- test/e2e/fixtures/workspace/docs/attrib.md | 5 + test/e2e/suite/attribution.test.ts | 131 +++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 test/e2e/fixtures/workspace/docs/attrib.md create mode 100644 test/e2e/suite/attribution.test.ts diff --git a/test/e2e/fixtures/workspace/docs/attrib.md b/test/e2e/fixtures/workspace/docs/attrib.md new file mode 100644 index 0000000..63bd9cc --- /dev/null +++ b/test/e2e/fixtures/workspace/docs/attrib.md @@ -0,0 +1,5 @@ +# Attribution fixture + +A stable first paragraph that predates tracking. + +The target sentence lives here for seam edits. diff --git a/test/e2e/suite/attribution.test.ts b/test/e2e/suite/attribution.test.ts new file mode 100644 index 0000000..7ae26cb --- /dev/null +++ b/test/e2e/suite/attribution.test.ts @@ -0,0 +1,131 @@ +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"; +import type { Artifact } from "../../../src/model"; + +const WS = process.env.E2E_WORKSPACE!; +const DOC_REL = "docs/attrib.md"; + +function sidecarPath(): string { + return path.join(WS, ".threads", "docs", "attrib.md.json"); +} +function readSidecar(): Artifact { + return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact; +} +async function openDoc(): Promise { + const uri = vscode.Uri.file(path.join(WS, DOC_REL)); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + return doc; +} +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.attributionController, "extension exports attributionController"); + return api; +} +async function externalWriteAndReload(uri: vscode.Uri, content: string): Promise { + fs.writeFileSync(uri.fsPath, content, "utf8"); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("workbench.action.files.revert"); + return doc; +} +const settle = () => new Promise((r) => setTimeout(r, 300)); + +suite("F3 live attribution (host E2E — seam-driven, no LLM)", () => { + const TYPED = "A human-typed sentence. "; + + test("typing produces a live human span", async () => { + const doc = await openDoc(); + const api = await getApi(); + const editor = vscode.window.activeTextEditor!; + const insertAt = doc.getText().length; + await editor.edit((eb) => eb.insert(doc.positionAt(insertAt), TYPED)); + await settle(); + const spans = api.attributionController.getSpans(DOC_REL); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].authorKind, "human"); + assert.strictEqual(doc.getText().slice(spans[0].range.start, spans[0].range.end), TYPED); + }); + + test("a seam edit produces an agent span; mixed edits stay char-honest (INV-7/INV-9)", async () => { + const doc = await openDoc(); + const api = await getApi(); + const target = "target sentence"; + const start = doc.getText().indexOf(target); + const ok = await api.attributionController.applyAgentEdit( + doc, + new vscode.Range(doc.positionAt(start), doc.positionAt(start + target.length)), + "REWRITTEN-BY-CLAUDE sentence", + { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-run" } }, + { turnId: "turn-e2e-1" }, + ); + assert.strictEqual(ok, true, "seam edit applies"); + await settle(); + const spans = api.attributionController.getSpans(DOC_REL); + const agent = spans.find((s) => s.authorKind === "agent"); + assert.ok(agent, "agent span exists"); + assert.strictEqual(agent!.turnId, "turn-e2e-1"); + assert.strictEqual( + doc.getText().slice(agent!.range.start, agent!.range.end), + "REWRITTEN-BY-CLAUDE sentence", + ); + assert.ok(spans.some((s) => s.authorKind === "human"), "human span still tracked"); + }); + + test("stale expectedVersion refuses to apply (spec §6.9)", async () => { + const doc = await openDoc(); + const api = await getApi(); + const ok = await api.attributionController.applyAgentEdit( + doc, + new vscode.Range(doc.positionAt(0), doc.positionAt(1)), + "X", + { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e" } }, + { expectedVersion: doc.version - 1 }, + ); + assert.strictEqual(ok, false); + }); + + test("save persists attributions[]; reload restores spans at re-resolved anchors (PUC-4)", async () => { + const doc = await openDoc(); + const api = await getApi(); + await doc.save(); + await settle(); + const art = readSidecar(); + assert.ok(art.attributions.length >= 2, "human + agent records persisted"); + const agentRec = art.attributions.find((a) => a.author.kind === "agent")!; + assert.strictEqual(art.anchors[agentRec.anchorId].fingerprint.text, "REWRITTEN-BY-CLAUDE sentence"); + assert.strictEqual(agentRec.turnId, "turn-e2e-1"); + + api.attributionController.loadAll(doc); + const spans = api.attributionController.getSpans(DOC_REL); + assert.ok(spans.some((s) => s.authorKind === "agent"), "agent span restored from sidecar"); + assert.strictEqual(api.attributionController.getOrphanCount(DOC_REL), 0); + }); + + test("external move re-anchors; mangling the text orphans (INV-1/INV-6)", async () => { + const uri = vscode.Uri.file(path.join(WS, DOC_REL)); + const original = fs.readFileSync(uri.fsPath, "utf8"); + const api = await getApi(); + + let doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + original); + await settle(); + api.attributionController.loadAll(doc); + let spans = api.attributionController.getSpans(DOC_REL); + const agent = spans.find((s) => s.authorKind === "agent")!; + const moved = doc.getText().indexOf("REWRITTEN-BY-CLAUDE sentence"); + assert.strictEqual(agent.range.start, moved, "agent span re-anchored after move"); + assert.strictEqual(api.attributionController.getOrphanCount(DOC_REL), 0); + + const mangled = doc.getText().replace("REWRITTEN-BY-CLAUDE sentence", "now something else entirely"); + doc = await externalWriteAndReload(uri, mangled); + await settle(); + api.attributionController.loadAll(doc); + spans = api.attributionController.getSpans(DOC_REL); + assert.ok(!spans.some((s) => s.authorKind === "agent"), "mangled agent span not rendered"); + assert.ok(api.attributionController.getOrphanCount(DOC_REL) >= 1, "…it is orphaned instead (INV-1)"); + }); +});