10 Commits

Author SHA1 Message Date
Ben Stull 9deb1f7c35 F3: add implementation plan (just-in-time from coauthoring-attribution spec §7.2) (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:09:59 -07:00
Ben Stull d85194224f F3 polish: active-editor status bar + E2E for command wrapper and toggle (PUC-5) (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:09:04 -07:00
Ben Stull 644e7b77a6 F3: document live attribution + test commands (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:01:52 -07:00
Ben Stull 41b1cb4f3b F3 hardening: observable seam misses, cheaper disk-compare, E2E ordering notes (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:00:28 -07:00
Ben Stull fc5fde1cc9 F3 SLICE-6: host E2E — type/seam/persist/re-anchor/orphan, no LLM in CI (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:50:58 -07:00
Ben Stull 2604ab4925 F3 SLICE-3 host fixes: disk-compare sync detection + self-minimized seam edits (INV-9) (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:50:07 -07:00
Ben Stull fe23ffa100 F3 SLICE-5 review fixes: fence-aware replacement extraction + editSelection guards (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:35:16 -07:00
Ben Stull 258d1fa914 F3 SLICE-5: correct manual-smoke failure-path doc to observed SDK binary discovery (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:27:28 -07:00
Ben Stull c08dc075af F3 SLICE-5: live claude-code turn via applyAgentEdit seam + manual smoke (INV-8) (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:25:56 -07:00
Ben Stull 20b709f794 F3 SLICE-3/4 review fixes: counted self-writes, persist deletion-to-empty, render all visible editors (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:12:07 -07:00
16 changed files with 2163 additions and 35 deletions
+35 -2
View File
@@ -11,6 +11,9 @@ Registers one command, **`Cowriting: Show Cline SDK Info`**, which loads
catalog (a pure, key-free SDK call) in a notification and the
"Cowriting (Cline SDK)" output channel.
Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
human/Claude attribution (Feature #6).
## Architecture
- CommonJS extension bundled with esbuild (`src/extension.ts``out/extension.cjs`).
@@ -43,10 +46,40 @@ sidecars under `.threads/<doc-path>.json` (plain, diffable JSON — no server).
Design: `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md`. No live
`@cline/sdk` turn and no credentials are involved in F2.
## F3 — Live human/Claude attribution (Feature #6)
As you and Claude coauthor, every span in the buffer carries an author: human
edits render with a subtle left border, Claude-authored spans with a background
tint. Text that predates tracking stays plain — the honest record. Edits that
touch a boundary (split, merge, partial overwrite) are handled char-precisely.
Attribution is persisted git-natively in the same `.threads/` sidecars
(`attributions[]`, sharing the same `anchors` fingerprints as F2 threads). On
reload, fingerprints re-resolve spans against the current document; spans that
can't be confidently re-found are **orphaned** (status-bar count + "Cowriting
Attribution" output channel) rather than silently moved or discarded.
**Commands**
- **`Cowriting: Ask Claude to Edit Selection`** — select text → enter an
instruction → a live `@cline/sdk` turn runs on the built-in `claude-code`
provider (rides your local Claude Code Pro/Max login; the extension stores no
credentials). The replacement lands in the buffer as a Claude-attributed span.
- **`Cowriting: Toggle Attribution`** — show/hide attribution decorations.
- `cowriting.applyAgentEdit` _(palette-hidden)_ — the single machine-edit
ingress seam. Tests drive this directly so CI requires no LLM.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-attribution.md`.
## Develop
- `npm run watch` — rebuild on change.
- `npm test` — vitest unit suite (SDK driver, schema, store, anchorer, thread mutations).
- `npx vitest run` unit suite (SDK driver, schema, store, anchorer, thread
mutations, attribution split/merge).
- `npm run test:e2e``@vscode/test-electron` host E2E
(create → reply → resolve → persist → reload → re-anchor → orphan).
(create → reply → resolve → persist → reload → re-anchor → orphan; drives
`cowriting.applyAgentEdit` directly — no LLM required).
- `npm run smoke:live` — scripted live-turn smoke test for F3; requires Claude
Code installed and signed in. See
[`docs/MANUAL-SMOKE-F3.md`](docs/MANUAL-SMOKE-F3.md).
- `npm run typecheck` — type-check without emit.
+48
View File
@@ -0,0 +1,48 @@
# F3 manual smoke — live `claude-code` turn (spec §6.8)
The live turn is deliberately NOT in CI (unit + host E2E drive the seam). It
gets this documented smoke, run once per machine that has Claude Code
installed and signed in (Pro/Max). The extension itself holds no credentials
(INV-8) — auth is entirely the local Claude Code login.
## 1. Scripted smoke (the quick check)
npm run smoke:live
Expected: prints a `replacement:` line containing "The smoke test passed.",
the model id, a non-empty `sessionId`, and exits 0.
## 2. In-editor smoke (the real PUC-2)
1. `npm run build`, then F5 (Extension Development Host) opening this repo.
2. Open any markdown file in the workspace; type a sentence — it renders with
the human gutter border (left edge) as you type.
3. Select the sentence → run **“Cowriting: Ask Claude to Edit Selection”** →
instruction: `rewrite this more formally`.
4. Expected: progress notification; on completion the replacement text lands
**tinted** (Claude-attributed), the human border remains on your other
edits, and after **save** the sidecar
`.threads/<doc>.json` has an `attributions[]` entry with
`author.kind: "agent"`, `agent.model`, `agent.sessionId`, and a `turnId`.
5. **Cowriting: Toggle Attribution** hides/shows both decorations (PUC-5).
## 3. Failure paths (INV-8 — graceful, tracking unaffected)
- Signed out / no Claude Code: hard to simulate on a machine where Claude
Code is installed — neither stripping `PATH` nor hiding the
`~/.local/bin/claude` symlink works (the SDK discovers the real install
under `~/.local/share/claude/versions/…`). The only true trigger is being
signed out (or having no installation at all), so exercise this on a
signed-out machine when one is available. Expected: the script exits 1
with a clear error (`runEditTurn` throws on any non-`completed` run
status); in-editor, the command shows an error notification and NO edit is
applied.
- Buffer edited mid-turn: start an edit-selection turn, type elsewhere in the
document before it completes → warning notification "document changed", no
partial application (stale `expectedVersion`, spec §6.9).
## Smoke log
| Date | Machine | Result |
| --- | --- | --- |
| 2026-06-10 | benstull mac (darwin) | PASS — `replacement: "The smoke test passed."`, model `sonnet`, sessionId `run_g_rYKGYl`, 6.9s, exit 0. Failure-path notes: turn still succeeded with `PATH="/usr/bin:/bin"` (6.2s) AND with `~/.local/bin/claude` renamed away (3.7s, `run_TQwQipJx`) — the SDK discovers the real install under `~/.local/share/claude/versions/…`, so only a genuine sign-out (not attempted from inside a live session) exercises the error path. |
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -9,7 +9,7 @@ const options = {
bundle: true,
platform: "node",
format: "cjs",
target: "node20",
target: "node22",
sourcemap: true,
// vscode is provided by the host; @cline/sdk is ESM-only and is loaded at
// runtime via dynamic import() from node_modules, so keep both external.
@@ -17,11 +17,26 @@ const options = {
logLevel: "info",
};
/** @type {import('esbuild').BuildOptions} */
const liveTurnOptions = {
entryPoints: ["src/liveTurn.ts"],
outfile: "out/liveTurn.mjs",
bundle: true,
platform: "node",
format: "esm",
target: "node22",
sourcemap: true,
external: ["vscode", "@cline/sdk"],
logLevel: "info",
};
if (watch) {
const ctx = await context(options);
await ctx.watch();
const ctxLive = await context(liveTurnOptions);
await Promise.all([ctx.watch(), ctxLive.watch()]);
console.log("esbuild: watching…");
} else {
await build(options);
console.log("esbuild: build complete → out/extension.cjs");
await build(liveTurnOptions);
console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs");
}
+4 -2
View File
@@ -28,8 +28,9 @@
{ "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" },
{ "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" },
{ "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" },
{ "command": "cowriting.toggleAttribution", "title": "Cowriting: Toggle Attribution", "category": "Cowriting" },
{ "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" }
{ "command": "cowriting.toggleAttribution", "title": "Toggle Attribution", "category": "Cowriting" },
{ "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" },
{ "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", "category": "Cowriting" }
],
"menus": {
"commandPalette": [
@@ -59,6 +60,7 @@
"test": "vitest run",
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
"test:e2e": "node ./out/test/e2e/runTest.js",
"smoke:live": "npm run build && node scripts/smoke-live-turn.mjs",
"vscode:prepublish": "node esbuild.mjs"
},
"dependencies": {
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
// Scripted half of the F3 manual smoke (docs/MANUAL-SMOKE-F3.md). Drives the
// REAL LiveTurn module against the local Claude Code login. Not run in CI.
import { runEditTurn } from "../out/liveTurn.mjs";
const instruction = process.argv[2] ?? "Replace this sentence with exactly: The smoke test passed.";
const text = process.argv[3] ?? "This sentence is the smoke-test input.";
console.log(`instruction: ${instruction}`);
console.log(`text: ${text}`);
try {
const t0 = Date.now();
const result = await runEditTurn(instruction, text);
console.log(`replacement: ${JSON.stringify(result.replacement)}`);
console.log(`model: ${result.model}`);
console.log(`sessionId: ${result.sessionId}`);
console.log(`elapsed: ${((Date.now() - t0) / 1000).toFixed(1)}s`);
process.exit(0);
} catch (err) {
console.error(`live turn failed (expected when Claude Code is absent/signed out): ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
+97 -16
View File
@@ -8,12 +8,13 @@
* FileSystemWatcher live in CoauthorStore / extension.ts (the sidecar is
* co-owned with ThreadController).
*/
import * as fs from "node:fs";
import * as vscode from "vscode";
import { CoauthorStore } from "./store";
import { newId, type AttributionRecord, type Provenance } from "./model";
import { buildFingerprint, resolve, type OffsetRange } from "./anchorer";
import { applyChange, coalesce, type LiveSpan } from "./attributionTracker";
import { PendingEditRegistry } from "./pendingEdits";
import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
/** Test-facing snapshot of live attribution state for a document. */
export interface RenderedSpan {
@@ -31,6 +32,12 @@ interface DocAttribution {
orphans: AttributionRecord[];
/** record metadata per live span id (updatedAt bookkeeping). */
records: Map<string, AttributionRecord>;
/**
* true once this doc has had any span/record this session — lets save persist
* deliberate deletion to empty (so stale records don't come back as phantom
* orphans on reload).
*/
hadAttributions: boolean;
}
const AGENT_DECO: vscode.DecorationRenderOptions = {
@@ -77,7 +84,7 @@ export class AttributionController implements vscode.Disposable {
private state(docPath: string): DocAttribution {
let s = this.docs.get(docPath);
if (!s) {
s = { docPath, spans: [], orphans: [], records: new Map() };
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false };
this.docs.set(docPath, s);
}
return s;
@@ -110,6 +117,7 @@ export class AttributionController implements vscode.Disposable {
}
}
s.spans = coalesce(s.spans);
if (artifact.attributions.length > 0) s.hadAttributions = true;
}
this.render(document);
}
@@ -129,13 +137,18 @@ export class AttributionController implements vscode.Disposable {
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
const docPath = this.docPathOf(e.document.uri);
if (!e.document.isDirty) {
// Disk sync (revert / reload): re-resolve, never attribute (PUC-4).
if (!e.document.isDirty && this.matchesDisk(e.document)) {
// Disk sync (revert / external reload): buffer now equals the file on
// disk — re-resolve, never attribute (PUC-4). A real edit can also
// arrive with isDirty still false (VS Code flips the flag after the
// change event), but then the buffer no longer matches the disk.
this.loadAll(e.document);
return;
}
const s = this.state(docPath);
for (const change of e.contentChanges) {
// Sort descending by offset so earlier changes don't invalidate later offsets
// (VS Code's order is undocumented; defensive sort is the safe guarantee).
for (const change of [...e.contentChanges].sort((a, b) => b.rangeOffset - a.rangeOffset)) {
const edit = {
start: change.rangeOffset,
end: change.rangeOffset + change.rangeLength,
@@ -143,15 +156,44 @@ export class AttributionController implements vscode.Disposable {
};
const hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text });
const author = hit ? hit.provenance : this.currentAuthor();
s.spans = applyChange(s.spans, edit, author, {
// On a seam hit, attribute the agent's FULL intended replacement (the
// registered edit is diff-minimized for transport only): same delta,
// wider span — the agent owns every char it asserted (INV-9).
s.spans = applyChange(s.spans, hit?.full ?? edit, author, {
newId: () => newId("at"),
now: () => new Date().toISOString(),
turnId: hit?.turnId,
});
}
if (s.spans.length > 0) s.hadAttributions = true;
this.render(e.document);
}
/**
* True when the document buffer is byte-identical to the file on disk.
* Fires only on `!isDirty` change events — first change after every clean
* state (frequent under `files.autoSave: afterDelay`) and reverts/reloads;
* O(file size) sync read. A size pre-check avoids the full read in the
* common case: if the byte lengths differ (accounting for a possible 3-byte
* UTF-8 BOM that `document.getText()` never includes) we return early. When
* a BOM is present the disk read is stripped before comparing so a genuine
* revert does not clobber attribution.
*/
private matchesDisk(document: vscode.TextDocument): boolean {
try {
const bufLen = Buffer.byteLength(document.getText(), "utf8");
const stat = fs.statSync(document.uri.fsPath);
// Allow size === bufLen (no BOM) OR size === bufLen + 3 (UTF-8 BOM).
if (stat.size !== bufLen && stat.size !== bufLen + 3) return false;
const disk = fs.readFileSync(document.uri.fsPath, "utf8").replace(/^/, "");
return disk === document.getText();
} catch {
// Unreadable/missing file: treat as a real edit (attribute), per
// fail-open honesty — misclassifying a sync as an edit is recoverable.
return false;
}
}
// ---- the seam (INV-9) ---------------------------------------------------------------
/**
@@ -160,6 +202,10 @@ export class AttributionController implements vscode.Disposable {
* document version or a rejected edit — never partial-applies (spec §6.9).
* The pending edit is unregistered unconditionally afterwards (a no-op when
* `match` already consumed it), so a no-op edit never leaks a registration.
* The replace is self-minimized (common prefix/suffix trimmed) to mirror the
* host's WorkspaceEdit diff-minimization, so registered == applied == delivered.
* Callers issuing concurrent edits on the same document must serialize them or
* pass `expectedVersion` (offsets are computed against the call-time snapshot).
*/
async applyAgentEdit(
document: vscode.TextDocument,
@@ -171,19 +217,42 @@ export class AttributionController implements vscode.Disposable {
if (!this.isTracked(document)) return false;
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
const docPath = this.docPathOf(document.uri);
const startOffset = document.offsetAt(range.start);
const endOffset = document.offsetAt(range.end);
const oldText = document.getText(range);
const { prefix, suffix } = minimizeReplace(oldText, newText);
const minStart = startOffset + prefix;
const minEnd = endOffset - suffix;
const minText = newText.slice(prefix, newText.length - suffix);
if (minStart === minEnd && minText.length === 0) {
// Replacement equals the existing text — a no-op; nothing to attribute.
return true;
}
const pendingEdit = {
docPath,
start: document.offsetAt(range.start),
end: document.offsetAt(range.end),
newText,
start: minStart,
end: minEnd,
newText: minText,
provenance,
turnId: opts?.turnId,
full: { start: startOffset, end: endOffset, newLength: newText.length },
};
this.pending.register(pendingEdit);
const we = new vscode.WorkspaceEdit();
we.replace(document.uri, range, newText);
we.replace(document.uri, new vscode.Range(document.positionAt(minStart), document.positionAt(minEnd)), minText);
const ok = await vscode.workspace.applyEdit(we);
this.pending.unregister(pendingEdit);
const removed = this.pending.unregister(pendingEdit);
if (ok && removed) {
// workspace.applyEdit resolves AFTER onDidChangeTextDocument is dispatched
// synchronously to listeners, so a matching change event should have already
// consumed the registration before we reach here. If it is still present,
// the host minimized the diff differently than we predicted — attribution
// may be wrong for this edit (INV-9).
this.output.appendLine(
"WARN: seam edit applied but its change event never matched the registration " +
"(host minimized differently?) — the edit may be mis-attributed (INV-9).",
);
}
return ok;
}
@@ -193,7 +262,10 @@ export class AttributionController implements vscode.Disposable {
if (!this.isTracked(document)) return;
const docPath = this.docPathOf(document.uri);
const s = this.docs.get(docPath);
if (!s || (s.spans.length === 0 && s.orphans.length === 0)) return;
// Allow save when hadAttributions is true even if spans/orphans are now empty:
// that means the user deliberately deleted all attributed text, and we must
// persist a.attributions=[] so stale records don't return as phantom orphans.
if (!s || (!s.hadAttributions && s.spans.length === 0 && s.orphans.length === 0)) return;
const text = document.getText();
const now = new Date().toISOString();
const records: AttributionRecord[] = [];
@@ -239,16 +311,25 @@ export class AttributionController implements vscode.Disposable {
}
private render(document: vscode.TextDocument): void {
const editor = vscode.window.visibleTextEditors.find((e) => e.document === document);
if (!editor || !this.isTracked(document)) return;
if (!this.isTracked(document)) return;
const s = this.docs.get(this.docPathOf(document.uri));
const spans = this.visible && s ? s.spans : [];
const toRange = (sp: LiveSpan) =>
new vscode.Range(document.positionAt(sp.start), document.positionAt(sp.end));
editor.setDecorations(this.agentType, spans.filter((x) => x.author.kind === "agent").map(toRange));
editor.setDecorations(this.humanType, spans.filter((x) => x.author.kind === "human").map(toRange));
const agentRanges = spans.filter((x) => x.author.kind === "agent").map(toRange);
const humanRanges = spans.filter((x) => x.author.kind === "human").map(toRange);
// Apply decorations to ALL visible split-editors showing this document, not
// just the first match — each editor pane has its own decoration layer.
for (const editor of vscode.window.visibleTextEditors) {
if (editor.document === document) {
editor.setDecorations(this.agentType, agentRanges);
editor.setDecorations(this.humanType, humanRanges);
}
}
if (document === vscode.window.activeTextEditor?.document) {
this.renderStatus(s);
}
}
private renderStatus(s: DocAttribution | undefined): void {
const n = s?.orphans.length ?? 0;
+65
View File
@@ -81,6 +81,71 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
),
);
// F3 SLICE-5: the live turn (PUC-2) — selection + instruction → one
// claude-code SDK turn (liveTurn.ts, INV-8) → the applyAgentEdit seam (INV-9).
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.editSelection", async () => {
const editor = vscode.window.activeTextEditor;
if (
!editor ||
editor.selection.isEmpty ||
editor.document.uri.scheme !== "file" ||
!editor.document.uri.fsPath.startsWith(root)
) {
void vscode.window.showWarningMessage("Cowriting: select some text in a workspace document first.");
return;
}
const instruction = await vscode.window.showInputBox({
prompt: "What should Claude do with the selection?",
placeHolder: "e.g. tighten this paragraph",
});
if (!instruction) return;
if (editor.selection.isEmpty) {
void vscode.window.showWarningMessage("Cowriting: select some text in a workspace document first.");
return;
}
const document = editor.document;
const selection = editor.selection;
const expectedVersion = document.version;
const selectedText = document.getText(selection);
const turnId = `turn-${Date.now().toString(36)}`;
try {
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
async () => {
const { runEditTurn } = await import("./liveTurn");
const turn = await runEditTurn(instruction, selectedText);
if (turn.replacement === "") {
void vscode.window.showWarningMessage(
"Cowriting: Claude returned an empty replacement — nothing was applied.",
);
return;
}
const ok = await attributionController.applyAgentEdit(
document,
new vscode.Range(selection.start, selection.end),
turn.replacement,
{
kind: "agent",
id: "claude",
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
},
{ expectedVersion, turnId },
);
if (!ok) {
void vscode.window.showWarningMessage(
"Cowriting: the edit could not be applied (the document changed during the turn, or the editor rejected the edit) — nothing was changed.",
);
}
},
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
}
}),
);
// Render threads + attributions for already-open editors, and on future opens.
const renderIfOpen = (doc: vscode.TextDocument) => {
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
+64
View File
@@ -0,0 +1,64 @@
/**
* LiveTurn the minimal machine-edit ingress (spec §6.2): one @cline/sdk
* Agent turn on the built-in `claude-code` provider, which rides the
* operator's local Claude Code Pro/Max login the extension holds NO
* credentials of any kind (INV-8). vscode-free; the editSelection command
* (extension.ts) feeds the result into the applyAgentEdit seam (INV-9).
*
* Like src/cline.ts: @cline/sdk is ESM-only, loaded via dynamic import(),
* never bundled (esbuild keeps it external).
*/
export interface EditTurnResult {
replacement: string;
model: string;
/** the SDK run id — recorded as provenance sessionId. */
sessionId: string;
}
const SYSTEM_PROMPT = [
"You are a precise text editor embedded in VS Code.",
"You will be given a piece of text and an instruction.",
"Respond with ONLY the edited replacement text — no explanation, no preamble,",
"no markdown code fences. Preserve the original's leading/trailing whitespace",
"unless the instruction says otherwise.",
].join(" ");
/**
* Strip a wrapping markdown code fence if the model added one anyway.
* If `selectedText` itself was fence-wrapped (i.e. the user's selection was a
* fenced block), the model mirroring that format is correct return
* `outputText` unchanged so the fence is preserved.
*/
export function extractReplacement(outputText: string, selectedText: string): string {
const fencePattern = /^\s*```[^\n]*\n[\s\S]*?\n?```\s*$/;
if (fencePattern.test(selectedText)) return outputText;
const m = outputText.match(/^\s*```[^\n]*\n([\s\S]*?)\n?```\s*$/);
return m ? m[1] : outputText;
}
export async function runEditTurn(
instruction: string,
selectedText: string,
opts?: { modelId?: string },
): Promise<EditTurnResult> {
const sdk = await import("@cline/sdk");
const modelId = opts?.modelId ?? "sonnet";
const agent = new sdk.Agent({
providerId: "claude-code",
modelId,
systemPrompt: SYSTEM_PROMPT,
});
const result = await agent.run(
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
);
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed"
// (@cline/shared agent.d.ts) — "completed" is the success status.
if (result.status !== "completed") {
throw new Error(
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
"(is Claude Code installed and signed in?)",
);
}
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
}
+36 -3
View File
@@ -7,6 +7,27 @@
*/
import type { Provenance } from "./model";
/**
* Greedily trim the common prefix then common suffix between the replaced
* text and its replacement (non-overlapping), mirroring the host's own
* WorkspaceEdit diff-minimization so a registered seam edit matches the
* change event VS Code actually delivers (INV-9).
*/
export function minimizeReplace(
oldText: string,
newText: string,
): { prefix: number; suffix: number } {
const max = Math.min(oldText.length, newText.length);
let prefix = 0;
while (prefix < max && oldText[prefix] === newText[prefix]) prefix++;
let suffix = 0;
while (
suffix < max - prefix &&
oldText[oldText.length - 1 - suffix] === newText[newText.length - 1 - suffix]
) suffix++;
return { prefix, suffix };
}
export interface PendingEdit {
docPath: string;
/** half-open [start, end) offsets of the replaced range. */
@@ -15,6 +36,14 @@ export interface PendingEdit {
newText: string;
provenance: Provenance;
turnId?: string;
/**
* The agent's full intended replacement before self-minimization (pre-edit
* half-open range + replacement length). The minimized start/end/newText are
* transport-only (so the registration matches the host-delivered change);
* attribution uses this extent the agent owns every char it asserted,
* including chars the minimized diff left unchanged.
*/
full?: { start: number; end: number; newLength: number };
}
export class PendingEditRegistry {
@@ -26,11 +55,15 @@ export class PendingEditRegistry {
/**
* Remove a registration that failed to apply. Identity-based (`===`), so
* callers must keep the exact registered object; it is a no-op when `match`
* already consumed the registration.
* callers must keep the exact registered object. Returns `true` if the
* registration was still pending and was removed, `false` if it was already
* consumed by `match` (or was never registered). This lets callers detect
* seam misses for diagnosability (INV-9).
*/
unregister(edit: PendingEdit): void {
unregister(edit: PendingEdit): boolean {
const before = this.pending.length;
this.pending = this.pending.filter((p) => p !== edit);
return this.pending.length < before;
}
/**
+25 -7
View File
@@ -11,19 +11,33 @@ import * as path from "node:path";
import { emptyArtifact, serializeArtifact, type Artifact } from "./model";
export class CoauthorStore {
/** sidecar paths this store just wrote, so the shared watcher can ignore them. */
private readonly selfWrites = new Set<string>();
/**
* Pending-write counts keyed by absolute sidecar path. A Map (not Set) because
* one doc save can trigger TWO store.update() calls (ThreadController then
* AttributionController), both writing the same sidecar a Set would let the
* second watcher event leak through as "external" (comment-UI flicker).
*/
private readonly selfWrites = new Map<string, number>();
/** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */
constructor(private readonly rootDir: string) {}
/**
* Delete-and-report: true iff `fsPath` was a sidecar we just wrote via
* `update`. The shared watcher (extension.ts) calls this to suppress
* self-write events before fanning out to the controllers.
* Decrement-and-report: true iff `fsPath` was a sidecar this store just
* wrote via `update`. Each successful `update` increments the counter once;
* each call here decrements it (deleting the key at zero). The shared watcher
* (extension.ts) calls this to suppress self-write events before fanning out
* to the controllers.
*/
consumeSelfWrite(fsPath: string): boolean {
return this.selfWrites.delete(fsPath);
const count = this.selfWrites.get(fsPath);
if (!count) return false;
if (count === 1) {
this.selfWrites.delete(fsPath);
} else {
this.selfWrites.set(fsPath, count - 1);
}
return true;
}
/** `.threads/<repo-relative-docPath>.json` (spec §6.3). */
@@ -61,8 +75,12 @@ export class CoauthorStore {
for (const id of Object.keys(artifact.anchors)) {
if (!referenced.has(id)) delete artifact.anchors[id];
}
this.selfWrites.add(this.sidecarPath(docPath));
this.save(docPath, artifact);
// Increment AFTER the synchronous write: fs.writeFileSync completes before
// this line executes, and watcher events are delivered async, so marking
// post-write is race-free for suppression purposes.
const p = this.sidecarPath(docPath);
this.selfWrites.set(p, (this.selfWrites.get(p) ?? 0) + 1);
return artifact;
}
}
@@ -0,0 +1,5 @@
# Attribution fixture
A stable first paragraph that predates tracking.
The target sentence lives here for seam edits.
+162
View File
@@ -0,0 +1,162 @@
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/attrib.md";
function sidecarPath(): string {
return path.join(WS, ".threads", "docs", "attrib.md.json");
}
function readSidecar(): Artifact {
return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact;
}
async function openDoc(): 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?.attributionController, "extension exports attributionController");
return api;
}
async function externalWriteAndReload(uri: vscode.Uri, content: string): Promise<vscode.TextDocument> {
fs.writeFileSync(uri.fsPath, content, "utf8");
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("workbench.action.files.revert");
return doc;
}
const settle = () => new Promise((r) => setTimeout(r, 300));
// Tests are ORDER-DEPENDENT: later tests consume earlier tests' document/sidecar
// state by design, mirroring the F2 suite. This suite owns docs/attrib.md;
// the threads suite owns docs/sample.md — keep fixtures disjoint.
suite("F3 live attribution (host E2E — seam-driven, no LLM)", () => {
const TYPED = "A human-typed sentence. ";
test("typing produces a live human span", async () => {
const doc = await openDoc();
const api = await getApi();
const editor = vscode.window.activeTextEditor!;
const insertAt = doc.getText().length;
await editor.edit((eb) => eb.insert(doc.positionAt(insertAt), TYPED));
await settle();
const spans = api.attributionController.getSpans(DOC_REL);
assert.strictEqual(spans.length, 1);
assert.strictEqual(spans[0].authorKind, "human");
assert.strictEqual(doc.getText().slice(spans[0].range.start, spans[0].range.end), TYPED);
});
test("a seam edit produces an agent span; mixed edits stay char-honest (INV-7/INV-9)", async () => {
const doc = await openDoc();
const api = await getApi();
const target = "target sentence";
const start = doc.getText().indexOf(target);
const ok = await api.attributionController.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(start), doc.positionAt(start + target.length)),
"REWRITTEN-BY-CLAUDE sentence",
{ kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-run" } },
{ turnId: "turn-e2e-1" },
);
assert.strictEqual(ok, true, "seam edit applies");
await settle();
const spans = api.attributionController.getSpans(DOC_REL);
const agent = spans.find((s) => s.authorKind === "agent");
assert.ok(agent, "agent span exists");
assert.strictEqual(agent!.turnId, "turn-e2e-1");
assert.strictEqual(
doc.getText().slice(agent!.range.start, agent!.range.end),
"REWRITTEN-BY-CLAUDE sentence",
);
assert.ok(spans.some((s) => s.authorKind === "human"), "human span still tracked");
});
test("stale expectedVersion refuses to apply (spec §6.9)", async () => {
const doc = await openDoc();
const api = await getApi();
const ok = await api.attributionController.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(0), doc.positionAt(1)),
"X",
{ kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e" } },
{ expectedVersion: doc.version - 1 },
);
assert.strictEqual(ok, false);
});
test("save persists attributions[]; reload restores spans at re-resolved anchors (PUC-4)", async () => {
const doc = await openDoc();
const api = await getApi();
await doc.save();
await settle();
const art = readSidecar();
assert.ok(art.attributions.length >= 2, "human + agent records persisted");
const agentRec = art.attributions.find((a) => a.author.kind === "agent")!;
assert.strictEqual(art.anchors[agentRec.anchorId].fingerprint.text, "REWRITTEN-BY-CLAUDE sentence");
assert.strictEqual(agentRec.turnId, "turn-e2e-1");
api.attributionController.loadAll(doc);
const spans = api.attributionController.getSpans(DOC_REL);
assert.ok(spans.some((s) => s.authorKind === "agent"), "agent span restored from sidecar");
assert.strictEqual(api.attributionController.getOrphanCount(DOC_REL), 0);
});
test("external move re-anchors; mangling the text orphans (INV-1/INV-6)", async () => {
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const original = fs.readFileSync(uri.fsPath, "utf8");
const api = await getApi();
let doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + original);
await settle();
api.attributionController.loadAll(doc);
let spans = api.attributionController.getSpans(DOC_REL);
const agent = spans.find((s) => s.authorKind === "agent")!;
const moved = doc.getText().indexOf("REWRITTEN-BY-CLAUDE sentence");
assert.strictEqual(agent.range.start, moved, "agent span re-anchored after move");
assert.strictEqual(api.attributionController.getOrphanCount(DOC_REL), 0);
const mangled = doc.getText().replace("REWRITTEN-BY-CLAUDE sentence", "now something else entirely");
doc = await externalWriteAndReload(uri, mangled);
await settle();
api.attributionController.loadAll(doc);
spans = api.attributionController.getSpans(DOC_REL);
assert.ok(!spans.some((s) => s.authorKind === "agent"), "mangled agent span not rendered");
assert.ok(api.attributionController.getOrphanCount(DOC_REL) >= 1, "…it is orphaned instead (INV-1)");
});
test("the applyAgentEdit command wrapper and the toggle command work end-to-end", async () => {
const doc = await openDoc();
const api = await getApi();
const anchor = "stable first paragraph";
const start = doc.getText().indexOf(anchor);
const ok = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + anchor.length,
newText: "stable COMMAND-EDITED paragraph",
model: "sonnet",
sessionId: "e2e-cmd",
turnId: "turn-e2e-cmd",
});
assert.strictEqual(ok, true, "command wrapper applies via the seam");
await settle();
const spans = api.attributionController.getSpans(DOC_REL);
const agent = spans.find((s) => s.turnId === "turn-e2e-cmd");
assert.ok(agent, "command-driven agent span exists");
assert.strictEqual(agent!.authorKind, "agent");
assert.strictEqual(api.attributionController.isVisible(), true);
await vscode.commands.executeCommand("cowriting.toggleAttribution");
assert.strictEqual(api.attributionController.isVisible(), false, "toggle hides (PUC-5)");
await vscode.commands.executeCommand("cowriting.toggleAttribution");
assert.strictEqual(api.attributionController.isVisible(), true, "toggle restores");
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { extractReplacement } from "../src/liveTurn";
describe("extractReplacement", () => {
it("returns plain text untouched", () => {
expect(extractReplacement("Hello world.", "x")).toBe("Hello world.");
});
it("strips a wrapping fence", () => {
expect(extractReplacement("```\nHello world.\n```", "x")).toBe("Hello world.");
});
it("strips a language-tagged fence", () => {
expect(extractReplacement("```markdown\nHello.\n```\n", "x")).toBe("Hello.");
});
it("leaves inner fences alone", () => {
const inner = "before\n```js\ncode\n```\nafter";
expect(extractReplacement(inner, "x")).toBe(inner);
});
it("keeps the fence when the original selection was itself fence-wrapped", () => {
const sel = "```python\nold()\n```";
const out = "```python\nnew()\n```";
expect(extractReplacement(out, sel)).toBe(out);
});
it("still strips when the selection was plain but the model added a fence", () => {
expect(extractReplacement("```\nplain\n```", "plain old text")).toBe("plain");
});
});
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { PendingEditRegistry } from "../src/pendingEdits";
import { minimizeReplace, PendingEditRegistry } from "../src/pendingEdits";
import type { Provenance } from "../src/model";
const AGENT: Provenance = {
@@ -29,4 +29,32 @@ describe("PendingEditRegistry (INV-9)", () => {
reg.unregister(p);
expect(reg.match("d.md", { start: 0, end: 0, text: "x" })).toBeNull();
});
it("unregister reports whether the registration was still pending", () => {
const reg = new PendingEditRegistry();
const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT };
reg.register(p);
expect(reg.unregister(p)).toBe(true);
reg.register(p);
reg.match("d.md", { start: 0, end: 0, text: "x" });
expect(reg.unregister(p)).toBe(false);
});
});
describe("minimizeReplace (host diff-minimization mirror)", () => {
it("trims a common suffix (the E2E-observed case)", () => {
expect(minimizeReplace("target sentence", "REWRITTEN-BY-CLAUDE sentence")).toEqual({ prefix: 0, suffix: 9 });
});
it("trims a common prefix", () => {
expect(minimizeReplace("Hello world", "Hello there")).toEqual({ prefix: 6, suffix: 0 });
});
it("trims both, never overlapping", () => {
expect(minimizeReplace("aba", "aa")).toEqual({ prefix: 1, suffix: 1 });
expect(minimizeReplace("aaaa", "aa")).toEqual({ prefix: 2, suffix: 0 });
});
it("identical texts minimize to nothing", () => {
expect(minimizeReplace("same", "same")).toEqual({ prefix: 4, suffix: 0 });
});
it("disjoint texts trim nothing", () => {
expect(minimizeReplace("abc", "xyz")).toEqual({ prefix: 0, suffix: 0 });
});
});
+18
View File
@@ -88,4 +88,22 @@ describe("CoauthorStore", () => {
});
expect(Object.keys(store.load("d.md")!.anchors)).toEqual([]);
});
it("consumeSelfWrite counts multiple writes to the same sidecar (double-save suppression)", () => {
const store = new CoauthorStore(root);
store.update("d.md", (a) => {
a.anchors["a_t"] = { fingerprint: { text: "t", before: "", after: "", lineHint: 0 } };
a.threads.push({ id: "t1", anchorId: "a_t", status: "open", messages: [] });
});
store.update("d.md", (a) => {
a.attributions.push({
id: "at1", anchorId: "a_t", author: { kind: "human", id: "ben" },
createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z",
});
});
const p = store.sidecarPath("d.md");
expect(store.consumeSelfWrite(p)).toBe(true);
expect(store.consumeSelfWrite(p)).toBe(true);
expect(store.consumeSelfWrite(p)).toBe(false);
});
});