F4: propose/accept diff flow — pending proposals, ✓/✗ review, seam-applied acceptance (Feature #12) #13

Merged
benstull merged 9 commits from feat/f4-propose-accept into main 2026-06-11 05:16:31 +00:00
18 changed files with 2092 additions and 60 deletions
+26 -2
View File
@@ -12,7 +12,8 @@ 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 Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
human/Claude attribution (Feature #6). human/Claude attribution (Feature #6), F4 propose/accept diff flow
(Feature #12).
## Architecture ## Architecture
@@ -67,13 +68,36 @@ Attribution" output channel) rather than silently moved or discarded.
- **`Cowriting: Ask Claude to Edit Selection`** — select text → enter an - **`Cowriting: Ask Claude to Edit Selection`** — select text → enter an
instruction → a live `@cline/sdk` turn runs on the built-in `claude-code` 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 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. credentials). As of F4 the turn ends in a **proposal** (see below); accepted
text lands as a Claude-attributed span.
- **`Cowriting: Toggle Attribution`** — show/hide attribution decorations. - **`Cowriting: Toggle Attribution`** — show/hide attribution decorations.
- `cowriting.applyAgentEdit` _(palette-hidden)_ — the single machine-edit - `cowriting.applyAgentEdit` _(palette-hidden)_ — the single machine-edit
ingress seam. Tests drive this directly so CI requires no LLM. ingress seam. Tests drive this directly so CI requires no LLM.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-attribution.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-attribution.md`.
## F4 — Propose/accept diff flow (Feature #12)
Claude's edits arrive as **pending proposals** — propose-by-default, the
document never changes without your say-so. A proposal renders two ways at
once: an **amber tint** on the target range, and a "Claude proposes" comment
thread showing a fenced `diff` of current → proposed text with two actions:
- **✓ Accept Proposal** — applies the replacement through the `applyAgentEdit`
seam, so it lands Claude-attributed (F3) — and the proposal disappears.
- **✗ Reject Proposal** — the document is untouched; the proposal disappears.
Pending proposals persist git-natively in the same sidecar (`proposals[]`,
sharing the F2/F3 `anchors` fingerprints), survive reload, and re-anchor as
surrounding text changes. If the **target text itself** changes, the proposal
goes **stale** (status-bar count, accept disabled — never applied by guess);
undo the change and it becomes decidable again. `cowriting.proposeAgentEdit`
_(palette-hidden)_ is the programmatic propose ingress E2E drives — no LLM in
CI.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-propose-accept.md`.
Live smoke: [`docs/MANUAL-SMOKE-F4.md`](docs/MANUAL-SMOKE-F4.md).
## Develop ## Develop
- `npm run watch` — rebuild on change. - `npm run watch` — rebuild on change.
+8 -3
View File
@@ -1,5 +1,10 @@
# F3 manual smoke — live `claude-code` turn (spec §6.8) # F3 manual smoke — live `claude-code` turn (spec §6.8)
> **As of F4 (#12)** the edit-selection turn ends in a **proposal** — the
> direct-apply behavior in §2 step 4 now happens on **✓ Accept Proposal**. The
> full propose→accept smoke is [`MANUAL-SMOKE-F4.md`](./MANUAL-SMOKE-F4.md);
> §1's scripted smoke is unchanged (it drives the SDK turn only, no editor).
The live turn is deliberately NOT in CI (unit + host E2E drive the seam). It 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 gets this documented smoke, run once per machine that has Claude Code
installed and signed in (Pro/Max). The extension itself holds no credentials installed and signed in (Pro/Max). The extension itself holds no credentials
@@ -37,9 +42,9 @@ the model id, a non-empty `sessionId`, and exits 0.
with a clear error (`runEditTurn` throws on any non-`completed` run with a clear error (`runEditTurn` throws on any non-`completed` run
status); in-editor, the command shows an error notification and NO edit is status); in-editor, the command shows an error notification and NO edit is
applied. applied.
- Buffer edited mid-turn: start an edit-selection turn, type elsewhere in the - Buffer edited mid-turn: superseded by F4 — the turn ends in a proposal, so a
document before it completes → warning notification "document changed", no mid-turn edit can't race an application; the proposal simply renders where
partial application (stale `expectedVersion`, spec §6.9). its target re-resolves (or stale if the target itself changed — F4 INV-11).
## Smoke log ## Smoke log
+20
View File
@@ -0,0 +1,20 @@
# Manual smoke — F4 propose/accept (live turn)
Pre-req: Claude Code installed + signed in (the `claude-code` provider rides
that login — the extension holds no credentials, INV-8). Run on a real machine,
not CI.
1. `npm run build`, then F5 (the EDH opens the committed `sandbox/` playground).
2. Open `playground.md`, select a sentence → right-click → **Ask Claude to Edit
Selection** → give an instruction.
3. ✅ A **proposal** appears: the selection gets an amber tint and a "Claude
proposes" comment thread shows a `diff` of current → proposed. **The document
text is unchanged** (INV-10).
4. Click **✓ Accept Proposal** in the thread title. ✅ The replacement lands and
renders Claude-tinted (the F3 attribution substrate); the proposal disappears.
5. Repeat with another selection and click **✗ Reject Proposal**. ✅ The document
is untouched; the proposal disappears.
6. Propose again, then save and reload the window. ✅ The pending proposal is
restored at its re-resolved range.
7. Failure path: edit the proposed-on text, then try Accept. ✅ Refused with a
warning ("target text changed"); undo your edit → Accept works.
File diff suppressed because it is too large Load Diff
+18 -2
View File
@@ -30,11 +30,17 @@
{ "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.toggleAttribution", "title": "Toggle Attribution", "category": "Cowriting" },
{ "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" }, { "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" },
{ "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", "category": "Cowriting" } { "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", "category": "Cowriting" },
{ "command": "cowriting.acceptProposal", "title": "✓ Accept Proposal", "category": "Cowriting" },
{ "command": "cowriting.rejectProposal", "title": "✗ Reject Proposal", "category": "Cowriting" },
{ "command": "cowriting.proposeAgentEdit", "title": "Propose Agent Edit (internal seam)", "category": "Cowriting" }
], ],
"menus": { "menus": {
"commandPalette": [ "commandPalette": [
{ "command": "cowriting.applyAgentEdit", "when": "false" } { "command": "cowriting.applyAgentEdit", "when": "false" },
{ "command": "cowriting.proposeAgentEdit", "when": "false" },
{ "command": "cowriting.acceptProposal", "when": "false" },
{ "command": "cowriting.rejectProposal", "when": "false" }
], ],
"editor/context": [ "editor/context": [
{ {
@@ -61,6 +67,16 @@
"command": "cowriting.reopenThread", "command": "cowriting.reopenThread",
"group": "inline", "group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" "when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
},
{
"command": "cowriting.acceptProposal",
"group": "inline@1",
"when": "commentController == cowriting.proposals && commentThread =~ /^pending$/"
},
{
"command": "cowriting.rejectProposal",
"group": "inline@2",
"when": "commentController == cowriting.proposals"
} }
] ]
} }
+4 -2
View File
@@ -8,8 +8,10 @@ open in your dev window (#8).
Play here: Play here:
1. Type a sentence below — it renders with the human left-border as you type. 1. Type a sentence below — it renders with the human left-border as you type.
2. Highlight it → right-click → **Ask Claude to Edit Selection**the 2. Highlight it → right-click → **Ask Claude to Edit Selection**Claude
replacement lands Claude-tinted. **proposes** a diff (amber tint + a "Claude proposes" thread); click
**✓ Accept Proposal** and it lands Claude-tinted, or **✗ Reject Proposal**
to discard.
3. Edit inside the tinted span — it splits character-precisely. 3. Edit inside the tinted span — it splits character-precisely.
4. Save, then peek at `sandbox/.threads/playground.md.json` — the git-native 4. Save, then peek at `sandbox/.threads/playground.md.json` — the git-native
attribution record. Reopen the file: spans re-resolve. attribution record. Reopen the file: spans re-resolve.
+22 -7
View File
@@ -146,6 +146,26 @@ export class AttributionController implements vscode.Disposable {
return; return;
} }
const s = this.state(docPath); const s = this.state(docPath);
// One applyEdit = one change event, but the host may deliver a seam edit
// as SEVERAL minimal hunks (word-level diffing). Match the EVENT's net
// effect against the registry; on a hit the agent owns its FULL intended
// replacement (INV-9) — apply it as ONE algebra edit, not per hunk.
const hit = this.pending.matchEvent(
docPath,
e.contentChanges.map((c) => ({
start: c.rangeOffset,
end: c.rangeOffset + c.rangeLength,
newLength: c.text.length,
})),
);
if (hit) {
const full = hit.full ?? { start: hit.start, end: hit.end, newLength: hit.newText.length };
s.spans = applyChange(s.spans, full, hit.provenance, {
newId: () => newId("at"),
now: () => new Date().toISOString(),
turnId: hit.turnId,
});
} else {
// Sort descending by offset so earlier changes don't invalidate later offsets // Sort descending by offset so earlier changes don't invalidate later offsets
// (VS Code's order is undocumented; defensive sort is the safe guarantee). // (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)) { for (const change of [...e.contentChanges].sort((a, b) => b.rangeOffset - a.rangeOffset)) {
@@ -154,17 +174,12 @@ export class AttributionController implements vscode.Disposable {
end: change.rangeOffset + change.rangeLength, end: change.rangeOffset + change.rangeLength,
newLength: change.text.length, newLength: change.text.length,
}; };
const hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text }); s.spans = applyChange(s.spans, edit, this.currentAuthor(), {
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"), newId: () => newId("at"),
now: () => new Date().toISOString(), now: () => new Date().toISOString(),
turnId: hit?.turnId,
}); });
} }
}
if (s.spans.length > 0) s.hadAttributions = true; if (s.spans.length > 0) s.hadAttributions = true;
this.render(e.document); this.render(e.document);
} }
+62 -11
View File
@@ -3,12 +3,15 @@ import { fetchSdkSummary } from "./cline";
import { CoauthorStore } from "./store"; import { CoauthorStore } from "./store";
import { ThreadController } from "./threadController"; import { ThreadController } from "./threadController";
import { AttributionController } from "./attributionController"; import { AttributionController } from "./attributionController";
import { ProposalController } from "./proposalController";
import { buildFingerprint } from "./anchorer";
const CHANNEL_NAME = "Cowriting (Cline SDK)"; const CHANNEL_NAME = "Cowriting (Cline SDK)";
export interface CowritingApi { export interface CowritingApi {
threadController: ThreadController; threadController: ThreadController;
attributionController: AttributionController; attributionController: AttributionController;
proposalController: ProposalController;
} }
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -55,6 +58,9 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
"cowriting.editSelection", "cowriting.editSelection",
"cowriting.toggleAttribution", "cowriting.toggleAttribution",
"cowriting.applyAgentEdit", "cowriting.applyAgentEdit",
"cowriting.acceptProposal",
"cowriting.rejectProposal",
"cowriting.proposeAgentEdit",
]) { ]) {
context.subscriptions.push(vscode.commands.registerCommand(command, stub)); context.subscriptions.push(vscode.commands.registerCommand(command, stub));
} }
@@ -68,6 +74,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const attributionController = new AttributionController(store, root); const attributionController = new AttributionController(store, root);
context.subscriptions.push(attributionController); context.subscriptions.push(attributionController);
// --- F4: propose/accept (Feature #12) ---
const proposalController = new ProposalController(store, attributionController, root);
context.subscriptions.push(proposalController);
// One SHARED sidecar watcher for both controllers; self-writes are // One SHARED sidecar watcher for both controllers; self-writes are
// suppressed centrally in the store (the sidecar is co-owned). // suppressed centrally in the store (the sidecar is co-owned).
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
@@ -75,6 +85,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
if (store.consumeSelfWrite(uri.fsPath)) return; if (store.consumeSelfWrite(uri.fsPath)) return;
threadController.handleExternalSidecarChange(uri); threadController.handleExternalSidecarChange(uri);
attributionController.handleExternalSidecarChange(uri); attributionController.handleExternalSidecarChange(uri);
proposalController.handleExternalSidecarChange(uri);
}; };
watcher.onDidChange(onSidecar); watcher.onDidChange(onSidecar);
watcher.onDidCreate(onSidecar); watcher.onDidCreate(onSidecar);
@@ -102,8 +113,34 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
), ),
); );
// F3 SLICE-5: the live turn (PUC-2) — selection + instruction → one // The propose ingress as a command (spec §6.4): records a pending proposal,
// claude-code SDK turn (liveTurn.ts, INV-8) → the applyAgentEdit seam (INV-9). // NEVER touches the document (INV-10) — for the host E2E harness.
context.subscriptions.push(
vscode.commands.registerCommand(
"cowriting.proposeAgentEdit",
(args: {
uri: string; start: number; end: number; newText: string;
model?: string; sessionId?: string; turnId?: string; instruction?: string;
}) => {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri);
if (!doc) return Promise.resolve(undefined);
const fp = buildFingerprint(doc.getText(), { start: args.start, end: args.end });
const provenance = {
kind: "agent" as const,
id: "claude",
agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" },
};
return proposalController.propose(doc, fp, args.newText, provenance, {
turnId: args.turnId,
instruction: args.instruction,
});
},
),
);
// F3 SLICE-5 → F4 SLICE-4: the live turn — selection + instruction → one
// claude-code SDK turn (liveTurn.ts, INV-8) → a PENDING PROPOSAL (F4,
// INV-10); the applyAgentEdit seam (INV-9) now fires only on accept.
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("cowriting.editSelection", async () => { vscode.commands.registerCommand("cowriting.editSelection", async () => {
const editor = vscode.window.activeTextEditor; const editor = vscode.window.activeTextEditor;
@@ -127,8 +164,13 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
} }
const document = editor.document; const document = editor.document;
const selection = editor.selection; const selection = editor.selection;
const expectedVersion = document.version;
const selectedText = document.getText(selection); const selectedText = document.getText(selection);
// Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits
// can't skew it — the proposal renders wherever the target re-resolves.
const fp = buildFingerprint(document.getText(), {
start: document.offsetAt(selection.start),
end: document.offsetAt(selection.end),
});
const turnId = `turn-${Date.now().toString(36)}`; const turnId = `turn-${Date.now().toString(36)}`;
try { try {
await vscode.window.withProgress( await vscode.window.withProgress(
@@ -138,24 +180,32 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const turn = await runEditTurn(instruction, selectedText); const turn = await runEditTurn(instruction, selectedText);
if (turn.replacement === "") { if (turn.replacement === "") {
void vscode.window.showWarningMessage( void vscode.window.showWarningMessage(
"Cowriting: Claude returned an empty replacement — nothing was applied.", "Cowriting: Claude returned an empty replacement — nothing was proposed.",
); );
return; return;
} }
const ok = await attributionController.applyAgentEdit( if (turn.replacement === selectedText) {
void vscode.window.showInformationMessage(
"Cowriting: Claude proposed no change to the selection.",
);
return;
}
// F4 (INV-10): the turn ends in a PROPOSAL, not a buffer mutation.
// The seam now fires only on accept (ProposalController, INV-9).
const id = await proposalController.propose(
document, document,
new vscode.Range(selection.start, selection.end), fp,
turn.replacement, turn.replacement,
{ {
kind: "agent", kind: "agent",
id: "claude", id: "claude",
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId }, agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
}, },
{ expectedVersion, turnId }, { turnId, instruction },
); );
if (!ok) { if (id) {
void vscode.window.showWarningMessage( void vscode.window.showInformationMessage(
"Cowriting: the edit could not be applied (the document changed during the turn, or the editor rejected the edit) — nothing was changed.", "Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).",
); );
} }
}, },
@@ -172,12 +222,13 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) { if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
threadController.renderAll(doc); threadController.renderAll(doc);
attributionController.loadAll(doc); attributionController.loadAll(doc);
proposalController.renderAll(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, attributionController }; return { threadController, attributionController, proposalController };
} }
export function deactivate(): void { export function deactivate(): void {
+27 -3
View File
@@ -61,6 +61,22 @@ export interface AttributionRecord {
turnId?: string; turnId?: string;
} }
/** F4 (spec §6.3, INV-13): a PENDING machine edit — state, not history. */
export interface Proposal {
id: string;
/** shared anchors map; fingerprint.text IS the exact target text (INV-11). */
anchorId: string;
/** the full proposed text for the anchored range. */
replacement: string;
author: Provenance;
/** ISO-8601. */
createdAt: string;
/** groups N proposals born of one live turn. */
turnId?: string;
/** what the human asked for (review context). */
instruction?: string;
}
export interface Artifact { export interface Artifact {
schemaVersion: number; schemaVersion: number;
/** repo-relative path; the sidecar key. */ /** repo-relative path; the sidecar key. */
@@ -70,8 +86,8 @@ export interface Artifact {
threads: Thread[]; threads: Thread[];
/** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */ /** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */
attributions: AttributionRecord[]; attributions: AttributionRecord[];
/** F4 extension point — reuses anchors[] + provenance; not in F2. */ /** F4 (spec §6.3, INV-13): pending proposals on shared anchors[] + Provenance. */
proposals: unknown[]; proposals: Proposal[];
} }
export function emptyArtifact(docPath: string): Artifact { export function emptyArtifact(docPath: string): Artifact {
@@ -139,7 +155,15 @@ export function serializeArtifact(a: Artifact): string {
updatedAt: at.updatedAt, updatedAt: at.updatedAt,
...(at.turnId !== undefined ? { turnId: at.turnId } : {}), ...(at.turnId !== undefined ? { turnId: at.turnId } : {}),
})), })),
proposals: a.proposals, proposals: a.proposals.map((p) => ({
id: p.id,
anchorId: p.anchorId,
replacement: p.replacement,
author: serializeProvenance(p.author),
createdAt: p.createdAt,
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
})),
}; };
return JSON.stringify(canonical, null, 2) + "\n"; return JSON.stringify(canonical, null, 2) + "\n";
} }
+21 -10
View File
@@ -67,17 +67,28 @@ export class PendingEditRegistry {
} }
/** /**
* Find-and-consume the registration exactly matching a change event * Find-and-consume the registration matching a change EVENT's net effect.
* (same doc, same replaced range, same inserted text). Null → human edit. * The host may deliver one applied WorkspaceEdit as SEVERAL minimal hunks
* (word-level diffing — observed on the F4 accept path), so per-hunk
* equality misses real seam edits and they fall through as "human typing".
* A pending edit matches an event when EVERY hunk lies inside its full
* pre-edit range and the event's net length delta equals the edit's
* (one applyEdit = one change event, so a seam event is never mixed with
* human hunks). Null → human edit.
*/ */
match(docPath: string, change: { start: number; end: number; text: string }): PendingEdit | null { matchEvent(
const i = this.pending.findIndex( docPath: string,
(p) => changes: ReadonlyArray<{ start: number; end: number; newLength: number }>,
p.docPath === docPath && ): PendingEdit | null {
p.start === change.start && if (changes.length === 0) return null;
p.end === change.end && const i = this.pending.findIndex((p) => {
p.newText === change.text, if (p.docPath !== docPath) return false;
); const full = p.full ?? { start: p.start, end: p.end, newLength: p.newText.length };
const delta = full.newLength - (full.end - full.start);
const eventDelta = changes.reduce((d, c) => d + (c.newLength - (c.end - c.start)), 0);
if (delta !== eventDelta) return false;
return changes.every((c) => c.start >= full.start && c.end <= full.end);
});
if (i === -1) return null; if (i === -1) return null;
const [hit] = this.pending.splice(i, 1); const [hit] = this.pending.splice(i, 1);
return hit; return hit;
+332
View File
@@ -0,0 +1,332 @@
/**
* ProposalController — the editor-facing layer for F4 (spec
* coauthoring-propose-accept §6.2). Owns the proposal lifecycle: the propose
* ingress (INV-10: NEVER mutates the document), persistence at propose time,
* resolve-or-flag on load/external change (INV-11 — a proposal's anchor is
* immutable for its life: no save-time re-fingerprint, unlike threads),
* rendering (second Comments controller + amber pending-range decoration),
* and the human-only accept/reject gestures (INV-12). Accept drives the seam
* (AttributionController.applyAgentEdit, INV-9) so accepted text lands
* Claude-attributed with zero new attribution code.
*/
import * as vscode from "vscode";
import { CoauthorStore } from "./store";
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
import { resolve, shift, type OffsetRange } from "./anchorer";
import { addProposal, proposalBody, removeProposal } from "./proposalModel";
import type { AttributionController } from "./attributionController";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedProposal {
id: string;
/** true → resolves exactly, decidable; false → stale/orphaned (INV-11). */
pending: boolean;
turnId?: string;
range: { start: number; end: number };
}
interface DocState {
docPath: string;
uri: vscode.Uri;
artifact: Artifact;
vsThreads: Map<string, vscode.CommentThread>;
/** proposal id -> live offset range (within-session optimization, INV-3). */
live: Map<string, OffsetRange>;
/** proposal ids whose anchor did not resolve at last render (stale/orphaned). */
unresolved: Set<string>;
}
const PENDING_DECO: vscode.DecorationRenderOptions = {
backgroundColor: "rgba(245, 158, 11, 0.18)",
overviewRulerColor: "rgba(245, 158, 11, 0.8)",
overviewRulerLane: vscode.OverviewRulerLane.Right,
};
export class ProposalController implements vscode.Disposable {
private readonly controller: vscode.CommentController;
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocState>(); // keyed by docPath
private readonly pendingType = vscode.window.createTextEditorDecorationType(PENDING_DECO);
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89);
constructor(
private readonly store: CoauthorStore,
private readonly attribution: AttributionController,
private readonly rootDir: string,
) {
// No commentingRangeProvider: humans never open proposal threads by hand —
// proposals are born of machine turns only (INV-12 keeps decisions human).
this.controller = vscode.comments.createCommentController("cowriting.proposals", "Claude Proposals");
this.disposables.push(this.controller, this.pendingType, this.statusItem);
this.disposables.push(
vscode.commands.registerCommand("cowriting.acceptProposal", (t: vscode.CommentThread) => this.acceptThread(t)),
vscode.commands.registerCommand("cowriting.rejectProposal", (t: vscode.CommentThread) => this.rejectThread(t)),
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
);
}
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 ensureState(document: vscode.TextDocument): DocState {
const docPath = this.docPathOf(document.uri);
let state = this.docs.get(docPath);
if (!state) {
state = {
docPath,
uri: document.uri,
artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
vsThreads: new Map(),
live: new Map(),
unresolved: new Set(),
};
this.docs.set(docPath, state);
}
return state;
}
// ---- PUC-1: the propose ingress (INV-10) -----------------------------------------
/**
* Record + render a pending proposal. The fingerprint is built by the CALLER
* at gesture time (editSelection captures it BEFORE the turn, so mid-turn
* edits can't skew the anchor). Never touches the document.
*/
async propose(
document: vscode.TextDocument,
fp: Fingerprint,
replacement: string,
author: Provenance,
opts?: { turnId?: string; instruction?: string },
): Promise<string | undefined> {
if (!this.isTracked(document)) return undefined;
const docPath = this.docPathOf(document.uri);
let proposalId: string | undefined;
this.store.update(docPath, (a) => {
proposalId = addProposal(a, fp, replacement, author, opts).proposalId;
});
this.renderAll(document);
return proposalId;
}
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
/** Accept by proposal id (test-facing twin of the thread-menu gesture). */
async acceptById(docPath: string, proposalId: string): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
return hit ? this.accept(hit.state, hit.proposal) : false;
}
/** Reject by proposal id (test-facing twin of the thread-menu gesture). */
rejectById(docPath: string, proposalId: string): boolean {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
this.reject(hit.state, hit.proposal);
return true;
}
private async acceptThread(vsThread: vscode.CommentThread): Promise<void> {
const hit = this.byThread(vsThread);
if (hit) await this.accept(hit.state, hit.proposal);
}
private rejectThread(vsThread: vscode.CommentThread): void {
const hit = this.byThread(vsThread);
if (hit) this.reject(hit.state, hit.proposal);
}
private async accept(state: DocState, proposal: Proposal): Promise<boolean> {
const document = this.openDoc(state);
if (!document) return false;
// INV-11: the fingerprint-guard — exact re-resolve at decision time.
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
if (resolved === "orphaned") {
void vscode.window.showWarningMessage(
"Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).",
);
this.renderAll(document);
return false;
}
const range = new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end));
// No awaits between resolve and the seam call: document.version is current.
const ok = await this.attribution.applyAgentEdit(document, range, proposal.replacement, proposal.author, {
expectedVersion: document.version,
turnId: proposal.turnId,
});
if (!ok) {
void vscode.window.showWarningMessage(
"Cowriting: the editor rejected the accept — the proposal is still pending.",
);
return false;
}
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
this.renderAll(document);
return true;
}
private reject(state: DocState, proposal: Proposal): void {
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
const document = this.openDoc(state);
if (document) this.renderAll(document);
}
// ---- PUC-4: load / external change / resolve-or-flag --------------------------------
/** Load + (re)render every pending proposal at its resolved anchor (or flagged). */
renderAll(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return;
const docPath = this.docPathOf(document.uri);
const state = this.ensureState(document);
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
for (const vsThread of state.vsThreads.values()) vsThread.dispose();
state.vsThreads.clear();
state.live.clear();
state.unresolved.clear();
const text = document.getText();
for (const proposal of state.artifact.proposals) {
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(text, fp) : "orphaned";
if (resolved === "orphaned") {
const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0;
const off = document.offsetAt(new vscode.Position(line, 0));
this.renderProposal(document, state, proposal, { start: off, end: off }, false);
} else {
this.renderProposal(document, state, proposal, resolved, true);
}
}
this.renderDecorations(document, state);
this.renderStatus(state);
}
/** Shared-watcher entry point (extension.ts): a sidecar changed externally. */
handleExternalSidecarChange(uri: vscode.Uri): void {
for (const state of this.docs.values()) {
if (this.store.sidecarPath(state.docPath) === uri.fsPath) {
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
if (doc) this.renderAll(doc);
}
}
}
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
const state = this.docs.get(this.docPathOf(e.document.uri));
if (!state || state.live.size === 0) return;
for (const change of e.contentChanges) {
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
for (const [id, range] of state.live) {
const next = shift(range, edit);
state.live.set(id, next);
const vsThread = state.vsThreads.get(id);
if (vsThread && !state.unresolved.has(id)) {
vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end));
}
}
}
// Live shift keeps the UI following; staleness is judged at decision time
// (accept re-resolves, INV-11) and at the next renderAll.
this.renderDecorations(e.document, state);
}
// ---- rendering -----------------------------------------------------------------------
private renderProposal(
document: vscode.TextDocument,
state: DocState,
proposal: Proposal,
offsets: OffsetRange,
pending: boolean,
): void {
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end));
const vsThread = this.controller.createCommentThread(document.uri, range, [
{
body: new vscode.MarkdownString(proposalBody(fp?.text ?? "", proposal)),
mode: vscode.CommentMode.Preview,
author: { name: proposal.author.id },
},
]);
vsThread.label = pending
? "Pending proposal"
: "⚠ Stale proposal (target text changed or missing) — accept disabled";
vsThread.contextValue = pending ? "pending" : "unresolved";
vsThread.collapsibleState = pending
? vscode.CommentThreadCollapsibleState.Expanded
: vscode.CommentThreadCollapsibleState.Collapsed;
state.vsThreads.set(proposal.id, vsThread);
state.live.set(proposal.id, offsets);
if (!pending) state.unresolved.add(proposal.id);
}
private renderDecorations(document: vscode.TextDocument, state: DocState): void {
const ranges: vscode.Range[] = [];
for (const [id, off] of state.live) {
if (state.unresolved.has(id)) continue;
ranges.push(new vscode.Range(document.positionAt(off.start), document.positionAt(off.end)));
}
for (const editor of vscode.window.visibleTextEditors) {
if (editor.document === document) editor.setDecorations(this.pendingType, ranges);
}
}
private renderStatus(state: DocState): void {
const n = state.unresolved.size;
if (n === 0) {
this.statusItem.hide();
return;
}
this.statusItem.text = `$(warning) ${n} stale proposal${n === 1 ? "" : "s"}`;
this.statusItem.tooltip =
"Cowriting: proposals whose target text changed or is missing — undo to restore, or reject";
this.statusItem.show();
}
// ---- lookups ----------------------------------------------------------------------------
private openDoc(state: DocState): vscode.TextDocument | undefined {
return vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
}
private byThread(vsThread: vscode.CommentThread): { state: DocState; proposal: Proposal } | undefined {
for (const state of this.docs.values()) {
for (const [id, t] of state.vsThreads) {
if (t === vsThread) {
const proposal = state.artifact.proposals.find((p) => p.id === id);
if (proposal) return { state, proposal };
}
}
}
return undefined;
}
private byId(docPath: string, proposalId: string): { state: DocState; proposal: Proposal } | undefined {
const state = this.docs.get(docPath);
const proposal = state?.artifact.proposals.find((p) => p.id === proposalId);
return state && proposal ? { state, proposal } : undefined;
}
// ---- test-facing surface ------------------------------------------------------------------
getRendered(docPath: string): RenderedProposal[] {
const state = this.docs.get(docPath);
if (!state) return [];
const out: RenderedProposal[] = [];
for (const [id] of state.vsThreads) {
const p = state.artifact.proposals.find((x) => x.id === id)!;
const off = state.live.get(id)!;
out.push({
id,
pending: !state.unresolved.has(id),
turnId: p.turnId,
range: { start: off.start, end: off.end },
});
}
return out;
}
getStaleCount(docPath: string): number {
return this.docs.get(docPath)?.unresolved.size ?? 0;
}
dispose(): void {
for (const d of this.disposables) d.dispose();
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* proposalModel — pure helpers over the F4 `proposals[]` section (spec §6.3,
* INV-13: pending-only). Mirrors threadModel.ts: vscode-free mutations against
* an Artifact, used inside CoauthorStore.update() by ProposalController.
* The diff body is PRESENTATION ONLY — the truth is fingerprint.text →
* replacement (INV-11); a whole-range -/+ rendering, not an LCS diff (§6.7).
*/
import { newId, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
export function addProposal(
artifact: Artifact,
fp: Fingerprint,
replacement: string,
author: Provenance,
opts?: { turnId?: string; instruction?: string },
): { proposalId: string; anchorId: string } {
const anchorId = newId("a");
const proposalId = newId("pr");
artifact.anchors[anchorId] = { fingerprint: fp };
artifact.proposals.push({
id: proposalId,
anchorId,
replacement,
author,
createdAt: new Date().toISOString(),
...(opts?.turnId !== undefined ? { turnId: opts.turnId } : {}),
...(opts?.instruction !== undefined ? { instruction: opts.instruction } : {}),
});
return { proposalId, anchorId };
}
/** Remove a pending proposal (accept and reject both end here — INV-13). */
export function removeProposal(artifact: Artifact, proposalId: string): boolean {
const before = artifact.proposals.length;
artifact.proposals = artifact.proposals.filter((p) => p.id !== proposalId);
return artifact.proposals.length < before;
}
/** Markdown comment body: instruction header + fenced whole-range diff. */
export function proposalBody(targetText: string, p: Proposal): string {
const header = p.instruction ? `**Claude proposes** — _${p.instruction}_` : "**Claude proposes**";
const diffLines = [
...targetText.split("\n").map((l) => `- ${l}`),
...p.replacement.split("\n").map((l) => `+ ${l}`),
].join("\n");
return `${header}\n\n\`\`\`diff\n${diffLines}\n\`\`\`\n\n✓ Accept applies this replacement · ✗ Reject discards it`;
}
+3 -2
View File
@@ -61,8 +61,8 @@ export class CoauthorStore {
* Read-modify-write for sidecar co-ownership (spec F3 §6.2): each controller * 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 * mutates only its own section against a FRESH load, so ThreadController and
* AttributionController never clobber each other. After the mutation, anchors * AttributionController never clobber each other. After the mutation, anchors
* referenced by neither threads nor attributions are pruned (proposals are * referenced by no thread, attribution, OR proposal are pruned (threads,
* still empty in F3 — revisit in F4). `mutate` MUST be synchronous: the * attributions, and proposals all keep their anchors — F4). `mutate` MUST be synchronous: the
* read-modify-write (and the self-write mark) completes within this call. * read-modify-write (and the self-write mark) completes within this call.
*/ */
update(docPath: string, mutate: (artifact: Artifact) => void): Artifact { update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
@@ -71,6 +71,7 @@ export class CoauthorStore {
const referenced = new Set<string>([ const referenced = new Set<string>([
...artifact.threads.map((t) => t.anchorId), ...artifact.threads.map((t) => t.anchorId),
...artifact.attributions.map((a) => a.anchorId), ...artifact.attributions.map((a) => a.anchorId),
...artifact.proposals.map((p) => p.anchorId),
]); ]);
for (const id of Object.keys(artifact.anchors)) { for (const id of Object.keys(artifact.anchors)) {
if (!referenced.has(id)) delete artifact.anchors[id]; if (!referenced.has(id)) delete artifact.anchors[id];
@@ -0,0 +1,9 @@
# Proposal fixture
A stable opening paragraph.
The propose target sentence lives here.
A second target for coexistence checks.
A stable closing paragraph.
@@ -25,6 +25,9 @@ suite("no-workspace activation (#8)", () => {
"cowriting.editSelection", "cowriting.editSelection",
"cowriting.toggleAttribution", "cowriting.toggleAttribution",
"cowriting.applyAgentEdit", "cowriting.applyAgentEdit",
"cowriting.acceptProposal",
"cowriting.rejectProposal",
"cowriting.proposeAgentEdit",
]) { ]) {
assert.ok(all.includes(command), `${command} is registered`); assert.ok(all.includes(command), `${command} is registered`);
} }
+163
View File
@@ -0,0 +1,163 @@
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/proposal.md";
function sidecarPath(): string {
return path.join(WS, ".threads", "docs", "proposal.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?.proposalController, "extension exports proposalController");
return api;
}
async function proposeViaCommand(
doc: vscode.TextDocument,
target: string,
newText: string,
turnId: string,
): Promise<string> {
const start = doc.getText().indexOf(target);
assert.ok(start >= 0, `fixture contains "${target}"`);
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + target.length,
newText,
model: "sonnet",
sessionId: "e2e-prop",
turnId,
instruction: "e2e instruction",
});
assert.ok(id, "propose returns the proposal id");
return id!;
}
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 (F2/F3 pattern): later tests consume earlier state.
// This suite OWNS docs/proposal.md; threads owns docs/sample.md, attribution
// owns docs/attrib.md — fixtures stay disjoint.
suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
const TARGET = "The propose target sentence lives here.";
const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence.";
test("propose records + renders a pending proposal and does NOT touch the document (INV-10)", async () => {
const doc = await openDoc();
const api = await getApi();
const before = doc.getText();
await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1");
await settle();
assert.strictEqual(doc.getText(), before, "document text unchanged (INV-10)");
const rendered = api.proposalController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1);
assert.strictEqual(rendered[0].pending, true);
assert.strictEqual(rendered[0].turnId, "turn-p1");
const art = readSidecar();
assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time");
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET);
});
test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => {
const doc = await openDoc();
const api = await getApi();
const id = api.proposalController.getRendered(DOC_REL)[0].id;
const ok = await api.proposalController.acceptById(DOC_REL, id);
assert.strictEqual(ok, true, "accept applies");
await settle();
assert.ok(doc.getText().includes(REPLACEMENT), "replacement landed");
assert.ok(!doc.getText().includes(TARGET), "target gone");
const spans = api.attributionController.getSpans(DOC_REL);
const agent = spans.find((s) => s.turnId === "turn-p1");
assert.ok(agent, `accepted text is Claude-attributed with the proposal's turnId — got spans: ${JSON.stringify(spans)}`);
assert.strictEqual(agent!.authorKind, "agent");
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0, "proposal gone (INV-13)");
assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar");
});
test("reject leaves the document untouched and removes the proposal (PUC-3)", async () => {
const doc = await openDoc();
const api = await getApi();
const before = doc.getText();
const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2");
await settle();
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
await settle();
assert.strictEqual(doc.getText(), before, "document untouched");
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
assert.strictEqual(readSidecar().proposals.length, 0);
});
test("a pending proposal persists, survives reload, and re-anchors after an external move (PUC-4)", async () => {
let doc = await openDoc();
const api = await getApi();
const anchor = "A stable closing paragraph.";
await proposeViaCommand(doc, anchor, "A PROPOSED closing paragraph.", "turn-p3");
await settle();
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText());
await settle();
api.proposalController.renderAll(doc);
const rendered = api.proposalController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1, "proposal survived reload");
assert.strictEqual(rendered[0].pending, true, "still decidable");
const moved = doc.getText().indexOf(anchor);
assert.strictEqual(rendered[0].range.start, moved, "re-anchored after the move");
});
test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => {
let doc = await openDoc();
const api = await getApi();
const id = api.proposalController.getRendered(DOC_REL)[0].id;
const mangled = doc.getText().replace("A stable closing paragraph.", "A reworded closing paragraph.");
doc = await externalWriteAndReload(vscode.Uri.file(path.join(WS, DOC_REL)), mangled);
await settle();
api.proposalController.renderAll(doc);
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale");
const before = doc.getText();
const ok = await api.proposalController.acceptById(DOC_REL, id);
assert.strictEqual(ok, false, "accept refused (INV-11)");
assert.strictEqual(doc.getText(), before, "document untouched");
assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)");
// discard the husk so later tests see a clean sidecar
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
});
test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => {
const doc = await openDoc();
const api = await getApi();
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4");
const id2 = await proposeViaCommand(doc, "PROPOSED-BY-CLAUDE replacement sentence.", "A REPLACED-AGAIN sentence.", "turn-p5");
await settle();
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2);
// decide the SECOND first, then the first — order independence
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true);
await settle();
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true);
await settle();
assert.ok(doc.getText().includes("An ACCEPTED opening paragraph."));
assert.ok(doc.getText().includes("A REPLACED-AGAIN sentence."));
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
});
});
+38 -11
View File
@@ -8,26 +8,53 @@ const AGENT: Provenance = {
}; };
describe("PendingEditRegistry (INV-9)", () => { describe("PendingEditRegistry (INV-9)", () => {
it("matches and consumes an exact registration", () => { it("matches and consumes a single-hunk event equal to the registration", () => {
const reg = new PendingEditRegistry(); const reg = new PendingEditRegistry();
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1" }); reg.register({
const hit = reg.match("d.md", { start: 3, end: 7, text: "new" }); docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1",
expect(hit?.turnId).toBe("t1"); full: { start: 3, end: 7, newLength: 3 },
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 hit = reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }]);
expect(hit?.turnId).toBe("t1");
expect(reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }])).toBeNull();
});
it("matches a HOST-SPLIT event: several hunks inside the full range with the same net delta", () => {
// The host word-diffs one applied replace into multiple minimal hunks
// (observed on F4 accept) — per-hunk equality misses; net-effect matches.
const reg = new PendingEditRegistry();
reg.register({
docPath: "d.md", start: 10, end: 50, newText: "x".repeat(45), provenance: AGENT, turnId: "t2",
full: { start: 10, end: 50, newLength: 45 },
});
const hit = reg.matchEvent("d.md", [
{ start: 12, end: 20, newLength: 9 },
{ start: 25, end: 30, newLength: 6 },
{ start: 40, end: 48, newLength: 11 },
]);
expect(hit?.turnId).toBe("t2");
});
it("does not match a different doc, hunks outside the full range, or a different delta", () => {
const reg = new PendingEditRegistry();
reg.register({
docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT,
full: { start: 3, end: 7, newLength: 3 },
});
expect(reg.matchEvent("other.md", [{ start: 3, end: 7, newLength: 3 }])).toBeNull();
expect(reg.matchEvent("d.md", [{ start: 3, end: 8, newLength: 4 }])).toBeNull();
expect(reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 4 }])).toBeNull();
expect(reg.matchEvent("d.md", [])).toBeNull();
});
it("falls back to the minimized range when no full extent was registered", () => {
const reg = new PendingEditRegistry(); const reg = new PendingEditRegistry();
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT }); 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.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }])).not.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", () => { it("unregister removes a failed application", () => {
const reg = new PendingEditRegistry(); const reg = new PendingEditRegistry();
const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT }; const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT };
reg.register(p); reg.register(p);
reg.unregister(p); reg.unregister(p);
expect(reg.match("d.md", { start: 0, end: 0, text: "x" })).toBeNull(); expect(reg.matchEvent("d.md", [{ start: 0, end: 0, newLength: 1 }])).toBeNull();
}); });
it("unregister reports whether the registration was still pending", () => { it("unregister reports whether the registration was still pending", () => {
const reg = new PendingEditRegistry(); const reg = new PendingEditRegistry();
@@ -35,7 +62,7 @@ describe("PendingEditRegistry (INV-9)", () => {
reg.register(p); reg.register(p);
expect(reg.unregister(p)).toBe(true); expect(reg.unregister(p)).toBe(true);
reg.register(p); reg.register(p);
reg.match("d.md", { start: 0, end: 0, text: "x" }); reg.matchEvent("d.md", [{ start: 0, end: 0, newLength: 1 }]);
expect(reg.unregister(p)).toBe(false); expect(reg.unregister(p)).toBe(false);
}); });
}); });
+107
View File
@@ -0,0 +1,107 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
emptyArtifact,
serializeArtifact,
type Artifact,
type Proposal,
} from "../src/model";
import { CoauthorStore } from "../src/store";
import { addProposal, proposalBody, removeProposal } from "../src/proposalModel";
const agent = {
kind: "agent" as const,
id: "claude",
agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "s1" },
};
describe("Proposal model (spec §6.3, INV-13)", () => {
it("serializes proposals with stable field order and survives a round-trip", () => {
const a = emptyArtifact("docs/x.md");
a.anchors["a_1"] = {
fingerprint: { text: "old text", before: "", after: "", lineHint: 0 },
};
const p: Proposal = {
id: "pr_1",
anchorId: "a_1",
replacement: "new text",
author: agent,
createdAt: "2026-06-10T00:00:00.000Z",
turnId: "turn-1",
instruction: "tighten",
};
a.proposals.push(p);
const reloaded = JSON.parse(serializeArtifact(a)) as Artifact;
expect(reloaded.proposals).toHaveLength(1);
expect(reloaded.proposals[0]).toEqual(p);
// optional fields are OMITTED (not null) when absent
const bare: Proposal = {
id: "pr_2", anchorId: "a_1", replacement: "r", author: agent,
createdAt: "2026-06-10T00:00:00.000Z",
};
a.proposals.push(bare);
const again = JSON.parse(serializeArtifact(a)) as Artifact;
expect("turnId" in again.proposals[1]).toBe(false);
expect("instruction" in again.proposals[1]).toBe(false);
// serialization is byte-stable (INV-2)
expect(serializeArtifact(again)).toBe(serializeArtifact(a));
});
});
describe("CoauthorStore prune with proposals (spec §6.3)", () => {
it("retains anchors referenced only by proposals; prunes them after removal", () => {
const dir = mkdtempSync(join(tmpdir(), "cowriting-prune-"));
try {
const store = new CoauthorStore(dir);
store.update("docs/x.md", (a) => {
a.anchors["a_p"] = {
fingerprint: { text: "t", before: "", after: "", lineHint: 0 },
};
a.proposals.push({
id: "pr_1", anchorId: "a_p", replacement: "r", author: agent,
createdAt: "2026-06-10T00:00:00.000Z",
});
});
expect(store.load("docs/x.md")!.anchors["a_p"]).toBeDefined();
store.update("docs/x.md", (a) => {
a.proposals = a.proposals.filter((p) => p.id !== "pr_1");
});
expect(store.load("docs/x.md")!.anchors["a_p"]).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("proposalModel helpers (spec §6.4)", () => {
const fp = { text: "old line one\nold line two", before: "", after: "", lineHint: 3 };
it("addProposal creates the anchor + pending record; removeProposal deletes by id", () => {
const a = emptyArtifact("docs/x.md");
const { proposalId, anchorId } = addProposal(a, fp, "new line", agent, {
turnId: "turn-9", instruction: "shorten",
});
expect(a.anchors[anchorId].fingerprint).toEqual(fp);
expect(a.proposals).toHaveLength(1);
expect(a.proposals[0].id).toBe(proposalId);
expect(a.proposals[0].replacement).toBe("new line");
expect(a.proposals[0].turnId).toBe("turn-9");
expect(a.proposals[0].instruction).toBe("shorten");
expect(removeProposal(a, proposalId)).toBe(true);
expect(a.proposals).toHaveLength(0);
expect(removeProposal(a, proposalId)).toBe(false);
});
it("proposalBody renders instruction + a fenced unified diff of old → new", () => {
const a = emptyArtifact("docs/x.md");
addProposal(a, fp, "new only line", agent, { instruction: "merge the lines" });
const body = proposalBody(fp.text, a.proposals[0]);
expect(body).toContain("**Claude proposes** — _merge the lines_");
expect(body).toContain("```diff");
expect(body).toContain("- old line one");
expect(body).toContain("- old line two");
expect(body).toContain("+ new only line");
});
});