fix(#70): rejectAll reverts tweaked proposals via the tracked span; skips are counted and reported

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 16:49:20 -07:00
parent 82cea74f2c
commit ddd634b30f
3 changed files with 48 additions and 13 deletions
+6 -6
View File
@@ -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."); void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals.");
return; return;
} }
const { reverted } = await proposalController.rejectAll(doc); const { reverted, skipped } = await proposalController.rejectAll(doc);
if (reverted > 0) { if (reverted === 0 && skipped === 0) return;
void vscode.window.showInformationMessage( const skipNote = skipped > 0 ? `, ${skipped} skipped (applied text not locatable — undo your edits or Keep)` : "";
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, void vscode.window.showInformationMessage(
); `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}${skipNote}.`,
} );
}), }),
); );
+15 -7
View File
@@ -490,11 +490,14 @@ export class ProposalController implements vscode.Disposable {
/** /**
* F12/#64 (INV-53): reject EVERY pending proposal on a document revert each in * 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 * DESCENDING span order (so an earlier revert never shifts a later one's
* offsets), symmetric with #46's accept-all. Returns the reverted count. * 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 }> { async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number; skipped: number }> {
if (!this.isTracked(document)) return { reverted: 0 }; if (!this.isTracked(document)) return { reverted: 0, skipped: 0 };
const docPath = this.keyOf(document); const docPath = this.keyOf(document);
const state = this.ensureState(document); const state = this.ensureState(document);
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
@@ -503,12 +506,17 @@ export class ProposalController implements vscode.Disposable {
.map((p) => { .map((p) => {
const fp = state.artifact.anchors[p.anchorId]?.fingerprint; const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
const r = fp ? resolve(text, fp) : "orphaned"; 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); .sort((a, b) => b.start - a.start);
let reverted = 0; let reverted = 0;
for (const it of ordered) if (await this.revertInPlace(docPath, it.id)) reverted++; let skipped = 0;
return { reverted }; 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 { private reject(state: DocState, proposal: Proposal): void {
+27
View File
@@ -293,6 +293,33 @@ suite("F12 inline diff — #70 reject after interior edit (INV-5)", () => {
// plain record-only reject. // plain record-only reject.
assert.strictEqual(p.rejectById(docKey, id!), true); 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 // Reload-safety: a proposal that was optimistically applied in a PRIOR session