From e5992840d24e37927fb2b56f24b85a0df396c560 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 07:39:14 -0700 Subject: [PATCH] =?UTF-8?q?feat(editor):=20EditorProposalController=20?= =?UTF-8?q?=E2=80=94=20optimistic=20apply=20+=20decorations=20+=20CodeLens?= =?UTF-8?q?=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/editorProposalController.ts | 151 +++++++++++++++++++++++++++ src/extension.ts | 8 ++ src/proposalController.ts | 26 ++++- test/e2e/suite/f10Review.test.ts | 12 ++- test/e2e/suite/f11Toolbar.test.ts | 9 +- test/e2e/suite/f12InlineDiff.test.ts | 36 +++++++ test/e2e/suite/f12Reach.test.ts | 6 +- test/e2e/suite/liveProgress.test.ts | 5 +- test/e2e/suite/proposals.test.ts | 55 +++++++--- 9 files changed, 279 insertions(+), 29 deletions(-) create mode 100644 src/editorProposalController.ts diff --git a/src/editorProposalController.ts b/src/editorProposalController.ts new file mode 100644 index 0000000..0e2441d --- /dev/null +++ b/src/editorProposalController.ts @@ -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(); + 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>(); + + 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 { + 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 { + 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(); + } +} diff --git a/src/extension.ts b/src/extension.ts index 08ec9b1..b5d65a1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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, }; } diff --git a/src/proposalController.ts b/src/proposalController.ts index 86acf10..1e66b42 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -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 { - 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); diff --git a/test/e2e/suite/f10Review.test.ts b/test/e2e/suite/f10Review.test.ts index 7aec9ee..496d8c5 100644 --- a/test/e2e/suite/f10Review.test.ts +++ b/test/e2e/suite/f10Review.test.ts @@ -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"); }); diff --git a/test/e2e/suite/f11Toolbar.test.ts b/test/e2e/suite/f11Toolbar.test.ts index 7d1034c..31e69d8 100644 --- a/test/e2e/suite/f11Toolbar.test.ts +++ b/test/e2e/suite/f11Toolbar.test.ts @@ -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; }); diff --git a/test/e2e/suite/f12InlineDiff.test.ts b/test/e2e/suite/f12InlineDiff.test.ts index 5ce3512..a1d6366 100644 --- a/test/e2e/suite/f12InlineDiff.test.ts +++ b/test/e2e/suite/f12InlineDiff.test.ts @@ -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"); + }); +}); diff --git a/test/e2e/suite/f12Reach.test.ts b/test/e2e/suite/f12Reach.test.ts index f96cda9..74f5582 100644 --- a/test/e2e/suite/f12Reach.test.ts +++ b/test/e2e/suite/f12Reach.test.ts @@ -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. diff --git a/test/e2e/suite/liveProgress.test.ts b/test/e2e/suite/liveProgress.test.ts index a10bbe7..1ca5c7c 100644 --- a/test/e2e/suite/liveProgress.test.ts +++ b/test/e2e/suite/liveProgress.test.ts @@ -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 () => { diff --git a/test/e2e/suite/proposals.test.ts b/test/e2e/suite/proposals.test.ts index 19d4e61..fc6721c 100644 --- a/test/e2e/suite/proposals.test.ts +++ b/test/e2e/suite/proposals.test.ts @@ -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); }); });