From ddd634b30f3449fef1a34e70551f084a522e8815 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 16:49:20 -0700 Subject: [PATCH] fix(#70): rejectAll reverts tweaked proposals via the tracked span; skips are counted and reported Co-Authored-By: Claude Fable 5 --- src/extension.ts | 12 ++++++------ src/proposalController.ts | 22 +++++++++++++++------- test/e2e/suite/f12InlineDiff.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 91916c3..37d17f2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -223,12 +223,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals."); return; } - const { reverted } = await proposalController.rejectAll(doc); - if (reverted > 0) { - void vscode.window.showInformationMessage( - `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, - ); - } + const { reverted, skipped } = await proposalController.rejectAll(doc); + if (reverted === 0 && skipped === 0) return; + const skipNote = skipped > 0 ? `, ${skipped} skipped (applied text not locatable — undo your edits or Keep)` : ""; + void vscode.window.showInformationMessage( + `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}${skipNote}.`, + ); }), ); diff --git a/src/proposalController.ts b/src/proposalController.ts index f7f0447..f31e02b 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -490,11 +490,14 @@ export class ProposalController implements vscode.Disposable { /** * F12/#64 (INV-53): reject EVERY pending proposal on a document — revert each in - * DESCENDING anchor order (so an earlier revert never shifts a later one's - * offsets), symmetric with #46's accept-all. Returns the reverted count. + * DESCENDING span order (so an earlier revert never shifts a later one's + * offsets), symmetric with #46's accept-all. #70: a tweaked proposal whose exact + * anchor is orphaned orders (and reverts) by its shift-tracked applied span; + * one with no locatable span is SKIPPED — counted, never guessed (INV-11) — + * and the per-proposal warning is suppressed in favor of the batch report. */ - async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number }> { - if (!this.isTracked(document)) return { reverted: 0 }; + async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number; skipped: number }> { + if (!this.isTracked(document)) return { reverted: 0, skipped: 0 }; const docPath = this.keyOf(document); const state = this.ensureState(document); state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); @@ -503,12 +506,17 @@ export class ProposalController implements vscode.Disposable { .map((p) => { const fp = state.artifact.anchors[p.anchorId]?.fingerprint; const r = fp ? resolve(text, fp) : "orphaned"; - return { id: p.id, start: r === "orphaned" ? -1 : r.start }; + const start = r !== "orphaned" ? r.start : state.appliedSpans.get(p.id)?.start ?? -1; + return { id: p.id, start }; }) .sort((a, b) => b.start - a.start); let reverted = 0; - for (const it of ordered) if (await this.revertInPlace(docPath, it.id)) reverted++; - return { reverted }; + let skipped = 0; + for (const it of ordered) { + if (await this.revertInPlace(docPath, it.id, { silent: true })) reverted++; + else skipped++; + } + return { reverted, skipped }; } private reject(state: DocState, proposal: Proposal): void { diff --git a/test/e2e/suite/f12InlineDiff.test.ts b/test/e2e/suite/f12InlineDiff.test.ts index 35dbfc8..3be02c7 100644 --- a/test/e2e/suite/f12InlineDiff.test.ts +++ b/test/e2e/suite/f12InlineDiff.test.ts @@ -293,6 +293,33 @@ suite("F12 inline diff — #70 reject after interior edit (INV-5)", () => { // 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"); + }); }); // Reload-safety: a proposal that was optimistically applied in a PRIOR session