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)); async function getApi(): Promise { const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const api = (await ext.activate()) as CowritingApi; assert.ok(api?.proposalController && api?.editorProposalController, "exports controllers"); return api; } async function freshDoc(rel: string, body: string): Promise { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body, "utf8"); const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs)); await vscode.window.showTextDocument(doc); // INV-10: attribution tracking (accept's per-word attribution, INV-40) is // gated on coediting (Task 4). await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return doc; } // #47 SLICE-2 (review, P1): a document rewrite proposes ONE F4 proposal per // CHANGED BLOCK (INV-39 supersedes INV-37's per-word cut), but accepting a block // reconciles attribution at WORD granularity (INV-40 — block = decision unit, // word = attribution unit). Host E2E, no LLM (the edit turn is stubbed). suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)", () => { // PUC-4: M changed blocks → M proposals; unchanged blocks → none. test("edits across two paragraphs → two proposals; the untouched paragraph yields none (INV-39)", async () => { const doc = await freshDoc( "docs/f12-multi.md", "# Doc\n\nFirst paragraph alpha.\n\nSecond paragraph beta.\n\nThird paragraph gamma.\n", ); const api = await getApi(); const editFlow = api.editFlow; editFlow.setEditTurnForTest(async () => ({ replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n", model: "sonnet", sessionId: "e2e-f12-multi", })); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns"); await settle(); assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none"); // each proposal's anchor spans a whole block, and the replacement is that block's rewrite const views = api.proposalController.listProposals(doc); const replacements = views.map((v) => v.replacement).sort(); assert.deepStrictEqual(replacements, ["First paragraph ALPHA.", "Third paragraph GAMMA."]); }); // PUC-4 + INV-23: a changed code fence is ONE atomic whole-fence proposal. test("a changed code fence → one atomic proposal over the whole fence (INV-23)", async () => { const doc = await freshDoc( "docs/f12-fence.md", "# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n", ); const api = await getApi(); const editFlow = api.editFlow; editFlow.setEditTurnForTest(async () => ({ replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n", model: "sonnet", sessionId: "e2e-f12-fence", })); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "bump a to 10"); await settle(); assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal"); const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0])!; assert.strictEqual(view.replacement, "```js\nconst a = 10;\nconst b = 2;\n```", "whole-fence replacement"); }); // PUC-5 / INV-40: accepting a block attributes ONLY the words Claude changed — // unchanged words in the block are NOT swept into Claude's authorship. test("accepting a block proposal attributes only the changed words to Claude (INV-40)", async () => { const doc = await freshDoc("docs/f12-attr.md", "# T\n\nThe quick brown fox jumps lazily.\n"); const api = await getApi(); const editFlow = api.editFlow; const key = api.proposalController.keyFor(doc); editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n", model: "sonnet", sessionId: "e2e-f12-attr", })); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "change two words"); await settle(); assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal"); const ok = await api.proposalController.acceptById(key, ids[0]); assert.ok(ok, "the block proposal accepts"); await settle(); assert.strictEqual(doc.getText(), "# T\n\nThe quick RED fox jumps SLOWLY.\n", "the whole block landed"); const agentTexts = api.attributionController .getSpans(key) .filter((s) => s.authorKind === "agent") .map((s) => doc.getText().slice(s.range.start, s.range.end).trim()) .filter((t) => t.length > 0); // Only the two words Claude actually changed are Claude-attributed. const joined = agentTexts.join(" "); assert.ok(joined.includes("RED"), "the changed word RED is Claude-attributed"); assert.ok(joined.includes("SLOWLY"), "the changed word SLOWLY is Claude-attributed"); assert.ok(!/\bquick\b/.test(joined), "the unchanged word 'quick' is NOT swept into Claude's authorship"); assert.ok(!/\bfox\b/.test(joined), "the unchanged word 'fox' is NOT swept into Claude's authorship"); }); // PUC-4 / INV-41: a block-insertion rewrite produces acceptable proposals and // accepting them all reconstructs the intended document (no born-orphaned hunk). test("a rewrite that inserts a paragraph → acceptable proposals; accept-all reaches the rewrite (INV-41)", async () => { const original = "# Ins\n\nAlpha block.\n\nBeta block.\n"; const rewrite = "# Ins\n\nAlpha block.\n\nBrand new middle block.\n\nBeta block.\n"; const doc = await freshDoc("docs/f12-insert.md", original); const api = await getApi(); const editFlow = api.editFlow; const key = api.proposalController.keyFor(doc); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" })); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph"); await settle(); assert.ok(ids.length >= 1, "the insertion produced at least one proposal"); for (const id of ids) { const ok = await api.proposalController.acceptById(key, id); assert.ok(ok, `proposal ${id} is acceptable (not born-orphaned, INV-41)`); await settle(); } assert.strictEqual(doc.getText(), rewrite, "accepting all proposals reconstructs the intended rewrite"); assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending"); }); }); // Task 8 (PUC-2 lens copy): the CodeLens titles read "✓ Keep"/"✗ Reject" per // proposal, and — once ≥2 proposals are pending — a top-of-file "Keep all // (N)"/"Reject all" pair routes straight at the batch commands. suite("F12 CodeLens copy (Task 8, PUC-2)", () => { test("one pending proposal → a per-proposal ✓ Keep / ✗ Reject pair, no top-of-file pair", async () => { const doc = await freshDoc("docs/f12-lens-one.md", "# Lens\n\nAlpha block here.\n"); const api = await getApi(); const editFlow = api.editFlow; editFlow.setEditTurnForTest(async () => ({ replacement: "# Lens\n\nALPHA block here.\n", model: "sonnet", sessionId: "e2e-lens-one", })); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase"); await settle(); assert.strictEqual(ids.length, 1, "one changed block → one proposal"); const lenses = api.editorProposalController.provideCodeLenses(doc); const titles = lenses.map((l) => l.command?.title); assert.deepStrictEqual(titles, ["✓ Keep", "✗ Reject"], "exactly one per-proposal pair, no top-of-file pair below threshold"); }); test("two pending proposals → a top-of-file 'Keep all (2)'/'Reject all' pair routed at the batch commands", async () => { const doc = await freshDoc( "docs/f12-lens-two.md", "# Lens\n\nFirst block here.\n\nSecond block here.\n", ); const api = await getApi(); const editFlow = api.editFlow; editFlow.setEditTurnForTest(async () => ({ replacement: "# Lens\n\nFIRST block here.\n\nSECOND block here.\n", model: "sonnet", sessionId: "e2e-lens-two", })); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase both"); await settle(); assert.strictEqual(ids.length, 2, "two changed blocks → two proposals"); const lenses = api.editorProposalController.provideCodeLenses(doc); const titles = lenses.map((l) => l.command?.title); assert.strictEqual(titles[0], "✓ Keep all (2)", "top-of-file accept-all lens leads"); assert.strictEqual(titles[1], "✗ Reject all", "top-of-file reject-all lens follows"); assert.deepStrictEqual(titles.slice(2), ["✓ Keep", "✗ Reject", "✓ Keep", "✗ Reject"], "per-proposal pairs follow"); const acceptAll = lenses[0].command!; const rejectAll = lenses[1].command!; assert.strictEqual(acceptAll.command, "cowriting.acceptAllProposals", "top-of-file accept-all routes at the batch command"); assert.strictEqual(rejectAll.command, "cowriting.rejectAllProposals", "top-of-file reject-all routes at the batch command"); assert.deepStrictEqual(acceptAll.arguments, undefined, "acceptAllProposals takes no arguments (targets the active editor)"); }); });