Merge pull request 'F4: propose/accept diff flow — pending proposals, ✓/✗ review, seam-applied acceptance (Feature #12)' (#13) from feat/f4-propose-accept into main

This commit was merged in pull request #13.
This commit is contained in:
2026-06-11 05:16:23 +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.
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
@@ -67,13 +68,36 @@ Attribution" output channel) rather than silently moved or discarded.
- **`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.
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.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`.
## 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
- `npm run watch` — rebuild on change.
+8 -3
View File
@@ -1,5 +1,10 @@
# 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
gets this documented smoke, run once per machine that has Claude Code
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
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).
- Buffer edited mid-turn: superseded by F4 — the turn ends in a proposal, so a
mid-turn edit can't race an application; the proposal simply renders where
its target re-resolves (or stale if the target itself changed — F4 INV-11).
## 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.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" }
{ "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": {
"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": [
{
@@ -61,6 +67,16 @@
"command": "cowriting.reopenThread",
"group": "inline",
"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:
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
replacement lands Claude-tinted.
2. Highlight it → right-click → **Ask Claude to Edit Selection**Claude
**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.
4. Save, then peek at `sandbox/.threads/playground.md.json` — the git-native
attribution record. Reopen the file: spans re-resolve.
+30 -15
View File
@@ -146,24 +146,39 @@ export class AttributionController implements vscode.Disposable {
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, {
// 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,
turnId: hit.turnId,
});
} else {
// 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,
};
s.spans = applyChange(s.spans, edit, this.currentAuthor(), {
newId: () => newId("at"),
now: () => new Date().toISOString(),
});
}
}
if (s.spans.length > 0) s.hadAttributions = true;
this.render(e.document);
+62 -11
View File
@@ -3,12 +3,15 @@ import { fetchSdkSummary } from "./cline";
import { CoauthorStore } from "./store";
import { ThreadController } from "./threadController";
import { AttributionController } from "./attributionController";
import { ProposalController } from "./proposalController";
import { buildFingerprint } from "./anchorer";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
export interface CowritingApi {
threadController: ThreadController;
attributionController: AttributionController;
proposalController: ProposalController;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -55,6 +58,9 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
"cowriting.editSelection",
"cowriting.toggleAttribution",
"cowriting.applyAgentEdit",
"cowriting.acceptProposal",
"cowriting.rejectProposal",
"cowriting.proposeAgentEdit",
]) {
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);
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
// suppressed centrally in the store (the sidecar is co-owned).
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;
threadController.handleExternalSidecarChange(uri);
attributionController.handleExternalSidecarChange(uri);
proposalController.handleExternalSidecarChange(uri);
};
watcher.onDidChange(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
// claude-code SDK turn (liveTurn.ts, INV-8) → the applyAgentEdit seam (INV-9).
// The propose ingress as a command (spec §6.4): records a pending proposal,
// 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(
vscode.commands.registerCommand("cowriting.editSelection", async () => {
const editor = vscode.window.activeTextEditor;
@@ -127,8 +164,13 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}
const document = editor.document;
const selection = editor.selection;
const expectedVersion = document.version;
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)}`;
try {
await vscode.window.withProgress(
@@ -138,24 +180,32 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const turn = await runEditTurn(instruction, selectedText);
if (turn.replacement === "") {
void vscode.window.showWarningMessage(
"Cowriting: Claude returned an empty replacement — nothing was applied.",
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
);
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,
new vscode.Range(selection.start, selection.end),
fp,
turn.replacement,
{
kind: "agent",
id: "claude",
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
},
{ expectedVersion, turnId },
{ turnId, instruction },
);
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.",
if (id) {
void vscode.window.showInformationMessage(
"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)) {
threadController.renderAll(doc);
attributionController.loadAll(doc);
proposalController.renderAll(doc);
}
};
vscode.workspace.textDocuments.forEach(renderIfOpen);
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
return { threadController, attributionController };
return { threadController, attributionController, proposalController };
}
export function deactivate(): void {
+27 -3
View File
@@ -61,6 +61,22 @@ export interface AttributionRecord {
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 {
schemaVersion: number;
/** repo-relative path; the sidecar key. */
@@ -70,8 +86,8 @@ export interface Artifact {
threads: Thread[];
/** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */
attributions: AttributionRecord[];
/** F4 extension point — reuses anchors[] + provenance; not in F2. */
proposals: unknown[];
/** F4 (spec §6.3, INV-13): pending proposals on shared anchors[] + Provenance. */
proposals: Proposal[];
}
export function emptyArtifact(docPath: string): Artifact {
@@ -139,7 +155,15 @@ export function serializeArtifact(a: Artifact): string {
updatedAt: at.updatedAt,
...(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";
}
+21 -10
View File
@@ -67,17 +67,28 @@ export class PendingEditRegistry {
}
/**
* Find-and-consume the registration exactly matching a change event
* (same doc, same replaced range, same inserted text). Null → human edit.
* Find-and-consume the registration matching a change EVENT's net effect.
* 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 {
const i = this.pending.findIndex(
(p) =>
p.docPath === docPath &&
p.start === change.start &&
p.end === change.end &&
p.newText === change.text,
);
matchEvent(
docPath: string,
changes: ReadonlyArray<{ start: number; end: number; newLength: number }>,
): PendingEdit | null {
if (changes.length === 0) return null;
const i = this.pending.findIndex((p) => {
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;
const [hit] = this.pending.splice(i, 1);
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
* 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
* referenced by no thread, attribution, OR proposal are pruned (threads,
* 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.
*/
update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
@@ -71,6 +71,7 @@ export class CoauthorStore {
const referenced = new Set<string>([
...artifact.threads.map((t) => t.anchorId),
...artifact.attributions.map((a) => a.anchorId),
...artifact.proposals.map((p) => p.anchorId),
]);
for (const id of Object.keys(artifact.anchors)) {
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.toggleAttribution",
"cowriting.applyAgentEdit",
"cowriting.acceptProposal",
"cowriting.rejectProposal",
"cowriting.proposeAgentEdit",
]) {
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);
});
});
+37 -10
View File
@@ -8,26 +8,53 @@ const AGENT: Provenance = {
};
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();
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" });
reg.register({
docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1",
full: { start: 3, end: 7, newLength: 3 },
});
const hit = reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }]);
expect(hit?.turnId).toBe("t1");
expect(reg.match("d.md", { start: 3, end: 7, text: "new" })).toBeNull();
expect(reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }])).toBeNull();
});
it("does not match a different doc, range, or text (human typing stays human)", () => {
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();
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();
expect(reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }])).not.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();
expect(reg.matchEvent("d.md", [{ start: 0, end: 0, newLength: 1 }])).toBeNull();
});
it("unregister reports whether the registration was still pending", () => {
const reg = new PendingEditRegistry();
@@ -35,7 +62,7 @@ describe("PendingEditRegistry (INV-9)", () => {
reg.register(p);
expect(reg.unregister(p)).toBe(true);
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);
});
});
+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");
});
});