From 0d69a292282f8246137ee9e41ce62da4b0d19acb Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 16:04:40 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20final-review=20wave=20=E2=80=94=20previe?= =?UTF-8?q?w=20host=20INV-10=20gate,=20thread=20status+offer=20contextValu?= =?UTF-8?q?e=20tokens,=20establish-on-open=20baseline,=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- package.json | 14 +++-- src/attributionController.ts | 9 +++- src/extension.ts | 22 ++++++-- src/previewAnnotations.ts | 20 +++++-- src/proposalController.ts | 9 +++- src/threadController.ts | 61 +++++++++++++++++++-- test/e2e/suite/baselineRouter.test.ts | 35 ++++++++++++ test/e2e/suite/coediting.test.ts | 77 +++++++++++++++++++++++++++ test/e2e/suite/commentLoop.test.ts | 27 ++++++++++ test/previewAnnotations.test.ts | 19 +++++++ 10 files changed, 274 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 6d7159c..b5be3a2 100644 --- a/package.json +++ b/package.json @@ -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/" } ] }, diff --git a/src/attributionController.ts b/src/attributionController.ts index 5b96950..5b28923 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -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); }), diff --git a/src/extension.ts b/src/extension.ts index 591b484..91916c3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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); diff --git a/src/previewAnnotations.ts b/src/previewAnnotations.ts index 413c0d4..35ccfb2 100644 --- a/src/previewAnnotations.ts +++ b/src/previewAnnotations.ts @@ -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- (`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 }); } diff --git a/src/proposalController.ts b/src/proposalController.ts index bbc5177..30372e7 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -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); }), diff --git a/src/threadController.ts b/src/threadController.ts index 8e311e4..ef85faf 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -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; /** thread id -> orphaned flag for the current render. */ orphaned: Map; + /** 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; } 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; diff --git a/test/e2e/suite/baselineRouter.test.ts b/test/e2e/suite/baselineRouter.test.ts index 2ba199d..428287c 100644 --- a/test/e2e/suite/baselineRouter.test.ts +++ b/test/e2e/suite/baselineRouter.test.ts @@ -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); + }); }); diff --git a/test/e2e/suite/coediting.test.ts b/test/e2e/suite/coediting.test.ts index 3047d2b..9a2984f 100644 --- a/test/e2e/suite/coediting.test.ts +++ b/test/e2e/suite/coediting.test.ts @@ -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("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("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}`); + }); }); diff --git a/test/e2e/suite/commentLoop.test.ts b/test/e2e/suite/commentLoop.test.ts index a3ab961..0bf0269 100644 --- a/test/e2e/suite/commentLoop.test.ts +++ b/test/e2e/suite/commentLoop.test.ts @@ -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 () => { diff --git a/test/previewAnnotations.test.ts b/test/previewAnnotations.test.ts index 7863dd1..20b9c82 100644 --- a/test/previewAnnotations.test.ts +++ b/test/previewAnnotations.test.ts @@ -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.