fix: final-review wave — preview host INV-10 gate, thread status+offer contextValue tokens, establish-on-open baseline, polish

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 16:04:40 -07:00
parent 17366a4fb9
commit 0d69a29228
10 changed files with 274 additions and 19 deletions
+9 -5
View File
@@ -185,9 +185,13 @@
"command": "cowriting.rejectProposal", "command": "cowriting.rejectProposal",
"when": "false" "when": "false"
}, },
{
"command": "cowriting.reviewChanges",
"when": "editorLangId == markdown && cowriting.isCoediting"
},
{ {
"command": "cowriting.markReviewed", "command": "cowriting.markReviewed",
"when": "editorLangId == markdown" "when": "editorLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot"
}, },
{ {
"command": "cowriting.edit", "command": "cowriting.edit",
@@ -307,24 +311,24 @@
{ {
"command": "cowriting.makeThreadEdit", "command": "cowriting.makeThreadEdit",
"group": "inline@2", "group": "inline@2",
"when": "commentController == cowriting.threads && commentThread == offer" "when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/"
} }
], ],
"comments/commentThread/title": [ "comments/commentThread/title": [
{ {
"command": "cowriting.makeThreadEdit", "command": "cowriting.makeThreadEdit",
"group": "inline@1", "group": "inline@1",
"when": "commentController == cowriting.threads && commentThread == offer" "when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/"
}, },
{ {
"command": "cowriting.resolveThread", "command": "cowriting.resolveThread",
"group": "inline", "group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^open$/" "when": "commentController == cowriting.threads && commentThread =~ /\\bopen\\b/"
}, },
{ {
"command": "cowriting.reopenThread", "command": "cowriting.reopenThread",
"group": "inline", "group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" "when": "commentController == cowriting.threads && commentThread =~ /\\bresolved\\b/"
} }
] ]
}, },
+8 -1
View File
@@ -88,7 +88,14 @@ export class AttributionController implements vscode.Disposable {
// EditorProposalController (construction order in extension.ts) so their // EditorProposalController (construction order in extension.ts) so their
// renders see fresh spans on the same enter transition. // renders see fresh spans on the same enter transition.
this.registry.onDidChange(({ uri, coediting }) => { this.registry.onDidChange(({ uri, coediting }) => {
if (!coediting) return; if (!coediting) {
// Finding 5 (final whole-branch review): exit hides the status-bar
// item too — otherwise a stale "N orphaned attributions" warning
// for a doc that's no longer even coediting stays pinned in the bar
// until some unrelated render happens to fire for it.
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
return;
}
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) this.loadAll(doc); if (doc) this.loadAll(doc);
}), }),
+19 -3
View File
@@ -402,6 +402,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
// at the call site and avoids the no-op calls for a non-entered doc. // at the call site and avoids the no-op calls for a non-entered doc.
const renderIfOpen = (doc: vscode.TextDocument) => { const renderIfOpen = (doc: vscode.TextDocument) => {
if (isAuthorable(doc.uri.scheme) && coeditingRegistry.isCoediting(doc.uri)) { if (isAuthorable(doc.uri.scheme) && coeditingRegistry.isCoediting(doc.uri)) {
// Finding 3 (final whole-branch review): DiffViewController only
// establishes a baseline on the registry's enter TRANSITION and, at
// startup, for documents already open. A doc that is a PERSISTED
// coediting member but wasn't open yet at either of those moments (e.g.
// lazily restored after a window reload) reaches this handler with no
// baseline/mode — establish it now so QuickDiff/Review-Changes/Mark-
// Reviewed never see a silent empty-baseline gap.
if (diffViewController.modeOf(doc.uri.toString()) === undefined) void diffViewController.establish(doc);
threadController.renderAll(doc); threadController.renderAll(doc);
attributionController.loadAll(doc); attributionController.loadAll(doc);
proposalController.renderAll(doc); proposalController.renderAll(doc);
@@ -412,7 +420,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
// --- Task 7 (D3/D21, PUC-3): annotations in the BUILT-IN Markdown preview --- // --- Task 7 (D3/D21, PUC-3): annotations in the BUILT-IN Markdown preview ---
// `env.currentDocument` (VS Code's markdown-it render env) is an OBSERVED // `env.currentDocument` (VS Code's markdown-it render env) is an OBSERVED
// preview-engine behavior, not a documented contract, so `lastActiveCoedited` // preview-engine behavior, not a documented contract, so `lastCoeditedUri`
// is the fallback that keeps single-doc correctness when it's absent — the // is the fallback that keeps single-doc correctness when it's absent — the
// last editor that WAS coediting when it lost focus (e.g. focus moved to the // last editor that WAS coediting when it lost focus (e.g. focus moved to the
// preview pane itself) stays the annotation target until a different // preview pane itself) stays the annotation target until a different
@@ -433,8 +441,16 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const previewAnnotationHost = { const previewAnnotationHost = {
inputsFor(env: unknown): AnnotationInputs | undefined { inputsFor(env: unknown): AnnotationInputs | undefined {
const envUri = (env as { currentDocument?: { toString(): string } } | undefined)?.currentDocument?.toString(); const envUri = (env as { currentDocument?: { toString(): string } } | undefined)?.currentDocument?.toString();
const key = envUri && coeditingRegistry.list().includes(envUri) ? envUri : lastCoeditedUri; // Finding 1 (final whole-branch review): the `lastCoeditedUri` fallback
if (!key) return undefined; // is for an ABSENT env only (env.currentDocument didn't resolve) — a
// present envUri is authoritative for the doc actually being previewed
// and must never fall back to a different (or since-exited) document's
// inputs. Either way, the winning key must currently be a coediting
// member (INV-10) — an exited doc's stale `lastCoeditedUri` fails this
// check too, closing the "preview stays annotated after stopCoediting"
// gap.
const key = envUri === undefined ? lastCoeditedUri : envUri;
if (!key || !coeditingRegistry.list().includes(key)) return undefined;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (!doc) return undefined; if (!doc) return undefined;
const baseline = diffViewController.getBaseline(key); const baseline = diffViewController.getBaseline(key);
+15 -5
View File
@@ -32,7 +32,15 @@
* `extension.ts`): `env.currentDocument` is an OBSERVED VS Code preview-engine * `extension.ts`): `env.currentDocument` is an OBSERVED VS Code preview-engine
* behavior, not a documented contract, so the host falls back to the last * behavior, not a documented contract, so the host falls back to the last
* actively-coedited document when it is absent (see extension.ts's * actively-coedited document when it is absent (see extension.ts's
* `lastActiveCoedited`). * `lastCoeditedUri`).
*
* Deliberate choice: the mermaid-fence rendering (the `options.highlight`
* override installed below) applies to EVERY markdown preview, coedited or
* not it is not gated on coediting state. Gating it would make a diagram's
* rendering flip (mermaid <-> plain fence) the instant a doc enters or exits
* coediting, which is a worse surprise than always rendering mermaid diagrams
* as diagrams; only the change-tracking OVERLAY on top (`buildMermaidQueue`'s
* diff augmentation) is gated on having a baseline to diff against.
*/ */
import type MarkdownIt from "markdown-it"; import type MarkdownIt from "markdown-it";
import { diffMermaid } from "./mermaidDiff"; import { diffMermaid } from "./mermaidDiff";
@@ -105,13 +113,15 @@ export function annotateSource(src: string, inputs: AnnotationInputs): string {
const appliedRanges: { start: number; end: number }[] = []; const appliedRanges: { start: number; end: number }[] = [];
// (c) pending F4 proposals already sit applied in `src` (F12 optimistic // (c) pending F4 proposals already sit applied in `src` (F12 optimistic
// apply, INV-48) — mark the applied range ins-claude (proposals are agent- // apply, INV-48) — mark the applied range ins-<author> (`p.author` — a
// authored, matching editorProposalController's own convention) and // proposal defaults to "claude" per `ProposalView.author`, but a human can
// resurface the pre-apply original as a deletion at the same point. // also be the proposer, e.g. a human-authored suggestion routed through the
// same F4 pending-proposal path) and resurface the pre-apply original as a
// deletion at the same point.
for (const p of proposals) { for (const p of proposals) {
if (p.anchorStart === null || p.anchorEnd === null) continue; if (p.anchorStart === null || p.anchorEnd === null) continue;
appliedRanges.push({ start: p.anchorStart, end: p.anchorEnd }); appliedRanges.push({ start: p.anchorStart, end: p.anchorEnd });
if (p.anchorEnd > p.anchorStart) insSpans.push({ start: p.anchorStart, end: p.anchorEnd, tag: "claude" }); if (p.anchorEnd > p.anchorStart) insSpans.push({ start: p.anchorStart, end: p.anchorEnd, tag: p.author ?? "claude" });
const original = p.original ?? p.replaced; const original = p.original ?? p.replaced;
if (original) deletions.push({ at: p.anchorStart, text: original }); if (original) deletions.push({ at: p.anchorStart, text: original });
} }
+8 -1
View File
@@ -76,7 +76,14 @@ export class ProposalController implements vscode.Disposable {
// (construction order in extension.ts) so decorateCommitted's spansFor // (construction order in extension.ts) so decorateCommitted's spansFor
// read sees fresh attribution on the same enter transition. // read sees fresh attribution on the same enter transition.
this.registry.onDidChange(({ uri, coediting }) => { this.registry.onDidChange(({ uri, coediting }) => {
if (!coediting) return; if (!coediting) {
// Finding 5 (final whole-branch review): exit hides the status-bar
// item too — otherwise a stale "N stale proposals" warning for a
// doc that's no longer even coediting stays pinned in the bar until
// some unrelated render happens to fire for it.
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
return;
}
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) this.renderAll(doc); if (doc) this.renderAll(doc);
}), }),
+57 -4
View File
@@ -26,6 +26,11 @@ export interface RenderedThread {
status: "open" | "resolved"; status: "open" | "resolved";
orphaned: boolean; orphaned: boolean;
range: { startLine: number; endLine: number }; range: { startLine: number; endLine: number };
/** Finding 2 (final whole-branch review): the raw `contextValue` VS Code's
* `comments/commentThread/*` `when`-clauses key on exposed directly so
* E2E can assert the status token survives an offer transition without a
* DOM/UI query. */
contextValue: string;
} }
/** D10/PUC-8: every human comment on a coedited doc runs this reply turn. */ /** D10/PUC-8: every human comment on a coedited doc runs this reply turn. */
@@ -47,6 +52,14 @@ interface DocState {
live: Map<string, OffsetRange>; live: Map<string, OffsetRange>;
/** thread id -> orphaned flag for the current render. */ /** thread id -> orphaned flag for the current render. */
orphaned: Map<string, boolean>; orphaned: Map<string, boolean>;
/** Finding 2 (final whole-branch review): thread id -> in-flight offer-flow
* token ("offer" once a machine reply lands, "offerdone" once spent),
* tracked SEPARATELY from the open/resolved/orphaned status so setting one
* never clobbers the other both are folded into one space-separated
* `contextValue` string by `applyContextValue` (package.json when-clauses
* match either token with an unanchored regex). Ephemeral: reset on every
* render (a fresh vsThread never carries a stale offer). */
offerToken: Map<string, "offer" | "offerdone">;
} }
export class ThreadController implements vscode.Disposable { export class ThreadController implements vscode.Disposable {
@@ -147,6 +160,7 @@ export class ThreadController implements vscode.Disposable {
vsThreads: new Map(), vsThreads: new Map(),
live: new Map(), live: new Map(),
orphaned: new Map(), orphaned: new Map(),
offerToken: new Map(),
}; };
this.docs.set(docPath, state); this.docs.set(docPath, state);
} }
@@ -234,6 +248,11 @@ export class ThreadController implements vscode.Disposable {
state.vsThreads.set(threadId, vsThread); state.vsThreads.set(threadId, vsThread);
state.live.set(threadId, offsets); state.live.set(threadId, offsets);
state.orphaned.set(threadId, false); state.orphaned.set(threadId, false);
// Finding 2 (final whole-branch review): a thread born via the native
// "+" gesture skipped renderThread entirely, so it never got a status
// contextValue at all — Resolve/Reopen never appeared for it. Stamp one
// now, same as every other thread.
this.applyContextValue(state, threadId, vsThread);
} }
this.persist(state); this.persist(state);
this.refreshComments(vsThread, state, threadId); this.refreshComments(vsThread, state, threadId);
@@ -285,7 +304,11 @@ export class ThreadController implements vscode.Disposable {
}); });
this.persist(state); this.persist(state);
this.refreshComments(vsThread, state, threadId); this.refreshComments(vsThread, state, threadId);
vsThread.contextValue = "offer"; // when-key: commentThread == offer // Finding 2: fold "offer" in alongside the status token — see
// `applyContextValue` — instead of clobbering it (when-key:
// `commentThread =~ /\boffer\b/`).
state.offerToken.set(threadId, "offer");
this.applyContextValue(state, threadId, vsThread);
this.offers.set(vsThread, { ask, threadId }); this.offers.set(vsThread, { ask, threadId });
}, },
); );
@@ -321,7 +344,13 @@ export class ThreadController implements vscode.Disposable {
const doc = await vscode.workspace.openTextDocument(vsThread.uri); const doc = await vscode.workspace.openTextDocument(vsThread.uri);
const target = this.targetOf(doc, vsThread); const target = this.targetOf(doc, vsThread);
const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask); const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask);
vsThread.contextValue = "offer-done"; // Finding 2 (final whole-branch review): fold "offerdone" in alongside the
// status token rather than clobbering it — see `applyContextValue`.
const state = this.stateOfThread(vsThread);
if (state) {
state.offerToken.set(offer.threadId, "offerdone");
this.applyContextValue(state, offer.threadId, vsThread);
}
// Finding 3 (seam hygiene): drop the offer once it's spent — otherwise the // Finding 3 (seam hygiene): drop the offer once it's spent — otherwise the
// `makeThreadEdit(threadId, docPath)` test seam (or a stray second UI click, // `makeThreadEdit(threadId, docPath)` test seam (or a stray second UI click,
// now dead per contextValue but still a valid direct call) could re-run the // now dead per contextValue but still a valid direct call) could re-run the
@@ -356,7 +385,7 @@ export class ThreadController implements vscode.Disposable {
setStatus(state.artifact, threadId, status); setStatus(state.artifact, threadId, status);
this.persist(state); this.persist(state);
vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
vsThread.contextValue = status; this.applyContextValue(state, threadId, vsThread); // preserves any in-flight offer token
} }
// ---- PUC-3/4: render, reload, re-anchor ----------------------------------------- // ---- PUC-3/4: render, reload, re-anchor -----------------------------------------
@@ -372,6 +401,7 @@ export class ThreadController implements vscode.Disposable {
state.vsThreads.clear(); state.vsThreads.clear();
state.live.clear(); state.live.clear();
state.orphaned.clear(); state.orphaned.clear();
state.offerToken.clear();
const text = document.getText(); const text = document.getText();
for (const thread of state.artifact.threads) { for (const thread of state.artifact.threads) {
const fp = state.artifact.anchors[thread.anchorId]?.fingerprint; const fp = state.artifact.anchors[thread.anchorId]?.fingerprint;
@@ -446,15 +476,36 @@ export class ThreadController implements vscode.Disposable {
thread.messages.map((m) => this.toComment(m.body, m.author.id)), thread.messages.map((m) => this.toComment(m.body, m.author.id)),
); );
vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined; vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined;
vsThread.contextValue = orphaned ? "orphaned" : thread.status;
vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
state.vsThreads.set(threadId, vsThread); state.vsThreads.set(threadId, vsThread);
state.live.set(threadId, offsets); state.live.set(threadId, offsets);
state.orphaned.set(threadId, orphaned); state.orphaned.set(threadId, orphaned);
state.offerToken.delete(threadId); // a freshly-created vsThread never carries a stale offer
this.applyContextValue(state, threadId, vsThread);
return vsThread; return vsThread;
} }
/**
* Finding 2 (final whole-branch review): `contextValue` is the ONE string
* VS Code's `comments/commentThread/*` `when`-clauses can key on, but two
* INDEPENDENT things need to show through it the open/resolved/orphaned
* status (Resolve/Reopen) and the offer-flow state (makeThreadEdit). Folding
* both into one space-separated string (`"open offer"`, `"resolved"`, )
* with package.json `when`s matched by an unanchored `=~ /\btoken\b/` regex
* lets either flip without erasing the other setting the offer flag alone
* used to stomp the status token entirely (every human comment now triggers
* a reply, so every thread on a coedited doc lost its Resolve/Reopen menu
* the moment the machine answered).
*/
private applyContextValue(state: DocState, threadId: string, vsThread: vscode.CommentThread): void {
const orphaned = !!state.orphaned.get(threadId);
const thread = state.artifact.threads.find((t) => t.id === threadId);
const statusToken = orphaned ? "orphaned" : (thread?.status ?? "open");
const offerToken = state.offerToken.get(threadId);
vsThread.contextValue = offerToken ? `${statusToken} ${offerToken}` : statusToken;
}
private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void { private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void {
const thread = state.artifact.threads.find((t) => t.id === threadId)!; const thread = state.artifact.threads.find((t) => t.id === threadId)!;
vsThread.comments = thread.messages.map((m) => this.toComment(m.body, m.author.id)); vsThread.comments = thread.messages.map((m) => this.toComment(m.body, m.author.id));
@@ -491,6 +542,7 @@ export class ThreadController implements vscode.Disposable {
state.vsThreads.clear(); state.vsThreads.clear();
state.live.clear(); state.live.clear();
state.orphaned.clear(); state.orphaned.clear();
state.offerToken.clear();
} }
// ---- test-facing surface -------------------------------------------------------- // ---- test-facing surface --------------------------------------------------------
@@ -507,6 +559,7 @@ export class ThreadController implements vscode.Disposable {
status: t.status, status: t.status,
orphaned: !!state.orphaned.get(id), orphaned: !!state.orphaned.get(id),
range: { startLine: r ? r.start.line : 0, endLine: r ? r.end.line : 0 }, range: { startLine: r ? r.start.line : 0, endLine: r ? r.end.line : 0 },
contextValue: typeof vsThread.contextValue === "string" ? vsThread.contextValue : "",
}); });
} }
return out; return out;
+35
View File
@@ -6,6 +6,8 @@ import * as path from "node:path";
import * as vscode from "vscode"; import * as vscode from "vscode";
import { activateApi, settle, settleUntil } from "./helpers"; import { activateApi, settle, settleUntil } from "./helpers";
const WS = process.env.E2E_WORKSPACE!;
suite("baseline router (PUC-1, INV-7/D13/D14)", () => { suite("baseline router (PUC-1, INV-7/D13/D14)", () => {
test("snapshot mode: enter captures; markReviewed re-pins clean", async () => { test("snapshot mode: enter captures; markReviewed re-pins clean", async () => {
const api = await activateApi(); const api = await activateApi();
@@ -49,4 +51,37 @@ suite("baseline router (PUC-1, INV-7/D13/D14)", () => {
); );
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.reason, "head"); assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.reason, "head");
}); });
// Finding 3 (final whole-branch review, session native-surfaces-exec):
// DiffViewController only establishes a baseline on the registry's ENTER
// transition (a doc it can find already open) and, at startup, for docs
// already open at that moment. A registry member entered while its document
// was NOT yet open (the real-world case: a persisted coediting set restored
// after a window reload, whose member is only lazily opened later) used to
// never get a baseline at all — reproduced here directly via
// `coeditingRegistry.enter(uri)` on a URI with no open document yet (the
// registry API doesn't require one), then a genuine later open.
test("a registry member entered before its doc was open gets its baseline established once the doc IS opened", async () => {
const api = await activateApi();
const rel = "docs/finding3-establish-on-open.md";
const abs = path.join(WS, rel);
const content = "# late open\n\nestablish-on-open should catch this.\n";
fs.writeFileSync(abs, content, "utf8");
const uri = vscode.Uri.file(abs);
// Membership without an open document — DiffViewController's enter-
// transition handler no-ops (`textDocuments.find` misses), reproducing
// the gap: a coediting member with no mode/baseline yet.
api.coeditingRegistry.enter(uri);
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "sanity: registry membership recorded");
assert.strictEqual(api.diffViewController.modeOf(uri.toString()), undefined, "sanity: the gap is reproduced");
// A genuine later open — the fix's `renderIfOpen`/`onDidOpenTextDocument`
// path must establish the baseline it missed.
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settleUntil(() => api.diffViewController.modeOf(uri.toString()) !== undefined, 10000);
assert.strictEqual(api.diffViewController.modeOf(uri.toString()), "snapshot");
assert.strictEqual(api.diffViewController.getBaseline(uri.toString())?.text, content);
});
}); });
+77
View File
@@ -1,7 +1,9 @@
import * as assert from "node:assert"; import * as assert from "node:assert";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import MarkdownIt from "markdown-it";
import * as vscode from "vscode"; import * as vscode from "vscode";
import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations";
import { activateApi, settle, settleUntil } from "./helpers"; import { activateApi, settle, settleUntil } from "./helpers";
const WS = process.env.E2E_WORKSPACE!; const WS = process.env.E2E_WORKSPACE!;
@@ -143,4 +145,79 @@ suite("coediting registry (PUC-7)", () => {
assert.strictEqual(api.proposalController.getRendered(key).length, 1, "proposal restored on re-enter"); assert.strictEqual(api.proposalController.getRendered(key).length, 1, "proposal restored on re-enter");
assert.ok(api.attributionController.getSpans(key).length >= 1, "attribution restored on re-enter"); assert.ok(api.attributionController.getSpans(key).length >= 1, "attribution restored on re-enter");
}); });
// Finding 1 (final whole-branch review, session native-surfaces-exec): the
// preview-annotation host's INV-10 gate must never leak one document's
// baseline/spans into another document's preview. The built-in preview's
// rendered DOM isn't queryable from a host E2E test (§6.8, mirrored from
// Task 7's own previewAnnotations.test.ts), so this drives the ACTUAL
// production `previewAnnotationHost` through a real markdown-it instance
// and asserts on the rendered HTML.
test("INV-10: a non-coedited doc's preview never inherits another doc's annotations", async () => {
const api = await activateApi();
const docA = await vscode.workspace.openTextDocument({ language: "markdown", content: "alpha original\n" });
await vscode.window.showTextDocument(docA);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const start = docA.getText().indexOf("original");
const okA = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
uri: docA.uri.toString(),
start,
end: start + "original".length,
newText: "REPLACED",
model: "sonnet",
sessionId: "e2e-inv10-leak",
turnId: "turn-e2e-inv10-leak",
});
assert.strictEqual(okA, true, "seam edit applies to docA");
await settle();
// docB is opened (becomes the active editor, so it would win any
// `lastCoeditedUri` fallback if the gate were wrong) but NEVER entered
// into coediting.
const docB = await vscode.workspace.openTextDocument({ language: "markdown", content: "bravo untouched\n" });
await vscode.window.showTextDocument(docB);
await settle();
const md = new MarkdownIt();
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
const html = md.render(docB.getText(), { currentDocument: docB.uri });
assert.ok(!html.includes("cw-ins-claude"), `docB must render with no annotations, got:\n${html}`);
assert.ok(!html.includes("original"), `docA's baseline text must never leak into docB's preview:\n${html}`);
assert.ok(html.includes("bravo untouched"), "docB renders its own content unannotated");
});
// Finding 1, failure mode (b): `lastCoeditedUri` is never re-validated
// against the registry, so a doc's OWN preview stayed annotated after
// `stopCoediting` — an INV-10 violation for the doc whose gate was just
// closed, not just for a different doc.
test("INV-10: stopCoediting immediately clears a doc's own preview annotations", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "gamma original\n" });
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const start = doc.getText().indexOf("original");
const ok = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + "original".length,
newText: "REPLACED",
model: "sonnet",
sessionId: "e2e-inv10-stop",
turnId: "turn-e2e-inv10-stop",
});
assert.strictEqual(ok, true, "seam edit applies");
await settle();
const md = new MarkdownIt();
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
const before = md.render(doc.getText(), { currentDocument: doc.uri });
assert.ok(before.includes("cw-ins-claude"), `sanity: annotated while coediting, got:\n${before}`);
await vscode.commands.executeCommand("cowriting.stopCoediting");
await settle();
const after = md.render(doc.getText(), { currentDocument: doc.uri });
assert.ok(!after.includes("cw-ins-claude"), `expected a clean render after stopCoediting, got:\n${after}`);
});
}); });
+27
View File
@@ -32,9 +32,36 @@ suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => {
const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId); const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId);
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent"; return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
}, 10000); }, 10000);
// Finding 2 (final whole-branch review): the machine reply flips the
// thread into an "offer" — this used to CLOBBER the "open" status token
// instead of folding in alongside it, so Resolve/Reopen disappeared from
// the menu the moment any thread got a reply. Assert both tokens survive
// together, directly via the rendered vsThread's contextValue (the exact
// string package.json's `comments/commentThread/title` `when`-clauses key
// on) — no DOM/UI query needed.
const docKey = api.proposalController.keyFor(doc);
const afterReply = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
assert.ok(afterReply, "thread is rendered after the reply lands");
assert.match(
afterReply!.contextValue,
/\bopen\b/,
`expected the "open" status token to survive the offer transition, got "${afterReply!.contextValue}"`,
);
assert.match(
afterReply!.contextValue,
/\boffer\b/,
`expected the "offer" token once the machine reply lands, got "${afterReply!.contextValue}"`,
);
const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc)); const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc));
assert.ok(ids.length >= 1); assert.ok(ids.length >= 1);
assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5 assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5
// Spending the offer flips it to "offerdone" — status token still intact,
// and the plain "offer" token must not still match (it's a DIFFERENT word,
// not just a substring: /\boffer\b/ must not match "offerdone").
const afterMakeEdit = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
assert.match(afterMakeEdit!.contextValue, /\bopen\b/, "status token still intact after spending the offer");
assert.match(afterMakeEdit!.contextValue, /\bofferdone\b/, "offer token flips to offerdone once spent");
assert.doesNotMatch(afterMakeEdit!.contextValue, /\boffer\b(?!done)/, "the bare offer token is gone once spent");
}); });
test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => { test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => {
+19
View File
@@ -63,6 +63,25 @@ describe("preview annotations (PUC-3/D21)", () => {
expect(html).toContain("cw-del"); expect(html).toContain("cw-del");
expect(html).toContain("world"); expect(html).toContain("world");
}); });
// Finding 4 (final whole-branch review, session native-surfaces-exec): the
// applied-proposal insertion mark used to hardcode tag: "claude", so a
// human-authored proposal (ProposalView.author = "human") still rendered
// cw-ins-claude. It must follow `p.author` (defaulting to "claude" only
// when the field is absent, per ProposalView's own documented default).
it("a human-authored pending proposal renders its applied text cw-ins-human, not cw-ins-claude", () => {
const src = "hello HUMAN world\n";
const html = render(src, {
...base,
baselineText: "hello world\n",
spans: [],
proposals: [
{ id: "p1", anchorStart: 6, anchorEnd: 11, replaced: "world", replacement: "HUMAN", author: "human" },
],
});
expect(html).toContain('class="cw-ins-human"');
expect(html).not.toContain('class="cw-ins-claude"');
expect(html).toContain("HUMAN");
});
// Task 8 (INV-32/33 coverage moved from f10Review.test.ts): a block with no // Task 8 (INV-32/33 coverage moved from f10Review.test.ts): a block with no
// spans and no baseline divergence carries no cw- marks at all, even when // spans and no baseline divergence carries no cw- marks at all, even when
// annotations are enabled — "changed since baseline" is the only trigger. // annotations are enabled — "changed since baseline" is the only trigger.