F3: live human/Claude attribution on @cline/sdk claude-code (Feature #6) #7

Merged
benstull merged 15 commits from feat/f3-live-attribution into main 2026-06-10 18:11:47 +00:00
2 changed files with 80 additions and 0 deletions
Showing only changes of commit 4b27acfcae - Show all commits
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { buildFingerprint, resolve } from "../src/anchorer";
import { applyChange, type LiveSpan } from "../src/attributionTracker";
import type { Provenance } from "../src/model";
const HUMAN: Provenance = { kind: "human", id: "ben" };
const DOC = "intro\nClaude wrote this sentence here.\noutro\n";
function liveSpan(): LiveSpan {
const start = DOC.indexOf("Claude wrote this sentence");
return {
id: "at_1", start, end: start + "Claude wrote this sentence".length,
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "r1" } },
createdAt: "2026-06-10T00:00:00.000Z",
};
}
describe("attribution persistence decision logic (SLICE-4, spec §6.5 PUC-4)", () => {
it("save → reload: fingerprint built from the live span resolves to the same range", () => {
const s = liveSpan();
const fp = buildFingerprint(DOC, { start: s.start, end: s.end });
expect(resolve(DOC, fp)).toEqual({ start: s.start, end: s.end });
});
it("external edit above: fingerprint re-resolves at the moved range", () => {
const s = liveSpan();
const fp = buildFingerprint(DOC, { start: s.start, end: s.end });
const edited = "a new line\n" + DOC;
const moved = edited.indexOf("Claude wrote this sentence");
expect(resolve(edited, fp)).toEqual({ start: moved, end: moved + (s.end - s.start) });
});
it("anchored text mangled: orphaned, never guessed (INV-1/INV-6)", () => {
const s = liveSpan();
const fp = buildFingerprint(DOC, { start: s.start, end: s.end });
const mangled = DOC.replace("Claude wrote this sentence", "Someone rewrote everything");
expect(resolve(mangled, fp)).toBe("orphaned");
});
it("in-session edits keep the live span fingerprint-able (tracker → anchorer round trip)", () => {
let spans = [liveSpan()];
let doc = DOC;
const insertAt = DOC.indexOf("intro") + "intro".length;
const inserted = " (note)";
doc = doc.slice(0, insertAt) + inserted + doc.slice(insertAt);
spans = applyChange(spans, { start: insertAt, end: insertAt, newLength: inserted.length }, HUMAN, {
newId: () => "n1", now: () => "2026-06-10T01:00:00.000Z",
});
const agent = spans.find((x) => x.author.kind === "agent")!;
expect(doc.slice(agent.start, agent.end)).toBe("Claude wrote this sentence");
const fp = buildFingerprint(doc, { start: agent.start, end: agent.end });
expect(resolve(doc, fp)).toEqual({ start: agent.start, end: agent.end });
});
});
+26
View File
@@ -125,3 +125,29 @@ describe("coalesce", () => {
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"]);
});
});