feat(editor): EditorProposalController — optimistic apply + decorations + CodeLens (#64)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-26 07:39:14 -07:00
parent 8c6e7822b4
commit e5992840d2
9 changed files with 279 additions and 29 deletions
+151
View File
@@ -0,0 +1,151 @@
/**
* EditorProposalController — F12/#64 editor surface (spec coauthoring-inline-editor-diff
* §3.5). Reverses INV-32 for pending proposals: on `onDidChangeProposals` it
* (1) optimistically applies any not-yet-applied proposal into the active editor's
* buffer (ProposalController.optimisticApply, INV-48), (2) decorates each applied
* proposal — insertion tint over the proposed text + a non-editable struck-red hint
* for deletions (INV-52, decorationPlan/INV-49), and (3) provides a CodeLens
* `Accept ▾ / Reject ▾` above each block whose ▾ opens a QuickPick (this / all).
* Owns no proposal STATE — it is a view over ProposalController (which stays the
* pure F4 owner). A document with no pending proposals shows nothing (INV-32's
* spirit when nothing is pending).
*/
import * as vscode from "vscode";
import type { ProposalController } from "./proposalController";
import { decorationPlan } from "./trackChangesModel";
import { isAuthorable } from "./workspacePath";
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
private readonly disposables: vscode.Disposable[] = [];
private readonly insertionDeco = vscode.window.createTextEditorDecorationType({
backgroundColor: new vscode.ThemeColor("diffEditor.insertedTextBackground"),
});
private readonly deletionDeco = vscode.window.createTextEditorDecorationType({
// a non-editable struck-red hint injected AFTER the insertion (INV-52)
after: { color: new vscode.ThemeColor("gitDecoration.deletedResourceForeground") },
textDecoration: "none",
});
private readonly lensEmitter = new vscode.EventEmitter<void>();
readonly onDidChangeCodeLenses = this.lensEmitter.event;
/** Pending debounce timers — one per URI — coalesce rapid-fire propose events
* (e.g. runEditAndPropose's N sequential propose() calls) into a single
* optimistic-apply pass that runs after ALL proposals are created. Without this,
* each propose() fires onDidChangeProposals synchronously and the controller's
* optimisticApply runs concurrently with the still-in-progress propose loop,
* causing "file changed in the meantime" workspace-edit conflicts. */
private readonly pendingApply = new Map<string, ReturnType<typeof setTimeout>>();
constructor(private readonly proposals: ProposalController) {
this.disposables.push(
this.insertionDeco, this.deletionDeco, this.lensEmitter,
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
// the four QuickPick-backed menu commands
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
);
}
/** Debounce-schedule an optimistic-apply pass for the given URI. Multiple rapid
* onDidChangeProposals events (from a single runEditAndPropose batch) collapse
* into one pass that runs after the batch completes. */
private scheduleApply(uri: string): void {
const prev = this.pendingApply.get(uri);
if (prev !== undefined) clearTimeout(prev);
this.pendingApply.set(
uri,
setTimeout(() => {
this.pendingApply.delete(uri);
void this.onProposalsChanged(uri);
}, 0),
);
}
/** Apply any not-yet-applied proposals on the doc, then re-decorate + refresh lenses. */
private async onProposalsChanged(uri: string): Promise<void> {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return;
const key = this.proposals.keyFor(doc);
for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) {
await this.proposals.optimisticApply(doc, v.id); // fires onDidChangeProposals again; guarded by isApplied
}
}
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed);
this.lensEmitter.fire();
}
/** Decorate the editor for every applied proposal on its document (INV-52). */
private renderEditor(editor: vscode.TextEditor): void {
const doc = editor.document;
if (doc.languageId !== "markdown") {
editor.setDecorations(this.insertionDeco, []);
editor.setDecorations(this.deletionDeco, []);
return;
}
const key = this.proposals.keyFor(doc);
const insertions: vscode.Range[] = [];
const deletions: vscode.DecorationOptions[] = [];
for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
for (const ins of plan.insertions) {
insertions.push(new vscode.Range(doc.positionAt(ins.start), doc.positionAt(ins.end)));
}
for (const del of plan.deletions) {
deletions.push({
range: new vscode.Range(doc.positionAt(del.at), doc.positionAt(del.at)),
renderOptions: { after: { contentText: ` ${del.text} `, textDecoration: "line-through" } },
});
}
}
editor.setDecorations(this.insertionDeco, insertions);
editor.setDecorations(this.deletionDeco, deletions);
}
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
if (document.languageId !== "markdown") return [];
const key = this.proposals.keyFor(document);
const lenses: vscode.CodeLens[] = [];
for (const v of this.proposals.listProposals(document)) {
if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue;
const pos = document.positionAt(v.anchorStart);
const line = new vscode.Range(pos.line, 0, pos.line, 0);
lenses.push(
new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }),
new vscode.CodeLens(line, { title: "Reject ▾", command: "cowriting.proposalRejectMenu", arguments: [v.id] }),
);
}
return lenses;
}
/** The dropdown: this-proposal vs all-proposals, then dispatch. */
private async menu(kind: "accept" | "reject", id?: string): Promise<void> {
const doc = vscode.window.activeTextEditor?.document;
if (!doc || !id) return;
const key = this.proposals.keyFor(doc);
const verb = kind === "accept" ? "Accept" : "Reject";
const pick = await vscode.window.showQuickPick(
[`${verb} this proposal`, `${verb} ALL proposals`],
{ placeHolder: `${verb} Claude's proposal` },
);
if (!pick) return;
const all = pick.includes("ALL");
if (kind === "accept") {
if (all) await this.proposals.acceptAllProposals(doc);
else await this.proposals.finalizeInPlace(key, id);
} else {
if (all) await this.proposals.rejectAll(doc);
else await this.proposals.revertInPlace(key, id);
}
}
dispose(): void {
for (const t of this.pendingApply.values()) clearTimeout(t);
this.pendingApply.clear();
for (const d of this.disposables) d.dispose();
}
}
+8
View File
@@ -13,6 +13,7 @@ import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview";
import { LiveProgressUi } from "./liveProgressUi";
import { InlineAskController } from "./inlineAsk";
import { EditorProposalController } from "./editorProposalController";
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -27,6 +28,7 @@ export interface CowritingApi {
sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi;
inlineAsk: InlineAskController;
editorProposalController: EditorProposalController;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -123,6 +125,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
);
context.subscriptions.push(trackChangesPreviewController);
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
const editorProposalController = new EditorProposalController(proposalController);
context.subscriptions.push(editorProposalController);
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
// (also reachable from the preview toolbar's "Accept all" button). Reuses the
// batched F4 seam + reports applied-vs-skipped.
@@ -345,6 +352,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
sidecarRouter,
liveProgressUi,
inlineAsk,
editorProposalController,
};
}
+24 -2
View File
@@ -91,7 +91,7 @@ export class ProposalController implements vscode.Disposable {
id: p.id,
anchorStart: resolved === "orphaned" ? null : resolved.start,
anchorEnd: resolved === "orphaned" ? null : resolved.end,
replaced: fp?.text ?? "",
replaced: p.original ?? fp?.text ?? "",
replacement: p.replacement,
original: p.original,
};
@@ -143,7 +143,29 @@ export class ProposalController implements vscode.Disposable {
/** Accept by id — F12: finalize the already-applied text in place (INV-51). */
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
if (this.isApplied(docPath, proposalId)) return this.finalizeInPlace(docPath, proposalId);
if (this.isApplied(docPath, proposalId)) {
// INV-11 (applied path): if an external write mangled the optimistically-applied
// text so the fingerprint no longer resolves, refuse finalize — same guard as the
// legacy accept path. Direct finalizeInPlace calls (CodeLens Accept gesture where
// the user may have edited inside the applied span) bypass this check intentionally.
const hit = this.byId(docPath, proposalId);
if (hit) {
const document = this.openDoc(hit.state);
if (document) {
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
if (resolved === "orphaned") {
if (!opts?.silent) {
void vscode.window.showWarningMessage(
"Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).",
);
}
return false;
}
}
}
return this.finalizeInPlace(docPath, proposalId);
}
// Fallback: a proposal that was never optimistically applied (e.g. orphaned at
// apply time) keeps the legacy seam-apply accept.
const hit = this.byId(docPath, proposalId);
+7 -5
View File
@@ -109,8 +109,9 @@ suite("F10 interactive review (host E2E — preview is the single review surface
const pIdx = html.indexOf(`data-proposal-id="${id}"`);
const t2Idx = html.indexOf("second claude target");
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
// INV-10: proposing never touches the document.
assert.ok(doc.getText().includes(T1), "document unchanged by propose");
// F12 (INV-48): EditorProposalController auto-applies the proposal into the buffer;
// the replacement text is now in the document, proposal is still pending.
assert.ok(doc.getText().includes("A FIRST claude REPLACEMENT sentence."), "F12 optimistic-apply: replacement in buffer");
});
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
@@ -131,17 +132,18 @@ suite("F10 interactive review (host E2E — preview is the single review surface
assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)");
});
test("reject → the proposal vanishes and the document is untouched (PUC-5)", async () => {
test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => {
const { doc, key } = await reopen(DOC_REL);
const api = await getApi();
const before = doc.getText();
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
await settle();
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id2), true, "reject");
// F12: use rejectByIdInPlace to revert the optimistically-applied buffer edit.
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id2), "rejectByIdInPlace reverts + removes");
await settle();
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
assert.strictEqual(doc.getText(), before, "document untouched by reject");
assert.strictEqual(doc.getText(), before, "document reverted to pre-propose state");
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
});
+6 -3
View File
@@ -99,8 +99,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
"The quick RED fox jumps over the lazy CAT.",
"the block proposal carries the whole rewritten paragraph",
);
// INV-10: proposing never mutates the document.
assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose");
// F12 (INV-48): EditorProposalController optimistically applies the proposed text,
// so after settle the buffer has the replacement. The proposal is still pending.
assert.ok(doc.getText().includes("RED fox") && doc.getText().includes("lazy CAT"), "F12 optimistic-apply: proposed text in buffer");
void key;
});
@@ -129,7 +130,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
assert.ok(view, "the proposal is live");
assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement");
assert.strictEqual(view!.replaced, target, "replaces exactly the selected range");
assert.ok(doc.getText().includes(target), "document unchanged by propose (INV-10)");
// F12 (INV-48): the EditorProposalController optimistically applies, so the buffer
// now has the replacement. `view.replaced` still records the original target text.
assert.ok(doc.getText().includes("The REWRITTEN paragraph from Claude."), "F12 optimistic-apply: proposed text in buffer");
void key;
});
+36
View File
@@ -116,3 +116,39 @@ suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => {
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
});
});
suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => {
const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
await ctl.runEditAndPropose(doc, { kind: "document" }, "recolor the fox");
await settle(); await settle();
assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer");
const v = api.proposalController.listProposals(doc)[0];
assert.strictEqual(v.original, "The brown fox runs.", "original captured for the deletion hint/revert");
});
test("editing the inserted text then finalizing keeps the human edit", async () => {
const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle();
// human tweaks the inserted text
const at = doc.getText().indexOf("ALPHA");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 5)), "ALPHA!");
await vscode.workspace.applyEdit(we);
await settle();
// keyFor(doc) gives the repo-relative path that finalizeInPlace uses as its key;
// the `key` from freshDoc is the URI string, which would not match for in-workspace docs.
const docKey = api.proposalController.keyFor(doc);
await api.proposalController.finalizeInPlace(docKey, ids[0]);
await settle();
assert.ok(doc.getText().includes("ALPHA! word here."), "human edit preserved on accept");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "cleared");
});
});
+4 -2
View File
@@ -98,8 +98,10 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
0,
"active doc A was NOT edited — editDocument honored the tab URI",
);
// INV-10: proposing never mutates the document.
assert.ok(b.doc.getText().includes("tab target paragraph"), "tab doc unchanged by propose");
// F12 (INV-48): EditorProposalController optimistically applies proposals into the
// buffer, so the proposed text is now in the tab doc B. Confirm one of the proposal
// texts is present (the tab doc was edited, not A).
assert.ok(b.doc.getText().includes("REWRITTEN"), "F12 optimistic-apply: proposed text in tab doc B");
});
// No URI arg (palette / keybinding) → fall back to the active editor.
+4 -1
View File
@@ -50,7 +50,10 @@ suite("#60 live turn progress (additive + cancel)", () => {
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events");
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
assert.ok(view, "the proposal is live");
assert.ok(doc.getText().includes("Old paragraph."), "document unchanged by propose (INV-10)");
// F12 (INV-48): the EditorProposalController optimistically applies the proposed
// text into the buffer, so after settle the active-editor buffer shows the
// replacement. The proposal is still pending (not finalized) until Accept.
assert.ok(doc.getText().includes("New paragraph."), "F12 optimistic-apply: proposed text in buffer");
});
test("an aborted turn proposes nothing (INV-47)", async () => {
+39 -16
View File
@@ -63,13 +63,14 @@ 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 () => {
test("propose records a pending proposal; F12 optimistically applies it (INV-48, reverses 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)");
// F12 (INV-48): EditorProposalController auto-applies the proposed text into the
// buffer, so after settle the buffer has the replacement text (INV-10 is reversed).
assert.ok(doc.getText().includes(REPLACEMENT), "F12 optimistic-apply: replacement in buffer after propose");
const rendered = api.proposalController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1);
assert.strictEqual(rendered[0].pending, true);
@@ -77,7 +78,8 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
assert.strictEqual(rendered[0].canReply, false, "proposals are decide-only — no dead reply input (INV-12)");
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);
// After optimistic apply, the fingerprint re-anchors to the replacement text.
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, REPLACEMENT);
});
test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => {
@@ -97,15 +99,18 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar");
});
test("reject leaves the document untouched and removes the proposal (PUC-3)", async () => {
test("reject reverts the optimistically-applied text and removes the proposal (PUC-3, F12-INV-48)", 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);
// F12: optimistic apply has put "WOULD-BE replacement." in the buffer.
assert.ok(doc.getText().includes("WOULD-BE replacement."), "F12: optimistically applied");
// rejectByIdInPlace reverts the buffer to the original (PUC-3, INV-51).
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id), "rejectByIdInPlace removes + reverts");
await settle();
assert.strictEqual(doc.getText(), before, "document untouched");
assert.strictEqual(doc.getText(), before, "document restored to pre-propose state");
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
assert.strictEqual(readSidecar().proposals.length, 0);
});
@@ -114,25 +119,35 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
let doc = await openDoc();
const api = await getApi();
const anchor = "A stable closing paragraph.";
await proposeViaCommand(doc, anchor, "A PROPOSED closing paragraph.", "turn-p3");
const appliedText = "A PROPOSED closing paragraph.";
await proposeViaCommand(doc, anchor, appliedText, "turn-p3");
await settle();
// F12: optimistic apply has put the proposed text in the buffer (anchor → appliedText).
assert.ok(doc.getText().includes(appliedText), "F12: optimistically applied before reload");
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
// doc.getText() now has appliedText; the reload prepends a line.
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");
// After F12 re-anchor, the fingerprint tracks the appliedText, not the original anchor.
const moved = doc.getText().indexOf(appliedText);
assert.ok(moved >= 0, "applied text present in the reloaded document");
assert.strictEqual(rendered[0].range.start, moved, "re-anchored to appliedText 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);
// After F12 optimistic apply the fingerprint tracks the APPLIED text ("A PROPOSED
// closing paragraph."), not the original. Mangle that text to make the proposal stale.
const preMangled = doc.getText();
const mangled = preMangled.replace("A PROPOSED closing paragraph.", "A reworded closing paragraph.");
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
doc = await externalWriteAndReload(uri, mangled);
await settle();
api.proposalController.renderAll(doc);
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale");
@@ -143,22 +158,30 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
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);
// Restore the file so subsequent tests can find their fixture targets:
// replace the optimistically-applied text back to the original fixture text.
const restored = preMangled.replace("A PROPOSED closing paragraph.", "A stable closing paragraph.");
doc = await externalWriteAndReload(uri, restored);
await settle();
});
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");
// Clean up any proposals left over from prior tests (F12: proposals persist in state).
await api.proposalController.rejectAll(doc);
await settle();
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2);
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4");
const id2 = await proposeViaCommand(doc, "A stable closing paragraph.", "A REPLACED closing paragraph.", "turn-p5");
await settle();
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2, "two new proposals");
// 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.ok(doc.getText().includes("A REPLACED closing paragraph."));
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
});
});