Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) #72
@@ -352,6 +352,7 @@
|
||||
"pretest:e2e": "npm run build && npm run clean:e2e && tsc -p tsconfig.e2e.json",
|
||||
"test:e2e": "node ./out/test/e2e/runTest.js",
|
||||
"smoke:live": "npm run build && node scripts/smoke-live-turn.mjs",
|
||||
"smoke:native": "npm run pretest:e2e && node scripts/smoke-native-loop.mjs",
|
||||
"vscode:prepublish": "node esbuild.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
// Task 9 / spec §7.1 rung 3: the manual, real-@cline/sdk smoke for the native
|
||||
// comment loop -- mirrors scripts/smoke-live-turn.mjs's role for F3 (a live
|
||||
// login-backed check, deliberately NOT in CI), but this one drives the whole
|
||||
// EDH (comment -> reply -> offer -> proposal needs vscode's own comment/doc
|
||||
// APIs, unlike smoke-live-turn.mjs's bare module call) via
|
||||
// test/e2e/smoke/nativeLoop.ts, launched exactly like test/e2e/runTest.ts
|
||||
// launches the regular host-E2E suites -- just against a single smoke entry
|
||||
// point that is NOT swept into `npm run test:e2e` (it lives outside
|
||||
// test/e2e/suite, which is the only directory suite/index.ts's mocha glob
|
||||
// searches).
|
||||
//
|
||||
// Prereqs: `npm run pretest:e2e` (builds the extension + compiles
|
||||
// test/e2e/**). `npm run smoke:native` does this for you.
|
||||
//
|
||||
// Opens the real sandbox/ folder (the repo's EDH workspace root), writes a
|
||||
// throwaway doc, posts a real comment via ThreadController.createThreadOnSelection,
|
||||
// waits for the real @cline/sdk reply + offer, makes + accepts the edit, then
|
||||
// prints the created proposal ids and deletes the throwaway doc.
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runTests } from "@vscode/test-electron";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function main() {
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const extensionDevelopmentPath = projectRoot;
|
||||
const extensionTestsPath = path.resolve(projectRoot, "out/test/e2e/smoke/nativeLoop");
|
||||
const sandbox = path.resolve(projectRoot, "sandbox");
|
||||
|
||||
if (!fs.existsSync(`${extensionTestsPath}.js`)) {
|
||||
console.error(
|
||||
`smoke-native-loop: ${extensionTestsPath}.js is missing -- run "npm run pretest:e2e" first (or "npm run smoke:native", which does this for you).`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Same macOS UNIX-socket-length workaround as test/e2e/runTest.ts (a long
|
||||
// worktree path + the default .vscode-test/user-data dir can blow past the
|
||||
// ~103-char limit).
|
||||
const userDataRoot = process.platform === "win32" ? os.tmpdir() : "/tmp";
|
||||
const userDataDir = fs.mkdtempSync(path.join(userDataRoot, "cwud-smoke-"));
|
||||
|
||||
try {
|
||||
await runTests({
|
||||
extensionDevelopmentPath,
|
||||
extensionTestsPath,
|
||||
launchArgs: [sandbox, "--disable-extensions", "--user-data-dir", userDataDir],
|
||||
});
|
||||
console.log("smoke-native-loop: PASS");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`smoke-native-loop: FAILED (expected when Claude Code is absent/signed out, or the sandbox/ folder is missing): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { settleUntil } from "../suite/helpers";
|
||||
|
||||
/**
|
||||
* Task 9 (spec §7.1 rung 3 — the real @cline/sdk, manual gate). Drives the
|
||||
* SAME comments-first ask -> reply -> offer -> proposal loop as
|
||||
* test/e2e/suite/commentLoop.test.ts / fullLoop.test.ts, but with BOTH turns
|
||||
* left UNSTUBBED: ThreadController's reply loop and EditFlow's edit turn hit
|
||||
* the real local `claude` login (INV-8 -- the extension itself holds no
|
||||
* credentials). Not part of `npm run test:e2e` / CI -- driven only by
|
||||
* `scripts/smoke-native-loop.mjs` (`npm run smoke:native`), mirroring
|
||||
* `scripts/smoke-live-turn.mjs`'s "manual gate, real SDK" role for F3.
|
||||
*
|
||||
* There is no VS Code API to drive the native comment-widget's OWN submit
|
||||
* gesture from outside the UI (see commentLoop.test.ts's "Finding 1" note) --
|
||||
* this calls the exact seam a real comment submission reaches
|
||||
* (ThreadController.createThreadOnSelection / .makeThreadEdit), which is the
|
||||
* closest a headless script gets to "post a real comment" on a live doc.
|
||||
*/
|
||||
export async function run(): Promise<void> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin");
|
||||
if (!ext) throw new Error("extension not found -- is extensionDevelopmentPath wired correctly?");
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
if (!api?.threadController || !api?.editFlow || !api?.proposalController || !api?.sidecarRouter) {
|
||||
throw new Error("extension did not export the expected controllers");
|
||||
}
|
||||
|
||||
const folder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!folder) throw new Error("no workspace folder -- launch with the sandbox/ folder open");
|
||||
const target = path.join(folder.uri.fsPath, `.smoke-native-loop-${process.pid}.md`);
|
||||
const ORIGINAL = "The quick brown fox jumps over the lazy dog paragraph.";
|
||||
fs.writeFileSync(target, `# Native-loop smoke\n\n${ORIGINAL}\n`, "utf8");
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(target);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
const ed = await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
|
||||
const docKey = api.proposalController.keyFor(doc);
|
||||
const start = doc.getText().indexOf(ORIGINAL);
|
||||
ed.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + ORIGINAL.length));
|
||||
|
||||
console.log("[smoke-native-loop] posting a real comment (createThreadOnSelection)...");
|
||||
const threadId = await api.threadController.createThreadOnSelection(
|
||||
"Rewrite this sentence to be more concise.",
|
||||
);
|
||||
if (!threadId) throw new Error("createThreadOnSelection refused (not coediting, or no selection)");
|
||||
console.log(`[smoke-native-loop] thread created: ${threadId} -- waiting for the real @cline/sdk reply (this calls the live SDK; may take a while)...`);
|
||||
|
||||
await settleUntil(() => {
|
||||
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === threadId);
|
||||
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
|
||||
}, 180000);
|
||||
console.log("[smoke-native-loop] reply landed -- thread flipped to \"offer\". Making the edit (real SDK)...");
|
||||
|
||||
const ids = await api.threadController.makeThreadEdit(threadId, docKey);
|
||||
if (ids.length === 0) throw new Error("makeThreadEdit produced no proposals (the turn returned an empty/no-op replacement)");
|
||||
console.log(`[smoke-native-loop] proposal id(s) created: ${ids.join(", ")}`);
|
||||
|
||||
await settleUntil(() => ids.every((id) => api.proposalController.isApplied(docKey, id)), 10000);
|
||||
for (const id of ids) {
|
||||
const ok = await api.proposalController.finalizeInPlace(docKey, id);
|
||||
console.log(`[smoke-native-loop] accepted proposal ${id}: ${ok}`);
|
||||
}
|
||||
console.log(`[smoke-native-loop] PASS -- ${ids.length} proposal(s) created and accepted: ${ids.join(", ")}`);
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(target, { force: true });
|
||||
} catch {
|
||||
// best-effort cleanup -- never let cleanup mask the real result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user