feat(editor): EditorProposalController — optimistic apply + decorations + CodeLens (#64)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user