61 KiB
F2 — Region-Anchored Threads Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Deliver F2 (benstull/vscode-cowriting-plugin#4) — durable, region-anchored discussion threads in VS Code, persisted as git-native .threads/ sidecars, rendered through the native Comments API, with hybrid anchoring that survives edits/reload/external change (orphaning rather than guessing) — on the POC skeleton (#2), per spec vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md.
Architecture: Three vscode-free units (unit-tested with vitest, mirroring the POC's cline.ts) plus one thin editor-facing controller and a vscode-free thread-mutation helper:
src/model.ts— the versioned artifact schema (sharedanchors+provenanceprimitives, INV-4) and stable serialization.src/store.ts—CoauthorStore: load/save.threads/sidecars via Nodefs, stable formatting (INV-2).src/anchorer.ts—buildFingerprint, the resolution ladder (exact-unique → context-disambiguated → lineHint tiebreak → orphaned, INV-1/INV-3), and live-offsetshift.src/threadModel.ts— vscode-free Artifact mutations (addThread,appendMessage,setStatus).src/threadController.ts— wires Store + Anchorer + threadModel tovscode.comments(create-on-selection, reply, resolve, render, reload, FileSystemWatcher re-anchor, orphaned surface).src/extension.ts—activatewires the controller (keeps the POC'sshowClineSdkInfo) and returns a small test-facing API.
The extension stays a CJS esbuild bundle (vscode + @cline/sdk external). No live @cline/sdk turn and no credentials in F2 (INV-5). The Comments surface is native (not a webview) ⇒ E2E uses @vscode/test-electron (not Playwright), and E2E host tests are first-class plan tasks (SLICE-5).
Tech Stack: TypeScript, VS Code Extension API (Comments API), esbuild (extension bundle), vitest (unit), @vscode/test-electron + mocha (host E2E), @cline/sdk@0.0.46 (already linked, untouched by F2).
File Structure
| File | Responsibility |
|---|---|
src/model.ts |
Artifact schema types (Artifact, Anchor, Fingerprint, Thread, Message, Provenance), SCHEMA_VERSION, emptyArtifact, serializeArtifact (stable key ordering), newId. vscode-free. |
src/store.ts |
CoauthorStore: sidecarPath/load/save over Node fs; one sidecar per document under .threads/. vscode-free. |
src/anchorer.ts |
OffsetRange, buildFingerprint, resolve (ladder + orphan), shift (live offsets). vscode-free. |
src/threadModel.ts |
vscode-free Artifact mutations: addThread, appendMessage, setStatus. |
src/threadController.ts |
vscode layer: CommentController, create/reply/resolve commands, renderAll, live change tracking, FileSystemWatcher re-anchor, orphaned rendering. |
src/extension.ts |
activate/deactivate; wires ThreadController; keeps showClineSdkInfo; returns { threadController } test API. |
test/model.test.ts |
vitest: serialize round-trip + stable formatting. |
test/store.test.ts |
vitest: save→load round-trip in a temp dir; byte-stable re-save. |
test/anchorer.test.ts |
vitest: fingerprint build; resolution ladder (exact/context/lineHint/orphan); shift cases. |
test/threadModel.test.ts |
vitest: addThread / appendMessage / setStatus. |
tsconfig.e2e.json |
Emit test/e2e + src to out/ as CommonJS for the host test run. |
test/e2e/runTest.ts |
@vscode/test-electron launcher (downloads VS Code, opens a temp workspace). |
test/e2e/suite/index.ts |
mocha runner that globs compiled *.test.js. |
test/e2e/suite/threads.test.ts |
host E2E: create→reply→resolve→persist→reload→re-anchor→orphan. |
test/e2e/fixtures/workspace/docs/sample.md |
E2E fixture document. |
package.json |
Add F2 commands/menus contributions, onStartupFinished activation, E2E scripts + dev deps. |
vitest.config.ts |
Exclude test/e2e/** (those require vscode). |
.gitignore |
Ignore E2E-generated .threads/ under the fixture. |
README.md |
Document the F2 feature + how to run unit and host E2E tests. |
Task 1 (SLICE-1): Artifact schema + CoauthorStore
Files:
-
Create:
src/model.ts -
Test:
test/model.test.ts -
Create:
src/store.ts -
Test:
test/store.test.ts -
Modify:
vitest.config.ts(exclude E2E dir up front so later tasks don't breaknpm test) -
Step 1: Exclude the (future) E2E dir from vitest
vitest.config.ts (replace whole file):
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
exclude: ["test/e2e/**", "node_modules/**"],
},
});
- Step 2: Write the failing model test
test/model.test.ts:
import { describe, it, expect } from "vitest";
import {
SCHEMA_VERSION,
emptyArtifact,
serializeArtifact,
type Artifact,
} from "../src/model";
function sampleArtifact(): Artifact {
return {
schemaVersion: SCHEMA_VERSION,
document: { path: "docs/spec.md" },
anchors: {
a2: { fingerprint: { text: "beta", before: "x", after: "y", lineHint: 5 } },
a1: { fingerprint: { text: "alpha", before: "", after: "", lineHint: 1 } },
},
threads: [
{
id: "t1",
anchorId: "a1",
status: "open",
messages: [
{ id: "m1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" },
],
},
],
attributions: [],
proposals: [],
};
}
describe("serializeArtifact", () => {
it("round-trips through JSON.parse", () => {
const a = sampleArtifact();
const parsed = JSON.parse(serializeArtifact(a)) as Artifact;
expect(parsed).toEqual(a);
});
it("emits stable, sorted anchor keys and a trailing newline regardless of insertion order", () => {
const a = sampleArtifact();
const out = serializeArtifact(a);
expect(out.endsWith("\n")).toBe(true);
// a1 must serialize before a2 even though a2 was inserted first
expect(out.indexOf('"a1"')).toBeLessThan(out.indexOf('"a2"'));
// byte-identical on re-serialize
expect(serializeArtifact(JSON.parse(out))).toBe(out);
});
it("emptyArtifact has the shared F3/F4 extension points present and empty", () => {
const e = emptyArtifact("docs/x.md");
expect(e.schemaVersion).toBe(SCHEMA_VERSION);
expect(e.document.path).toBe("docs/x.md");
expect(e.anchors).toEqual({});
expect(e.threads).toEqual([]);
expect(e.attributions).toEqual([]);
expect(e.proposals).toEqual([]);
});
});
- Step 3: Run the test to verify it fails
Run: npm test -- test/model.test.ts
Expected: FAIL — cannot resolve ../src/model.
- Step 4: Write
src/model.ts
/**
* Versioned coauthoring artifact schema (spec §6.3) — the shared git-native
* envelope under every coauthoring capability. `anchors` and the `Provenance`
* author field are SHARED primitives (INV-4): F3 (attributions) and F4
* (proposals) add arrays that reference the same shapes without a format break.
*
* vscode-free and pure, so it is unit-testable in Node (mirrors src/cline.ts).
*/
import { randomUUID } from "node:crypto";
export const SCHEMA_VERSION = 1 as const;
/** Anchored text + bounded context + a line tie-breaker (spec §6.3). */
export interface Fingerprint {
text: string;
/** <= ~3 lines / 120 chars leading context. */
before: string;
/** <= ~3 lines / 120 chars trailing context. */
after: string;
/** 0-based line of the anchor start; a tie-breaker, NOT truth (INV-3). */
lineHint: number;
}
export interface Anchor {
fingerprint: Fingerprint;
}
/** The reusable author field (spec §6.3). `agent` only present when kind=agent. */
export type Provenance =
| { kind: "human"; id: string }
| { kind: "agent"; id: string; agent: { sdk: string; model: string; sessionId: string } };
export interface Message {
id: string;
author: Provenance;
body: string;
/** ISO-8601. */
createdAt: string;
}
export type ThreadStatus = "open" | "resolved";
export interface Thread {
id: string;
anchorId: string;
status: ThreadStatus;
messages: Message[];
}
export interface Artifact {
schemaVersion: number;
/** repo-relative path; the sidecar key. */
document: { path: string };
/** SHARED primitive (INV-4). */
anchors: Record<string, Anchor>;
threads: Thread[];
/** F3 extension point — reuses anchors[]; not implemented in F2. */
attributions: unknown[];
/** F4 extension point — reuses anchors[] + provenance; not in F2. */
proposals: unknown[];
}
export function emptyArtifact(docPath: string): Artifact {
return {
schemaVersion: SCHEMA_VERSION,
document: { path: docPath },
anchors: {},
threads: [],
attributions: [],
proposals: [],
};
}
/** Stable, generated id (spec §6.3). */
export function newId(prefix: string): string {
return `${prefix}_${randomUUID()}`;
}
function serializeProvenance(p: Provenance): Record<string, unknown> {
return p.kind === "agent"
? { kind: p.kind, id: p.id, agent: { sdk: p.agent.sdk, model: p.agent.model, sessionId: p.agent.sessionId } }
: { kind: p.kind, id: p.id };
}
/**
* Pretty-printed JSON with STABLE key ordering (anchors sorted by id), so
* sidecar diffs stay minimal and merge-friendly (spec §6.3, INV-2). Always
* ends with a trailing newline.
*/
export function serializeArtifact(a: Artifact): string {
const canonical = {
schemaVersion: a.schemaVersion,
document: { path: a.document.path },
anchors: Object.fromEntries(
Object.keys(a.anchors)
.sort()
.map((k) => [
k,
{
fingerprint: {
text: a.anchors[k].fingerprint.text,
before: a.anchors[k].fingerprint.before,
after: a.anchors[k].fingerprint.after,
lineHint: a.anchors[k].fingerprint.lineHint,
},
},
]),
),
threads: a.threads.map((t) => ({
id: t.id,
anchorId: t.anchorId,
status: t.status,
messages: t.messages.map((m) => ({
id: m.id,
author: serializeProvenance(m.author),
body: m.body,
createdAt: m.createdAt,
})),
})),
attributions: a.attributions,
proposals: a.proposals,
};
return JSON.stringify(canonical, null, 2) + "\n";
}
- Step 5: Run the model test to verify it passes
Run: npm test -- test/model.test.ts
Expected: PASS — 3 tests green.
- Step 6: Write the failing store test
test/store.test.ts:
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { CoauthorStore } from "../src/store";
import { emptyArtifact, newId, serializeArtifact, type Artifact } from "../src/model";
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "coauthor-store-"));
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
function withThread(): Artifact {
const a = emptyArtifact("docs/spec.md");
a.anchors["a1"] = { fingerprint: { text: "hello", before: "", after: "", lineHint: 0 } };
a.threads.push({
id: "t1",
anchorId: "a1",
status: "open",
messages: [{ id: newId("m"), author: { kind: "human", id: "ben" }, body: "note", createdAt: "2026-06-10T00:00:00.000Z" }],
});
return a;
}
describe("CoauthorStore", () => {
it("returns null for a document with no sidecar", () => {
const store = new CoauthorStore(root);
expect(store.load("docs/spec.md")).toBeNull();
});
it("computes the sidecar path as .threads/<docPath>.json", () => {
const store = new CoauthorStore(root);
expect(store.sidecarPath("docs/spec.md")).toBe(path.join(root, ".threads", "docs", "spec.md.json"));
});
it("save then load round-trips the artifact", () => {
const store = new CoauthorStore(root);
const a = withThread();
store.save("docs/spec.md", a);
expect(store.load("docs/spec.md")).toEqual(a);
});
it("writes byte-stable, pretty JSON (re-save is identical)", () => {
const store = new CoauthorStore(root);
const a = withThread();
store.save("docs/spec.md", a);
const first = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8");
store.save("docs/spec.md", store.load("docs/spec.md")!);
const second = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8");
expect(second).toBe(first);
expect(first).toBe(serializeArtifact(a));
});
});
- Step 7: Run the store test to verify it fails
Run: npm test -- test/store.test.ts
Expected: FAIL — cannot resolve ../src/store.
- Step 8: Write
src/store.ts
/**
* CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4).
* Git-native, serverless, plain pretty JSON (INV-2). vscode-free (Node fs only),
* so it is unit-testable; the FileSystemWatcher that triggers re-anchoring on
* external change is wired in the vscode layer (ThreadController, SLICE-4).
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { serializeArtifact, type Artifact } from "./model";
export class CoauthorStore {
/** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */
constructor(private readonly rootDir: string) {}
/** `.threads/<repo-relative-docPath>.json` (spec §6.3). */
sidecarPath(docPath: string): string {
return path.join(this.rootDir, ".threads", `${docPath}.json`);
}
load(docPath: string): Artifact | null {
const p = this.sidecarPath(docPath);
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact;
}
save(docPath: string, artifact: Artifact): void {
const p = this.sidecarPath(docPath);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, serializeArtifact(artifact), "utf8");
}
}
- Step 9: Run the full unit suite to verify it passes
Run: npm test
Expected: PASS — model + store tests green (plus the existing cline test).
- Step 10: Typecheck
Run: npm run typecheck
Expected: PASS — no type errors.
- Step 11: Commit
git add src/model.ts test/model.test.ts src/store.ts test/store.test.ts vitest.config.ts
git commit -m "F2 SLICE-1: artifact schema + CoauthorStore with round-trip tests (#4)"
Task 2 (SLICE-2): Anchorer — fingerprint, resolution ladder, live shift
Files:
-
Create:
src/anchorer.ts -
Test:
test/anchorer.test.ts -
Step 1: Write the failing anchorer test
test/anchorer.test.ts:
import { describe, it, expect } from "vitest";
import { buildFingerprint, resolve, shift, type OffsetRange } from "../src/anchorer";
const DOC = "line0\nline1 needle here\nline2\nneedle again on line3\n";
describe("buildFingerprint", () => {
it("captures the exact text, bounded context, and 0-based lineHint", () => {
const start = DOC.indexOf("needle here");
const range: OffsetRange = { start, end: start + "needle".length };
const fp = buildFingerprint(DOC, range);
expect(fp.text).toBe("needle");
expect(fp.lineHint).toBe(1);
expect(fp.before.endsWith("line1 ")).toBe(true);
expect(fp.after.startsWith(" here")).toBe(true);
expect(fp.before.length).toBeLessThanOrEqual(120);
expect(fp.after.length).toBeLessThanOrEqual(120);
});
});
describe("resolve", () => {
it("exact-unique: returns the single occurrence's range", () => {
const fp = { text: "line2", before: "", after: "", lineHint: 0 };
const start = DOC.indexOf("line2");
expect(resolve(DOC, fp)).toEqual({ start, end: start + 5 });
});
it("context-disambiguated: picks the occurrence whose before/after match", () => {
const fp = { text: "needle", before: "line1 ", after: " here", lineHint: 99 };
const start = DOC.indexOf("needle here");
expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 });
});
it("lineHint tiebreak: when context does not disambiguate, the closest line wins", () => {
// both occurrences of 'needle', no usable context, hint points at line 3
const fp = { text: "needle", before: "", after: "", lineHint: 3 };
const start = DOC.indexOf("needle again");
expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 });
});
it("orphaned: returns 'orphaned' when the text is gone", () => {
const fp = { text: "absent-text", before: "", after: "", lineHint: 0 };
expect(resolve(DOC, fp)).toBe("orphaned");
});
it("orphaned: returns 'orphaned' rather than guessing on an unbreakable tie (INV-1)", () => {
const doc = "needle\n....\nneedle\n"; // two identical occurrences, equidistant from hint
const fp = { text: "needle", before: "", after: "", lineHint: 1 };
expect(resolve(doc, fp)).toBe("orphaned");
});
});
describe("shift", () => {
it("edit entirely before the range shifts both endpoints by the delta", () => {
const r: OffsetRange = { start: 10, end: 15 };
// insert 2 chars at offset 0 (replace [0,0) with len 2)
expect(shift(r, { start: 0, end: 0, newLength: 2 })).toEqual({ start: 12, end: 17 });
});
it("edit entirely after the range leaves it unchanged", () => {
const r: OffsetRange = { start: 2, end: 5 };
expect(shift(r, { start: 10, end: 12, newLength: 0 })).toEqual({ start: 2, end: 5 });
});
it("edit overlapping the range clamps the touched endpoints to the edit start", () => {
const r: OffsetRange = { start: 5, end: 10 };
// replace [3,7) with 1 char (delta = -3)
expect(shift(r, { start: 3, end: 7, newLength: 1 })).toEqual({ start: 3, end: 7 });
});
});
- Step 2: Run the test to verify it fails
Run: npm test -- test/anchorer.test.ts
Expected: FAIL — cannot resolve ../src/anchorer.
- Step 3: Write
src/anchorer.ts
/**
* Anchorer — hybrid anchoring (spec §6.5). The durable FINGERPRINT is the source
* of truth for an anchor's location (INV-3); live OFFSET ranges are a within-
* session optimization. The resolution ladder is:
* exact-unique → context-disambiguated → lineHint tiebreak → orphaned.
* It NEVER guesses on an unbreakable tie — it orphans instead (INV-1).
*
* vscode-free: operates on plain strings and character-offset ranges, so it is
* unit-testable in Node. The vscode layer converts OffsetRange <-> vscode.Range.
*/
import type { Fingerprint } from "./model";
export interface OffsetRange {
start: number;
end: number;
}
/** A document edit: the half-open range [start,end) was replaced with text of `newLength`. */
export interface TextEdit {
start: number;
end: number;
newLength: number;
}
const MAX_CTX_CHARS = 120;
const MAX_CTX_LINES = 3;
function lineNumberAt(doc: string, offset: number): number {
let line = 0;
const bound = Math.min(offset, doc.length);
for (let i = 0; i < bound; i++) if (doc.charCodeAt(i) === 10) line++;
return line;
}
function leadingContext(doc: string, start: number): string {
let s = doc.slice(Math.max(0, start - MAX_CTX_CHARS), start);
const lines = s.split("\n");
if (lines.length > MAX_CTX_LINES) s = lines.slice(lines.length - MAX_CTX_LINES).join("\n");
return s;
}
function trailingContext(doc: string, end: number): string {
let s = doc.slice(end, Math.min(doc.length, end + MAX_CTX_CHARS));
const lines = s.split("\n");
if (lines.length > MAX_CTX_LINES) s = lines.slice(0, MAX_CTX_LINES).join("\n");
return s;
}
export function buildFingerprint(docText: string, range: OffsetRange): Fingerprint {
return {
text: docText.slice(range.start, range.end),
before: leadingContext(docText, range.start),
after: trailingContext(docText, range.end),
lineHint: lineNumberAt(docText, range.start),
};
}
function allIndexesOf(hay: string, needle: string): number[] {
if (needle.length === 0) return [];
const out: number[] = [];
let from = 0;
let idx = hay.indexOf(needle, from);
while (idx !== -1) {
out.push(idx);
from = idx + 1;
idx = hay.indexOf(needle, from);
}
return out;
}
function contextMatches(doc: string, i: number, fp: Fingerprint): boolean {
const beforeOk = fp.before.length === 0 || doc.slice(Math.max(0, i - fp.before.length), i) === fp.before;
const afterStart = i + fp.text.length;
const afterOk = fp.after.length === 0 || doc.slice(afterStart, afterStart + fp.after.length) === fp.after;
return beforeOk && afterOk;
}
const rangeAt = (i: number, text: string): OffsetRange => ({ start: i, end: i + text.length });
/**
* Re-resolve a fingerprint against current text. Returns the resolved range or
* "orphaned" when no confident, unique match exists (INV-1).
*/
export function resolve(docText: string, fp: Fingerprint): OffsetRange | "orphaned" {
const occ = allIndexesOf(docText, fp.text);
if (occ.length === 0) return "orphaned";
if (occ.length === 1) return rangeAt(occ[0], fp.text);
// Multiple exact matches: try context disambiguation.
const ctx = occ.filter((i) => contextMatches(docText, i, fp));
if (ctx.length === 1) return rangeAt(ctx[0], fp.text);
// Still ambiguous: break ties by proximity to lineHint within the best pool.
const pool = ctx.length > 0 ? ctx : occ;
const dists = pool.map((i) => ({ i, d: Math.abs(lineNumberAt(docText, i) - fp.lineHint) }));
const min = Math.min(...dists.map((x) => x.d));
const closest = dists.filter((x) => x.d === min);
if (closest.length === 1) return rangeAt(closest[0].i, fp.text);
// Unbreakable tie — refuse to guess.
return "orphaned";
}
/** Maintain a live range across an in-session edit (spec §6.4 `shift`). */
export function shift(range: OffsetRange, edit: TextEdit): OffsetRange {
const delta = edit.newLength - (edit.end - edit.start);
const point = (p: number): number => {
if (p <= edit.start) return p;
if (p >= edit.end) return p + delta;
return edit.start; // inside the replaced span — clamp to its start
};
return { start: point(range.start), end: point(range.end) };
}
- Step 4: Run the test to verify it passes
Run: npm test -- test/anchorer.test.ts
Expected: PASS — fingerprint, all four resolution-ladder cases, and the three shift cases green.
- Step 5: Typecheck
Run: npm run typecheck
Expected: PASS.
- Step 6: Commit
git add src/anchorer.ts test/anchorer.test.ts
git commit -m "F2 SLICE-2: Anchorer (fingerprint, resolution ladder, live shift) (#4)"
Task 3 (SLICE-3): ThreadController wiring to the Comments API
This task has two halves: (a) a vscode-free threadModel.ts carrying the Artifact mutations (unit-tested), and (b) the threadController.ts vscode wiring + the package.json contributions (typecheck/build-verified; behavior is exercised by SLICE-5 E2E, since the native Comments UI cannot be driven from vitest).
Files:
- Create:
src/threadModel.ts - Test:
test/threadModel.test.ts - Create:
src/threadController.ts - Modify:
src/extension.ts - Modify:
package.json(contributions + activation)
3a — vscode-free thread mutations (TDD)
- Step 1: Write the failing threadModel test
test/threadModel.test.ts:
import { describe, it, expect } from "vitest";
import { emptyArtifact, type Provenance } from "../src/model";
import { addThread, appendMessage, setStatus } from "../src/threadModel";
const BEN: Provenance = { kind: "human", id: "ben" };
const fp = { text: "anchored", before: "a", after: "b", lineHint: 3 };
describe("addThread", () => {
it("adds an anchor + an open thread with the first message and returns their ids", () => {
const a = emptyArtifact("docs/x.md");
const { threadId, anchorId } = addThread(a, fp, { author: BEN, body: "first" });
expect(Object.keys(a.anchors)).toContain(anchorId);
expect(a.anchors[anchorId].fingerprint).toEqual(fp);
const t = a.threads.find((x) => x.id === threadId)!;
expect(t.anchorId).toBe(anchorId);
expect(t.status).toBe("open");
expect(t.messages).toHaveLength(1);
expect(t.messages[0].body).toBe("first");
expect(t.messages[0].author).toEqual(BEN);
expect(t.messages[0].id).toMatch(/^m_/);
expect(t.messages[0].createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
});
describe("appendMessage", () => {
it("appends a message to the named thread", () => {
const a = emptyArtifact("docs/x.md");
const { threadId } = addThread(a, fp, { author: BEN, body: "first" });
appendMessage(a, threadId, { author: BEN, body: "reply" });
const t = a.threads.find((x) => x.id === threadId)!;
expect(t.messages.map((m) => m.body)).toEqual(["first", "reply"]);
});
it("throws for an unknown thread id", () => {
const a = emptyArtifact("docs/x.md");
expect(() => appendMessage(a, "nope", { author: BEN, body: "x" })).toThrow(/nope/);
});
});
describe("setStatus", () => {
it("flips a thread between open and resolved", () => {
const a = emptyArtifact("docs/x.md");
const { threadId } = addThread(a, fp, { author: BEN, body: "first" });
setStatus(a, threadId, "resolved");
expect(a.threads[0].status).toBe("resolved");
setStatus(a, threadId, "open");
expect(a.threads[0].status).toBe("open");
});
});
- Step 2: Run the test to verify it fails
Run: npm test -- test/threadModel.test.ts
Expected: FAIL — cannot resolve ../src/threadModel.
- Step 3: Write
src/threadModel.ts
/**
* vscode-free mutations on the Artifact (spec §6.5). Keeping thread logic here
* (not in the vscode controller) keeps it unit-testable; ThreadController is a
* thin adapter that calls these and persists via CoauthorStore.
*/
import {
newId,
type Artifact,
type Fingerprint,
type Provenance,
type ThreadStatus,
} from "./model";
export interface NewMessage {
author: Provenance;
body: string;
}
function message(m: NewMessage) {
return { id: newId("m"), author: m.author, body: m.body, createdAt: new Date().toISOString() };
}
/** Lazily-created anchor + thread for a new region discussion (PUC-1). */
export function addThread(
artifact: Artifact,
fingerprint: Fingerprint,
first: NewMessage,
): { threadId: string; anchorId: string } {
const anchorId = newId("a");
const threadId = newId("t");
artifact.anchors[anchorId] = { fingerprint };
artifact.threads.push({ id: threadId, anchorId, status: "open", messages: [message(first)] });
return { threadId, anchorId };
}
/** Append a reply (PUC-2). Throws if the thread is unknown. */
export function appendMessage(artifact: Artifact, threadId: string, m: NewMessage): void {
const t = artifact.threads.find((x) => x.id === threadId);
if (!t) throw new Error(`unknown thread id: ${threadId}`);
t.messages.push(message(m));
}
/** Resolve/reopen a thread (PUC-2). Throws if the thread is unknown. */
export function setStatus(artifact: Artifact, threadId: string, status: ThreadStatus): void {
const t = artifact.threads.find((x) => x.id === threadId);
if (!t) throw new Error(`unknown thread id: ${threadId}`);
t.status = status;
}
- Step 4: Run the test to verify it passes
Run: npm test -- test/threadModel.test.ts
Expected: PASS — addThread / appendMessage / setStatus green.
3b — vscode ThreadController + contributions
- Step 5: Add F2 contributions + activation to
package.json
Replace the activationEvents and contributes blocks (keep everything else, including cowriting.showClineSdkInfo):
"activationEvents": ["onStartupFinished"],
"contributes": {
"commands": [
{
"command": "cowriting.showClineSdkInfo",
"title": "Cowriting: Show Cline SDK Info",
"category": "Cowriting"
},
{
"command": "cowriting.createThread",
"title": "Cowriting: Add Coauthoring Thread on Selection",
"category": "Cowriting"
},
{ "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" },
{ "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" },
{ "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" }
],
"menus": {
"comments/commentThread/context": [
{ "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" }
],
"comments/commentThread/title": [
{
"command": "cowriting.resolveThread",
"group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^open$/"
},
{
"command": "cowriting.reopenThread",
"group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
}
]
}
},
Note: we set each vscode.CommentThread.contextValue to its status string ("open"/"resolved"), so the commentThread =~ /…/ clauses show Resolve only on open threads and Reopen only on resolved ones.
- Step 6: Write
src/threadController.ts
/**
* ThreadController — the thin editor-facing layer (spec §6.4). Wires
* CoauthorStore + Anchorer + threadModel to vscode.comments: create-on-
* selection, reply, resolve, render, live-range tracking, reload, and
* FileSystemWatcher-driven external re-anchoring (SLICE-4). Threads are NEVER
* silently moved — an unresolvable anchor renders as an orphaned thread (INV-1).
*/
import * as vscode from "vscode";
import { CoauthorStore } from "./store";
import { emptyArtifact, type Artifact, type Provenance } from "./model";
import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer";
import { addThread, appendMessage, setStatus } from "./threadModel";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedThread {
id: string;
status: "open" | "resolved";
orphaned: boolean;
range: { startLine: number; endLine: number };
}
interface DocState {
docPath: string;
uri: vscode.Uri;
artifact: Artifact;
/** thread id -> vscode thread. */
vsThreads: Map<string, vscode.CommentThread>;
/** thread id -> live offset range (within-session optimization, INV-3). */
live: Map<string, OffsetRange>;
/** thread id -> orphaned flag for the current render. */
orphaned: Map<string, boolean>;
}
export class ThreadController implements vscode.Disposable {
private readonly controller: vscode.CommentController;
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocState>(); // keyed by docPath
/** sidecar paths we just wrote, to ignore our own watcher events. */
private readonly selfWrites = new Set<string>();
constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
this.controller.commentingRangeProvider = {
provideCommentingRanges: (document) => {
if (!this.isInRoot(document.uri)) return [];
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
},
};
this.disposables.push(this.controller);
this.disposables.push(
vscode.commands.registerCommand("cowriting.createThread", () => this.createThreadOnSelection()),
vscode.commands.registerCommand("cowriting.reply", (r: vscode.CommentReply) => this.reply(r)),
vscode.commands.registerCommand("cowriting.resolveThread", (t: vscode.CommentThread) =>
this.setThreadStatus(t, "resolved"),
),
vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) =>
this.setThreadStatus(t, "open"),
),
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
);
// Re-anchor on external change to any sidecar (a git pull, a manual edit).
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
const onSidecar = (uri: vscode.Uri) => this.onExternalSidecarChange(uri);
watcher.onDidChange(onSidecar);
watcher.onDidCreate(onSidecar);
this.disposables.push(watcher);
}
private isInRoot(uri: vscode.Uri): boolean {
return uri.scheme === "file" && uri.fsPath.startsWith(this.rootDir);
}
private docPathOf(uri: vscode.Uri): string {
return vscode.workspace.asRelativePath(uri, false);
}
private currentAuthor(): Provenance {
const id = (vscode.workspace.getConfiguration("git").get<string>("user.name")) || process.env.USER || "human";
return { kind: "human", id };
}
private persist(state: DocState): void {
this.selfWrites.add(this.store.sidecarPath(state.docPath));
this.store.save(state.docPath, state.artifact);
}
private ensureState(document: vscode.TextDocument): DocState {
const docPath = this.docPathOf(document.uri);
let state = this.docs.get(docPath);
if (!state) {
state = {
docPath,
uri: document.uri,
artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
vsThreads: new Map(),
live: new Map(),
orphaned: new Map(),
};
this.docs.set(docPath, state);
}
return state;
}
// ---- PUC-1: create on selection -------------------------------------------------
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
const editor = vscode.window.activeTextEditor;
if (!editor || editor.selection.isEmpty || !this.isInRoot(editor.document.uri)) return undefined;
const document = editor.document;
const state = this.ensureState(document);
const offsets: OffsetRange = {
start: document.offsetAt(editor.selection.start),
end: document.offsetAt(editor.selection.end),
};
const fp = buildFingerprint(document.getText(), offsets);
const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody });
this.persist(state);
this.renderThread(document, state, threadId, offsets, false);
return threadId;
}
// ---- PUC-2: reply / resolve -----------------------------------------------------
reply(r: vscode.CommentReply): void {
const threadId = this.threadIdOf(r.thread);
const state = this.stateOfThread(r.thread);
if (!threadId || !state) return;
appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text });
this.persist(state);
this.refreshComments(r.thread, state, threadId);
}
private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void {
const threadId = this.threadIdOf(vsThread);
const state = this.stateOfThread(vsThread);
if (!threadId || !state) return;
setStatus(state.artifact, threadId, status);
this.persist(state);
vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
vsThread.contextValue = status;
}
// ---- PUC-3/4: render, reload, re-anchor -----------------------------------------
/** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */
renderAll(document: vscode.TextDocument): void {
const docPath = this.docPathOf(document.uri);
const state = this.ensureState(document);
// fresh artifact from disk (reload / external change)
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
for (const vsThread of state.vsThreads.values()) vsThread.dispose();
state.vsThreads.clear();
state.live.clear();
state.orphaned.clear();
const text = document.getText();
for (const thread of state.artifact.threads) {
const fp = state.artifact.anchors[thread.anchorId]?.fingerprint;
const resolved = fp ? resolve(text, fp) : "orphaned";
if (resolved === "orphaned") {
const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0;
this.renderThread(document, state, thread.id, { start: document.offsetAt(new vscode.Position(line, 0)), end: document.offsetAt(new vscode.Position(line, 0)) }, true);
} else {
this.renderThread(document, state, thread.id, resolved, false);
}
}
}
private onExternalSidecarChange(uri: vscode.Uri): void {
const p = uri.fsPath;
if (this.selfWrites.has(p)) {
this.selfWrites.delete(p);
return; // our own write
}
for (const state of this.docs.values()) {
if (this.store.sidecarPath(state.docPath) === p) {
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
if (doc) this.renderAll(doc);
}
}
}
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
const state = this.docs.get(this.docPathOf(e.document.uri));
if (!state || state.live.size === 0) return;
for (const change of e.contentChanges) {
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
for (const [id, range] of state.live) {
const next = shift(range, edit);
state.live.set(id, next);
const vsThread = state.vsThreads.get(id);
if (vsThread && !state.orphaned.get(id)) {
vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end));
}
}
}
}
private onDidSave(document: vscode.TextDocument): void {
const state = this.docs.get(this.docPathOf(document.uri));
if (!state || state.live.size === 0) return;
const text = document.getText();
let changed = false;
for (const thread of state.artifact.threads) {
if (state.orphaned.get(thread.id)) continue;
const range = state.live.get(thread.id);
if (!range) continue;
state.artifact.anchors[thread.anchorId] = { fingerprint: buildFingerprint(text, range) };
changed = true;
}
if (changed) this.persist(state);
}
// ---- rendering helpers ----------------------------------------------------------
private renderThread(
document: vscode.TextDocument,
state: DocState,
threadId: string,
offsets: OffsetRange,
orphaned: boolean,
): void {
const thread = state.artifact.threads.find((t) => t.id === threadId)!;
const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end));
const vsThread = this.controller.createCommentThread(document.uri, range, thread.messages.map((m) => this.toComment(m.body, m.author.id)));
vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined;
vsThread.contextValue = orphaned ? "orphaned" : thread.status;
vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
state.vsThreads.set(threadId, vsThread);
state.live.set(threadId, offsets);
state.orphaned.set(threadId, orphaned);
}
private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void {
const thread = state.artifact.threads.find((t) => t.id === threadId)!;
vsThread.comments = thread.messages.map((m) => this.toComment(m.body, m.author.id));
}
private toComment(body: string, authorName: string): vscode.Comment {
return {
body: new vscode.MarkdownString(body),
mode: vscode.CommentMode.Preview,
author: { name: authorName },
};
}
private threadIdOf(vsThread: vscode.CommentThread): string | undefined {
for (const state of this.docs.values()) {
for (const [id, t] of state.vsThreads) if (t === vsThread) return id;
}
return undefined;
}
private stateOfThread(vsThread: vscode.CommentThread): DocState | undefined {
for (const state of this.docs.values()) {
for (const t of state.vsThreads.values()) if (t === vsThread) return state;
}
return undefined;
}
// ---- test-facing surface --------------------------------------------------------
getRendered(docPath: string): RenderedThread[] {
const state = this.docs.get(docPath);
if (!state) return [];
const out: RenderedThread[] = [];
for (const [id, vsThread] of state.vsThreads) {
const t = state.artifact.threads.find((x) => x.id === id)!;
out.push({
id,
status: t.status,
orphaned: !!state.orphaned.get(id),
range: { startLine: vsThread.range.start.line, endLine: vsThread.range.end.line },
});
}
return out;
}
dispose(): void {
for (const d of this.disposables) d.dispose();
}
}
- Step 7: Wire the controller into
src/extension.ts
Replace src/extension.ts with (keeps showClineSdkInfo, adds the controller, returns a test API):
import * as vscode from "vscode";
import { fetchSdkSummary } from "./cline";
import { CoauthorStore } from "./store";
import { ThreadController } from "./threadController";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
export interface CowritingApi {
threadController: ThreadController;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
// --- POC command (Feature #2), unchanged ---
const output = vscode.window.createOutputChannel(CHANNEL_NAME);
context.subscriptions.push(output);
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
try {
const summary = await fetchSdkSummary();
output.clear();
output.appendLine(`@cline/sdk version: ${summary.version}`);
output.appendLine(`Builtin tools (${summary.tools.length}):`);
for (const tool of summary.tools) output.appendLine(` • ${tool.id} — ${tool.description}`);
output.show(true);
await vscode.window.showInformationMessage(
`Cline SDK ${summary.version} loaded — ${summary.tools.length} builtin tools. See the "${CHANNEL_NAME}" output channel.`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
output.appendLine(`Failed to drive @cline/sdk: ${message}`);
output.show(true);
await vscode.window.showErrorMessage(`Cowriting: failed to load @cline/sdk — ${message}`);
}
}),
);
// --- F2: region-anchored threads (Feature #4) ---
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!root) return undefined; // no workspace → nothing to anchor against
const store = new CoauthorStore(root);
const threadController = new ThreadController(store, root);
context.subscriptions.push(threadController);
// Render threads for already-open editors, and on future opens.
const renderIfOpen = (doc: vscode.TextDocument) => {
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) threadController.renderAll(doc);
};
vscode.workspace.textDocuments.forEach(renderIfOpen);
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
return { threadController };
}
export function deactivate(): void {
// Disposables registered on the context handle cleanup.
}
- Step 8: Typecheck and build
Run: npm run typecheck && npm run build
Expected: PASS — no type errors; out/extension.cjs rebuilt.
- Step 9: Run the full unit suite
Run: npm test
Expected: PASS — model, store, anchorer, threadModel, and the existing cline test all green (E2E excluded).
- Step 10: Commit
git add src/threadModel.ts test/threadModel.test.ts src/threadController.ts src/extension.ts package.json
git commit -m "F2 SLICE-3: ThreadController on the Comments API + thread mutations (#4)"
Task 4 (SLICE-4): Reload + external-change re-anchoring + orphaned surface
The mechanics (FileSystemWatcher, renderAll reload, onDidChange/onDidSave live tracking, orphaned rendering) were implemented in Task 3's controller. This task hardens and verifies them with focused vscode-free unit tests of the re-anchor decision logic and a manual checklist; full behavioral proof lands in the SLICE-5 E2E.
Files:
-
Test:
test/reanchor.test.ts(vscode-free coverage of the reload/edit/orphan decisions) -
Step 1: Write
test/reanchor.test.ts
This locks the spec's resolution-ladder behavior under the three SLICE-4 scenarios (reload unchanged, edit-then-reanchor, edit-to-orphan) at the pure level the controller relies on:
import { describe, it, expect } from "vitest";
import { buildFingerprint, resolve } from "../src/anchorer";
const ORIGINAL = "intro\nThe quick brown fox\noutro\n";
describe("re-anchor scenarios (SLICE-4 decision logic)", () => {
it("reload, document unchanged: resolves to the same range", () => {
const start = ORIGINAL.indexOf("quick brown");
const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length });
expect(resolve(ORIGINAL, fp)).toEqual({ start, end: start + "quick brown".length });
});
it("external edit that moves the anchored text down: re-resolves to the new range", () => {
const start = ORIGINAL.indexOf("quick brown");
const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length });
const edited = "a new first line\nanother line\n" + ORIGINAL;
const newStart = edited.indexOf("quick brown");
expect(newStart).not.toBe(start);
expect(resolve(edited, fp)).toEqual({ start: newStart, end: newStart + "quick brown".length });
});
it("external edit that deletes the anchored text: orphaned, never moved (INV-1)", () => {
const start = ORIGINAL.indexOf("quick brown");
const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length });
const edited = "intro\nThe lazy dog\noutro\n";
expect(resolve(edited, fp)).toBe("orphaned");
});
});
- Step 2: Run it to verify it passes
Run: npm test -- test/reanchor.test.ts
Expected: PASS — all three SLICE-4 decisions green (the controller composes exactly these calls).
- Step 3: Self-write-guard review (no code change expected)
Confirm in src/threadController.ts that persist() adds the sidecar path to selfWrites before store.save, and onExternalSidecarChange() consumes-and-skips that path — so the watcher does not re-render on our own writes (which would dispose the thread the user is mid-reply on). If missing, add it as shown in Task 3 Step 6. Run npm run typecheck after any change.
- Step 4: Commit
git add test/reanchor.test.ts
git commit -m "F2 SLICE-4: lock reload/re-anchor/orphan decision logic with unit tests (#4)"
Task 5 (SLICE-5): Host E2E with @vscode/test-electron
The Comments surface is native VS Code UI, so it is exercised in the real Extension Host. Where the harness cannot click the native Comments gutter, we drive the extension's returned ThreadController API and assert its rendered state + the on-disk sidecar (spec §6.8 fallback).
Files:
-
Modify:
package.json(E2E dev deps + scripts) -
Create:
tsconfig.e2e.json -
Create:
test/e2e/runTest.ts -
Create:
test/e2e/suite/index.ts -
Create:
test/e2e/suite/threads.test.ts -
Create:
test/e2e/fixtures/workspace/docs/sample.md -
Modify:
.gitignore -
Step 1: Add E2E dev deps and scripts to
package.json
Add to devDependencies:
"@vscode/test-electron": "^2.4.0",
"mocha": "^10.7.0",
"@types/mocha": "^10.0.7",
"glob": "^11.0.0"
Add to scripts:
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
"test:e2e": "node ./out/test/e2e/runTest.js"
- Step 2: Install the new dev deps
Run: npm install
Expected: lockfile updated; @vscode/test-electron, mocha, glob present; no error exit.
- Step 3: Create
tsconfig.e2e.json
Compiles src + test/e2e to CommonJS under out/ so the host can require them:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": "out",
"rootDir": ".",
"sourceMap": true,
"types": ["node", "mocha", "vscode"]
},
"include": ["src", "test/e2e"]
}
Note: src is compiled here only so the E2E suite can import controller types/helpers if needed; the extension itself still loads from the esbuild bundle out/extension.cjs via package.json#main. The dynamic import("@cline/sdk") in src/cline.ts is untouched by F2 and is not invoked by these tests (INV-5).
- Step 4: Create the fixture document
test/e2e/fixtures/workspace/docs/sample.md
# Sample Document
The quick brown fox jumps over the lazy dog.
A second paragraph with the phrase anchor target inside it.
Closing line.
- Step 5: Create
test/e2e/runTest.ts
Copies the fixture workspace to a temp dir (so the test's .threads/ writes never dirty the repo), then launches the host pointed at it:
import * as path from "path";
import * as os from "os";
import * as fs from "fs";
import { runTests } from "@vscode/test-electron";
async function main(): Promise<void> {
const projectRoot = path.resolve(__dirname, "../../../");
const extensionDevelopmentPath = projectRoot;
const extensionTestsPath = path.resolve(__dirname, "./suite/index");
// Copy the fixture workspace to a temp dir so test writes are throwaway.
const fixture = path.resolve(projectRoot, "test/e2e/fixtures/workspace");
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-e2e-"));
fs.cpSync(fixture, workspace, { recursive: true });
try {
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: [workspace, "--disable-extensions"],
extensionTestsEnv: { E2E_WORKSPACE: workspace },
});
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
}
main().catch((err) => {
console.error("E2E run failed:", err);
process.exit(1);
});
- Step 6: Create the mocha runner
test/e2e/suite/index.ts
import * as path from "path";
import Mocha from "mocha";
import { glob } from "glob";
export async function run(): Promise<void> {
const mocha = new Mocha({ ui: "bdd", color: true, timeout: 60000 });
const testsRoot = path.resolve(__dirname);
const files = await glob("**/*.test.js", { cwd: testsRoot });
for (const f of files) mocha.addFile(path.resolve(testsRoot, f));
await new Promise<void>((resolve, reject) => {
mocha.run((failures) => (failures > 0 ? reject(new Error(`${failures} E2E test(s) failed`)) : resolve()));
});
}
- Step 7: Create the host E2E
test/e2e/suite/threads.test.ts
Exercises create → reply → resolve → persist → reload → re-anchor → orphan via the extension API + on-disk sidecar:
import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
import type { Artifact } from "../../../src/model";
const WS = process.env.E2E_WORKSPACE!;
const DOC_REL = "docs/sample.md";
function sidecarPath(): string {
return path.join(WS, ".threads", "docs", "sample.md.json");
}
function readSidecar(): Artifact {
return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact;
}
async function openSample(): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
return doc;
}
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.threadController, "extension should export threadController");
return api;
}
suite("F2 region-anchored threads (host E2E)", () => {
test("create on selection persists a thread sidecar", async () => {
const doc = await openSample();
const api = await getApi();
const start = doc.getText().indexOf("quick brown fox");
const editor = vscode.window.activeTextEditor!;
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length));
const threadId = await api.threadController.createThreadOnSelection("First note");
assert.ok(threadId, "createThreadOnSelection returns an id");
assert.ok(fs.existsSync(sidecarPath()), "sidecar written to disk");
const art = readSidecar();
assert.strictEqual(art.threads.length, 1);
assert.strictEqual(art.threads[0].status, "open");
assert.strictEqual(art.threads[0].messages[0].body, "First note");
const anchorId = art.threads[0].anchorId;
assert.strictEqual(art.anchors[anchorId].fingerprint.text, "quick brown fox");
const rendered = api.threadController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1);
assert.strictEqual(rendered[0].orphaned, false);
});
test("reply appends and resolve flips status, both persisted", async () => {
const api = await getApi();
const art0 = readSidecar();
const threadId = art0.threads[0].id;
api.threadController.reply({ thread: findVsThread(api, threadId), text: "A reply" } as any);
const art1 = readSidecar();
assert.deepStrictEqual(art1.threads[0].messages.map((m) => m.body), ["First note", "A reply"]);
setStatusViaApi(api, threadId, "resolved");
const art2 = readSidecar();
assert.strictEqual(art2.threads[0].status, "resolved");
});
test("reload renders the persisted thread at its anchor", async () => {
const doc = await openSample();
const api = await getApi();
api.threadController.renderAll(doc);
const rendered = api.threadController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1);
assert.strictEqual(rendered[0].orphaned, false);
const anchorLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line;
assert.strictEqual(rendered[0].range.startLine, anchorLine);
});
test("external edit that moves the text re-anchors; deleting it orphans (INV-1)", async () => {
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const original = fs.readFileSync(uri.fsPath, "utf8");
// Move the anchored text down by prepending lines (external change).
fs.writeFileSync(uri.fsPath, "PREPENDED LINE ONE\nPREPENDED LINE TWO\n" + original, "utf8");
let doc = await vscode.workspace.openTextDocument(uri);
const api = await getApi();
api.threadController.renderAll(doc);
let rendered = api.threadController.getRendered(DOC_REL);
assert.strictEqual(rendered[0].orphaned, false, "still anchored after a move");
const movedLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line;
assert.strictEqual(rendered[0].range.startLine, movedLine);
// Now delete the anchored text entirely → must orphan, not move.
fs.writeFileSync(uri.fsPath, "PREPENDED LINE ONE\nThe slow grey cat\n" + original.replace("The quick brown fox jumps over the lazy dog.", "The slow grey cat naps."), "utf8");
doc = await vscode.workspace.openTextDocument(uri);
api.threadController.renderAll(doc);
rendered = api.threadController.getRendered(DOC_REL);
assert.strictEqual(rendered[0].orphaned, true, "deleted anchor must orphan (INV-1)");
});
});
// --- helpers that reach the live vscode thread for reply/resolve ---
function findVsThread(api: CowritingApi, threadId: string): vscode.CommentThread {
const rendered = api.threadController.getRendered(DOC_REL);
assert.ok(rendered.some((r) => r.id === threadId), "thread is rendered");
return (api.threadController as unknown as { docs: Map<string, { vsThreads: Map<string, vscode.CommentThread> }> }).docs
.get(DOC_REL)!.vsThreads.get(threadId)!;
}
function setStatusViaApi(api: CowritingApi, threadId: string, status: "open" | "resolved"): void {
const vsThread = findVsThread(api, threadId);
void vscode.commands.executeCommand(status === "resolved" ? "cowriting.resolveThread" : "cowriting.reopenThread", vsThread);
}
Note: findVsThread/setStatusViaApi reach the controller's internal docs map for the reply/resolve handlers (which expect a live vscode.CommentThread). This is the spec §6.8 "drive the ThreadController and assert Comments-controller state" fallback. Keep the docs field accessible (it is a private field but reachable via the cast above; do not add a public getter solely for this unless typecheck forces it).
- Step 8: Add the E2E artifacts to
.gitignore
Append to .gitignore:
# E2E host-test build output + throwaway sidecars
out/test/
test/e2e/fixtures/workspace/.threads/
- Step 9: Compile the E2E suite (typecheck the host tests)
Run: npm run pretest:e2e
Expected: PASS — npm run build writes out/extension.cjs; tsc -p tsconfig.e2e.json emits out/test/e2e/**/*.js and out/src/**/*.js with no type errors.
- Step 10: Run the host E2E
Run: npm run test:e2e
Expected: @vscode/test-electron downloads a VS Code build (first run only), launches the Extension Development Host on the temp workspace, and the 4 suites pass: create-persists, reply+resolve, reload, re-anchor+orphan.
If the sandbox cannot download VS Code (no network), record this as the one operator-run step: re-run npm run test:e2e on the workstation. Do not mark SLICE-5 done on an unrun suite — note it explicitly in the transcript's Deferred decisions and the finalize report.
- Step 11: Commit
git add package.json package-lock.json tsconfig.e2e.json test/e2e .gitignore
git commit -m "F2 SLICE-5: @vscode/test-electron host E2E (create→reload→re-anchor→orphan) (#4)"
Task 6: README + full verification sweep
Files:
-
Modify:
README.md -
Step 1: Add an F2 section to
README.md
Append (after the existing POC content):
## F2 — Region-anchored threads (Feature #4)
Attach durable, region-anchored discussion threads to any document. Threads
render in the native VS Code **Comments** gutter and persist as git-native
sidecars under `.threads/<doc-path>.json` (plain, diffable JSON — no server).
- **Create:** select text → run **"Cowriting: Add Coauthoring Thread on Selection"**
(or the Comments gutter "+").
- **Reply / Resolve:** use the native Comments reply box and the thread's
Resolve/Reopen actions.
- **Survives edits, reload, and external change (`git pull`):** a hybrid anchor
(durable content fingerprint + live offset tracking) re-resolves the thread.
If the anchored text can't be confidently re-found, the thread is shown as
**orphaned** at its last-known line — never silently moved.
### Tests
- `npm test` — vitest unit suite (schema, store, anchorer, thread mutations).
- `npm run test:e2e` — `@vscode/test-electron` host E2E
(create → reply → resolve → persist → reload → re-anchor → orphan).
- Step 2: Full verification sweep
Run: npm install && npm run typecheck && npm test && npm run build
Expected: install OK; typecheck OK; all vitest suites pass; build writes out/extension.cjs.
- Step 3: Run the host E2E (if not already green)
Run: npm run test:e2e
Expected: host suites pass (or recorded as the operator-run step per SLICE-5 Step 10).
- Step 4: Commit
git add README.md
git commit -m "F2: document region-anchored threads + test commands (#4)"
Manual / operator verification (cannot be fully automated in-session)
- Press F5 → Extension Development Host opens on a folder with a text doc, no activation error.
- Select a phrase → "Cowriting: Add Coauthoring Thread on Selection" → a Comments thread appears on the gutter;
.threads/<doc>.jsonis created. - Reply in the thread, then Resolve → the sidecar reflects the extra message and
status: "resolved". - Close & reopen the folder → the thread reloads on the same span.
- Edit above the anchor (push it down) and save → the thread tracks to the new line. Delete the anchored text → the thread shows as orphaned, not moved (INV-1).
This is F2's acceptance: region-anchored threads create/reply/resolve via native Comments, persist as git-native sidecars, and survive edits/reload/external change — orphaning rather than guessing — with unit + host E2E green (spec §7.3).
Spec coverage self-review
- SLICE-1 schema + CoauthorStore → Task 1. Shared
anchors/provenance+schemaVersion+ emptyattributions/proposalsextension points (INV-4) present inmodel.ts. Stable formatting (INV-2) tested. - SLICE-2 Anchorer → Task 2. Resolution ladder exact→context→lineHint→orphaned (INV-1/INV-3) + live
shifttested. - SLICE-3 ThreadController via
vscode.comments(create/reply/resolve/render) → Task 3, with vscode-freethreadModelunit-tested. - SLICE-4 reload + FileSystemWatcher external re-anchor + orphaned surface → Task 3 (mechanics) + Task 4 (decision-logic tests).
- SLICE-5
@vscode/test-electronE2E across create→persist→reload→re-anchor → Task 5. - INV-5 no live
@cline/sdkturn / no credentials → nothing in F2 calls the SDK;cline.tsuntouched. - PUC-1..4 → create (T3), reply/resolve (T3), reload (T3/T5), re-anchor/orphan (T3/T4/T5).
- UI surface ⇒ E2E host tests are first-class plan tasks → SLICE-5 is Task 5, not a follow-up (handbook §4 / spec §6.8, §7.2).