F4: propose/accept diff flow — pending proposals, ✓/✗ review, seam-applied acceptance (Feature #12) #13
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -146,6 +146,26 @@ export class AttributionController implements vscode.Disposable {
|
||||
return;
|
||||
}
|
||||
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
|
||||
// (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)) {
|
||||
@@ -154,17 +174,12 @@ export class AttributionController implements vscode.Disposable {
|
||||
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, {
|
||||
s.spans = applyChange(s.spans, edit, this.currentAuthor(), {
|
||||
newId: () => newId("at"),
|
||||
now: () => new Date().toISOString(),
|
||||
turnId: hit?.turnId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (s.spans.length > 0) s.hadAttributions = true;
|
||||
this.render(e.document);
|
||||
}
|
||||
|
||||
+62
-11
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
expect(hit?.turnId).toBe("t1");
|
||||
expect(reg.match("d.md", { start: 3, end: 7, text: "new" })).toBeNull();
|
||||
reg.register({
|
||||
docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1",
|
||||
full: { start: 3, end: 7, newLength: 3 },
|
||||
});
|
||||
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();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user