F3: live human/Claude attribution on @cline/sdk claude-code (Feature #6) #7
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* PendingEditRegistry — the seam's bookkeeping (spec §6.2, INV-9). Before
|
||||
* applyAgentEdit applies a WorkspaceEdit, it registers the exact expected
|
||||
* change; the controller's change handler consumes a matching registration to
|
||||
* attribute that change to the agent. Anything unmatched is human typing.
|
||||
* vscode-free and unit-testable.
|
||||
*/
|
||||
import type { Provenance } from "./model";
|
||||
|
||||
export interface PendingEdit {
|
||||
docPath: string;
|
||||
/** half-open [start, end) offsets of the replaced range. */
|
||||
start: number;
|
||||
end: number;
|
||||
newText: string;
|
||||
provenance: Provenance;
|
||||
turnId?: string;
|
||||
}
|
||||
|
||||
export class PendingEditRegistry {
|
||||
private pending: PendingEdit[] = [];
|
||||
|
||||
register(edit: PendingEdit): void {
|
||||
this.pending.push(edit);
|
||||
}
|
||||
|
||||
/** Remove a registration that failed to apply. */
|
||||
unregister(edit: PendingEdit): void {
|
||||
this.pending = this.pending.filter((p) => p !== edit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find-and-consume the registration exactly matching a change event
|
||||
* (same doc, same replaced range, same inserted text). Null → human edit.
|
||||
*/
|
||||
match(docPath: string, change: { start: number; end: number; text: string }): PendingEdit | null {
|
||||
const i = this.pending.findIndex(
|
||||
(p) =>
|
||||
p.docPath === docPath &&
|
||||
p.start === change.start &&
|
||||
p.end === change.end &&
|
||||
p.newText === change.text,
|
||||
);
|
||||
if (i === -1) return null;
|
||||
const [hit] = this.pending.splice(i, 1);
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { serializeArtifact, type Artifact } from "./model";
|
||||
import { emptyArtifact, serializeArtifact, type Artifact } from "./model";
|
||||
|
||||
export class CoauthorStore {
|
||||
/** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */
|
||||
@@ -28,4 +28,25 @@ export class CoauthorStore {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, serializeArtifact(artifact), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-modify-write for sidecar co-ownership (spec F3 §6.2): each controller
|
||||
* mutates only its own section against a FRESH load, so ThreadController and
|
||||
* AttributionController never clobber each other. After the mutation, anchors
|
||||
* referenced by neither threads nor attributions are pruned (proposals are
|
||||
* still empty in F3 — revisit in F4).
|
||||
*/
|
||||
update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
|
||||
const artifact = this.load(docPath) ?? emptyArtifact(docPath);
|
||||
mutate(artifact);
|
||||
const referenced = new Set<string>([
|
||||
...artifact.threads.map((t) => t.anchorId),
|
||||
...artifact.attributions.map((a) => a.anchorId),
|
||||
]);
|
||||
for (const id of Object.keys(artifact.anchors)) {
|
||||
if (!referenced.has(id)) delete artifact.anchors[id];
|
||||
}
|
||||
this.save(docPath, artifact);
|
||||
return artifact;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,12 @@ export class ThreadController implements vscode.Disposable {
|
||||
|
||||
private persist(state: DocState): void {
|
||||
this.selfWrites.add(this.store.sidecarPath(state.docPath));
|
||||
this.store.save(state.docPath, state.artifact);
|
||||
this.store.update(state.docPath, (a) => {
|
||||
a.threads = state.artifact.threads;
|
||||
for (const t of state.artifact.threads) {
|
||||
a.anchors[t.anchorId] = state.artifact.anchors[t.anchorId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ensureState(document: vscode.TextDocument): DocState {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PendingEditRegistry } from "../src/pendingEdits";
|
||||
import type { Provenance } from "../src/model";
|
||||
|
||||
const AGENT: Provenance = {
|
||||
kind: "agent", id: "claude",
|
||||
agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" },
|
||||
};
|
||||
|
||||
describe("PendingEditRegistry (INV-9)", () => {
|
||||
it("matches and consumes an exact registration", () => {
|
||||
const reg = new PendingEditRegistry();
|
||||
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1" });
|
||||
const hit = reg.match("d.md", { start: 3, end: 7, text: "new" });
|
||||
expect(hit?.turnId).toBe("t1");
|
||||
expect(reg.match("d.md", { start: 3, end: 7, text: "new" })).toBeNull();
|
||||
});
|
||||
it("does not match a different doc, range, or text (human typing stays human)", () => {
|
||||
const reg = new PendingEditRegistry();
|
||||
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT });
|
||||
expect(reg.match("other.md", { start: 3, end: 7, text: "new" })).toBeNull();
|
||||
expect(reg.match("d.md", { start: 3, end: 8, text: "new" })).toBeNull();
|
||||
expect(reg.match("d.md", { start: 3, end: 7, text: "neww" })).toBeNull();
|
||||
});
|
||||
it("unregister removes a failed application", () => {
|
||||
const reg = new PendingEditRegistry();
|
||||
const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT };
|
||||
reg.register(p);
|
||||
reg.unregister(p);
|
||||
expect(reg.match("d.md", { start: 0, end: 0, text: "x" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -54,4 +54,38 @@ describe("CoauthorStore", () => {
|
||||
expect(second).toBe(first);
|
||||
expect(first).toBe(serializeArtifact(a));
|
||||
});
|
||||
|
||||
it("update(): two writers merge sections instead of clobbering (F3 §6.2)", () => {
|
||||
const store = new CoauthorStore(root);
|
||||
store.update("d.md", (a) => {
|
||||
a.anchors["a_t"] = { fingerprint: { text: "t", before: "", after: "", lineHint: 0 } };
|
||||
a.threads.push({ id: "t1", anchorId: "a_t", status: "open", messages: [] });
|
||||
});
|
||||
store.update("d.md", (a) => {
|
||||
a.anchors["a_at"] = { fingerprint: { text: "x", before: "", after: "", lineHint: 0 } };
|
||||
a.attributions.push({
|
||||
id: "at1", anchorId: "a_at", author: { kind: "human", id: "ben" },
|
||||
createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
const loaded = store.load("d.md")!;
|
||||
expect(loaded.threads).toHaveLength(1);
|
||||
expect(loaded.attributions).toHaveLength(1);
|
||||
expect(Object.keys(loaded.anchors).sort()).toEqual(["a_at", "a_t"]);
|
||||
});
|
||||
|
||||
it("update() prunes anchors no longer referenced by threads or attributions", () => {
|
||||
const store = new CoauthorStore(root);
|
||||
store.update("d.md", (a) => {
|
||||
a.anchors["a_old"] = { fingerprint: { text: "o", before: "", after: "", lineHint: 0 } };
|
||||
a.attributions.push({
|
||||
id: "at1", anchorId: "a_old", author: { kind: "human", id: "ben" },
|
||||
createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
store.update("d.md", (a) => {
|
||||
a.attributions = [];
|
||||
});
|
||||
expect(Object.keys(store.load("d.md")!.anchors)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user