F4 SLICE-5: host E2E (propose/accept/reject/persist/stale/coexist) + seam fix: event-level net-effect matching survives host word-diff splitting (INV-9) (#12)
The accept E2E exposed a latent F3 limitation: VS Code word-diffs one applied WorkspaceEdit into several minimal hunks when old/new share interior tokens, so the registry's per-hunk exact match missed and seam edits fell through as fragmented human spans. PendingEditRegistry.match is replaced by matchEvent (all hunks inside the registered full range + equal net delta — one applyEdit is one change event). Plan AMENDMENT 1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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<vscode.TextDocument> {
|
||||
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<CowritingApi> {
|
||||
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<string> {
|
||||
const start = doc.getText().indexOf(target);
|
||||
assert.ok(start >= 0, `fixture contains "${target}"`);
|
||||
const id = await vscode.commands.executeCommand<string>("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<vscode.TextDocument> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user