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 { TrackChangesPreviewController } from "./trackChangesPreview";
import { LiveProgressUi } from "./liveProgressUi"; import { LiveProgressUi } from "./liveProgressUi";
import { InlineAskController } from "./inlineAsk"; import { InlineAskController } from "./inlineAsk";
import { EditorProposalController } from "./editorProposalController";
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
const CHANNEL_NAME = "Cowriting (Cline SDK)"; const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -27,6 +28,7 @@ export interface CowritingApi {
sidecarRouter: SidecarRouter; sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi; liveProgressUi: LiveProgressUi;
inlineAsk: InlineAskController; inlineAsk: InlineAskController;
editorProposalController: EditorProposalController;
} }
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -123,6 +125,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
); );
context.subscriptions.push(trackChangesPreviewController); 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 // #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 // (also reachable from the preview toolbar's "Accept all" button). Reuses the
// batched F4 seam + reports applied-vs-skipped. // batched F4 seam + reports applied-vs-skipped.
@@ -345,6 +352,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
sidecarRouter, sidecarRouter,
liveProgressUi, liveProgressUi,
inlineAsk, inlineAsk,
editorProposalController,
}; };
} }
+24 -2
View File
@@ -91,7 +91,7 @@ export class ProposalController implements vscode.Disposable {
id: p.id, id: p.id,
anchorStart: resolved === "orphaned" ? null : resolved.start, anchorStart: resolved === "orphaned" ? null : resolved.start,
anchorEnd: resolved === "orphaned" ? null : resolved.end, anchorEnd: resolved === "orphaned" ? null : resolved.end,
replaced: fp?.text ?? "", replaced: p.original ?? fp?.text ?? "",
replacement: p.replacement, replacement: p.replacement,
original: p.original, 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). */ /** Accept by id — F12: finalize the already-applied text in place (INV-51). */
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> { 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 // Fallback: a proposal that was never optimistically applied (e.g. orphaned at
// apply time) keeps the legacy seam-apply accept. // apply time) keeps the legacy seam-apply accept.
const hit = this.byId(docPath, proposalId); 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 pIdx = html.indexOf(`data-proposal-id="${id}"`);
const t2Idx = html.indexOf("second claude target"); const t2Idx = html.indexOf("second claude target");
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)"); assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
// INV-10: proposing never touches the document. // F12 (INV-48): EditorProposalController auto-applies the proposal into the buffer;
assert.ok(doc.getText().includes(T1), "document unchanged by propose"); // 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 () => { 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)"); 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 { doc, key } = await reopen(DOC_REL);
const api = await getApi(); const api = await getApi();
const before = doc.getText(); const before = doc.getText();
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2"); const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
await settle(); await settle();
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending"); 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(); await settle();
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone"); 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); const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview"); 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 quick RED fox jumps over the lazy CAT.",
"the block proposal carries the whole rewritten paragraph", "the block proposal carries the whole rewritten paragraph",
); );
// INV-10: proposing never mutates the document. // F12 (INV-48): EditorProposalController optimistically applies the proposed text,
assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose"); // 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; 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.ok(view, "the proposal is live");
assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement"); assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement");
assert.strictEqual(view!.replaced, target, "replaces exactly the selected range"); 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; 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"); 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, 0,
"active doc A was NOT edited — editDocument honored the tab URI", "active doc A was NOT edited — editDocument honored the tab URI",
); );
// INV-10: proposing never mutates the document. // F12 (INV-48): EditorProposalController optimistically applies proposals into the
assert.ok(b.doc.getText().includes("tab target paragraph"), "tab doc unchanged by propose"); // 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. // 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"); 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]); const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
assert.ok(view, "the proposal is live"); 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 () => { 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 TARGET = "The propose target sentence lives here.";
const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence."; 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 doc = await openDoc();
const api = await getApi(); const api = await getApi();
const before = doc.getText();
await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1"); await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1");
await settle(); 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); const rendered = api.proposalController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1); assert.strictEqual(rendered.length, 1);
assert.strictEqual(rendered[0].pending, true); 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)"); assert.strictEqual(rendered[0].canReply, false, "proposals are decide-only — no dead reply input (INV-12)");
const art = readSidecar(); const art = readSidecar();
assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time"); 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 () => { 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"); 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 doc = await openDoc();
const api = await getApi(); const api = await getApi();
const before = doc.getText(); const before = doc.getText();
const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2"); const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2");
await settle(); 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(); 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(api.proposalController.getRendered(DOC_REL).length, 0);
assert.strictEqual(readSidecar().proposals.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(); let doc = await openDoc();
const api = await getApi(); const api = await getApi();
const anchor = "A stable closing paragraph."; 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(); 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)); 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()); doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText());
await settle(); await settle();
api.proposalController.renderAll(doc); api.proposalController.renderAll(doc);
const rendered = api.proposalController.getRendered(DOC_REL); const rendered = api.proposalController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1, "proposal survived reload"); assert.strictEqual(rendered.length, 1, "proposal survived reload");
assert.strictEqual(rendered[0].pending, true, "still decidable"); assert.strictEqual(rendered[0].pending, true, "still decidable");
const moved = doc.getText().indexOf(anchor); // After F12 re-anchor, the fingerprint tracks the appliedText, not the original anchor.
assert.strictEqual(rendered[0].range.start, moved, "re-anchored after the move"); 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 () => { test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => {
let doc = await openDoc(); let doc = await openDoc();
const api = await getApi(); const api = await getApi();
const id = api.proposalController.getRendered(DOC_REL)[0].id; const id = api.proposalController.getRendered(DOC_REL)[0].id;
const mangled = doc.getText().replace("A stable closing paragraph.", "A reworded closing paragraph."); // After F12 optimistic apply the fingerprint tracks the APPLIED text ("A PROPOSED
doc = await externalWriteAndReload(vscode.Uri.file(path.join(WS, DOC_REL)), mangled); // 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(); await settle();
api.proposalController.renderAll(doc); api.proposalController.renderAll(doc);
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale"); 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)"); assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)");
// discard the husk so later tests see a clean sidecar // discard the husk so later tests see a clean sidecar
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true); 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 () => { test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => {
const doc = await openDoc(); const doc = await openDoc();
const api = await getApi(); const api = await getApi();
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4"); // Clean up any proposals left over from prior tests (F12: proposals persist in state).
const id2 = await proposeViaCommand(doc, "PROPOSED-BY-CLAUDE replacement sentence.", "A REPLACED-AGAIN sentence.", "turn-p5"); await api.proposalController.rejectAll(doc);
await settle(); 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 // decide the SECOND first, then the first — order independence
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true); assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true);
await settle(); await settle();
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true); assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true);
await settle(); await settle();
assert.ok(doc.getText().includes("An ACCEPTED opening paragraph.")); 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); assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
}); });
}); });