7583165354
Reject on an optimistically-applied proposal now restores the retained original even after interior tweaks: appliedSpans tracked-range fallback (pure shiftTracked, boundary-straddle distrust, close-clears, rebuild-only resync), honest hard failure with a Discard action, INV-16 read-only guards, rejectAll {reverted,skipped} reporting on all batch surfaces, CodeLens reachability at the tracked span. 312 unit + 94/5 host E2E.
Closes #70.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
180 lines
9.8 KiB
TypeScript
180 lines
9.8 KiB
TypeScript
import * as assert from "node:assert";
|
|
import * as vscode from "vscode";
|
|
import { activateApi, settle, settleUntil } from "./helpers";
|
|
|
|
/**
|
|
* Task 9 (spec §6.8 named E2E, §7.1 rung 2 — stub-turned): the full
|
|
* native-surfaces inner loop end to end, in ONE doc:
|
|
* PUC-7 (enter coediting, snapshot baseline)
|
|
* → PUC-8 (comment → reply → offer → pending proposal, D10/D19)
|
|
* → PUC-2 (buffer review: tweak under typing / re-anchor, keep, reject)
|
|
* → PUC-1 (pin the baseline clean).
|
|
* Both turns are stubbed (no real @cline/sdk call) — the real-SDK rung 3 is
|
|
* `scripts/smoke-native-loop.mjs`, operator-run against live credentials.
|
|
*/
|
|
suite("full native loop — PUC-7 → PUC-8 → PUC-2 → PUC-1 (§6.8, §7.1 rung 2)", () => {
|
|
test("enter, ask twice, offer, tweak-under-typing, keep one, reject one, pin — clean review", async () => {
|
|
const api = await activateApi();
|
|
const ORIG_KEEP = "The quick brown fox jumps over the lazy dog paragraph.";
|
|
const ORIG_REJECT = "A second paragraph that should stay exactly as written.";
|
|
const doc = await vscode.workspace.openTextDocument({
|
|
language: "markdown",
|
|
content: `# Full loop\n\n${ORIG_KEEP}\n\n${ORIG_REJECT}\n`,
|
|
});
|
|
const ed = await vscode.window.showTextDocument(doc);
|
|
|
|
// PUC-7: enter coediting. An untitled doc has no git HEAD, so it lands in
|
|
// snapshot mode (INV-7/D13/D14 — baselineRouter.test.ts covers the fork).
|
|
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
|
await settle();
|
|
assert.strictEqual(
|
|
api.diffViewController.modeOf(doc.uri.toString()),
|
|
"snapshot",
|
|
"untitled doc enters coediting in snapshot mode",
|
|
);
|
|
|
|
const docKey = api.proposalController.keyFor(doc);
|
|
const KEEP_REPLACEMENT = "The quick fox jumps the lazy dog.";
|
|
const REJECT_REPLACEMENT = "A rewritten second paragraph nobody asked to keep.";
|
|
|
|
// ---- PUC-8, first ask: the "keep" paragraph -------------------------------------
|
|
api.threadController.setTurnRunnerForTest(async () => ({
|
|
replacement: "I'd tighten this sentence.",
|
|
model: "stub",
|
|
sessionId: "full-loop-1",
|
|
}));
|
|
api.editFlow.setEditTurnForTest(async () => ({
|
|
replacement: KEEP_REPLACEMENT,
|
|
model: "stub",
|
|
sessionId: "full-loop-1",
|
|
}));
|
|
const keepStart = doc.getText().indexOf(ORIG_KEEP);
|
|
ed.selection = new vscode.Selection(doc.positionAt(keepStart), doc.positionAt(keepStart + ORIG_KEEP.length));
|
|
const keepThreadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
|
|
assert.ok(keepThreadId, "thread created on a coedited doc (INV-10 gate open)");
|
|
// The reply loop fires on the human message; wait for the machine reply to
|
|
// land and flip the thread to "offer" (D10/PUC-8).
|
|
await settleUntil(() => {
|
|
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === keepThreadId);
|
|
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
|
|
}, 10000);
|
|
const keepIds = await api.threadController.makeThreadEdit(keepThreadId, docKey);
|
|
assert.strictEqual(keepIds.length, 1, "one pending proposal from the ranged ask");
|
|
const keepId = keepIds[0];
|
|
|
|
// ---- PUC-8, second ask: the "reject" paragraph ----------------------------------
|
|
api.editFlow.setEditTurnForTest(async () => ({
|
|
replacement: REJECT_REPLACEMENT,
|
|
model: "stub",
|
|
sessionId: "full-loop-2",
|
|
}));
|
|
const rejectStart = doc.getText().indexOf(ORIG_REJECT);
|
|
ed.selection = new vscode.Selection(doc.positionAt(rejectStart), doc.positionAt(rejectStart + ORIG_REJECT.length));
|
|
const rejectThreadId = (await api.threadController.createThreadOnSelection("rewrite this"))!;
|
|
await settleUntil(() => {
|
|
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === rejectThreadId);
|
|
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
|
|
}, 10000);
|
|
const rejectIds = await api.threadController.makeThreadEdit(rejectThreadId, docKey);
|
|
assert.strictEqual(rejectIds.length, 1, "one pending proposal from the second ranged ask");
|
|
const rejectId = rejectIds[0];
|
|
|
|
// ---- decorated pending proposals (F12 optimistic-apply) --------------------------
|
|
await settleUntil(
|
|
() => api.proposalController.isApplied(docKey, keepId) && api.proposalController.isApplied(docKey, rejectId),
|
|
5000,
|
|
);
|
|
let views = api.proposalController.listProposals(doc);
|
|
assert.strictEqual(views.length, 2, "two pending proposals decorated");
|
|
for (const id of [keepId, rejectId]) {
|
|
const v = views.find((x) => x.id === id)!;
|
|
assert.notStrictEqual(v.anchorStart, null, "each proposal resolves (pending, not orphaned)");
|
|
assert.ok(api.proposalController.isApplied(docKey, id), "optimistically applied into the buffer");
|
|
}
|
|
assert.ok(doc.getText().includes(KEEP_REPLACEMENT), "claude's first proposed rewrite is live in the buffer");
|
|
assert.ok(doc.getText().includes(REJECT_REPLACEMENT), "claude's second proposed rewrite is live in the buffer");
|
|
|
|
// ---- PUC-2, tweak: type INSIDE the still-pending "keep" range --------------------
|
|
// Verified against src/anchorer.ts: `resolve()` is an EXACT-substring search
|
|
// (INV-1 — no fuzzy tolerance), so an interior edit to a proposal's own
|
|
// fingerprinted text unconditionally breaks that fingerprint — this is INV-11,
|
|
// already covered by proposals.test.ts's "editing the target text makes the
|
|
// proposal stale" case. F12's finalizeInPlace ("Keep") is deliberately built to
|
|
// bypass that staleness check (see its own doc comment: "Direct finalizeInPlace
|
|
// calls ... where the user may have edited inside the applied span bypass this
|
|
// check intentionally") — the tweaked text is ALREADY the accepted result
|
|
// sitting in the buffer, so no re-resolve is needed to keep it. That is the
|
|
// real "re-anchor" story for a pending-range tweak: the proposal survives as a
|
|
// live, addressable id (not silently dropped) and Keep still lands it, even
|
|
// though its raw anchor is (correctly) stale.
|
|
const at = doc.getText().indexOf(KEEP_REPLACEMENT);
|
|
const tweak = new vscode.WorkspaceEdit();
|
|
tweak.replace(doc.uri, new vscode.Range(doc.positionAt(at + 10), doc.positionAt(at + 13)), "FOX"); // "fox" -> "FOX"
|
|
assert.ok(await vscode.workspace.applyEdit(tweak), "human tweak applied inside the pending range");
|
|
await settle();
|
|
assert.ok(doc.getText().includes("The quick FOX jumps the lazy dog."), "human tweak landed in the buffer");
|
|
views = api.proposalController.listProposals(doc);
|
|
const keepView = views.find((v) => v.id === keepId);
|
|
assert.ok(keepView, "the tweaked proposal is still a live, addressable pending proposal (not silently dropped)");
|
|
assert.strictEqual(
|
|
keepView!.anchorStart,
|
|
null,
|
|
"INV-11: the interior tweak breaks the exact-fingerprint anchor (flagged stale, never guessed)",
|
|
);
|
|
|
|
// ---- PUC-2, keep: finalize the tweaked proposal in place -------------------------
|
|
assert.ok(await api.proposalController.finalizeInPlace(docKey, keepId), "keep finalizes in place");
|
|
await settle();
|
|
assert.strictEqual(
|
|
api.proposalController.listProposals(doc).find((v) => v.id === keepId),
|
|
undefined,
|
|
"kept proposal cleared",
|
|
);
|
|
// Attribution split (F3xF12): Claude's words AND the human tweak are both
|
|
// attributed, char-honest (spansFor).
|
|
const spans = api.attributionController.spansFor(doc);
|
|
const claudeSpan = spans.find(
|
|
(s) => s.author === "claude" && doc.getText().slice(s.start, s.end).includes("jumps the lazy dog"),
|
|
);
|
|
const humanSpan = spans.find((s) => s.author === "human" && doc.getText().slice(s.start, s.end) === "FOX");
|
|
assert.ok(claudeSpan, "claude's words are attributed");
|
|
assert.ok(humanSpan, "the human tweak is attributed separately (char-honest split)");
|
|
|
|
// ---- PUC-2, reject: revert the second proposal in place --------------------------
|
|
// #70 (INV-5): the reject leg now ALSO takes an interior tweak first — the
|
|
// original deliberately rejected only an un-tweaked proposal because the
|
|
// pre-fix revert silently skipped an orphaned anchor. The tweak orphans the
|
|
// exact anchor; the revert must restore ORIG_REJECT via the tracked span.
|
|
const rejAt = doc.getText().indexOf(REJECT_REPLACEMENT);
|
|
assert.ok(rejAt >= 0, "reject proposal's applied text present");
|
|
const rejTweak = new vscode.WorkspaceEdit();
|
|
rejTweak.replace(doc.uri, new vscode.Range(doc.positionAt(rejAt + 2), doc.positionAt(rejAt + 2)), "x");
|
|
assert.ok(await vscode.workspace.applyEdit(rejTweak), "human tweak inside the reject proposal's range");
|
|
await settle();
|
|
assert.strictEqual(
|
|
api.proposalController.listProposals(doc).find((v) => v.id === rejectId)!.anchorStart,
|
|
null,
|
|
"tweak orphans the reject proposal's exact anchor (INV-11)",
|
|
);
|
|
assert.ok(await api.proposalController.revertInPlace(docKey, rejectId), "reject reverts in place");
|
|
await settle();
|
|
assert.strictEqual(
|
|
api.proposalController.listProposals(doc).find((v) => v.id === rejectId),
|
|
undefined,
|
|
"rejected proposal cleared",
|
|
);
|
|
// INV-5: the rejected range returns EXACTLY to its pre-proposal text.
|
|
assert.ok(doc.getText().includes(ORIG_REJECT), "rejected range restored EXACTLY to its pre-proposal text (INV-5)");
|
|
assert.ok(!doc.getText().includes(REJECT_REPLACEMENT), "claude's rejected rewrite is gone from the buffer");
|
|
|
|
// ---- PUC-1: pin the baseline (Mark Changes as Reviewed) --------------------------
|
|
await vscode.commands.executeCommand("cowriting.markReviewed");
|
|
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 0, 5000);
|
|
assert.strictEqual(
|
|
api.diffViewController.getBaseline(doc.uri.toString())?.reason,
|
|
"pinned",
|
|
"baseline re-pinned to the clean, fully-reviewed buffer",
|
|
);
|
|
});
|
|
});
|