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/proposal.md"; function sidecarPath(): string { return path.join(WS, ".threads", "docs", "proposal.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?.proposalController, "extension exports proposalController"); return api; } async function proposeViaCommand( doc: vscode.TextDocument, target: string, newText: string, turnId: string, ): Promise { const start = doc.getText().indexOf(target); assert.ok(start >= 0, `fixture contains "${target}"`); const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { uri: doc.uri.toString(), start, end: start + target.length, newText, model: "sonnet", sessionId: "e2e-prop", turnId, instruction: "e2e instruction", }); assert.ok(id, "propose returns the proposal id"); return id!; } 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)); // Tests are ORDER-DEPENDENT (F2/F3 pattern): later tests consume earlier state. // This suite OWNS docs/proposal.md; threads owns docs/sample.md, attribution // owns docs/attrib.md — fixtures stay disjoint. suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => { const TARGET = "The propose target sentence lives here."; const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence."; test("propose records + renders a pending proposal and does NOT touch the document (INV-10)", async () => { const doc = await openDoc(); const api = await getApi(); const before = doc.getText(); await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1"); await settle(); assert.strictEqual(doc.getText(), before, "document text unchanged (INV-10)"); const rendered = api.proposalController.getRendered(DOC_REL); assert.strictEqual(rendered.length, 1); assert.strictEqual(rendered[0].pending, true); assert.strictEqual(rendered[0].turnId, "turn-p1"); const art = readSidecar(); assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time"); assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET); }); test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => { const doc = await openDoc(); const api = await getApi(); const id = api.proposalController.getRendered(DOC_REL)[0].id; const ok = await api.proposalController.acceptById(DOC_REL, id); assert.strictEqual(ok, true, "accept applies"); await settle(); assert.ok(doc.getText().includes(REPLACEMENT), "replacement landed"); assert.ok(!doc.getText().includes(TARGET), "target gone"); const spans = api.attributionController.getSpans(DOC_REL); const agent = spans.find((s) => s.turnId === "turn-p1"); assert.ok(agent, `accepted text is Claude-attributed with the proposal's turnId — got spans: ${JSON.stringify(spans)}`); assert.strictEqual(agent!.authorKind, "agent"); assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0, "proposal gone (INV-13)"); assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar"); }); test("reject leaves the document untouched and removes the proposal (PUC-3)", async () => { const doc = await openDoc(); const api = await getApi(); const before = doc.getText(); const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2"); await settle(); assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true); await settle(); assert.strictEqual(doc.getText(), before, "document untouched"); assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0); assert.strictEqual(readSidecar().proposals.length, 0); }); test("a pending proposal persists, survives reload, and re-anchors after an external move (PUC-4)", async () => { let doc = await openDoc(); const api = await getApi(); const anchor = "A stable closing paragraph."; await proposeViaCommand(doc, anchor, "A PROPOSED closing paragraph.", "turn-p3"); await settle(); const uri = vscode.Uri.file(path.join(WS, DOC_REL)); doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText()); await settle(); api.proposalController.renderAll(doc); const rendered = api.proposalController.getRendered(DOC_REL); assert.strictEqual(rendered.length, 1, "proposal survived reload"); assert.strictEqual(rendered[0].pending, true, "still decidable"); const moved = doc.getText().indexOf(anchor); assert.strictEqual(rendered[0].range.start, moved, "re-anchored after the move"); }); test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => { let doc = await openDoc(); const api = await getApi(); const id = api.proposalController.getRendered(DOC_REL)[0].id; const mangled = doc.getText().replace("A stable closing paragraph.", "A reworded closing paragraph."); doc = await externalWriteAndReload(vscode.Uri.file(path.join(WS, DOC_REL)), mangled); await settle(); api.proposalController.renderAll(doc); assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale"); const before = doc.getText(); const ok = await api.proposalController.acceptById(DOC_REL, id); assert.strictEqual(ok, false, "accept refused (INV-11)"); assert.strictEqual(doc.getText(), before, "document untouched"); assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)"); // discard the husk so later tests see a clean sidecar assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true); }); test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => { const doc = await openDoc(); const api = await getApi(); const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4"); const id2 = await proposeViaCommand(doc, "PROPOSED-BY-CLAUDE replacement sentence.", "A REPLACED-AGAIN sentence.", "turn-p5"); await settle(); assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2); // decide the SECOND first, then the first — order independence assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true); await settle(); assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true); await settle(); assert.ok(doc.getText().includes("An ACCEPTED opening paragraph.")); assert.ok(doc.getText().includes("A REPLACED-AGAIN sentence.")); assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0); }); });