Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5645b926a3 | |||
| 64c7d7bad2 | |||
| ab00cbefcc | |||
| c67749a53c | |||
| ba7623f813 |
@@ -0,0 +1,86 @@
|
|||||||
|
# Session 0047.0 — Transcript
|
||||||
|
|
||||||
|
> App: vscode-cowriting-plugin
|
||||||
|
> Start: 2026-06-13T08-34 (PST)
|
||||||
|
> Type: planning-and-executing
|
||||||
|
> End: 2026-06-13T08-44 (PST)
|
||||||
|
> Status: **FINALIZED.**
|
||||||
|
> Posture: autonomous (yolo)
|
||||||
|
|
||||||
|
## Launch prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
/goal plan-and-execute #33 (intra-emphasis sentinel hardening — token-aware fix for the 2 characterized failure modes)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
**#33 (task, P3)** — harden the F9/F10 author-coloring PUA sentinels against
|
||||||
|
intra-emphasis markdown. Anchor: leaf `task` → §4.3 R2 (no design gate). Picked
|
||||||
|
autonomously (next clean plan-and-executable backlog item; doesn't touch the
|
||||||
|
content repo). Approach = the token-aware fix recommended in the issue #33 comment.
|
||||||
|
|
||||||
|
Two characterized failure modes (issue #33 comment, session 0032):
|
||||||
|
- **CASE1** — a span boundary lands strictly inside a delimiter run (`a**b**c`,
|
||||||
|
boundary between the two `*`): the injected sentinel splits `**` → markdown parse
|
||||||
|
breaks (stray `<em></em>`, raw `**` left).
|
||||||
|
- **CASE3** — a span boundary inside an emphasis run (`**bold**`, span covers
|
||||||
|
`**bo`): emphasis renders but the author `<span>` and `<strong>` **misnest**
|
||||||
|
(`<strong>bo</span>ld</strong>`).
|
||||||
|
|
||||||
|
Fix (both needed):
|
||||||
|
1. `injectSentinels` — **clamp** any sentinel offset that lands strictly inside a
|
||||||
|
markdown delimiter run (`* _ ~ \``) to the run's start, so a sentinel never
|
||||||
|
splits a delimiter (fixes CASE1). Skip now-zero-width spans.
|
||||||
|
2. `sentinelsToSpans` — replace the naive split/join with a **token-aware walker**
|
||||||
|
over the rendered HTML: emit `cw-by-*` spans only around TEXT runs, closing the
|
||||||
|
span before any `<tag>` and reopening after, so a span never crosses an element
|
||||||
|
boundary (fixes CASE3 — one span segment per text run). Strip any stray sentinel
|
||||||
|
left inside a tag (no PUA leakage).
|
||||||
|
|
||||||
|
Pure/vscode-free/deterministic (INV-33). Non-goals: link/attribute sentinel cases,
|
||||||
|
visual language, attribution model.
|
||||||
|
|
||||||
|
Tasks: unit tests reproducing CASE1 + CASE3 (+ regression on existing
|
||||||
|
colorByAuthor/renderReview cases) → implement clamp + walker → green; spec already
|
||||||
|
notes the hardening (§1.7/§9). No deploy pipeline (extension).
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
**#33 (task, P3) shipped to `main`** — PR
|
||||||
|
[#53](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/53)
|
||||||
|
(merged), issue #33 closed. Picked autonomously (next clean plan-and-executable
|
||||||
|
leaf; doesn't touch the content repo).
|
||||||
|
|
||||||
|
- `trackChangesModel.ts` `injectSentinels` — clamp any sentinel offset landing
|
||||||
|
strictly inside a delimiter run (`* _ ~ \``) to the run's start (fixes CASE1
|
||||||
|
parse-break); drop spans that clamp to empty.
|
||||||
|
- `trackChangesModel.ts` `sentinelsToSpans` — token-aware walker emitting the
|
||||||
|
`cw-by-*` span only around text runs, split at every `<tag>` boundary (fixes
|
||||||
|
CASE3 misnest); strays stripped (no PUA leak).
|
||||||
|
- 4 new unit tests (CASE1/CASE3/CASE2-regression/no-leak), real markdown-it via
|
||||||
|
`renderReview`.
|
||||||
|
|
||||||
|
Verification: `tsc --noEmit` clean; **222 unit** green; **74/5 host E2E** green;
|
||||||
|
the F10/authorship E2E (`cw-by-claude`/`cw-by-human`) still pass (common case
|
||||||
|
byte-identical).
|
||||||
|
|
||||||
|
## Session arc
|
||||||
|
|
||||||
|
1. Stop hook required autonomously determining/executing the next milestone;
|
||||||
|
chose #33 (leaf task, no content-repo dependency). Claimed 0047, clean `main`.
|
||||||
|
2. Read issue #33 + its investigation comment (2 failure modes, recommended
|
||||||
|
token-aware approach) + session 0032 characterization + the sentinel code.
|
||||||
|
3. TDD: CASE1/CASE3 reproduced red → implemented clamp + walker → green; full
|
||||||
|
suite + E2E green; self-reviewed (one pure module).
|
||||||
|
4. Shipped (PR #53), closed #33.
|
||||||
|
|
||||||
|
## Deferred decisions
|
||||||
|
|
||||||
|
_Autonomous-mode low-confidence calls the driver made and would have
|
||||||
|
liked operator input on. Empty if none._
|
||||||
|
|
||||||
|
- _No low-confidence calls this session._ (Approach was the one recommended in the
|
||||||
|
issue #33 comment; the fix is a pure-module robustness change with full test
|
||||||
|
coverage and no regression.)
|
||||||
+5
-5
@@ -1,18 +1,18 @@
|
|||||||
# Session 0047.0 — Transcript
|
# Session 0048.0 — Transcript
|
||||||
|
|
||||||
> App: vscode-cowriting-plugin
|
> App: vscode-cowriting-plugin
|
||||||
> Start: 2026-06-13T08-34 (PST)
|
> Start: 2026-06-13T08-45 (PST)
|
||||||
> Type: planning-and-executing
|
> Type: planning-and-executing
|
||||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||||
>
|
>
|
||||||
> This file reserves session ID 0047 for vscode-cowriting-plugin. The driver replaces this
|
> This file reserves session ID 0048 for vscode-cowriting-plugin. The driver replaces this
|
||||||
> body with the full transcript and renames the file to its final
|
> body with the full transcript and renames the file to its final
|
||||||
> SESSION-0047.0-TRANSCRIPT-2026-06-13T08-34--<end>.md form at session end.
|
> SESSION-0048.0-TRANSCRIPT-2026-06-13T08-45--<end>.md form at session end.
|
||||||
|
|
||||||
## Launch prompt
|
## Launch prompt
|
||||||
|
|
||||||
```
|
```
|
||||||
/goal plan-and-execute #33 (intra-emphasis sentinel hardening — token-aware fix for the 2 characterized failure modes)
|
/goal plan-and-execute #40 (restore exact author attribution on undo/redo — follow-up to #38)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,5 +139,8 @@
|
|||||||
},
|
},
|
||||||
"0047": {
|
"0047": {
|
||||||
"title": ""
|
"title": ""
|
||||||
|
},
|
||||||
|
"0048": {
|
||||||
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,19 @@ interface DocAttribution {
|
|||||||
* orphans on reload).
|
* orphans on reload).
|
||||||
*/
|
*/
|
||||||
hadAttributions: boolean;
|
hadAttributions: boolean;
|
||||||
|
/**
|
||||||
|
* #40: text-keyed attribution snapshots for exact provenance restoration on
|
||||||
|
* undo/redo. Maps a document-text state → the live spans at that state (offsets
|
||||||
|
* valid for that exact text). Captured after every forward edit + at load;
|
||||||
|
* consulted on undo/redo to restore the matching state's spans. Bounded (oldest
|
||||||
|
* evicted) — a far-back/evicted state falls back to the #38 neutral reconcile.
|
||||||
|
*/
|
||||||
|
attrHistory: Map<string, LiveSpan[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #40: cap on the per-doc attribution-snapshot history (oldest evicted). */
|
||||||
|
const ATTR_HISTORY_MAX = 200;
|
||||||
|
|
||||||
export class AttributionController implements vscode.Disposable {
|
export class AttributionController implements vscode.Disposable {
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
private readonly docs = new Map<string, DocAttribution>();
|
private readonly docs = new Map<string, DocAttribution>();
|
||||||
@@ -90,7 +101,7 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
private state(docPath: string): DocAttribution {
|
private state(docPath: string): DocAttribution {
|
||||||
let s = this.docs.get(docPath);
|
let s = this.docs.get(docPath);
|
||||||
if (!s) {
|
if (!s) {
|
||||||
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false };
|
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false, attrHistory: new Map() };
|
||||||
this.docs.set(docPath, s);
|
this.docs.set(docPath, s);
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
@@ -125,6 +136,9 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
s.spans = coalesce(s.spans);
|
s.spans = coalesce(s.spans);
|
||||||
if (artifact.attributions.length > 0) s.hadAttributions = true;
|
if (artifact.attributions.length > 0) s.hadAttributions = true;
|
||||||
}
|
}
|
||||||
|
// #40: seed the snapshot history with the loaded state so undoing back to it
|
||||||
|
// restores its exact attribution.
|
||||||
|
this.snapshotAttribution(s, document.getText());
|
||||||
this.render(document);
|
this.render(document);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +154,17 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
|
|
||||||
// ---- PUC-1/PUC-3: live tracking ---------------------------------------------------
|
// ---- PUC-1/PUC-3: live tracking ---------------------------------------------------
|
||||||
|
|
||||||
|
/** #40: snapshot the current spans keyed by the document's current text. */
|
||||||
|
private snapshotAttribution(s: DocAttribution, text: string): void {
|
||||||
|
s.attrHistory.delete(text); // re-insert at the end (recency order)
|
||||||
|
s.attrHistory.set(text, s.spans.map((sp) => ({ ...sp })));
|
||||||
|
while (s.attrHistory.size > ATTR_HISTORY_MAX) {
|
||||||
|
const oldest = s.attrHistory.keys().next().value as string | undefined;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
s.attrHistory.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||||
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
||||||
const docPath = this.keyOf(e.document);
|
const docPath = this.keyOf(e.document);
|
||||||
@@ -202,6 +227,18 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// #40: exact provenance across history navigation. After a FORWARD edit,
|
||||||
|
// snapshot the new state's spans. On UNDO/REDO, the geometry reconcile above
|
||||||
|
// left re-inserted text neutral (#38); if a snapshot's text equals the current
|
||||||
|
// buffer, restore that state's spans exactly (offsets are valid — identical
|
||||||
|
// text) so Claude's restored text is blue again, the human's green. No match
|
||||||
|
// (far-back/evicted state) keeps the #38 neutral fallback.
|
||||||
|
if (isUndoRedo) {
|
||||||
|
const restored = s.attrHistory.get(e.document.getText());
|
||||||
|
if (restored) s.spans = restored.map((sp) => ({ ...sp }));
|
||||||
|
} else {
|
||||||
|
this.snapshotAttribution(s, e.document.getText());
|
||||||
|
}
|
||||||
if (s.spans.length > 0) s.hadAttributions = true;
|
if (s.spans.length > 0) s.hadAttributions = true;
|
||||||
this.render(e.document);
|
this.render(e.document);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -545,13 +545,33 @@ function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } |
|
|||||||
: { cls: "cw-by-human", label: "You" };
|
: { cls: "cw-by-human", label: "You" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Markdown emphasis / code delimiters whose RUNS must never be split by an
|
||||||
|
// injected sentinel — a sentinel between two run chars (e.g. `*|*`) breaks
|
||||||
|
// markdown-it's delimiter pairing (#33 CASE1).
|
||||||
|
const isDelimChar = (c: string): boolean => c === "*" || c === "_" || c === "~" || c === "`";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #33 (CASE1): a sentinel must not land STRICTLY INSIDE a delimiter run. If `at`
|
||||||
|
* sits between two identical delimiter chars, snap it to the run's start (a
|
||||||
|
* position outside the run) so the run stays intact. Delimiters are invisible once
|
||||||
|
* rendered, so snapping only shifts the colored boundary across markup, never over
|
||||||
|
* visible text.
|
||||||
|
*/
|
||||||
|
function clampOffDelimiterRun(raw: string, at: number): number {
|
||||||
|
if (at <= 0 || at >= raw.length) return at;
|
||||||
|
if (!(isDelimChar(raw[at]) && raw[at - 1] === raw[at])) return at;
|
||||||
|
let p = at;
|
||||||
|
while (p > 0 && raw[p - 1] === raw[at]) p--;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
|
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
|
||||||
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
|
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
|
||||||
const inserts: { at: number; marker: string }[] = [];
|
const inserts: { at: number; marker: string }[] = [];
|
||||||
for (const s of spans) {
|
for (const s of spans) {
|
||||||
const lo = Math.max(0, s.start - blockStart);
|
const lo = clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart));
|
||||||
const hi = Math.min(raw.length, s.end - blockStart);
|
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
|
||||||
if (hi <= lo) continue;
|
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
|
||||||
inserts.push({ at: lo, marker: SENT[s.author].open });
|
inserts.push({ at: lo, marker: SENT[s.author].open });
|
||||||
inserts.push({ at: hi, marker: SENT[s.author].close });
|
inserts.push({ at: hi, marker: SENT[s.author].close });
|
||||||
}
|
}
|
||||||
@@ -564,13 +584,69 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace the rendered sentinels with author <span> tags. */
|
const SENTINEL_OF: Record<string, { author: AuthorKind; open: boolean } | undefined> = {
|
||||||
|
[SENT.claude.open]: { author: "claude", open: true },
|
||||||
|
[SENT.claude.close]: { author: "claude", open: false },
|
||||||
|
[SENT.human.open]: { author: "human", open: true },
|
||||||
|
[SENT.human.close]: { author: "human", open: false },
|
||||||
|
};
|
||||||
|
const ALL_SENTINELS = new RegExp(
|
||||||
|
`[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`,
|
||||||
|
"g",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #33: token-aware replacement of the rendered author sentinels with `cw-by-*`
|
||||||
|
* spans. A naive global string-replace (the old approach) could emit a span that
|
||||||
|
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
|
||||||
|
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
|
||||||
|
* emits the author span only around TEXT runs, CLOSING it before any `<tag>` and
|
||||||
|
* REOPENING it after, so a span is always well-nested within the inline elements
|
||||||
|
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray
|
||||||
|
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
|
||||||
|
*/
|
||||||
function sentinelsToSpans(html: string): string {
|
function sentinelsToSpans(html: string): string {
|
||||||
return html
|
const out: string[] = [];
|
||||||
.split(SENT.claude.open).join('<span class="cw-by-claude">')
|
let current: AuthorKind | null = null; // which author region we're inside
|
||||||
.split(SENT.claude.close).join("</span>")
|
let spanOpen = false; // whether a <span> is currently open in `out`
|
||||||
.split(SENT.human.open).join('<span class="cw-by-human">')
|
const openSpan = () => {
|
||||||
.split(SENT.human.close).join("</span>");
|
if (current && !spanOpen) {
|
||||||
|
out.push(`<span class="cw-by-${current}">`);
|
||||||
|
spanOpen = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const closeSpan = () => {
|
||||||
|
if (spanOpen) {
|
||||||
|
out.push("</span>");
|
||||||
|
spanOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (let i = 0; i < html.length; i++) {
|
||||||
|
const ch = html[i];
|
||||||
|
const sentinel = SENTINEL_OF[ch];
|
||||||
|
if (sentinel) {
|
||||||
|
if (sentinel.open) current = sentinel.author; // span opens lazily before the next text char
|
||||||
|
else {
|
||||||
|
closeSpan();
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "<") {
|
||||||
|
// An HTML tag: never let an author span straddle it (text `<` is escaped to
|
||||||
|
// <, so a raw `<` is always a real tag). Copy the tag verbatim, sentinel-free.
|
||||||
|
closeSpan();
|
||||||
|
const gt = html.indexOf(">", i);
|
||||||
|
const end = gt === -1 ? html.length - 1 : gt;
|
||||||
|
out.push(html.slice(i, end + 1).replace(ALL_SENTINELS, ""));
|
||||||
|
i = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
openSpan();
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
closeSpan();
|
||||||
|
return out.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const WS = process.env.E2E_WORKSPACE!;
|
||||||
|
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||||
|
const AGENT = { kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-s40" } };
|
||||||
|
|
||||||
|
async function getApi(): Promise<CowritingApi> {
|
||||||
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||||
|
const api = (await ext.activate()) as CowritingApi;
|
||||||
|
assert.ok(api?.attributionController, "exports attribution");
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||||
|
const abs = path.join(WS, rel);
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
fs.writeFileSync(abs, body, "utf8");
|
||||||
|
const uri = vscode.Uri.file(abs);
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
await settle();
|
||||||
|
return { doc, key: uri.toString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// #40 (follow-up to #38): on undo/redo, restore each re-inserted char's EXACT
|
||||||
|
// prior author attribution rather than leaving it neutral (#38). Driven mid-edit
|
||||||
|
// (buffer dirty) so the attribution branch runs, not the disk-sync one.
|
||||||
|
suite("F3 #40 — undo/redo restores exact author attribution (host E2E, no LLM)", () => {
|
||||||
|
test("undo of a deletion of AGENT text restores its agent span (not neutral, not human)", async () => {
|
||||||
|
const { doc } = await freshDoc("docs/s40agent.md", "Human start. xxxx end.\n");
|
||||||
|
const api = await getApi();
|
||||||
|
const ctl = api.attributionController;
|
||||||
|
|
||||||
|
// Make "xxxx" agent-authored via the seam → an agent span over "ROBOT".
|
||||||
|
const t = "xxxx";
|
||||||
|
const start = doc.getText().indexOf(t);
|
||||||
|
const ok = await ctl.applyAgentEdit(
|
||||||
|
doc,
|
||||||
|
new vscode.Range(doc.positionAt(start), doc.positionAt(start + t.length)),
|
||||||
|
"ROBOT",
|
||||||
|
AGENT,
|
||||||
|
{ turnId: "turn-s40" },
|
||||||
|
);
|
||||||
|
assert.strictEqual(ok, true, "seam edit applies");
|
||||||
|
await settle();
|
||||||
|
const key = api.proposalController.keyFor(doc);
|
||||||
|
assert.ok(
|
||||||
|
ctl.getSpans(key).some((s) => s.authorKind === "agent" && doc.getText().slice(s.range.start, s.range.end).includes("ROBOT")),
|
||||||
|
"ROBOT is agent-attributed after the seam edit",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Human deletes "ROBOT" (buffer stays dirty: it already diverged from disk).
|
||||||
|
const rs = doc.getText().indexOf("ROBOT");
|
||||||
|
const del = new vscode.WorkspaceEdit();
|
||||||
|
del.delete(doc.uri, new vscode.Range(doc.positionAt(rs), doc.positionAt(rs + "ROBOT".length)));
|
||||||
|
assert.ok(await vscode.workspace.applyEdit(del), "delete applied");
|
||||||
|
await settle();
|
||||||
|
assert.ok(!doc.getText().includes("ROBOT"), "ROBOT deleted");
|
||||||
|
|
||||||
|
// Undo the deletion → ROBOT re-inserted. #40: its AGENT span is restored.
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
await vscode.commands.executeCommand("undo");
|
||||||
|
await settle();
|
||||||
|
assert.ok(doc.getText().includes("ROBOT"), "undo restored ROBOT");
|
||||||
|
assert.ok(doc.isDirty, "buffer still dirty → attribution branch ran");
|
||||||
|
|
||||||
|
const r2 = doc.getText().indexOf("ROBOT");
|
||||||
|
const spans = ctl.getSpans(key);
|
||||||
|
const over = spans.filter((s) => s.range.start < r2 + 5 && s.range.end > r2);
|
||||||
|
assert.ok(over.length > 0, "restored ROBOT carries a span");
|
||||||
|
assert.ok(over.every((s) => s.authorKind === "agent"), `restored ROBOT is agent-attributed, got ${JSON.stringify(over)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit → undo → redo round-trips attribution to identical state", async () => {
|
||||||
|
const { doc } = await freshDoc("docs/s40roundtrip.md", "Base alpha. yyyy omega.\n");
|
||||||
|
const api = await getApi();
|
||||||
|
const ctl = api.attributionController;
|
||||||
|
const key = api.proposalController.keyFor(doc);
|
||||||
|
|
||||||
|
const start = doc.getText().indexOf("yyyy");
|
||||||
|
await ctl.applyAgentEdit(
|
||||||
|
doc,
|
||||||
|
new vscode.Range(doc.positionAt(start), doc.positionAt(start + 4)),
|
||||||
|
"BLUEWORD",
|
||||||
|
AGENT,
|
||||||
|
{ turnId: "turn-s40b" },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
const norm = (k: string) =>
|
||||||
|
ctl
|
||||||
|
.getSpans(k)
|
||||||
|
.map((s) => ({ a: s.authorKind, t: doc.getText().slice(s.range.start, s.range.end) }))
|
||||||
|
.sort((x, y) => (x.t < y.t ? -1 : 1));
|
||||||
|
const afterAgent = JSON.stringify(norm(key));
|
||||||
|
assert.ok(afterAgent.includes("BLUEWORD") && afterAgent.includes("agent"), "agent span present after the edit");
|
||||||
|
|
||||||
|
// Delete BLUEWORD, then undo (restore) then redo (re-delete) then undo again.
|
||||||
|
const bs = doc.getText().indexOf("BLUEWORD");
|
||||||
|
const del = new vscode.WorkspaceEdit();
|
||||||
|
del.delete(doc.uri, new vscode.Range(doc.positionAt(bs), doc.positionAt(bs + "BLUEWORD".length)));
|
||||||
|
await vscode.workspace.applyEdit(del);
|
||||||
|
await settle();
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
await vscode.commands.executeCommand("undo"); // restore BLUEWORD
|
||||||
|
await settle();
|
||||||
|
assert.ok(doc.getText().includes("BLUEWORD"), "undo restored BLUEWORD");
|
||||||
|
assert.strictEqual(JSON.stringify(norm(key)), afterAgent, "attribution after undo matches the pre-deletion state exactly");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -168,6 +168,45 @@ describe("colorByAuthor", () => {
|
|||||||
expect(html).toContain('<span class="cw-by-human">hello</span>');
|
expect(html).toContain('<span class="cw-by-human">hello</span>');
|
||||||
expect(html).toContain("world");
|
expect(html).toContain("world");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #33: author-coloring must be sentinel-safe around markdown emphasis. These
|
||||||
|
// exercise the REAL markdown-it renderer (via renderReview on an unchanged doc),
|
||||||
|
// since the failure is in markdown-it's inline parsing / element nesting.
|
||||||
|
// CASE1 — a span boundary strictly inside a delimiter run must not split it.
|
||||||
|
test("#33 CASE1: a span boundary inside ** does not break emphasis parsing", () => {
|
||||||
|
const doc = "a**b**c";
|
||||||
|
const html = renderReview(doc, doc, [{ start: 0, end: 2, author: "human" }], []);
|
||||||
|
expect(html).toContain("<strong>b</strong>"); // emphasis still renders
|
||||||
|
expect(html).not.toContain("**"); // no raw delimiters left
|
||||||
|
expect(html).not.toContain("<em></em>"); // no stray empty emphasis (the parse-break symptom)
|
||||||
|
expect(html).toContain('class="cw-by-human"'); // coloring present
|
||||||
|
});
|
||||||
|
// CASE3 — a span boundary inside an emphasis run must color the text without
|
||||||
|
// misnesting span/element (the span is split at the element boundary).
|
||||||
|
test("#33 CASE3: a span boundary inside **bold** colors the text without misnesting", () => {
|
||||||
|
const doc = "**bold**";
|
||||||
|
const html = renderReview(doc, doc, [{ start: 0, end: 4, author: "human" }], []); // covers "**bo"
|
||||||
|
expect(html).toContain("<strong>");
|
||||||
|
expect(html).toContain('<span class="cw-by-human">bo</span>ld</strong>'); // span INSIDE strong, closed before "ld"
|
||||||
|
expect(html).not.toContain('cw-by-human"><strong>'); // NOT the old misnest (span wrapping the <strong> open)
|
||||||
|
});
|
||||||
|
// CASE2 — a span covering a whole emphasis run stays correct (regression).
|
||||||
|
test("#33 CASE2: a span over the whole **bold** colors it correctly", () => {
|
||||||
|
const doc = "**bold**";
|
||||||
|
const html = renderReview(doc, doc, [{ start: 0, end: 8, author: "human" }], []);
|
||||||
|
expect(html).toContain("<strong>");
|
||||||
|
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
|
||||||
|
expect(html).toContain("bold");
|
||||||
|
});
|
||||||
|
test("#33: no Private-Use-Area sentinel chars leak into the rendered output", () => {
|
||||||
|
const doc = "a**b**c and `co de` and _x_";
|
||||||
|
const spans: AuthorSpan[] = [
|
||||||
|
{ start: 0, end: 2, author: "human" },
|
||||||
|
{ start: 12, end: 16, author: "claude" },
|
||||||
|
];
|
||||||
|
const html = renderReview(doc, doc, spans, []);
|
||||||
|
expect(html).not.toMatch(/[\uE000-\uF8FF]/); // no leftover BMP Private-Use-Area sentinels
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
import { renderPlain } from "../src/trackChangesModel";
|
import { renderPlain } from "../src/trackChangesModel";
|
||||||
|
|||||||
Reference in New Issue
Block a user