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:
+9
-5
@@ -185,9 +185,13 @@
|
||||
"command": "cowriting.rejectProposal",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.reviewChanges",
|
||||
"when": "editorLangId == markdown && cowriting.isCoediting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.markReviewed",
|
||||
"when": "editorLangId == markdown"
|
||||
"when": "editorLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
@@ -307,24 +311,24 @@
|
||||
{
|
||||
"command": "cowriting.makeThreadEdit",
|
||||
"group": "inline@2",
|
||||
"when": "commentController == cowriting.threads && commentThread == offer"
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/title": [
|
||||
{
|
||||
"command": "cowriting.makeThreadEdit",
|
||||
"group": "inline@1",
|
||||
"when": "commentController == cowriting.threads && commentThread == offer"
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.resolveThread",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /^open$/"
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /\\bopen\\b/"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.reopenThread",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /\\bresolved\\b/"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -88,7 +88,14 @@ export class AttributionController implements vscode.Disposable {
|
||||
// EditorProposalController (construction order in extension.ts) so their
|
||||
// renders see fresh spans on the same enter transition.
|
||||
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);
|
||||
if (doc) this.loadAll(doc);
|
||||
}),
|
||||
|
||||
+19
-3
@@ -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.
|
||||
const renderIfOpen = (doc: vscode.TextDocument) => {
|
||||
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);
|
||||
attributionController.loadAll(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 ---
|
||||
// `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
|
||||
// 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
|
||||
@@ -433,8 +441,16 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
const previewAnnotationHost = {
|
||||
inputsFor(env: unknown): AnnotationInputs | undefined {
|
||||
const envUri = (env as { currentDocument?: { toString(): string } } | undefined)?.currentDocument?.toString();
|
||||
const key = envUri && coeditingRegistry.list().includes(envUri) ? envUri : lastCoeditedUri;
|
||||
if (!key) return undefined;
|
||||
// Finding 1 (final whole-branch review): the `lastCoeditedUri` fallback
|
||||
// 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);
|
||||
if (!doc) return undefined;
|
||||
const baseline = diffViewController.getBaseline(key);
|
||||
|
||||
@@ -32,7 +32,15 @@
|
||||
* `extension.ts`): `env.currentDocument` is an OBSERVED VS Code preview-engine
|
||||
* behavior, not a documented contract, so the host falls back to the last
|
||||
* 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 { diffMermaid } from "./mermaidDiff";
|
||||
@@ -105,13 +113,15 @@ export function annotateSource(src: string, inputs: AnnotationInputs): string {
|
||||
const appliedRanges: { start: number; end: number }[] = [];
|
||||
|
||||
// (c) pending F4 proposals already sit applied in `src` (F12 optimistic
|
||||
// apply, INV-48) — mark the applied range ins-claude (proposals are agent-
|
||||
// authored, matching editorProposalController's own convention) and
|
||||
// resurface the pre-apply original as a deletion at the same point.
|
||||
// apply, INV-48) — mark the applied range ins-<author> (`p.author` — a
|
||||
// proposal defaults to "claude" per `ProposalView.author`, but a human can
|
||||
// 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) {
|
||||
if (p.anchorStart === null || p.anchorEnd === null) continue;
|
||||
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;
|
||||
if (original) deletions.push({ at: p.anchorStart, text: original });
|
||||
}
|
||||
|
||||
@@ -76,7 +76,14 @@ export class ProposalController implements vscode.Disposable {
|
||||
// (construction order in extension.ts) so decorateCommitted's spansFor
|
||||
// read sees fresh attribution on the same enter transition.
|
||||
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);
|
||||
if (doc) this.renderAll(doc);
|
||||
}),
|
||||
|
||||
+57
-4
@@ -26,6 +26,11 @@ export interface RenderedThread {
|
||||
status: "open" | "resolved";
|
||||
orphaned: boolean;
|
||||
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. */
|
||||
@@ -47,6 +52,14 @@ interface DocState {
|
||||
live: Map<string, OffsetRange>;
|
||||
/** thread id -> orphaned flag for the current render. */
|
||||
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 {
|
||||
@@ -147,6 +160,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
vsThreads: new Map(),
|
||||
live: new Map(),
|
||||
orphaned: new Map(),
|
||||
offerToken: new Map(),
|
||||
};
|
||||
this.docs.set(docPath, state);
|
||||
}
|
||||
@@ -234,6 +248,11 @@ export class ThreadController implements vscode.Disposable {
|
||||
state.vsThreads.set(threadId, vsThread);
|
||||
state.live.set(threadId, offsets);
|
||||
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.refreshComments(vsThread, state, threadId);
|
||||
@@ -285,7 +304,11 @@ export class ThreadController implements vscode.Disposable {
|
||||
});
|
||||
this.persist(state);
|
||||
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 });
|
||||
},
|
||||
);
|
||||
@@ -321,7 +344,13 @@ export class ThreadController implements vscode.Disposable {
|
||||
const doc = await vscode.workspace.openTextDocument(vsThread.uri);
|
||||
const target = this.targetOf(doc, vsThread);
|
||||
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
|
||||
// `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
|
||||
@@ -356,7 +385,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
setStatus(state.artifact, threadId, status);
|
||||
this.persist(state);
|
||||
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 -----------------------------------------
|
||||
@@ -372,6 +401,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
state.vsThreads.clear();
|
||||
state.live.clear();
|
||||
state.orphaned.clear();
|
||||
state.offerToken.clear();
|
||||
const text = document.getText();
|
||||
for (const thread of state.artifact.threads) {
|
||||
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)),
|
||||
);
|
||||
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.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
|
||||
state.vsThreads.set(threadId, vsThread);
|
||||
state.live.set(threadId, offsets);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const thread = state.artifact.threads.find((t) => t.id === threadId)!;
|
||||
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.live.clear();
|
||||
state.orphaned.clear();
|
||||
state.offerToken.clear();
|
||||
}
|
||||
|
||||
// ---- test-facing surface --------------------------------------------------------
|
||||
@@ -507,6 +559,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
status: t.status,
|
||||
orphaned: !!state.orphaned.get(id),
|
||||
range: { startLine: r ? r.start.line : 0, endLine: r ? r.end.line : 0 },
|
||||
contextValue: typeof vsThread.contextValue === "string" ? vsThread.contextValue : "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import { activateApi, settle, settleUntil } from "./helpers";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
|
||||
suite("baseline router (PUC-1, INV-7/D13/D14)", () => {
|
||||
test("snapshot mode: enter captures; markReviewed re-pins clean", async () => {
|
||||
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");
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as assert from "node:assert";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import * as vscode from "vscode";
|
||||
import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations";
|
||||
import { activateApi, settle, settleUntil } from "./helpers";
|
||||
|
||||
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.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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
|
||||
}, 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));
|
||||
assert.ok(ids.length >= 1);
|
||||
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 () => {
|
||||
|
||||
@@ -63,6 +63,25 @@ describe("preview annotations (PUC-3/D21)", () => {
|
||||
expect(html).toContain("cw-del");
|
||||
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
|
||||
// spans and no baseline divergence carries no cw- marks at all, even when
|
||||
// annotations are enabled — "changed since baseline" is the only trigger.
|
||||
|
||||
Reference in New Issue
Block a user