import { describe, it, expect } from "vitest"; import { applyChange, coalesce, type LiveSpan, type TrackerCtx } from "../src/attributionTracker"; import type { Provenance } from "../src/model"; const HUMAN: Provenance = { kind: "human", id: "ben" }; const AGENT: Provenance = { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" }, }; function ctx(turnId?: string): TrackerCtx { let n = 0; return { newId: () => `n${++n}`, now: () => "2026-06-10T00:00:00.000Z", turnId }; } function span(id: string, start: number, end: number, author: Provenance, turnId?: string): LiveSpan { return { id, start, end, author, turnId, createdAt: "2026-06-09T00:00:00.000Z" }; } const ranges = (s: LiveSpan[]) => s.map((x) => [x.start, x.end]); const authors = (s: LiveSpan[]) => s.map((x) => x.author.kind); describe("applyChange — insertions", () => { it("insertion into empty span list creates one author span", () => { const r = applyChange([], { start: 5, end: 5, newLength: 3 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[5, 8]]); expect(r[0].author).toEqual(HUMAN); }); it("insertion before a span shifts it", () => { const r = applyChange([span("a", 10, 14, AGENT)], { start: 0, end: 0, newLength: 2 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[0, 2], [12, 16]]); expect(authors(r)).toEqual(["human", "agent"]); }); it("insertion after a span leaves it untouched", () => { const r = applyChange([span("a", 0, 4, AGENT)], { start: 10, end: 10, newLength: 2 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[0, 4], [10, 12]]); }); it("PUC-3: insertion strictly inside a different-author span splits it char-precisely", () => { const r = applyChange([span("a", 0, 10, AGENT)], { start: 4, end: 4, newLength: 3 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[0, 4], [4, 7], [7, 13]]); expect(authors(r)).toEqual(["agent", "human", "agent"]); }); it("symmetric: agent insertion inside a human span splits it (INV-7)", () => { const r = applyChange([span("a", 0, 10, HUMAN)], { start: 4, end: 4, newLength: 3 }, AGENT, ctx("t1")); expect(authors(r)).toEqual(["human", "agent", "human"]); expect(r[1].turnId).toBe("t1"); }); it("insertion inside a SAME-author span coalesces back to one span", () => { const r = applyChange([span("a", 0, 10, HUMAN)], { start: 4, end: 4, newLength: 3 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[0, 13]]); }); it("insertion at a span boundary does not split; same author coalesces", () => { const r = applyChange([span("a", 0, 5, HUMAN)], { start: 5, end: 5, newLength: 2 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[0, 7]]); }); }); describe("applyChange — deletions", () => { it("deletion before a span shifts it left", () => { const r = applyChange([span("a", 10, 14, HUMAN)], { start: 0, end: 4, newLength: 0 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[6, 10]]); }); it("deletion entirely inside a span shrinks it (no re-authoring, INV-6)", () => { const r = applyChange([span("a", 0, 10, AGENT)], { start: 3, end: 6, newLength: 0 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[0, 7]]); expect(authors(r)).toEqual(["agent"]); }); it("deletion covering a whole span removes it", () => { const r = applyChange([span("a", 3, 6, AGENT)], { start: 0, end: 10, newLength: 0 }, HUMAN, ctx()); expect(r).toEqual([]); }); it("deletion clipping a span head keeps the tail", () => { const r = applyChange([span("a", 4, 10, AGENT)], { start: 0, end: 6, newLength: 0 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[0, 4]]); }); it("deletion clipping a span tail keeps the head", () => { const r = applyChange([span("a", 0, 6, AGENT)], { start: 4, end: 10, newLength: 0 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[0, 4]]); }); }); describe("applyChange — replacements", () => { it("replacement inside an agent span: human chars in the middle, agent halves keep ids/createdAt", () => { const r = applyChange([span("a", 0, 12, AGENT)], { start: 4, end: 8, newLength: 2 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[0, 4], [4, 6], [6, 10]]); expect(authors(r)).toEqual(["agent", "human", "agent"]); expect(r[0].id).toBe("a"); expect(r[0].createdAt).toBe("2026-06-09T00:00:00.000Z"); }); it("replacement spanning two spans clips both and inserts the author's span between", () => { const r = applyChange( [span("a", 0, 6, AGENT), span("b", 6, 12, HUMAN)], { start: 4, end: 8, newLength: 5 }, AGENT, ctx("t2"), ); expect(ranges(r)).toEqual([[0, 4], [4, 9], [9, 13]]); expect(authors(r)).toEqual(["agent", "agent", "human"]); expect(r[1].turnId).toBe("t2"); }); }); describe("multi-edit sequences", () => { it("type, agent-replace, type inside agent span — ends char-honest", () => { const c = ctx(); let s = applyChange([], { start: 0, end: 0, newLength: 20 }, HUMAN, c); s = applyChange(s, { start: 5, end: 10, newLength: 8 }, AGENT, { ...c, turnId: "t1" }); s = applyChange(s, { start: 9, end: 9, newLength: 1 }, HUMAN, c); expect(authors(s)).toEqual(["human", "agent", "human", "agent", "human"]); expect(ranges(s)).toEqual([[0, 5], [5, 9], [9, 10], [10, 14], [14, 24]]); }); }); describe("applyChange — geometric-only (undo/redo, #38)", () => { it("attributeInserted=false adds NO span for re-inserted text (restored text stays neutral)", () => { // Undo re-inserts 'bravo ' at offset 6 into an empty span list → no span. const r = applyChange([], { start: 6, end: 6, newLength: 6 }, HUMAN, ctx(), false); expect(r).toEqual([]); }); it("attributeInserted=false still SHIFTS existing spans by the edit delta", () => { // A real human span sits after the re-insert point; it must shift right by 6, // but the re-inserted chars themselves get no new span. const r = applyChange([span("tail", 20, 30, HUMAN)], { start: 6, end: 6, newLength: 6 }, HUMAN, ctx(), false); expect(ranges(r)).toEqual([[26, 36]]); expect(authors(r)).toEqual(["human"]); }); it("attributeInserted=false still reconciles geometry of a deletion (removes a covered span)", () => { const r = applyChange([span("a", 3, 6, AGENT)], { start: 0, end: 10, newLength: 0 }, HUMAN, ctx(), false); expect(r).toEqual([]); }); }); describe("coalesce", () => { it("merges adjacent same-author same-turn spans, keeps earliest createdAt", () => { const a = { ...span("a", 0, 3, HUMAN), createdAt: "2026-06-09T00:00:00.000Z" }; const b = { ...span("b", 3, 6, HUMAN), createdAt: "2026-06-08T00:00:00.000Z" }; const r = coalesce([a, b]); expect(ranges(r)).toEqual([[0, 6]]); expect(r[0].id).toBe("a"); expect(r[0].createdAt).toBe("2026-06-08T00:00:00.000Z"); }); it("does NOT merge different authors, different turnIds, or non-adjacent spans", () => { expect(coalesce([span("a", 0, 3, HUMAN), span("b", 3, 6, AGENT)])).toHaveLength(2); expect(coalesce([span("a", 0, 3, AGENT, "t1"), span("b", 3, 6, AGENT, "t2")])).toHaveLength(2); expect(coalesce([span("a", 0, 3, HUMAN), span("b", 4, 6, HUMAN)])).toHaveLength(2); }); it("drops empty spans", () => { expect(coalesce([span("a", 5, 5, HUMAN)])).toEqual([]); }); }); describe("edge probes (post-review)", () => { it("applyChange does not mutate its input array or spans", () => { const input = [span("a", 0, 10, AGENT)]; const snapshot = JSON.parse(JSON.stringify(input)); applyChange(input, { start: 2, end: 4, newLength: 1 }, HUMAN, ctx()); expect(input).toEqual(snapshot); }); it("an edit swallowing a whole middle span across three spans removes only it", () => { // Deletion [3,9): a → [0,3); b swallowed; c's remainder [9,12) shifts -6 to // [3,6). The two surviving HUMAN spans are adjacent at 3 with the same // (undefined) turnId, so coalesce merges them into ONE span — only the // agent-authored middle span's characters are gone (INV-6/INV-7). const r = applyChange( [span("a", 0, 4, HUMAN), span("b", 4, 8, AGENT), span("c", 8, 12, HUMAN)], { start: 3, end: 9, newLength: 0 }, HUMAN, ctx(), ); expect(ranges(r)).toEqual([[0, 6]]); expect(authors(r)).toEqual(["human"]); }); it("a replacement exactly covering a span replaces it with the editor's span", () => { const r = applyChange([span("a", 4, 8, AGENT)], { start: 4, end: 8, newLength: 4 }, HUMAN, ctx()); expect(ranges(r)).toEqual([[4, 8]]); expect(authors(r)).toEqual(["human"]); }); });