F3: live human/Claude attribution on @cline/sdk claude-code (Feature #6) #7
@@ -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
|
catalog (a pure, key-free SDK call) in a notification and the
|
||||||
"Cowriting (Cline SDK)" output channel.
|
"Cowriting (Cline SDK)" output channel.
|
||||||
|
|
||||||
|
Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
|
||||||
|
human/Claude attribution (Feature #6).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- CommonJS extension bundled with esbuild (`src/extension.ts` → `out/extension.cjs`).
|
- 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
|
Design: `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md`. No live
|
||||||
`@cline/sdk` turn and no credentials are involved in F2.
|
`@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
|
## Develop
|
||||||
|
|
||||||
- `npm run watch` — rebuild on change.
|
- `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
|
- `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.
|
- `npm run typecheck` — type-check without emit.
|
||||||
|
|||||||
@@ -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
@@ -9,7 +9,7 @@ const options = {
|
|||||||
bundle: true,
|
bundle: true,
|
||||||
platform: "node",
|
platform: "node",
|
||||||
format: "cjs",
|
format: "cjs",
|
||||||
target: "node20",
|
target: "node22",
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
// vscode is provided by the host; @cline/sdk is ESM-only and is loaded at
|
// 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.
|
// runtime via dynamic import() from node_modules, so keep both external.
|
||||||
@@ -17,11 +17,26 @@ const options = {
|
|||||||
logLevel: "info",
|
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) {
|
if (watch) {
|
||||||
const ctx = await context(options);
|
const ctx = await context(options);
|
||||||
await ctx.watch();
|
const ctxLive = await context(liveTurnOptions);
|
||||||
|
await Promise.all([ctx.watch(), ctxLive.watch()]);
|
||||||
console.log("esbuild: watching…");
|
console.log("esbuild: watching…");
|
||||||
} else {
|
} else {
|
||||||
await build(options);
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -27,9 +27,15 @@
|
|||||||
},
|
},
|
||||||
{ "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" },
|
{ "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" },
|
||||||
{ "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" },
|
{ "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" },
|
||||||
{ "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" }
|
{ "command": "cowriting.reopenThread", "title": "Reopen Thread", "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": {
|
"menus": {
|
||||||
|
"commandPalette": [
|
||||||
|
{ "command": "cowriting.applyAgentEdit", "when": "false" }
|
||||||
|
],
|
||||||
"comments/commentThread/context": [
|
"comments/commentThread/context": [
|
||||||
{ "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" }
|
{ "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" }
|
||||||
],
|
],
|
||||||
@@ -54,6 +60,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
|
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
|
||||||
"test:e2e": "node ./out/test/e2e/runTest.js",
|
"test:e2e": "node ./out/test/e2e/runTest.js",
|
||||||
|
"smoke:live": "npm run build && node scripts/smoke-live-turn.mjs",
|
||||||
"vscode:prepublish": "node esbuild.mjs"
|
"vscode:prepublish": "node esbuild.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* AttributionController — the thin editor-facing layer for F3 (spec §6.2).
|
||||||
|
* Wires the pure AttributionTracker + PendingEditRegistry + Store/Anchorer to
|
||||||
|
* the editor: human typing → human spans, seam edits → agent spans (INV-9),
|
||||||
|
* decorations (Claude tint / human gutter border / toggle), save-time
|
||||||
|
* persistence, load/external-change resolve-or-orphan (INV-1/INV-6), and the
|
||||||
|
* orphan status-bar count. Sidecar self-write suppression and the shared
|
||||||
|
* 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 { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
|
||||||
|
|
||||||
|
/** Test-facing snapshot of live attribution state for a document. */
|
||||||
|
export interface RenderedSpan {
|
||||||
|
id: string;
|
||||||
|
authorKind: "human" | "agent";
|
||||||
|
authorId: string;
|
||||||
|
turnId?: string;
|
||||||
|
range: { start: number; end: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocAttribution {
|
||||||
|
docPath: string;
|
||||||
|
spans: LiveSpan[];
|
||||||
|
/** persisted records whose fingerprints failed to resolve (PUC-4). */
|
||||||
|
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 = {
|
||||||
|
backgroundColor: "rgba(99, 102, 241, 0.18)",
|
||||||
|
overviewRulerColor: "rgba(99, 102, 241, 0.8)",
|
||||||
|
overviewRulerLane: vscode.OverviewRulerLane.Right,
|
||||||
|
};
|
||||||
|
const HUMAN_DECO: vscode.DecorationRenderOptions = {
|
||||||
|
borderColor: "rgba(16, 185, 129, 0.8)",
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: "0 0 0 2px",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AttributionController implements vscode.Disposable {
|
||||||
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
|
private readonly docs = new Map<string, DocAttribution>();
|
||||||
|
private readonly pending = new PendingEditRegistry();
|
||||||
|
private readonly agentType = vscode.window.createTextEditorDecorationType(AGENT_DECO);
|
||||||
|
private readonly humanType = vscode.window.createTextEditorDecorationType(HUMAN_DECO);
|
||||||
|
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 90);
|
||||||
|
private readonly output = vscode.window.createOutputChannel("Cowriting Attribution");
|
||||||
|
private visible = true;
|
||||||
|
|
||||||
|
constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
|
||||||
|
this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output);
|
||||||
|
this.disposables.push(
|
||||||
|
vscode.commands.registerCommand("cowriting.toggleAttribution", () => this.toggle()),
|
||||||
|
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||||
|
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
|
||||||
|
vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTracked(document: vscode.TextDocument): boolean {
|
||||||
|
return document.uri.scheme === "file" && document.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 state(docPath: string): DocAttribution {
|
||||||
|
let s = this.docs.get(docPath);
|
||||||
|
if (!s) {
|
||||||
|
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false };
|
||||||
|
this.docs.set(docPath, s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PUC-4: load / resolve-or-orphan ---------------------------------------------
|
||||||
|
|
||||||
|
/** Load the sidecar and re-resolve every attribution (live span | orphan). */
|
||||||
|
loadAll(document: vscode.TextDocument): void {
|
||||||
|
if (!this.isTracked(document)) return;
|
||||||
|
const docPath = this.docPathOf(document.uri);
|
||||||
|
const s = this.state(docPath);
|
||||||
|
const artifact = this.store.load(docPath);
|
||||||
|
s.spans = [];
|
||||||
|
s.orphans = [];
|
||||||
|
s.records.clear();
|
||||||
|
if (artifact) {
|
||||||
|
const text = document.getText();
|
||||||
|
for (const rec of artifact.attributions) {
|
||||||
|
const fp = artifact.anchors[rec.anchorId]?.fingerprint;
|
||||||
|
const resolved = fp ? resolve(text, fp) : "orphaned";
|
||||||
|
if (resolved === "orphaned") {
|
||||||
|
s.orphans.push(rec);
|
||||||
|
} else {
|
||||||
|
s.spans.push({
|
||||||
|
id: rec.id, start: resolved.start, end: resolved.end,
|
||||||
|
author: rec.author, turnId: rec.turnId, createdAt: rec.createdAt,
|
||||||
|
});
|
||||||
|
s.records.set(rec.id, rec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.spans = coalesce(s.spans);
|
||||||
|
if (artifact.attributions.length > 0) s.hadAttributions = true;
|
||||||
|
}
|
||||||
|
this.render(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared-watcher entry point (extension.ts): a sidecar changed externally. */
|
||||||
|
handleExternalSidecarChange(uri: vscode.Uri): void {
|
||||||
|
for (const s of this.docs.values()) {
|
||||||
|
if (this.store.sidecarPath(s.docPath) === uri.fsPath) {
|
||||||
|
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === s.docPath);
|
||||||
|
if (doc) this.loadAll(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PUC-1/PUC-3: live tracking ---------------------------------------------------
|
||||||
|
|
||||||
|
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 && 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);
|
||||||
|
// 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,
|
||||||
|
newLength: change.text.length,
|
||||||
|
};
|
||||||
|
const hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text });
|
||||||
|
const author = hit ? hit.provenance : this.currentAuthor();
|
||||||
|
// 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) ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONLY machine-edit ingress: register the exact expected change, then
|
||||||
|
* apply it as a WorkspaceEdit. Returns false (applying nothing) on a stale
|
||||||
|
* 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,
|
||||||
|
range: vscode.Range,
|
||||||
|
newText: string,
|
||||||
|
provenance: Provenance,
|
||||||
|
opts?: { expectedVersion?: number; turnId?: string },
|
||||||
|
): Promise<boolean> {
|
||||||
|
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: 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, new vscode.Range(document.positionAt(minStart), document.positionAt(minEnd)), minText);
|
||||||
|
const ok = await vscode.workspace.applyEdit(we);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PUC-4: persistence on save ----------------------------------------------------
|
||||||
|
|
||||||
|
private onDidSave(document: vscode.TextDocument): void {
|
||||||
|
if (!this.isTracked(document)) return;
|
||||||
|
const docPath = this.docPathOf(document.uri);
|
||||||
|
const s = this.docs.get(docPath);
|
||||||
|
// 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[] = [];
|
||||||
|
const anchorOf = new Map<string, OffsetRange>();
|
||||||
|
for (const span of s.spans) {
|
||||||
|
const prev = s.records.get(span.id);
|
||||||
|
const rec: AttributionRecord = {
|
||||||
|
id: span.id,
|
||||||
|
anchorId: prev?.anchorId ?? newId("a"),
|
||||||
|
author: span.author,
|
||||||
|
createdAt: span.createdAt,
|
||||||
|
updatedAt: prev ? prev.updatedAt : now,
|
||||||
|
...(span.turnId !== undefined ? { turnId: span.turnId } : {}),
|
||||||
|
};
|
||||||
|
anchorOf.set(rec.anchorId, { start: span.start, end: span.end });
|
||||||
|
records.push(rec);
|
||||||
|
s.records.set(span.id, rec);
|
||||||
|
}
|
||||||
|
this.store.update(docPath, (a) => {
|
||||||
|
for (const rec of records) {
|
||||||
|
const range = anchorOf.get(rec.anchorId)!;
|
||||||
|
const fp = buildFingerprint(text, range);
|
||||||
|
const prevFp = a.anchors[rec.anchorId]?.fingerprint;
|
||||||
|
if (prevFp && JSON.stringify(prevFp) !== JSON.stringify(fp)) rec.updatedAt = now;
|
||||||
|
a.anchors[rec.anchorId] = { fingerprint: fp };
|
||||||
|
}
|
||||||
|
// Orphans ride along unchanged (recoverable, spec §6.9): their records
|
||||||
|
// stay in attributions[], so update()'s prune keeps their anchors too.
|
||||||
|
a.attributions = [...records, ...s.orphans];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PUC-5: rendering ----------------------------------------------------------------
|
||||||
|
|
||||||
|
private toggle(): void {
|
||||||
|
this.visible = !this.visible;
|
||||||
|
this.renderActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderActive(): void {
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (editor) this.render(editor.document);
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(document: vscode.TextDocument): void {
|
||||||
|
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));
|
||||||
|
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;
|
||||||
|
if (n === 0) {
|
||||||
|
this.statusItem.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.statusItem.text = `$(warning) ${n} orphaned attribution${n === 1 ? "" : "s"}`;
|
||||||
|
this.statusItem.tooltip = "Cowriting: attribution anchors that no longer resolve (see output channel)";
|
||||||
|
this.statusItem.show();
|
||||||
|
this.output.clear();
|
||||||
|
for (const o of s!.orphans) this.output.appendLine(`orphaned ${o.id} (${o.author.kind}:${o.author.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- test-facing surface ---------------------------------------------------------------
|
||||||
|
|
||||||
|
getSpans(docPath: string): RenderedSpan[] {
|
||||||
|
const s = this.docs.get(docPath);
|
||||||
|
if (!s) return [];
|
||||||
|
return s.spans.map((sp) => ({
|
||||||
|
id: sp.id,
|
||||||
|
authorKind: sp.author.kind,
|
||||||
|
authorId: sp.author.id,
|
||||||
|
turnId: sp.turnId,
|
||||||
|
range: { start: sp.start, end: sp.end },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
getOrphanCount(docPath: string): number {
|
||||||
|
return this.docs.get(docPath)?.orphans.length ?? 0;
|
||||||
|
}
|
||||||
|
isVisible(): boolean {
|
||||||
|
return this.visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const d of this.disposables) d.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* AttributionTracker — pure span algebra over offset ranges (spec §6.2/§6.5).
|
||||||
|
* Char-honest (INV-7): attributes exactly the characters each author produced —
|
||||||
|
* split character-precisely, coalesce only adjacent same-author/same-turn
|
||||||
|
* spans. vscode-free; the controller converts vscode change events to TextEdit.
|
||||||
|
*/
|
||||||
|
import type { TextEdit } from "./anchorer";
|
||||||
|
import type { Provenance } from "./model";
|
||||||
|
|
||||||
|
export interface LiveSpan {
|
||||||
|
id: string;
|
||||||
|
/** half-open [start, end) character offsets in the live document. */
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
author: Provenance;
|
||||||
|
turnId?: string;
|
||||||
|
/** ISO-8601; survives splits/merges (earliest wins on merge). */
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injected effects so the algebra stays pure and deterministic in tests. */
|
||||||
|
export interface TrackerCtx {
|
||||||
|
newId: () => string;
|
||||||
|
now: () => string;
|
||||||
|
turnId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameAuthor(a: LiveSpan, b: LiveSpan): boolean {
|
||||||
|
return JSON.stringify(a.author) === JSON.stringify(b.author) && a.turnId === b.turnId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge adjacent same-author/same-turn spans; drop empties; sort by start. */
|
||||||
|
export function coalesce(spans: LiveSpan[]): LiveSpan[] {
|
||||||
|
const sorted = spans.filter((s) => s.end > s.start).sort((a, b) => a.start - b.start);
|
||||||
|
const out: LiveSpan[] = [];
|
||||||
|
for (const s of sorted) {
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (last && last.end === s.start && sameAuthor(last, s)) {
|
||||||
|
last.end = s.end;
|
||||||
|
if (s.createdAt < last.createdAt) last.createdAt = s.createdAt;
|
||||||
|
} else {
|
||||||
|
out.push({ ...s });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one document edit (the half-open range [start,end) replaced by
|
||||||
|
* newLength chars) authored by `author`. Existing spans shift/split/clip;
|
||||||
|
* inserted chars become a new span of `author` (INV-7).
|
||||||
|
*/
|
||||||
|
export function applyChange(
|
||||||
|
spans: LiveSpan[],
|
||||||
|
edit: TextEdit,
|
||||||
|
author: Provenance,
|
||||||
|
ctx: TrackerCtx,
|
||||||
|
): LiveSpan[] {
|
||||||
|
const delta = edit.newLength - (edit.end - edit.start);
|
||||||
|
const out: LiveSpan[] = [];
|
||||||
|
for (const s of spans) {
|
||||||
|
if (s.end <= edit.start) {
|
||||||
|
out.push({ ...s });
|
||||||
|
} else if (s.start >= edit.end) {
|
||||||
|
out.push({ ...s, start: s.start + delta, end: s.end + delta });
|
||||||
|
} else {
|
||||||
|
const left = s.start < edit.start ? { ...s, end: edit.start } : null;
|
||||||
|
const right =
|
||||||
|
s.end > edit.end ? { ...s, start: edit.end + delta, end: s.end + delta } : null;
|
||||||
|
if (left) out.push(left);
|
||||||
|
if (right) out.push(left ? { ...right, id: ctx.newId() } : right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (edit.newLength > 0) {
|
||||||
|
out.push({
|
||||||
|
id: ctx.newId(),
|
||||||
|
start: edit.start,
|
||||||
|
end: edit.start + edit.newLength,
|
||||||
|
author,
|
||||||
|
turnId: ctx.turnId,
|
||||||
|
createdAt: ctx.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return coalesce(out);
|
||||||
|
}
|
||||||
+111
-3
@@ -2,11 +2,13 @@ import * as vscode from "vscode";
|
|||||||
import { fetchSdkSummary } from "./cline";
|
import { fetchSdkSummary } from "./cline";
|
||||||
import { CoauthorStore } from "./store";
|
import { CoauthorStore } from "./store";
|
||||||
import { ThreadController } from "./threadController";
|
import { ThreadController } from "./threadController";
|
||||||
|
import { AttributionController } from "./attributionController";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
|
|
||||||
export interface CowritingApi {
|
export interface CowritingApi {
|
||||||
threadController: ThreadController;
|
threadController: ThreadController;
|
||||||
|
attributionController: AttributionController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||||
@@ -41,14 +43,120 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
const threadController = new ThreadController(store, root);
|
const threadController = new ThreadController(store, root);
|
||||||
context.subscriptions.push(threadController);
|
context.subscriptions.push(threadController);
|
||||||
|
|
||||||
// Render threads for already-open editors, and on future opens.
|
// --- F3: live attribution (Feature #6) ---
|
||||||
|
const attributionController = new AttributionController(store, root);
|
||||||
|
context.subscriptions.push(attributionController);
|
||||||
|
|
||||||
|
// One SHARED sidecar watcher for both controllers; self-writes are
|
||||||
|
// suppressed centrally in the store (the sidecar is co-owned).
|
||||||
|
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
|
||||||
|
const onSidecar = (uri: vscode.Uri) => {
|
||||||
|
if (store.consumeSelfWrite(uri.fsPath)) return;
|
||||||
|
threadController.handleExternalSidecarChange(uri);
|
||||||
|
attributionController.handleExternalSidecarChange(uri);
|
||||||
|
};
|
||||||
|
watcher.onDidChange(onSidecar);
|
||||||
|
watcher.onDidCreate(onSidecar);
|
||||||
|
context.subscriptions.push(watcher);
|
||||||
|
|
||||||
|
// The seam as a command (INV-9): the only machine-edit ingress, hidden from
|
||||||
|
// the palette — for the host E2E harness and future SDK turn plumbing.
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand(
|
||||||
|
"cowriting.applyAgentEdit",
|
||||||
|
(args: {
|
||||||
|
uri: string; start: number; end: number; newText: string;
|
||||||
|
model?: string; sessionId?: string; turnId?: string;
|
||||||
|
}) => {
|
||||||
|
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri);
|
||||||
|
if (!doc) return Promise.resolve(false);
|
||||||
|
const range = new vscode.Range(doc.positionAt(args.start), doc.positionAt(args.end));
|
||||||
|
const provenance = {
|
||||||
|
kind: "agent" as const,
|
||||||
|
id: "claude",
|
||||||
|
agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" },
|
||||||
|
};
|
||||||
|
return attributionController.applyAgentEdit(doc, range, args.newText, provenance, { turnId: args.turnId });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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) => {
|
const renderIfOpen = (doc: vscode.TextDocument) => {
|
||||||
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) threadController.renderAll(doc);
|
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
|
||||||
|
threadController.renderAll(doc);
|
||||||
|
attributionController.loadAll(doc);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
||||||
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
|
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
|
||||||
|
|
||||||
return { threadController };
|
return { threadController, attributionController };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate(): void {
|
export function deactivate(): void {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
+24
-3
@@ -47,6 +47,20 @@ export interface Thread {
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** F3 (spec §6.3): an anchored, author-attributed span — state, not history. */
|
||||||
|
export interface AttributionRecord {
|
||||||
|
id: string;
|
||||||
|
/** shared anchors map — same Fingerprint primitive (INV-4). */
|
||||||
|
anchorId: string;
|
||||||
|
author: Provenance;
|
||||||
|
/** ISO-8601. */
|
||||||
|
createdAt: string;
|
||||||
|
/** ISO-8601; bumped when the span's extent/fingerprint changes. */
|
||||||
|
updatedAt: string;
|
||||||
|
/** groups all spans applied by one live turn. */
|
||||||
|
turnId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Artifact {
|
export interface Artifact {
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
/** repo-relative path; the sidecar key. */
|
/** repo-relative path; the sidecar key. */
|
||||||
@@ -54,8 +68,8 @@ export interface Artifact {
|
|||||||
/** SHARED primitive (INV-4). */
|
/** SHARED primitive (INV-4). */
|
||||||
anchors: Record<string, Anchor>;
|
anchors: Record<string, Anchor>;
|
||||||
threads: Thread[];
|
threads: Thread[];
|
||||||
/** F3 extension point — reuses anchors[]; not implemented in F2. */
|
/** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */
|
||||||
attributions: unknown[];
|
attributions: AttributionRecord[];
|
||||||
/** F4 extension point — reuses anchors[] + provenance; not in F2. */
|
/** F4 extension point — reuses anchors[] + provenance; not in F2. */
|
||||||
proposals: unknown[];
|
proposals: unknown[];
|
||||||
}
|
}
|
||||||
@@ -117,7 +131,14 @@ export function serializeArtifact(a: Artifact): string {
|
|||||||
createdAt: m.createdAt,
|
createdAt: m.createdAt,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
attributions: a.attributions,
|
attributions: a.attributions.map((at) => ({
|
||||||
|
id: at.id,
|
||||||
|
anchorId: at.anchorId,
|
||||||
|
author: serializeProvenance(at.author),
|
||||||
|
createdAt: at.createdAt,
|
||||||
|
updatedAt: at.updatedAt,
|
||||||
|
...(at.turnId !== undefined ? { turnId: at.turnId } : {}),
|
||||||
|
})),
|
||||||
proposals: a.proposals,
|
proposals: a.proposals,
|
||||||
};
|
};
|
||||||
return JSON.stringify(canonical, null, 2) + "\n";
|
return JSON.stringify(canonical, null, 2) + "\n";
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* PendingEditRegistry — the seam's bookkeeping (spec §6.2, INV-9). Before
|
||||||
|
* applyAgentEdit applies a WorkspaceEdit, it registers the exact expected
|
||||||
|
* change; the controller's change handler consumes a matching registration to
|
||||||
|
* attribute that change to the agent. Anything unmatched is human typing.
|
||||||
|
* vscode-free and unit-testable.
|
||||||
|
*/
|
||||||
|
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. */
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
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 {
|
||||||
|
private pending: PendingEdit[] = [];
|
||||||
|
|
||||||
|
register(edit: PendingEdit): void {
|
||||||
|
this.pending.push(edit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a registration that failed to apply. Identity-based (`===`), so
|
||||||
|
* 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): boolean {
|
||||||
|
const before = this.pending.length;
|
||||||
|
this.pending = this.pending.filter((p) => p !== edit);
|
||||||
|
return this.pending.length < before;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find-and-consume the registration exactly matching a change event
|
||||||
|
* (same doc, same replaced range, same inserted text). Null → human edit.
|
||||||
|
*/
|
||||||
|
match(docPath: string, change: { start: number; end: number; text: string }): PendingEdit | null {
|
||||||
|
const i = this.pending.findIndex(
|
||||||
|
(p) =>
|
||||||
|
p.docPath === docPath &&
|
||||||
|
p.start === change.start &&
|
||||||
|
p.end === change.end &&
|
||||||
|
p.newText === change.text,
|
||||||
|
);
|
||||||
|
if (i === -1) return null;
|
||||||
|
const [hit] = this.pending.splice(i, 1);
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-3
@@ -1,17 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4).
|
* CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4).
|
||||||
* Git-native, serverless, plain pretty JSON (INV-2). vscode-free (Node fs only),
|
* 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
|
* so it is unit-testable; the SHARED FileSystemWatcher that triggers
|
||||||
* external change is wired in the vscode layer (ThreadController, SLICE-4).
|
* re-anchoring on external change is wired in the vscode layer (extension.ts),
|
||||||
|
* with self-write suppression centralized here because two controllers
|
||||||
|
* (ThreadController, AttributionController) co-own the sidecar (F3 §6.2).
|
||||||
*/
|
*/
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { serializeArtifact, type Artifact } from "./model";
|
import { emptyArtifact, serializeArtifact, type Artifact } from "./model";
|
||||||
|
|
||||||
export class CoauthorStore {
|
export class CoauthorStore {
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */
|
||||||
constructor(private readonly rootDir: string) {}
|
constructor(private readonly rootDir: string) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
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). */
|
/** `.threads/<repo-relative-docPath>.json` (spec §6.3). */
|
||||||
sidecarPath(docPath: string): string {
|
sidecarPath(docPath: string): string {
|
||||||
return path.join(this.rootDir, ".threads", `${docPath}.json`);
|
return path.join(this.rootDir, ".threads", `${docPath}.json`);
|
||||||
@@ -28,4 +56,31 @@ export class CoauthorStore {
|
|||||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
fs.writeFileSync(p, serializeArtifact(artifact), "utf8");
|
fs.writeFileSync(p, serializeArtifact(artifact), "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-modify-write for sidecar co-ownership (spec F3 §6.2): each controller
|
||||||
|
* mutates only its own section against a FRESH load, so ThreadController and
|
||||||
|
* AttributionController never clobber each other. After the mutation, anchors
|
||||||
|
* referenced by neither threads nor attributions are pruned (proposals are
|
||||||
|
* still empty in F3 — revisit in F4). `mutate` MUST be synchronous: the
|
||||||
|
* read-modify-write (and the self-write mark) completes within this call.
|
||||||
|
*/
|
||||||
|
update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
|
||||||
|
const artifact = this.load(docPath) ?? emptyArtifact(docPath);
|
||||||
|
mutate(artifact);
|
||||||
|
const referenced = new Set<string>([
|
||||||
|
...artifact.threads.map((t) => t.anchorId),
|
||||||
|
...artifact.attributions.map((a) => a.anchorId),
|
||||||
|
]);
|
||||||
|
for (const id of Object.keys(artifact.anchors)) {
|
||||||
|
if (!referenced.has(id)) delete artifact.anchors[id];
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-19
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* ThreadController — the thin editor-facing layer (spec §6.4). Wires
|
* ThreadController — the thin editor-facing layer (spec §6.4). Wires
|
||||||
* CoauthorStore + Anchorer + threadModel to vscode.comments: create-on-
|
* CoauthorStore + Anchorer + threadModel to vscode.comments: create-on-
|
||||||
* selection, reply, resolve, render, live-range tracking, reload, and
|
* selection, reply, resolve, render, live-range tracking, reload, and external
|
||||||
* FileSystemWatcher-driven external re-anchoring (SLICE-4). Threads are NEVER
|
* re-anchoring (SLICE-4) — driven by the SHARED sidecar watcher in extension.ts
|
||||||
* silently moved — an unresolvable anchor renders as an orphaned thread (INV-1).
|
* via handleExternalSidecarChange (self-writes are suppressed in CoauthorStore).
|
||||||
|
* Threads are NEVER silently moved — an unresolvable anchor renders as an
|
||||||
|
* orphaned thread (INV-1).
|
||||||
*/
|
*/
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { CoauthorStore } from "./store";
|
import { CoauthorStore } from "./store";
|
||||||
@@ -35,8 +37,6 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
private readonly controller: vscode.CommentController;
|
private readonly controller: vscode.CommentController;
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
private readonly docs = new Map<string, DocState>(); // keyed by docPath
|
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) {
|
constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
|
||||||
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
||||||
@@ -60,13 +60,6 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||||
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
|
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 {
|
private isInRoot(uri: vscode.Uri): boolean {
|
||||||
@@ -83,8 +76,12 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private persist(state: DocState): void {
|
private persist(state: DocState): void {
|
||||||
this.selfWrites.add(this.store.sidecarPath(state.docPath));
|
this.store.update(state.docPath, (a) => {
|
||||||
this.store.save(state.docPath, state.artifact);
|
a.threads = state.artifact.threads;
|
||||||
|
for (const t of state.artifact.threads) {
|
||||||
|
a.anchors[t.anchorId] = state.artifact.anchors[t.anchorId];
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureState(document: vscode.TextDocument): DocState {
|
private ensureState(document: vscode.TextDocument): DocState {
|
||||||
@@ -169,12 +166,9 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onExternalSidecarChange(uri: vscode.Uri): void {
|
/** Shared-watcher entry point (extension.ts): a sidecar changed externally. */
|
||||||
|
handleExternalSidecarChange(uri: vscode.Uri): void {
|
||||||
const p = uri.fsPath;
|
const p = uri.fsPath;
|
||||||
if (this.selfWrites.has(p)) {
|
|
||||||
this.selfWrites.delete(p);
|
|
||||||
return; // our own write
|
|
||||||
}
|
|
||||||
for (const state of this.docs.values()) {
|
for (const state of this.docs.values()) {
|
||||||
if (this.store.sidecarPath(state.docPath) === p) {
|
if (this.store.sidecarPath(state.docPath) === p) {
|
||||||
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
|
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildFingerprint, resolve } from "../src/anchorer";
|
||||||
|
import { applyChange, type LiveSpan } from "../src/attributionTracker";
|
||||||
|
import type { Provenance } from "../src/model";
|
||||||
|
|
||||||
|
const HUMAN: Provenance = { kind: "human", id: "ben" };
|
||||||
|
const DOC = "intro\nClaude wrote this sentence here.\noutro\n";
|
||||||
|
|
||||||
|
function liveSpan(): LiveSpan {
|
||||||
|
const start = DOC.indexOf("Claude wrote this sentence");
|
||||||
|
return {
|
||||||
|
id: "at_1", start, end: start + "Claude wrote this sentence".length,
|
||||||
|
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "r1" } },
|
||||||
|
createdAt: "2026-06-10T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("attribution persistence decision logic (SLICE-4, spec §6.5 PUC-4)", () => {
|
||||||
|
it("save → reload: fingerprint built from the live span resolves to the same range", () => {
|
||||||
|
const s = liveSpan();
|
||||||
|
const fp = buildFingerprint(DOC, { start: s.start, end: s.end });
|
||||||
|
expect(resolve(DOC, fp)).toEqual({ start: s.start, end: s.end });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("external edit above: fingerprint re-resolves at the moved range", () => {
|
||||||
|
const s = liveSpan();
|
||||||
|
const fp = buildFingerprint(DOC, { start: s.start, end: s.end });
|
||||||
|
const edited = "a new line\n" + DOC;
|
||||||
|
const moved = edited.indexOf("Claude wrote this sentence");
|
||||||
|
expect(resolve(edited, fp)).toEqual({ start: moved, end: moved + (s.end - s.start) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anchored text mangled: orphaned, never guessed (INV-1/INV-6)", () => {
|
||||||
|
const s = liveSpan();
|
||||||
|
const fp = buildFingerprint(DOC, { start: s.start, end: s.end });
|
||||||
|
const mangled = DOC.replace("Claude wrote this sentence", "Someone rewrote everything");
|
||||||
|
expect(resolve(mangled, fp)).toBe("orphaned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("in-session edits keep the live span fingerprint-able (tracker → anchorer round trip)", () => {
|
||||||
|
let spans = [liveSpan()];
|
||||||
|
let doc = DOC;
|
||||||
|
const insertAt = DOC.indexOf("intro") + "intro".length;
|
||||||
|
const inserted = " (note)";
|
||||||
|
doc = doc.slice(0, insertAt) + inserted + doc.slice(insertAt);
|
||||||
|
spans = applyChange(spans, { start: insertAt, end: insertAt, newLength: inserted.length }, HUMAN, {
|
||||||
|
newId: () => "n1", now: () => "2026-06-10T01:00:00.000Z",
|
||||||
|
});
|
||||||
|
const agent = spans.find((x) => x.author.kind === "agent")!;
|
||||||
|
expect(doc.slice(agent.start, agent.end)).toBe("Claude wrote this sentence");
|
||||||
|
const fp = buildFingerprint(doc, { start: agent.start, end: agent.end });
|
||||||
|
expect(resolve(doc, fp)).toEqual({ start: agent.start, end: agent.end });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { applyChange, coalesce, type LiveSpan, type TrackerCtx } from "../src/attributionTracker";
|
||||||
|
import type { Provenance } from "../src/model";
|
||||||
|
|
||||||
|
const HUMAN: Provenance = { kind: "human", id: "ben" };
|
||||||
|
const AGENT: Provenance = {
|
||||||
|
kind: "agent", id: "claude",
|
||||||
|
agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ctx(turnId?: string): TrackerCtx {
|
||||||
|
let n = 0;
|
||||||
|
return { newId: () => `n${++n}`, now: () => "2026-06-10T00:00:00.000Z", turnId };
|
||||||
|
}
|
||||||
|
function span(id: string, start: number, end: number, author: Provenance, turnId?: string): LiveSpan {
|
||||||
|
return { id, start, end, author, turnId, createdAt: "2026-06-09T00:00:00.000Z" };
|
||||||
|
}
|
||||||
|
const ranges = (s: LiveSpan[]) => s.map((x) => [x.start, x.end]);
|
||||||
|
const authors = (s: LiveSpan[]) => s.map((x) => x.author.kind);
|
||||||
|
|
||||||
|
describe("applyChange — insertions", () => {
|
||||||
|
it("insertion into empty span list creates one author span", () => {
|
||||||
|
const r = applyChange([], { start: 5, end: 5, newLength: 3 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[5, 8]]);
|
||||||
|
expect(r[0].author).toEqual(HUMAN);
|
||||||
|
});
|
||||||
|
it("insertion before a span shifts it", () => {
|
||||||
|
const r = applyChange([span("a", 10, 14, AGENT)], { start: 0, end: 0, newLength: 2 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[0, 2], [12, 16]]);
|
||||||
|
expect(authors(r)).toEqual(["human", "agent"]);
|
||||||
|
});
|
||||||
|
it("insertion after a span leaves it untouched", () => {
|
||||||
|
const r = applyChange([span("a", 0, 4, AGENT)], { start: 10, end: 10, newLength: 2 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[0, 4], [10, 12]]);
|
||||||
|
});
|
||||||
|
it("PUC-3: insertion strictly inside a different-author span splits it char-precisely", () => {
|
||||||
|
const r = applyChange([span("a", 0, 10, AGENT)], { start: 4, end: 4, newLength: 3 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[0, 4], [4, 7], [7, 13]]);
|
||||||
|
expect(authors(r)).toEqual(["agent", "human", "agent"]);
|
||||||
|
});
|
||||||
|
it("symmetric: agent insertion inside a human span splits it (INV-7)", () => {
|
||||||
|
const r = applyChange([span("a", 0, 10, HUMAN)], { start: 4, end: 4, newLength: 3 }, AGENT, ctx("t1"));
|
||||||
|
expect(authors(r)).toEqual(["human", "agent", "human"]);
|
||||||
|
expect(r[1].turnId).toBe("t1");
|
||||||
|
});
|
||||||
|
it("insertion inside a SAME-author span coalesces back to one span", () => {
|
||||||
|
const r = applyChange([span("a", 0, 10, HUMAN)], { start: 4, end: 4, newLength: 3 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[0, 13]]);
|
||||||
|
});
|
||||||
|
it("insertion at a span boundary does not split; same author coalesces", () => {
|
||||||
|
const r = applyChange([span("a", 0, 5, HUMAN)], { start: 5, end: 5, newLength: 2 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[0, 7]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyChange — deletions", () => {
|
||||||
|
it("deletion before a span shifts it left", () => {
|
||||||
|
const r = applyChange([span("a", 10, 14, HUMAN)], { start: 0, end: 4, newLength: 0 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[6, 10]]);
|
||||||
|
});
|
||||||
|
it("deletion entirely inside a span shrinks it (no re-authoring, INV-6)", () => {
|
||||||
|
const r = applyChange([span("a", 0, 10, AGENT)], { start: 3, end: 6, newLength: 0 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[0, 7]]);
|
||||||
|
expect(authors(r)).toEqual(["agent"]);
|
||||||
|
});
|
||||||
|
it("deletion covering a whole span removes it", () => {
|
||||||
|
const r = applyChange([span("a", 3, 6, AGENT)], { start: 0, end: 10, newLength: 0 }, HUMAN, ctx());
|
||||||
|
expect(r).toEqual([]);
|
||||||
|
});
|
||||||
|
it("deletion clipping a span head keeps the tail", () => {
|
||||||
|
const r = applyChange([span("a", 4, 10, AGENT)], { start: 0, end: 6, newLength: 0 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[0, 4]]);
|
||||||
|
});
|
||||||
|
it("deletion clipping a span tail keeps the head", () => {
|
||||||
|
const r = applyChange([span("a", 0, 6, AGENT)], { start: 4, end: 10, newLength: 0 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[0, 4]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyChange — replacements", () => {
|
||||||
|
it("replacement inside an agent span: human chars in the middle, agent halves keep ids/createdAt", () => {
|
||||||
|
const r = applyChange([span("a", 0, 12, AGENT)], { start: 4, end: 8, newLength: 2 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[0, 4], [4, 6], [6, 10]]);
|
||||||
|
expect(authors(r)).toEqual(["agent", "human", "agent"]);
|
||||||
|
expect(r[0].id).toBe("a");
|
||||||
|
expect(r[0].createdAt).toBe("2026-06-09T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
it("replacement spanning two spans clips both and inserts the author's span between", () => {
|
||||||
|
const r = applyChange(
|
||||||
|
[span("a", 0, 6, AGENT), span("b", 6, 12, HUMAN)],
|
||||||
|
{ start: 4, end: 8, newLength: 5 }, AGENT, ctx("t2"),
|
||||||
|
);
|
||||||
|
expect(ranges(r)).toEqual([[0, 4], [4, 9], [9, 13]]);
|
||||||
|
expect(authors(r)).toEqual(["agent", "agent", "human"]);
|
||||||
|
expect(r[1].turnId).toBe("t2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-edit sequences", () => {
|
||||||
|
it("type, agent-replace, type inside agent span — ends char-honest", () => {
|
||||||
|
const c = ctx();
|
||||||
|
let s = applyChange([], { start: 0, end: 0, newLength: 20 }, HUMAN, c);
|
||||||
|
s = applyChange(s, { start: 5, end: 10, newLength: 8 }, AGENT, { ...c, turnId: "t1" });
|
||||||
|
s = applyChange(s, { start: 9, end: 9, newLength: 1 }, HUMAN, c);
|
||||||
|
expect(authors(s)).toEqual(["human", "agent", "human", "agent", "human"]);
|
||||||
|
expect(ranges(s)).toEqual([[0, 5], [5, 9], [9, 10], [10, 14], [14, 24]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("coalesce", () => {
|
||||||
|
it("merges adjacent same-author same-turn spans, keeps earliest createdAt", () => {
|
||||||
|
const a = { ...span("a", 0, 3, HUMAN), createdAt: "2026-06-09T00:00:00.000Z" };
|
||||||
|
const b = { ...span("b", 3, 6, HUMAN), createdAt: "2026-06-08T00:00:00.000Z" };
|
||||||
|
const r = coalesce([a, b]);
|
||||||
|
expect(ranges(r)).toEqual([[0, 6]]);
|
||||||
|
expect(r[0].id).toBe("a");
|
||||||
|
expect(r[0].createdAt).toBe("2026-06-08T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
it("does NOT merge different authors, different turnIds, or non-adjacent spans", () => {
|
||||||
|
expect(coalesce([span("a", 0, 3, HUMAN), span("b", 3, 6, AGENT)])).toHaveLength(2);
|
||||||
|
expect(coalesce([span("a", 0, 3, AGENT, "t1"), span("b", 3, 6, AGENT, "t2")])).toHaveLength(2);
|
||||||
|
expect(coalesce([span("a", 0, 3, HUMAN), span("b", 4, 6, HUMAN)])).toHaveLength(2);
|
||||||
|
});
|
||||||
|
it("drops empty spans", () => {
|
||||||
|
expect(coalesce([span("a", 5, 5, HUMAN)])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge probes (post-review)", () => {
|
||||||
|
it("applyChange does not mutate its input array or spans", () => {
|
||||||
|
const input = [span("a", 0, 10, AGENT)];
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(input));
|
||||||
|
applyChange(input, { start: 2, end: 4, newLength: 1 }, HUMAN, ctx());
|
||||||
|
expect(input).toEqual(snapshot);
|
||||||
|
});
|
||||||
|
it("an edit swallowing a whole middle span across three spans removes only it", () => {
|
||||||
|
// Deletion [3,9): a → [0,3); b swallowed; c's remainder [9,12) shifts -6 to
|
||||||
|
// [3,6). The two surviving HUMAN spans are adjacent at 3 with the same
|
||||||
|
// (undefined) turnId, so coalesce merges them into ONE span — only the
|
||||||
|
// agent-authored middle span's characters are gone (INV-6/INV-7).
|
||||||
|
const r = applyChange(
|
||||||
|
[span("a", 0, 4, HUMAN), span("b", 4, 8, AGENT), span("c", 8, 12, HUMAN)],
|
||||||
|
{ start: 3, end: 9, newLength: 0 }, HUMAN, ctx(),
|
||||||
|
);
|
||||||
|
expect(ranges(r)).toEqual([[0, 6]]);
|
||||||
|
expect(authors(r)).toEqual(["human"]);
|
||||||
|
});
|
||||||
|
it("a replacement exactly covering a span replaces it with the editor's span", () => {
|
||||||
|
const r = applyChange([span("a", 4, 8, AGENT)], { start: 4, end: 8, newLength: 4 }, HUMAN, ctx());
|
||||||
|
expect(ranges(r)).toEqual([[4, 8]]);
|
||||||
|
expect(authors(r)).toEqual(["human"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Attribution fixture
|
||||||
|
|
||||||
|
A stable first paragraph that predates tracking.
|
||||||
|
|
||||||
|
The target sentence lives here for seam edits.
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,3 +56,35 @@ describe("serializeArtifact", () => {
|
|||||||
expect(e.proposals).toEqual([]);
|
expect(e.proposals).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("attributions[] (F3 SLICE-1)", () => {
|
||||||
|
it("round-trips an attribution record through serialize → parse", () => {
|
||||||
|
const a = emptyArtifact("docs/x.md");
|
||||||
|
a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: " beta", lineHint: 0 } };
|
||||||
|
a.attributions.push({
|
||||||
|
id: "at_1",
|
||||||
|
anchorId: "a_1",
|
||||||
|
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" } },
|
||||||
|
createdAt: "2026-06-10T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-10T00:00:00.000Z",
|
||||||
|
turnId: "turn-1",
|
||||||
|
});
|
||||||
|
const parsed = JSON.parse(serializeArtifact(a)) as Artifact;
|
||||||
|
expect(parsed.attributions).toEqual(a.attributions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits turnId when undefined and serializes stably", () => {
|
||||||
|
const a = emptyArtifact("docs/x.md");
|
||||||
|
a.attributions.push({
|
||||||
|
id: "at_2",
|
||||||
|
anchorId: "a_2",
|
||||||
|
author: { kind: "human", id: "ben" },
|
||||||
|
createdAt: "2026-06-10T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-10T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
const s1 = serializeArtifact(a);
|
||||||
|
expect(s1.includes("turnId")).toBe(false);
|
||||||
|
const s2 = serializeArtifact(JSON.parse(s1) as Artifact);
|
||||||
|
expect(s2).toBe(s1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { minimizeReplace, PendingEditRegistry } from "../src/pendingEdits";
|
||||||
|
import type { Provenance } from "../src/model";
|
||||||
|
|
||||||
|
const AGENT: Provenance = {
|
||||||
|
kind: "agent", id: "claude",
|
||||||
|
agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("PendingEditRegistry (INV-9)", () => {
|
||||||
|
it("matches and consumes an exact registration", () => {
|
||||||
|
const reg = new PendingEditRegistry();
|
||||||
|
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1" });
|
||||||
|
const hit = reg.match("d.md", { start: 3, end: 7, text: "new" });
|
||||||
|
expect(hit?.turnId).toBe("t1");
|
||||||
|
expect(reg.match("d.md", { start: 3, end: 7, text: "new" })).toBeNull();
|
||||||
|
});
|
||||||
|
it("does not match a different doc, range, or text (human typing stays human)", () => {
|
||||||
|
const reg = new PendingEditRegistry();
|
||||||
|
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT });
|
||||||
|
expect(reg.match("other.md", { start: 3, end: 7, text: "new" })).toBeNull();
|
||||||
|
expect(reg.match("d.md", { start: 3, end: 8, text: "new" })).toBeNull();
|
||||||
|
expect(reg.match("d.md", { start: 3, end: 7, text: "neww" })).toBeNull();
|
||||||
|
});
|
||||||
|
it("unregister removes a failed application", () => {
|
||||||
|
const reg = new PendingEditRegistry();
|
||||||
|
const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT };
|
||||||
|
reg.register(p);
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,4 +54,56 @@ describe("CoauthorStore", () => {
|
|||||||
expect(second).toBe(first);
|
expect(second).toBe(first);
|
||||||
expect(first).toBe(serializeArtifact(a));
|
expect(first).toBe(serializeArtifact(a));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("update(): two writers merge sections instead of clobbering (F3 §6.2)", () => {
|
||||||
|
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.anchors["a_at"] = { fingerprint: { text: "x", before: "", after: "", lineHint: 0 } };
|
||||||
|
a.attributions.push({
|
||||||
|
id: "at1", anchorId: "a_at", author: { kind: "human", id: "ben" },
|
||||||
|
createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const loaded = store.load("d.md")!;
|
||||||
|
expect(loaded.threads).toHaveLength(1);
|
||||||
|
expect(loaded.attributions).toHaveLength(1);
|
||||||
|
expect(Object.keys(loaded.anchors).sort()).toEqual(["a_at", "a_t"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update() prunes anchors no longer referenced by threads or attributions", () => {
|
||||||
|
const store = new CoauthorStore(root);
|
||||||
|
store.update("d.md", (a) => {
|
||||||
|
a.anchors["a_old"] = { fingerprint: { text: "o", before: "", after: "", lineHint: 0 } };
|
||||||
|
a.attributions.push({
|
||||||
|
id: "at1", anchorId: "a_old", author: { kind: "human", id: "ben" },
|
||||||
|
createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
store.update("d.md", (a) => {
|
||||||
|
a.attributions = [];
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user