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 untouched second proposal in place ---------------- 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", ); }); });