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:
Ben Stull
2026-06-10 22:12:15 -07:00
parent 452c071efb
commit 8fdea97d36
7 changed files with 278 additions and 35 deletions
@@ -0,0 +1,9 @@
# Proposal fixture
A stable opening paragraph.
The propose target sentence lives here.
A second target for coexistence checks.
A stable closing paragraph.
@@ -25,6 +25,9 @@ suite("no-workspace activation (#8)", () => {
"cowriting.editSelection",
"cowriting.toggleAttribution",
"cowriting.applyAgentEdit",
"cowriting.acceptProposal",
"cowriting.rejectProposal",
"cowriting.proposeAgentEdit",
]) {
assert.ok(all.includes(command), `${command} is registered`);
}
+163
View File
@@ -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);
});
});
+37 -10
View File
@@ -8,26 +8,53 @@ const AGENT: Provenance = {
};
describe("PendingEditRegistry (INV-9)", () => {
it("matches and consumes an exact registration", () => {
it("matches and consumes a single-hunk event equal to the registration", () => {
const reg = new PendingEditRegistry();
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1" });
const hit = reg.match("d.md", { start: 3, end: 7, text: "new" });
reg.register({
docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1",
full: { start: 3, end: 7, newLength: 3 },
});
const hit = reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }]);
expect(hit?.turnId).toBe("t1");
expect(reg.match("d.md", { start: 3, end: 7, text: "new" })).toBeNull();
expect(reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }])).toBeNull();
});
it("does not match a different doc, range, or text (human typing stays human)", () => {
it("matches a HOST-SPLIT event: several hunks inside the full range with the same net delta", () => {
// The host word-diffs one applied replace into multiple minimal hunks
// (observed on F4 accept) — per-hunk equality misses; net-effect matches.
const reg = new PendingEditRegistry();
reg.register({
docPath: "d.md", start: 10, end: 50, newText: "x".repeat(45), provenance: AGENT, turnId: "t2",
full: { start: 10, end: 50, newLength: 45 },
});
const hit = reg.matchEvent("d.md", [
{ start: 12, end: 20, newLength: 9 },
{ start: 25, end: 30, newLength: 6 },
{ start: 40, end: 48, newLength: 11 },
]);
expect(hit?.turnId).toBe("t2");
});
it("does not match a different doc, hunks outside the full range, or a different delta", () => {
const reg = new PendingEditRegistry();
reg.register({
docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT,
full: { start: 3, end: 7, newLength: 3 },
});
expect(reg.matchEvent("other.md", [{ start: 3, end: 7, newLength: 3 }])).toBeNull();
expect(reg.matchEvent("d.md", [{ start: 3, end: 8, newLength: 4 }])).toBeNull();
expect(reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 4 }])).toBeNull();
expect(reg.matchEvent("d.md", [])).toBeNull();
});
it("falls back to the minimized range when no full extent was registered", () => {
const reg = new PendingEditRegistry();
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT });
expect(reg.match("other.md", { start: 3, end: 7, text: "new" })).toBeNull();
expect(reg.match("d.md", { start: 3, end: 8, text: "new" })).toBeNull();
expect(reg.match("d.md", { start: 3, end: 7, text: "neww" })).toBeNull();
expect(reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }])).not.toBeNull();
});
it("unregister removes a failed application", () => {
const reg = new PendingEditRegistry();
const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT };
reg.register(p);
reg.unregister(p);
expect(reg.match("d.md", { start: 0, end: 0, text: "x" })).toBeNull();
expect(reg.matchEvent("d.md", [{ start: 0, end: 0, newLength: 1 }])).toBeNull();
});
it("unregister reports whether the registration was still pending", () => {
const reg = new PendingEditRegistry();
@@ -35,7 +62,7 @@ describe("PendingEditRegistry (INV-9)", () => {
reg.register(p);
expect(reg.unregister(p)).toBe(true);
reg.register(p);
reg.match("d.md", { start: 0, end: 0, text: "x" });
reg.matchEvent("d.md", [{ start: 0, end: 0, newLength: 1 }]);
expect(reg.unregister(p)).toBe(false);
});
});