F8: out-of-workspace authoring — hybrid sidecar persistence (#25) #26
@@ -1,21 +1,27 @@
|
|||||||
import * as assert from "assert";
|
import * as assert from "assert";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
|
import type { CowritingApi } from "../../../src/extension";
|
||||||
|
|
||||||
// Regression for #8: with NO workspace folder open, activate() used to return
|
// F8: with NO workspace folder open, authoring used to be stubbed (#8 registered
|
||||||
// before registering the F2/F3 commands, so the palette errored with
|
// warning stubs and activate() returned undefined). F8 makes the authoring
|
||||||
// "command 'cowriting.editSelection' not found". The fix registers warning
|
// commands REAL folder-less (the F6 #19 precedent), routing every doc to global
|
||||||
// stubs instead. This suite runs in a second EDH pass launched WITHOUT a
|
// storage — so activate() returns a real API and propose→accept works on an
|
||||||
// folder (see runTest.ts).
|
// untitled buffer with no folder. This suite runs in a second EDH pass launched
|
||||||
|
// WITHOUT a folder (see runTest.ts).
|
||||||
|
|
||||||
suite("no-workspace activation (#8)", () => {
|
suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
|
||||||
test("EDH really has no workspace folder", () => {
|
test("EDH really has no workspace folder", () => {
|
||||||
assert.strictEqual(vscode.workspace.workspaceFolders, undefined);
|
assert.strictEqual(vscode.workspace.workspaceFolders, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("all contributed coauthoring commands are registered as warning stubs", async () => {
|
test("activate returns a real API even with no workspace folder (F8)", async () => {
|
||||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||||
const api = await ext.activate();
|
const api = (await ext.activate()) as CowritingApi;
|
||||||
assert.strictEqual(api, undefined, "no-root activation returns no API");
|
assert.ok(api?.proposalController, "no-folder activation returns the authoring API (F8)");
|
||||||
|
assert.ok(api?.sidecarRouter, "router exposed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all contributed coauthoring commands are registered (real, not stubs)", async () => {
|
||||||
const all = await vscode.commands.getCommands(true);
|
const all = await vscode.commands.getCommands(true);
|
||||||
for (const command of [
|
for (const command of [
|
||||||
"cowriting.createThread",
|
"cowriting.createThread",
|
||||||
@@ -33,9 +39,32 @@ suite("no-workspace activation (#8)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("invoking a stub does not throw (shows a warning instead)", async () => {
|
test("authoring works folder-less: propose→accept on an untitled buffer routes to global storage (F8)", async () => {
|
||||||
await vscode.commands.executeCommand("cowriting.editSelection");
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||||
await vscode.commands.executeCommand("cowriting.createThread");
|
const api = (await ext.activate()) as CowritingApi;
|
||||||
|
const untitled = await vscode.workspace.openTextDocument({
|
||||||
|
content: "Edit this scratch sentence please.\n",
|
||||||
|
language: "markdown",
|
||||||
|
});
|
||||||
|
await vscode.window.showTextDocument(untitled);
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
const key = untitled.uri.toString();
|
||||||
|
const target = "Edit this scratch sentence please.";
|
||||||
|
const start = untitled.getText().indexOf(target);
|
||||||
|
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||||
|
uri: key,
|
||||||
|
start,
|
||||||
|
end: start + target.length,
|
||||||
|
newText: "REPLACED scratch sentence.",
|
||||||
|
model: "sonnet",
|
||||||
|
sessionId: "e2e-nf",
|
||||||
|
turnId: "turn-nf",
|
||||||
|
});
|
||||||
|
assert.ok(id, "propose returns an id for an untitled buffer with no folder");
|
||||||
|
assert.ok(await api.proposalController.acceptById(key, id!), "accept applies");
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
assert.ok(untitled.getText().includes("REPLACED scratch sentence."), "replacement landed in the untitled buffer");
|
||||||
|
assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)");
|
||||||
});
|
});
|
||||||
|
|
||||||
// F6 (#19) is workspace-INDEPENDENT: its commands are real (not stubs) even
|
// F6 (#19) is workspace-INDEPENDENT: its commands are real (not stubs) even
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import * as assert from "assert";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import type { CowritingApi } from "../../../src/extension";
|
||||||
|
|
||||||
|
const WS = process.env.E2E_WORKSPACE!;
|
||||||
|
const settle = () => new Promise((r) => setTimeout(r, 300));
|
||||||
|
|
||||||
|
async function getApi(): Promise<CowritingApi> {
|
||||||
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||||
|
const api = (await ext.activate()) as CowritingApi;
|
||||||
|
assert.ok(api?.proposalController && api?.sidecarRouter, "extension exports the authoring API + router");
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
async function proposeViaCommand(uri: string, text: string, target: string, newText: string, turnId: string): Promise<string> {
|
||||||
|
const start = text.indexOf(target);
|
||||||
|
assert.ok(start >= 0, `text contains "${target}"`);
|
||||||
|
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||||
|
uri, start, end: start + target.length, newText, model: "sonnet", sessionId: "e2e-oow", turnId,
|
||||||
|
});
|
||||||
|
assert.ok(id, "propose returns the proposal id");
|
||||||
|
return id!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// F8 host E2E (no LLM): authoring on a file OUTSIDE the workspace folder and on
|
||||||
|
// an untitled buffer, plus an in-workspace byte-for-byte regression. Runs in the
|
||||||
|
// WITH-workspace EDH pass (a folder is open; the out-of-folder file lives in a
|
||||||
|
// temp dir outside it — exactly like diffView's out-of-folder test).
|
||||||
|
suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)", () => {
|
||||||
|
test("out-of-folder file: propose→accept lands Claude-attributed; persisted in GLOBAL storage, no .threads (PUC-1, INV-9/24/25)", async () => {
|
||||||
|
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-oow-"));
|
||||||
|
const outsidePath = path.join(outsideDir, "sibling.md");
|
||||||
|
const TARGET = "The sibling-repo sentence Claude edits.";
|
||||||
|
fs.writeFileSync(outsidePath, `# Sibling\n\n${TARGET}\n`, "utf8");
|
||||||
|
const uri = vscode.Uri.file(outsidePath);
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
await settle();
|
||||||
|
const api = await getApi();
|
||||||
|
const key = uri.toString();
|
||||||
|
|
||||||
|
const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1");
|
||||||
|
await settle();
|
||||||
|
// routed to global storage, never the repo
|
||||||
|
const sidecar = api.sidecarRouter.sidecarPath(key)!;
|
||||||
|
assert.ok(sidecar && fs.existsSync(sidecar), `global sidecar exists at ${sidecar}`);
|
||||||
|
assert.ok(sidecar.includes(`${path.sep}sidecars${path.sep}`), "lives under <globalStorage>/sidecars/");
|
||||||
|
assert.ok(!sidecar.includes(`${path.sep}.threads${path.sep}`), "NOT in a .threads/ tree (INV-25)");
|
||||||
|
assert.ok(!fs.existsSync(path.join(outsideDir, ".threads")), "no .threads/ written beside the out-of-folder file");
|
||||||
|
|
||||||
|
assert.ok(await api.proposalController.acceptById(key, id), "accept applies via the seam");
|
||||||
|
await settle();
|
||||||
|
assert.ok(doc.getText().includes("The sibling sentence CLAUDE REWROTE."), "replacement landed");
|
||||||
|
const agent = api.attributionController.getSpans(key).find((s) => s.turnId === "turn-oow1");
|
||||||
|
assert.ok(agent && agent.authorKind === "agent", "accepted text is Claude-attributed");
|
||||||
|
assert.strictEqual(api.proposalController.getRendered(key).length, 0, "proposal gone (INV-13)");
|
||||||
|
|
||||||
|
// a thread can be opened on the out-of-folder doc
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
const editor = vscode.window.activeTextEditor!;
|
||||||
|
editor.selection = new vscode.Selection(doc.positionAt(0), doc.positionAt(8));
|
||||||
|
const threadId = await api.threadController.createThreadOnSelection("thread on a sibling file");
|
||||||
|
assert.ok(threadId, "a thread opens on the out-of-folder doc");
|
||||||
|
|
||||||
|
// reload-restore from the GLOBAL sidecar (re-anchor content-based)
|
||||||
|
fs.writeFileSync(outsidePath, "PREPENDED\n\n" + doc.getText(), "utf8");
|
||||||
|
const reloaded = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(reloaded);
|
||||||
|
await vscode.commands.executeCommand("workbench.action.files.revert");
|
||||||
|
await settle();
|
||||||
|
api.threadController.renderAll(reloaded);
|
||||||
|
assert.strictEqual(api.threadController.getRendered(key).length, 1, "thread restored from the global sidecar after reload");
|
||||||
|
|
||||||
|
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("untitled buffer: propose→accept works in-session, state in-memory only (PUC-2)", async () => {
|
||||||
|
const TARGET = "The untitled draft sentence.";
|
||||||
|
const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" });
|
||||||
|
await vscode.window.showTextDocument(untitled);
|
||||||
|
await settle();
|
||||||
|
const api = await getApi();
|
||||||
|
const key = untitled.uri.toString();
|
||||||
|
assert.strictEqual(untitled.uri.scheme, "untitled", "really untitled");
|
||||||
|
|
||||||
|
const id = await proposeViaCommand(key, untitled.getText(), TARGET, "The untitled draft, CLAUDE-EDITED.", "turn-oow2");
|
||||||
|
await settle();
|
||||||
|
assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)");
|
||||||
|
assert.ok(await api.proposalController.acceptById(key, id), "accept applies");
|
||||||
|
await settle();
|
||||||
|
assert.ok(untitled.getText().includes("The untitled draft, CLAUDE-EDITED."), "replacement landed in the untitled buffer");
|
||||||
|
const agent = api.attributionController.getSpans(key).find((s) => s.turnId === "turn-oow2");
|
||||||
|
assert.ok(agent && agent.authorKind === "agent", "untitled accepted text is Claude-attributed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("in-workspace regression: artifact still lands at <root>/.threads/<repo-rel>.json with the repo-relative document.path (INV-2)", async () => {
|
||||||
|
const DOC_REL = "docs/f8regression.md";
|
||||||
|
const TARGET = "An in-folder sentence for the F8 regression.";
|
||||||
|
const abs = path.join(WS, DOC_REL);
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
fs.writeFileSync(abs, `# F8 regression\n\n${TARGET}\n`, "utf8");
|
||||||
|
const uri = vscode.Uri.file(abs);
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
await settle();
|
||||||
|
const api = await getApi();
|
||||||
|
|
||||||
|
// the in-workspace key is the repo-relative path, not the URI string
|
||||||
|
assert.strictEqual(
|
||||||
|
api.sidecarRouter.keyOf({ uri: uri.toString(), fsPath: abs, scheme: "file" }),
|
||||||
|
DOC_REL,
|
||||||
|
"in-folder key is the repo-relative path",
|
||||||
|
);
|
||||||
|
const id = await proposeViaCommand(uri.toString(), doc.getText(), TARGET, "An in-folder sentence, EDITED.", "turn-oow3");
|
||||||
|
await settle();
|
||||||
|
assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept by the repo-relative key");
|
||||||
|
await settle();
|
||||||
|
const sidecar = api.sidecarRouter.sidecarPath(DOC_REL)!;
|
||||||
|
assert.strictEqual(sidecar, path.join(WS, ".threads", "docs", "f8regression.md.json"), "committable .threads/ path unchanged (INV-2)");
|
||||||
|
assert.ok(fs.existsSync(sidecar), "sidecar written in the repo");
|
||||||
|
const onDisk = JSON.parse(fs.readFileSync(sidecar, "utf8"));
|
||||||
|
assert.strictEqual(onDisk.document.path, DOC_REL, "document.path is the repo-relative path (byte-for-byte)");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user