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.
|
"Cowriting (Cline SDK)" output channel.
|
||||||
|
|
||||||
Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
|
Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
|
||||||
human/Claude attribution (Feature #6).
|
human/Claude attribution (Feature #6), F4 propose/accept diff flow
|
||||||
|
(Feature #12).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -67,13 +68,36 @@ Attribution" output channel) rather than silently moved or discarded.
|
|||||||
- **`Cowriting: Ask Claude to Edit Selection`** — select text → enter an
|
- **`Cowriting: Ask Claude to Edit Selection`** — select text → enter an
|
||||||
instruction → a live `@cline/sdk` turn runs on the built-in `claude-code`
|
instruction → a live `@cline/sdk` turn runs on the built-in `claude-code`
|
||||||
provider (rides your local Claude Code Pro/Max login; the extension stores no
|
provider (rides your local Claude Code Pro/Max login; the extension stores no
|
||||||
credentials). The replacement lands in the buffer as a Claude-attributed span.
|
credentials). As of F4 the turn ends in a **proposal** (see below); accepted
|
||||||
|
text lands as a Claude-attributed span.
|
||||||
- **`Cowriting: Toggle Attribution`** — show/hide attribution decorations.
|
- **`Cowriting: Toggle Attribution`** — show/hide attribution decorations.
|
||||||
- `cowriting.applyAgentEdit` _(palette-hidden)_ — the single machine-edit
|
- `cowriting.applyAgentEdit` _(palette-hidden)_ — the single machine-edit
|
||||||
ingress seam. Tests drive this directly so CI requires no LLM.
|
ingress seam. Tests drive this directly so CI requires no LLM.
|
||||||
|
|
||||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-attribution.md`.
|
Design: `vscode-cowriting-plugin-content/specs/coauthoring-attribution.md`.
|
||||||
|
|
||||||
|
## F4 — Propose/accept diff flow (Feature #12)
|
||||||
|
|
||||||
|
Claude's edits arrive as **pending proposals** — propose-by-default, the
|
||||||
|
document never changes without your say-so. A proposal renders two ways at
|
||||||
|
once: an **amber tint** on the target range, and a "Claude proposes" comment
|
||||||
|
thread showing a fenced `diff` of current → proposed text with two actions:
|
||||||
|
|
||||||
|
- **✓ Accept Proposal** — applies the replacement through the `applyAgentEdit`
|
||||||
|
seam, so it lands Claude-attributed (F3) — and the proposal disappears.
|
||||||
|
- **✗ Reject Proposal** — the document is untouched; the proposal disappears.
|
||||||
|
|
||||||
|
Pending proposals persist git-natively in the same sidecar (`proposals[]`,
|
||||||
|
sharing the F2/F3 `anchors` fingerprints), survive reload, and re-anchor as
|
||||||
|
surrounding text changes. If the **target text itself** changes, the proposal
|
||||||
|
goes **stale** (status-bar count, accept disabled — never applied by guess);
|
||||||
|
undo the change and it becomes decidable again. `cowriting.proposeAgentEdit`
|
||||||
|
_(palette-hidden)_ is the programmatic propose ingress E2E drives — no LLM in
|
||||||
|
CI.
|
||||||
|
|
||||||
|
Design: `vscode-cowriting-plugin-content/specs/coauthoring-propose-accept.md`.
|
||||||
|
Live smoke: [`docs/MANUAL-SMOKE-F4.md`](docs/MANUAL-SMOKE-F4.md).
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
- `npm run watch` — rebuild on change.
|
- `npm run watch` — rebuild on change.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# F3 manual smoke — live `claude-code` turn (spec §6.8)
|
# F3 manual smoke — live `claude-code` turn (spec §6.8)
|
||||||
|
|
||||||
|
> **As of F4 (#12)** the edit-selection turn ends in a **proposal** — the
|
||||||
|
> direct-apply behavior in §2 step 4 now happens on **✓ Accept Proposal**. The
|
||||||
|
> full propose→accept smoke is [`MANUAL-SMOKE-F4.md`](./MANUAL-SMOKE-F4.md);
|
||||||
|
> §1's scripted smoke is unchanged (it drives the SDK turn only, no editor).
|
||||||
|
|
||||||
The live turn is deliberately NOT in CI (unit + host E2E drive the seam). It
|
The live turn is deliberately NOT in CI (unit + host E2E drive the seam). It
|
||||||
gets this documented smoke, run once per machine that has Claude Code
|
gets this documented smoke, run once per machine that has Claude Code
|
||||||
installed and signed in (Pro/Max). The extension itself holds no credentials
|
installed and signed in (Pro/Max). The extension itself holds no credentials
|
||||||
@@ -37,9 +42,9 @@ the model id, a non-empty `sessionId`, and exits 0.
|
|||||||
with a clear error (`runEditTurn` throws on any non-`completed` run
|
with a clear error (`runEditTurn` throws on any non-`completed` run
|
||||||
status); in-editor, the command shows an error notification and NO edit is
|
status); in-editor, the command shows an error notification and NO edit is
|
||||||
applied.
|
applied.
|
||||||
- Buffer edited mid-turn: start an edit-selection turn, type elsewhere in the
|
- Buffer edited mid-turn: superseded by F4 — the turn ends in a proposal, so a
|
||||||
document before it completes → warning notification "document changed", no
|
mid-turn edit can't race an application; the proposal simply renders where
|
||||||
partial application (stale `expectedVersion`, spec §6.9).
|
its target re-resolves (or stale if the target itself changed — F4 INV-11).
|
||||||
|
|
||||||
## Smoke log
|
## Smoke log
|
||||||
|
|
||||||
|
|||||||
@@ -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.reopenThread", "title": "Reopen Thread", "category": "Cowriting" },
|
||||||
{ "command": "cowriting.toggleAttribution", "title": "Toggle Attribution", "category": "Cowriting" },
|
{ "command": "cowriting.toggleAttribution", "title": "Toggle Attribution", "category": "Cowriting" },
|
||||||
{ "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" },
|
{ "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" },
|
||||||
{ "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", "category": "Cowriting" }
|
{ "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", "category": "Cowriting" },
|
||||||
|
{ "command": "cowriting.acceptProposal", "title": "✓ Accept Proposal", "category": "Cowriting" },
|
||||||
|
{ "command": "cowriting.rejectProposal", "title": "✗ Reject Proposal", "category": "Cowriting" },
|
||||||
|
{ "command": "cowriting.proposeAgentEdit", "title": "Propose Agent Edit (internal seam)", "category": "Cowriting" }
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
"commandPalette": [
|
"commandPalette": [
|
||||||
{ "command": "cowriting.applyAgentEdit", "when": "false" }
|
{ "command": "cowriting.applyAgentEdit", "when": "false" },
|
||||||
|
{ "command": "cowriting.proposeAgentEdit", "when": "false" },
|
||||||
|
{ "command": "cowriting.acceptProposal", "when": "false" },
|
||||||
|
{ "command": "cowriting.rejectProposal", "when": "false" }
|
||||||
],
|
],
|
||||||
"editor/context": [
|
"editor/context": [
|
||||||
{
|
{
|
||||||
@@ -61,6 +67,16 @@
|
|||||||
"command": "cowriting.reopenThread",
|
"command": "cowriting.reopenThread",
|
||||||
"group": "inline",
|
"group": "inline",
|
||||||
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
|
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.acceptProposal",
|
||||||
|
"group": "inline@1",
|
||||||
|
"when": "commentController == cowriting.proposals && commentThread =~ /^pending$/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.rejectProposal",
|
||||||
|
"group": "inline@2",
|
||||||
|
"when": "commentController == cowriting.proposals"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ open in your dev window (#8).
|
|||||||
Play here:
|
Play here:
|
||||||
|
|
||||||
1. Type a sentence below — it renders with the human left-border as you type.
|
1. Type a sentence below — it renders with the human left-border as you type.
|
||||||
2. Highlight it → right-click → **Ask Claude to Edit Selection** — the
|
2. Highlight it → right-click → **Ask Claude to Edit Selection** — Claude
|
||||||
replacement lands Claude-tinted.
|
**proposes** a diff (amber tint + a "Claude proposes" thread); click
|
||||||
|
**✓ Accept Proposal** and it lands Claude-tinted, or **✗ Reject Proposal**
|
||||||
|
to discard.
|
||||||
3. Edit inside the tinted span — it splits character-precisely.
|
3. Edit inside the tinted span — it splits character-precisely.
|
||||||
4. Save, then peek at `sandbox/.threads/playground.md.json` — the git-native
|
4. Save, then peek at `sandbox/.threads/playground.md.json` — the git-native
|
||||||
attribution record. Reopen the file: spans re-resolve.
|
attribution record. Reopen the file: spans re-resolve.
|
||||||
|
|||||||
@@ -146,24 +146,39 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const s = this.state(docPath);
|
const s = this.state(docPath);
|
||||||
// Sort descending by offset so earlier changes don't invalidate later offsets
|
// One applyEdit = one change event, but the host may deliver a seam edit
|
||||||
// (VS Code's order is undocumented; defensive sort is the safe guarantee).
|
// as SEVERAL minimal hunks (word-level diffing). Match the EVENT's net
|
||||||
for (const change of [...e.contentChanges].sort((a, b) => b.rangeOffset - a.rangeOffset)) {
|
// effect against the registry; on a hit the agent owns its FULL intended
|
||||||
const edit = {
|
// replacement (INV-9) — apply it as ONE algebra edit, not per hunk.
|
||||||
start: change.rangeOffset,
|
const hit = this.pending.matchEvent(
|
||||||
end: change.rangeOffset + change.rangeLength,
|
docPath,
|
||||||
newLength: change.text.length,
|
e.contentChanges.map((c) => ({
|
||||||
};
|
start: c.rangeOffset,
|
||||||
const hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text });
|
end: c.rangeOffset + c.rangeLength,
|
||||||
const author = hit ? hit.provenance : this.currentAuthor();
|
newLength: c.text.length,
|
||||||
// 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).
|
if (hit) {
|
||||||
s.spans = applyChange(s.spans, hit?.full ?? edit, author, {
|
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"),
|
newId: () => newId("at"),
|
||||||
now: () => new Date().toISOString(),
|
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;
|
if (s.spans.length > 0) s.hadAttributions = true;
|
||||||
this.render(e.document);
|
this.render(e.document);
|
||||||
|
|||||||
+62
-11
@@ -3,12 +3,15 @@ import { fetchSdkSummary } from "./cline";
|
|||||||
import { CoauthorStore } from "./store";
|
import { CoauthorStore } from "./store";
|
||||||
import { ThreadController } from "./threadController";
|
import { ThreadController } from "./threadController";
|
||||||
import { AttributionController } from "./attributionController";
|
import { AttributionController } from "./attributionController";
|
||||||
|
import { ProposalController } from "./proposalController";
|
||||||
|
import { buildFingerprint } from "./anchorer";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
|
|
||||||
export interface CowritingApi {
|
export interface CowritingApi {
|
||||||
threadController: ThreadController;
|
threadController: ThreadController;
|
||||||
attributionController: AttributionController;
|
attributionController: AttributionController;
|
||||||
|
proposalController: ProposalController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||||
@@ -55,6 +58,9 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
"cowriting.editSelection",
|
"cowriting.editSelection",
|
||||||
"cowriting.toggleAttribution",
|
"cowriting.toggleAttribution",
|
||||||
"cowriting.applyAgentEdit",
|
"cowriting.applyAgentEdit",
|
||||||
|
"cowriting.acceptProposal",
|
||||||
|
"cowriting.rejectProposal",
|
||||||
|
"cowriting.proposeAgentEdit",
|
||||||
]) {
|
]) {
|
||||||
context.subscriptions.push(vscode.commands.registerCommand(command, stub));
|
context.subscriptions.push(vscode.commands.registerCommand(command, stub));
|
||||||
}
|
}
|
||||||
@@ -68,6 +74,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
const attributionController = new AttributionController(store, root);
|
const attributionController = new AttributionController(store, root);
|
||||||
context.subscriptions.push(attributionController);
|
context.subscriptions.push(attributionController);
|
||||||
|
|
||||||
|
// --- F4: propose/accept (Feature #12) ---
|
||||||
|
const proposalController = new ProposalController(store, attributionController, root);
|
||||||
|
context.subscriptions.push(proposalController);
|
||||||
|
|
||||||
// One SHARED sidecar watcher for both controllers; self-writes are
|
// One SHARED sidecar watcher for both controllers; self-writes are
|
||||||
// suppressed centrally in the store (the sidecar is co-owned).
|
// suppressed centrally in the store (the sidecar is co-owned).
|
||||||
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
|
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
|
||||||
@@ -75,6 +85,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
if (store.consumeSelfWrite(uri.fsPath)) return;
|
if (store.consumeSelfWrite(uri.fsPath)) return;
|
||||||
threadController.handleExternalSidecarChange(uri);
|
threadController.handleExternalSidecarChange(uri);
|
||||||
attributionController.handleExternalSidecarChange(uri);
|
attributionController.handleExternalSidecarChange(uri);
|
||||||
|
proposalController.handleExternalSidecarChange(uri);
|
||||||
};
|
};
|
||||||
watcher.onDidChange(onSidecar);
|
watcher.onDidChange(onSidecar);
|
||||||
watcher.onDidCreate(onSidecar);
|
watcher.onDidCreate(onSidecar);
|
||||||
@@ -102,8 +113,34 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// F3 SLICE-5: the live turn (PUC-2) — selection + instruction → one
|
// The propose ingress as a command (spec §6.4): records a pending proposal,
|
||||||
// claude-code SDK turn (liveTurn.ts, INV-8) → the applyAgentEdit seam (INV-9).
|
// NEVER touches the document (INV-10) — for the host E2E harness.
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand(
|
||||||
|
"cowriting.proposeAgentEdit",
|
||||||
|
(args: {
|
||||||
|
uri: string; start: number; end: number; newText: string;
|
||||||
|
model?: string; sessionId?: string; turnId?: string; instruction?: string;
|
||||||
|
}) => {
|
||||||
|
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri);
|
||||||
|
if (!doc) return Promise.resolve(undefined);
|
||||||
|
const fp = buildFingerprint(doc.getText(), { start: args.start, end: args.end });
|
||||||
|
const provenance = {
|
||||||
|
kind: "agent" as const,
|
||||||
|
id: "claude",
|
||||||
|
agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" },
|
||||||
|
};
|
||||||
|
return proposalController.propose(doc, fp, args.newText, provenance, {
|
||||||
|
turnId: args.turnId,
|
||||||
|
instruction: args.instruction,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// F3 SLICE-5 → F4 SLICE-4: the live turn — selection + instruction → one
|
||||||
|
// claude-code SDK turn (liveTurn.ts, INV-8) → a PENDING PROPOSAL (F4,
|
||||||
|
// INV-10); the applyAgentEdit seam (INV-9) now fires only on accept.
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.commands.registerCommand("cowriting.editSelection", async () => {
|
vscode.commands.registerCommand("cowriting.editSelection", async () => {
|
||||||
const editor = vscode.window.activeTextEditor;
|
const editor = vscode.window.activeTextEditor;
|
||||||
@@ -127,8 +164,13 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
}
|
}
|
||||||
const document = editor.document;
|
const document = editor.document;
|
||||||
const selection = editor.selection;
|
const selection = editor.selection;
|
||||||
const expectedVersion = document.version;
|
|
||||||
const selectedText = document.getText(selection);
|
const selectedText = document.getText(selection);
|
||||||
|
// Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits
|
||||||
|
// can't skew it — the proposal renders wherever the target re-resolves.
|
||||||
|
const fp = buildFingerprint(document.getText(), {
|
||||||
|
start: document.offsetAt(selection.start),
|
||||||
|
end: document.offsetAt(selection.end),
|
||||||
|
});
|
||||||
const turnId = `turn-${Date.now().toString(36)}`;
|
const turnId = `turn-${Date.now().toString(36)}`;
|
||||||
try {
|
try {
|
||||||
await vscode.window.withProgress(
|
await vscode.window.withProgress(
|
||||||
@@ -138,24 +180,32 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
const turn = await runEditTurn(instruction, selectedText);
|
const turn = await runEditTurn(instruction, selectedText);
|
||||||
if (turn.replacement === "") {
|
if (turn.replacement === "") {
|
||||||
void vscode.window.showWarningMessage(
|
void vscode.window.showWarningMessage(
|
||||||
"Cowriting: Claude returned an empty replacement — nothing was applied.",
|
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await attributionController.applyAgentEdit(
|
if (turn.replacement === selectedText) {
|
||||||
|
void vscode.window.showInformationMessage(
|
||||||
|
"Cowriting: Claude proposed no change to the selection.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// F4 (INV-10): the turn ends in a PROPOSAL, not a buffer mutation.
|
||||||
|
// The seam now fires only on accept (ProposalController, INV-9).
|
||||||
|
const id = await proposalController.propose(
|
||||||
document,
|
document,
|
||||||
new vscode.Range(selection.start, selection.end),
|
fp,
|
||||||
turn.replacement,
|
turn.replacement,
|
||||||
{
|
{
|
||||||
kind: "agent",
|
kind: "agent",
|
||||||
id: "claude",
|
id: "claude",
|
||||||
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
|
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
|
||||||
},
|
},
|
||||||
{ expectedVersion, turnId },
|
{ turnId, instruction },
|
||||||
);
|
);
|
||||||
if (!ok) {
|
if (id) {
|
||||||
void vscode.window.showWarningMessage(
|
void vscode.window.showInformationMessage(
|
||||||
"Cowriting: the edit could not be applied (the document changed during the turn, or the editor rejected the edit) — nothing was changed.",
|
"Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -172,12 +222,13 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
|
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
|
||||||
threadController.renderAll(doc);
|
threadController.renderAll(doc);
|
||||||
attributionController.loadAll(doc);
|
attributionController.loadAll(doc);
|
||||||
|
proposalController.renderAll(doc);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
||||||
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
|
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
|
||||||
|
|
||||||
return { threadController, attributionController };
|
return { threadController, attributionController, proposalController };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate(): void {
|
export function deactivate(): void {
|
||||||
|
|||||||
+27
-3
@@ -61,6 +61,22 @@ export interface AttributionRecord {
|
|||||||
turnId?: string;
|
turnId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** F4 (spec §6.3, INV-13): a PENDING machine edit — state, not history. */
|
||||||
|
export interface Proposal {
|
||||||
|
id: string;
|
||||||
|
/** shared anchors map; fingerprint.text IS the exact target text (INV-11). */
|
||||||
|
anchorId: string;
|
||||||
|
/** the full proposed text for the anchored range. */
|
||||||
|
replacement: string;
|
||||||
|
author: Provenance;
|
||||||
|
/** ISO-8601. */
|
||||||
|
createdAt: string;
|
||||||
|
/** groups N proposals born of one live turn. */
|
||||||
|
turnId?: string;
|
||||||
|
/** what the human asked for (review context). */
|
||||||
|
instruction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Artifact {
|
export interface Artifact {
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
/** repo-relative path; the sidecar key. */
|
/** repo-relative path; the sidecar key. */
|
||||||
@@ -70,8 +86,8 @@ export interface Artifact {
|
|||||||
threads: Thread[];
|
threads: Thread[];
|
||||||
/** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */
|
/** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */
|
||||||
attributions: AttributionRecord[];
|
attributions: AttributionRecord[];
|
||||||
/** F4 extension point — reuses anchors[] + provenance; not in F2. */
|
/** F4 (spec §6.3, INV-13): pending proposals on shared anchors[] + Provenance. */
|
||||||
proposals: unknown[];
|
proposals: Proposal[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyArtifact(docPath: string): Artifact {
|
export function emptyArtifact(docPath: string): Artifact {
|
||||||
@@ -139,7 +155,15 @@ export function serializeArtifact(a: Artifact): string {
|
|||||||
updatedAt: at.updatedAt,
|
updatedAt: at.updatedAt,
|
||||||
...(at.turnId !== undefined ? { turnId: at.turnId } : {}),
|
...(at.turnId !== undefined ? { turnId: at.turnId } : {}),
|
||||||
})),
|
})),
|
||||||
proposals: a.proposals,
|
proposals: a.proposals.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
anchorId: p.anchorId,
|
||||||
|
replacement: p.replacement,
|
||||||
|
author: serializeProvenance(p.author),
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
|
||||||
|
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
return JSON.stringify(canonical, null, 2) + "\n";
|
return JSON.stringify(canonical, null, 2) + "\n";
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-10
@@ -67,17 +67,28 @@ export class PendingEditRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find-and-consume the registration exactly matching a change event
|
* Find-and-consume the registration matching a change EVENT's net effect.
|
||||||
* (same doc, same replaced range, same inserted text). Null → human edit.
|
* The host may deliver one applied WorkspaceEdit as SEVERAL minimal hunks
|
||||||
|
* (word-level diffing — observed on the F4 accept path), so per-hunk
|
||||||
|
* equality misses real seam edits and they fall through as "human typing".
|
||||||
|
* A pending edit matches an event when EVERY hunk lies inside its full
|
||||||
|
* pre-edit range and the event's net length delta equals the edit's
|
||||||
|
* (one applyEdit = one change event, so a seam event is never mixed with
|
||||||
|
* human hunks). Null → human edit.
|
||||||
*/
|
*/
|
||||||
match(docPath: string, change: { start: number; end: number; text: string }): PendingEdit | null {
|
matchEvent(
|
||||||
const i = this.pending.findIndex(
|
docPath: string,
|
||||||
(p) =>
|
changes: ReadonlyArray<{ start: number; end: number; newLength: number }>,
|
||||||
p.docPath === docPath &&
|
): PendingEdit | null {
|
||||||
p.start === change.start &&
|
if (changes.length === 0) return null;
|
||||||
p.end === change.end &&
|
const i = this.pending.findIndex((p) => {
|
||||||
p.newText === change.text,
|
if (p.docPath !== docPath) return false;
|
||||||
);
|
const full = p.full ?? { start: p.start, end: p.end, newLength: p.newText.length };
|
||||||
|
const delta = full.newLength - (full.end - full.start);
|
||||||
|
const eventDelta = changes.reduce((d, c) => d + (c.newLength - (c.end - c.start)), 0);
|
||||||
|
if (delta !== eventDelta) return false;
|
||||||
|
return changes.every((c) => c.start >= full.start && c.end <= full.end);
|
||||||
|
});
|
||||||
if (i === -1) return null;
|
if (i === -1) return null;
|
||||||
const [hit] = this.pending.splice(i, 1);
|
const [hit] = this.pending.splice(i, 1);
|
||||||
return hit;
|
return hit;
|
||||||
|
|||||||
@@ -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
|
* Read-modify-write for sidecar co-ownership (spec F3 §6.2): each controller
|
||||||
* mutates only its own section against a FRESH load, so ThreadController and
|
* mutates only its own section against a FRESH load, so ThreadController and
|
||||||
* AttributionController never clobber each other. After the mutation, anchors
|
* AttributionController never clobber each other. After the mutation, anchors
|
||||||
* referenced by neither threads nor attributions are pruned (proposals are
|
* referenced by no thread, attribution, OR proposal are pruned (threads,
|
||||||
* still empty in F3 — revisit in F4). `mutate` MUST be synchronous: the
|
* attributions, and proposals all keep their anchors — F4). `mutate` MUST be synchronous: the
|
||||||
* read-modify-write (and the self-write mark) completes within this call.
|
* read-modify-write (and the self-write mark) completes within this call.
|
||||||
*/
|
*/
|
||||||
update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
|
update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
|
||||||
@@ -71,6 +71,7 @@ export class CoauthorStore {
|
|||||||
const referenced = new Set<string>([
|
const referenced = new Set<string>([
|
||||||
...artifact.threads.map((t) => t.anchorId),
|
...artifact.threads.map((t) => t.anchorId),
|
||||||
...artifact.attributions.map((a) => a.anchorId),
|
...artifact.attributions.map((a) => a.anchorId),
|
||||||
|
...artifact.proposals.map((p) => p.anchorId),
|
||||||
]);
|
]);
|
||||||
for (const id of Object.keys(artifact.anchors)) {
|
for (const id of Object.keys(artifact.anchors)) {
|
||||||
if (!referenced.has(id)) delete artifact.anchors[id];
|
if (!referenced.has(id)) delete artifact.anchors[id];
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Proposal fixture
|
||||||
|
|
||||||
|
A stable opening paragraph.
|
||||||
|
|
||||||
|
The propose target sentence lives here.
|
||||||
|
|
||||||
|
A second target for coexistence checks.
|
||||||
|
|
||||||
|
A stable closing paragraph.
|
||||||
@@ -25,6 +25,9 @@ suite("no-workspace activation (#8)", () => {
|
|||||||
"cowriting.editSelection",
|
"cowriting.editSelection",
|
||||||
"cowriting.toggleAttribution",
|
"cowriting.toggleAttribution",
|
||||||
"cowriting.applyAgentEdit",
|
"cowriting.applyAgentEdit",
|
||||||
|
"cowriting.acceptProposal",
|
||||||
|
"cowriting.rejectProposal",
|
||||||
|
"cowriting.proposeAgentEdit",
|
||||||
]) {
|
]) {
|
||||||
assert.ok(all.includes(command), `${command} is registered`);
|
assert.ok(all.includes(command), `${command} is registered`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -8,26 +8,53 @@ const AGENT: Provenance = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("PendingEditRegistry (INV-9)", () => {
|
describe("PendingEditRegistry (INV-9)", () => {
|
||||||
it("matches and consumes an exact registration", () => {
|
it("matches and consumes a single-hunk event equal to the registration", () => {
|
||||||
const reg = new PendingEditRegistry();
|
const reg = new PendingEditRegistry();
|
||||||
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1" });
|
reg.register({
|
||||||
const hit = reg.match("d.md", { start: 3, end: 7, text: "new" });
|
docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1",
|
||||||
|
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(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();
|
const reg = new PendingEditRegistry();
|
||||||
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT });
|
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT });
|
||||||
expect(reg.match("other.md", { start: 3, end: 7, text: "new" })).toBeNull();
|
expect(reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }])).not.toBeNull();
|
||||||
expect(reg.match("d.md", { start: 3, end: 8, text: "new" })).toBeNull();
|
|
||||||
expect(reg.match("d.md", { start: 3, end: 7, text: "neww" })).toBeNull();
|
|
||||||
});
|
});
|
||||||
it("unregister removes a failed application", () => {
|
it("unregister removes a failed application", () => {
|
||||||
const reg = new PendingEditRegistry();
|
const reg = new PendingEditRegistry();
|
||||||
const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT };
|
const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT };
|
||||||
reg.register(p);
|
reg.register(p);
|
||||||
reg.unregister(p);
|
reg.unregister(p);
|
||||||
expect(reg.match("d.md", { start: 0, end: 0, text: "x" })).toBeNull();
|
expect(reg.matchEvent("d.md", [{ start: 0, end: 0, newLength: 1 }])).toBeNull();
|
||||||
});
|
});
|
||||||
it("unregister reports whether the registration was still pending", () => {
|
it("unregister reports whether the registration was still pending", () => {
|
||||||
const reg = new PendingEditRegistry();
|
const reg = new PendingEditRegistry();
|
||||||
@@ -35,7 +62,7 @@ describe("PendingEditRegistry (INV-9)", () => {
|
|||||||
reg.register(p);
|
reg.register(p);
|
||||||
expect(reg.unregister(p)).toBe(true);
|
expect(reg.unregister(p)).toBe(true);
|
||||||
reg.register(p);
|
reg.register(p);
|
||||||
reg.match("d.md", { start: 0, end: 0, text: "x" });
|
reg.matchEvent("d.md", [{ start: 0, end: 0, newLength: 1 }]);
|
||||||
expect(reg.unregister(p)).toBe(false);
|
expect(reg.unregister(p)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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