3 Commits

Author SHA1 Message Date
Ben Stull 5645b926a3 #40 (WIP, UNVERIFIED): restore exact author attribution on undo/redo
Follow-up to #38. #38 made undo/redo re-inserted text neutral (no false human
coloring); #40 restores its EXACT prior attribution so Claude's restored text is
blue again, the human's green.

Mechanism — text-keyed attribution snapshots: per-doc Map<documentText, spans>,
snapshotted after every forward edit + at load; on undo/redo, after the #38
geometry reconcile, if a snapshot's text equals the current buffer the spans are
restored exactly (offsets valid — identical text). Robust to VS Code undo
coalescing (only the resulting-text-matching event restores); far-back/evicted
states fall back to #38 neutral. History bounded (ATTR_HISTORY_MAX, oldest
evicted).

NOT MERGED / NOT VERIFIED END-TO-END: 222 unit + typecheck green, but the #40
host E2E (and the pre-existing #38 undoMarks E2E) depend on
executeCommand("undo"), which is broken in this test environment — the untouched
#38 test fails identically on clean main (the known undoMarks flake, now
deterministic). Clearing .vscode-test/user-data (the usual remedy) is
permission-blocked here. The operator should verify in a working E2E environment
before merging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:56:43 -07:00
Ben Stull 64c7d7bad2 claim vscode-cowriting-plugin session 0048 (placeholder) + sessions.json entry 2026-06-13 08:45:48 -07:00
Ben Stull ab00cbefcc add sessions/0047/SESSION-0047.0-TRANSCRIPT-2026-06-13T08-34--2026-06-13T08-44.md + replace placeholder/variant SESSION-0047.0-TRANSCRIPT-2026-06-13T08-34--INPROGRESS.md 2026-06-13 08:43:50 -07:00
5 changed files with 245 additions and 6 deletions
@@ -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.)
@@ -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)
``` ```
+3
View File
@@ -139,5 +139,8 @@
}, },
"0047": { "0047": {
"title": "" "title": ""
},
"0048": {
"title": ""
} }
} }
+38 -1
View File
@@ -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);
} }
+113
View File
@@ -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");
});
});