fix(#70): INV-5 — reject after an interior tweak restores the retained original (PR #73)

Reject on an optimistically-applied proposal now restores the retained original even after interior tweaks: appliedSpans tracked-range fallback (pure shiftTracked, boundary-straddle distrust, close-clears, rebuild-only resync), honest hard failure with a Discard action, INV-16 read-only guards, rejectAll {reverted,skipped} reporting on all batch surfaces, CodeLens reachability at the tracked span. 312 unit + 94/5 host E2E.

Closes #70.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit was merged in pull request #73.
This commit is contained in:
2026-07-03 01:19:46 +00:00
parent 42740f7cc6
commit 7583165354
8 changed files with 897 additions and 42 deletions
+52 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { buildFingerprint, resolve, shift, type OffsetRange } from "../src/anchorer";
import { buildFingerprint, resolve, shift, shiftTracked, type OffsetRange } from "../src/anchorer";
const DOC = "line0\nline1 needle here\nline2\nneedle again on line3\n";
@@ -67,3 +67,54 @@ describe("shift", () => {
expect(shift(r, { start: 3, end: 7, newLength: 1 })).toEqual({ start: 3, end: 7 });
});
});
describe("shiftTracked (#70 — tracked applied spans)", () => {
const r: OffsetRange = { start: 10, end: 20 };
it("edit entirely before the span shifts both endpoints by the delta", () => {
expect(shiftTracked(r, { start: 0, end: 0, newLength: 3 })).toEqual({ start: 13, end: 23 });
});
it("edit entirely after the span leaves it unchanged", () => {
expect(shiftTracked(r, { start: 25, end: 28, newLength: 0 })).toEqual({ start: 10, end: 20 });
});
it("edit fully inside the span keeps the start and grows/shrinks the end", () => {
// replace [12,15) with 5 chars (delta = +2)
expect(shiftTracked(r, { start: 12, end: 15, newLength: 5 })).toEqual({ start: 10, end: 22 });
});
it("replacing the span's exact full contents is an interior edit", () => {
expect(shiftTracked(r, { start: 10, end: 20, newLength: 4 })).toEqual({ start: 10, end: 14 });
});
it("an insertion exactly at the span start lands BEFORE the span (never absorbed)", () => {
expect(shiftTracked(r, { start: 10, end: 10, newLength: 6 })).toEqual({ start: 16, end: 26 });
});
it("an insertion exactly at the span end lands AFTER the span (never absorbed)", () => {
expect(shiftTracked(r, { start: 20, end: 20, newLength: 6 })).toEqual({ start: 10, end: 20 });
});
it("an insertion at an EMPTY span's point lands before it (deletion-proposal span)", () => {
const empty: OffsetRange = { start: 10, end: 10 };
expect(shiftTracked(empty, { start: 10, end: 10, newLength: 3 })).toEqual({ start: 13, end: 13 });
});
it("an edit straddling the span's start boundary is distrusted (INV-11)", () => {
expect(shiftTracked(r, { start: 8, end: 12, newLength: 0 })).toBe("distrusted");
});
it("an edit straddling the span's end boundary is distrusted (INV-11)", () => {
expect(shiftTracked(r, { start: 18, end: 22, newLength: 1 })).toBe("distrusted");
});
it("an edit fully containing the span (e.g. a wholesale replace) is distrusted (INV-11)", () => {
expect(shiftTracked(r, { start: 0, end: 30, newLength: 12 })).toBe("distrusted");
});
it("a deletion consuming an EMPTY span's neighborhood is distrusted (INV-11)", () => {
const empty: OffsetRange = { start: 10, end: 10 };
expect(shiftTracked(empty, { start: 8, end: 12, newLength: 0 })).toBe("distrusted");
});
});
+147
View File
@@ -204,6 +204,153 @@ suite("F12 inline diff — control parity (#64, INV-53)", () => {
});
});
// #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
+16 -1
View File
@@ -140,7 +140,22 @@ suite("full native loop — PUC-7 → PUC-8 → PUC-2 → PUC-1 (§6.8, §7.1 ru
assert.ok(claudeSpan, "claude's words are attributed");
assert.ok(humanSpan, "the human tweak is attributed separately (char-honest split)");
// ---- PUC-2, reject: revert the untouched second proposal in place ----------------
// ---- PUC-2, reject: revert the second proposal in place --------------------------
// #70 (INV-5): the reject leg now ALSO takes an interior tweak first — the
// original deliberately rejected only an un-tweaked proposal because the
// pre-fix revert silently skipped an orphaned anchor. The tweak orphans the
// exact anchor; the revert must restore ORIG_REJECT via the tracked span.
const rejAt = doc.getText().indexOf(REJECT_REPLACEMENT);
assert.ok(rejAt >= 0, "reject proposal's applied text present");
const rejTweak = new vscode.WorkspaceEdit();
rejTweak.replace(doc.uri, new vscode.Range(doc.positionAt(rejAt + 2), doc.positionAt(rejAt + 2)), "x");
assert.ok(await vscode.workspace.applyEdit(rejTweak), "human tweak inside the reject proposal's range");
await settle();
assert.strictEqual(
api.proposalController.listProposals(doc).find((v) => v.id === rejectId)!.anchorStart,
null,
"tweak orphans the reject proposal's exact anchor (INV-11)",
);
assert.ok(await api.proposalController.revertInPlace(docKey, rejectId), "reject reverts in place");
await settle();
assert.strictEqual(