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 { addProposal, setProposalApplied } from "../../../src/proposalModel"; import { buildFingerprint } from "../../../src/anchorer"; 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?.diffViewController && api?.proposalController, "exports controllers"); return api; } /** Also enters coediting (INV-10/Task 2): the baseline is established only on * entry, not merely on open. */ 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 vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } suite("F12 inline diff — seam landBaseline (#64, INV-48)", () => { test("applyAgentEdit with landBaseline:false applies text but does NOT advance the baseline", async () => { const { doc, key } = await freshDoc("docs/f12-land.md", "# T\n\nalpha here.\n"); const api = await getApi(); const before = api.diffViewController.getBaseline(key)?.text ?? doc.getText(); const start = doc.getText().indexOf("alpha"); const ok = await api.attributionController.applyAgentEdit( doc, new vscode.Range(doc.positionAt(start), doc.positionAt(start + "alpha".length)), "ALPHA", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { landBaseline: false }, ); await settle(); assert.ok(ok, "edit applied"); assert.ok(doc.getText().includes("ALPHA"), "text in buffer"); assert.strictEqual(api.diffViewController.getBaseline(key)?.text ?? doc.getText(), before, "baseline unchanged"); }); }); suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => { // Optimistic apply lands the text + re-anchors; the buffer becomes the accepted result. test("optimisticApply puts the proposed text in the buffer and re-anchors", async () => { const { doc } = await freshDoc("docs/f12-opt.md", "# T\n\nReplace alpha please.\n"); const api = await getApi(); const p = api.proposalController; const fp = { text: "Replace alpha please.", before: "", after: "", lineHint: 2 }; const id = await p.propose(doc, fp, "Replace ALPHA please.", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); await api.proposalController.optimisticApply(doc, id!); await settle(); assert.ok(doc.getText().includes("Replace ALPHA please."), "applied to buffer"); // re-anchored: the proposal still resolves against the mutated buffer assert.strictEqual(p.listProposals(doc)[0].anchorStart !== null, true, "re-anchored, resolves"); assert.strictEqual(p.listProposals(doc)[0].original, "Replace alpha please.", "original stored"); }); test("finalizeInPlace clears the proposal and keeps the applied text (no double-apply)", async () => { const { doc } = await freshDoc("docs/f12-fin.md", "# T\n\nKeep alpha now.\n"); const api = await getApi(); const p = api.proposalController; const docPath = p.keyFor(doc); const fp = { text: "Keep alpha now.", before: "", after: "", lineHint: 2 }; const id = await p.propose(doc, fp, "Keep ALPHA now.", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); await p.optimisticApply(doc, id!); await settle(); const ok = await p.finalizeInPlace(docPath, id!); await settle(); assert.ok(ok, "finalized"); assert.strictEqual(doc.getText(), "# T\n\nKeep ALPHA now.\n", "applied text retained, no double-apply"); assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared"); }); test("revertInPlace restores the original and clears the proposal", async () => { const { doc } = await freshDoc("docs/f12-rev.md", "# T\n\nUndo alpha here.\n"); const api = await getApi(); const p = api.proposalController; const docPath = p.keyFor(doc); const fp = { text: "Undo alpha here.", before: "", after: "", lineHint: 2 }; const id = await p.propose(doc, fp, "Undo ALPHA here.", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); await p.optimisticApply(doc, id!); await settle(); const ok = await p.revertInPlace(docPath, id!); await settle(); assert.ok(ok, "reverted"); assert.strictEqual(doc.getText(), "# T\n\nUndo alpha here.\n", "original restored"); assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared"); }); test("rejectAll reverts every pending proposal", async () => { const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n"); const api = await getApi(); const editFlow = api.editFlow; const p = api.proposalController; editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" })); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); for (const id of ids) await p.optimisticApply(doc, id); await settle(); assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"), "both applied"); const { reverted } = await p.rejectAll(doc); await settle(); assert.strictEqual(reverted, ids.length, "all reverted"); assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document restored"); assert.strictEqual(p.listProposals(doc).length, 0, "all cleared"); }); }); suite("F12 inline diff — INV-50 listProposals.replaced", () => { test("listProposals reports the original as `replaced` after optimistic apply", async () => { const { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n"); const api = await getApi(); const editFlow = api.editFlow; editFlow.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" })); await editFlow.runEditAndPropose(doc, { kind: "document" }, "x"); await settle(); await settle(); assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here."); }); }); suite("F12 inline diff — editor surface (#64, INV-48/52)", () => { test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => { const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n"); const api = await getApi(); const editFlow = api.editFlow; editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" })); await editFlow.runEditAndPropose(doc, { kind: "document" }, "recolor the fox"); await settle(); await settle(); assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer"); const v = api.proposalController.listProposals(doc)[0]; assert.strictEqual(v.original, "The brown fox runs.", "original captured for the deletion hint/revert"); }); test("editing the inserted text then finalizing keeps the human edit", async () => { const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n"); const api = await getApi(); const editFlow = api.editFlow; editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" })); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); await settle(); // human tweaks the inserted text const at = doc.getText().indexOf("ALPHA"); const we = new vscode.WorkspaceEdit(); we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 5)), "ALPHA!"); await vscode.workspace.applyEdit(we); await settle(); // keyFor(doc) gives the repo-relative path that finalizeInPlace uses as its key; // the `key` from freshDoc is the URI string, which would not match for in-workspace docs. const docKey = api.proposalController.keyFor(doc); await api.proposalController.finalizeInPlace(docKey, ids[0]); await settle(); assert.ok(doc.getText().includes("ALPHA! word here."), "human edit preserved on accept"); assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "cleared"); }); }); suite("F12 inline diff — control parity (#64, INV-53)", () => { test("reject-one via revertInPlace reverts in place; rejectAll clears every proposal", async () => { const { doc } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n"); const api = await getApi(); const p = api.proposalController; const editFlow = api.editFlow; editFlow.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" })); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); await settle(); assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB")); // reject ONE via the same seam the per-proposal "✗ Reject" CodeLens uses // (Task 8, PUC-2) → that block reverts, the other stays applied. const docKey = p.keyFor(doc); assert.ok(await p.revertInPlace(docKey, ids[0]), "revert one proposal"); await settle(); await settle(); assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied"); // rejectAll via the same seam the top-of-file "✗ Reject all" CodeLens uses → // all gone, document restored. const { reverted } = await p.rejectAll(doc); assert.strictEqual(reverted, 1, "the one remaining proposal reverted"); await settle(); await settle(); assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored"); assert.strictEqual(api.proposalController.listProposals(doc).length, 0); }); test("cowriting.rejectAllProposals is registered + markdown-guarded", async () => { const all = await vscode.commands.getCommands(true); assert.ok(all.includes("cowriting.rejectAllProposals")); const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( (m) => m.command === "cowriting.rejectAllProposals", ); assert.ok(entry && /editorLangId == markdown/.test(entry.when ?? ""), "palette-guarded on markdown"); }); }); // #70 (INV-5): rejecting a proposal AFTER typing inside its pending range must // still restore the retained original. The exact-substring anchor is orphaned by // the interior tweak (INV-1/INV-11) — the revert falls back to the F12 // applied-range bookkeeping (appliedSpans), which shift-tracks the span through // interior edits. suite("F12 inline diff — #70 reject after interior edit (INV-5)", () => { test("revertInPlace restores the original after a tweak inside the pending range", async () => { const ORIGINAL = "The quick brown fox jumps."; const { doc } = await freshDoc("docs/s70-reject-tweaked.md", `# T\n\n${ORIGINAL}\n`); const api = await getApi(); const p = api.proposalController; const docKey = p.keyFor(doc); const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 }; const id = await p.propose(doc, fp, "The rapid brown fox jumps.", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); await p.optimisticApply(doc, id!); await settle(); assert.ok(doc.getText().includes("The rapid brown fox jumps."), "optimistically applied"); // Human types INSIDE the pending range → orphans the exact anchor (INV-11). const at = doc.getText().indexOf("rapid"); const we = new vscode.WorkspaceEdit(); we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + "rapid".length)), "RAPID-ish"); assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied"); await settle(); assert.strictEqual(p.listProposals(doc).find((v) => v.id === id)!.anchorStart, null, "anchor orphaned by the tweak"); // ✗ Reject: must restore the retained original EXACTLY (INV-5), not skip. assert.ok(await p.revertInPlace(docKey, id!), "reject reports success"); await settle(); assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)"); assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared"); }); test("rejectByIdInPlace routes the tweaked-applied case the same way", async () => { const ORIGINAL = "A steady closing line."; const { doc } = await freshDoc("docs/s70-reject-route.md", `# T\n\n${ORIGINAL}\n`); const api = await getApi(); const p = api.proposalController; const docKey = p.keyFor(doc); const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 }; const id = await p.propose(doc, fp, "A wobbly closing line.", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); await p.optimisticApply(doc, id!); await settle(); const at = doc.getText().indexOf("wobbly"); const we = new vscode.WorkspaceEdit(); we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at)), "very-"); assert.ok(await vscode.workspace.applyEdit(we), "interior insertion applied"); await settle(); assert.ok(await p.rejectByIdInPlace(docKey, id!), "gesture-level reject succeeds"); await settle(); assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)"); }); // Fix direction (b): when the tracked span itself is distrusted (an edit // straddled its boundary — here a deletion running from the heading into the // span's interior), the reject FAILS honestly: warning path, proposal kept, // buffer untouched. Silent-skip-and-report-success (the #70 bug) must not come // back; reverting at a clamped guessed offset (INV-11) must not either. // (A boundary-straddling DELETION is used because VS Code minimizes workspace // edits before change events fire — a wholesale rewrite sharing a prefix/suffix // decomposes into interior hunks the span legitimately survives.) test("reject fails loudly — proposal kept, buffer untouched — when the span is distrusted", async () => { const ORIGINAL = "Sentence one stands here."; const { doc } = await freshDoc("docs/s70-reject-distrust.md", `# T\n\n${ORIGINAL}\n`); const api = await getApi(); const p = api.proposalController; const docKey = p.keyFor(doc); const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 }; const id = await p.propose(doc, fp, "Sentence ONE stands here.", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); await p.optimisticApply(doc, id!); await settle(); // Delete from inside the heading through the middle of the applied span: // the edit straddles the span's start boundary → the tracked span is // distrusted AND the exact anchor no longer resolves. const mid = doc.getText().indexOf("ONE"); const we = new vscode.WorkspaceEdit(); we.replace(doc.uri, new vscode.Range(doc.positionAt(2), doc.positionAt(mid + 1)), ""); assert.ok(await vscode.workspace.applyEdit(we), "boundary-straddling deletion applied"); await settle(); const before = doc.getText(); assert.strictEqual(await p.revertInPlace(docKey, id!), false, "reject reports failure (no silent success)"); await settle(); assert.strictEqual(doc.getText(), before, "buffer untouched — never reverted by guess (INV-11)"); assert.ok(p.listProposals(doc).some((v) => v.id === id), "proposal kept (not silently dropped)"); // Cleanup for later tests: the never-locatable husk is discarded via the // plain record-only reject. assert.strictEqual(p.rejectById(docKey, id!), true); }); // rejectAll must revert a tweaked (orphaned-anchor) proposal via the same // tracked-span fallback, ordered descending by that span so earlier reverts // never shift later ones — and must count what it could not revert. test("rejectAll reverts tweaked proposals via the tracked span and reports skips", async () => { const { doc } = await freshDoc("docs/s70-rejall-tweak.md", "# T\n\nOne aaa.\n\nTwo bbb.\n"); const api = await getApi(); const p = api.proposalController; const editFlow = api.editFlow; editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" })); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up"); await settle(); for (const id of ids) await p.optimisticApply(doc, id); await settle(); // Tweak INSIDE the first applied block → its exact anchor orphans. const at = doc.getText().indexOf("AAA"); const we = new vscode.WorkspaceEdit(); we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 3)), "AAAZ"); assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied"); await settle(); const { reverted, skipped } = await p.rejectAll(doc); await settle(); assert.strictEqual(reverted, ids.length, "ALL proposals reverted, tweaked one included"); assert.strictEqual(skipped, 0, "nothing skipped"); assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document fully restored (INV-5)"); assert.strictEqual(p.listProposals(doc).length, 0, "all cleared"); }); // The decision gestures must stay REACHABLE for a tweaked proposal: the ✓/✗ // CodeLens pair anchors at the tracked span when the interior tweak orphans // the exact anchor — otherwise the fixed reject path exists only at the API. test("the ✓ Keep / ✗ Reject CodeLens pair survives an interior tweak (tracked-span anchor)", async () => { const ORIGINAL = "Lens target sentence here."; const { doc } = await freshDoc("docs/s70-lens-tweak.md", `# T\n\n${ORIGINAL}\n`); const api = await getApi(); const p = api.proposalController; const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 }; const id = await p.propose(doc, fp, "Lens TARGET sentence here.", { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }); await p.optimisticApply(doc, id!); await settle(); const at = doc.getText().indexOf("TARGET"); const we = new vscode.WorkspaceEdit(); we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 6)), "TARGET-tweaked"); assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied"); await settle(); assert.strictEqual(p.listProposals(doc).find((v) => v.id === id)!.anchorStart, null, "anchor orphaned"); const lenses = api.editorProposalController.provideCodeLenses(doc); const titles = lenses.map((l) => l.command?.title); assert.deepStrictEqual(titles, ["✓ Keep", "✗ Reject"], "the pair survives the tweak"); assert.deepStrictEqual(lenses[1].command!.arguments, [id], "reject lens still targets the tweaked proposal"); // The reject it routes at still restores the original (the Task-1 contract). assert.ok(await p.revertInPlace(p.keyFor(doc), id!), "reject via the surviving gesture succeeds"); await settle(); assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)"); }); }); // Reload-safety: a proposal that was optimistically applied in a PRIOR session // persists with `original` set and its fp re-anchored to the applied text. A fresh // controller (empty in-memory `applied` set) must NOT re-capture `original` from the // already-applied buffer — doing so would clobber the true revert target and break // Reject. (Regression for the final-review CRITICAL; INV-51/54.) suite("F12 inline diff — reload-safety (#64, INV-51/54)", () => { test("optimisticApply does not clobber a previously-persisted original", async () => { const TRUE_ORIGINAL = "The original sentence here."; const APPLIED = "The APPLIED sentence here."; const { doc } = await freshDoc("docs/f12-reload.md", `# R\n\n${TRUE_ORIGINAL}\n`); const api = await getApi(); const p = api.proposalController; const key = p.keyFor(doc); // 1) The buffer holds the APPLIED text (as the saved-while-pending file would). const at = doc.getText().indexOf(TRUE_ORIGINAL); const we = new vscode.WorkspaceEdit(); we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + TRUE_ORIGINAL.length)), APPLIED); assert.ok(await vscode.workspace.applyEdit(we), "apply the prior-session applied text"); await settle(); // 2) Record the proposal directly in the sidecar exactly as a prior session left // it: fp anchored to the APPLIED text + `original` = the TRUE original. We do // NOT call optimisticApply, so this controller's in-memory `applied` stays empty. const appliedAt = doc.getText().indexOf(APPLIED); const appliedFp = buildFingerprint(doc.getText(), { start: appliedAt, end: appliedAt + APPLIED.length }); let id = ""; api.sidecarRouter.update(key, (a) => { id = addProposal(a, appliedFp, APPLIED, { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }).proposalId; setProposalApplied(a, id, appliedFp, TRUE_ORIGINAL); }); p.renderAll(doc); await settle(); // 3) Re-entry as a reload would trigger (EditorProposalController re-applies on // onDidChangeProposals because `applied` is empty). The guard must preserve original. await p.optimisticApply(doc, id); await settle(); const view = p.listProposals(doc).find((v) => v.id === id); assert.ok(view, "proposal still present"); assert.strictEqual(view!.original, TRUE_ORIGINAL, "true original preserved, NOT clobbered with the applied text"); // 4) Reject restores the TRUE original (not the applied text). assert.ok(await p.revertInPlace(key, id), "revert"); await settle(); assert.ok(doc.getText().includes(TRUE_ORIGINAL), "reject restored the true original"); assert.ok(!doc.getText().includes(APPLIED), "applied text removed on reject"); }); });