File size: 6,143 Bytes
d63dcfe | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | /**
* Agent Undo Batch Tests
*
* Covers `docs/TESTS.md` 5.3 - undo batching for AI-agent edits.
*
* The agent runs multiple tool calls per turn (replaceSelection, applyDiff,
* updateFrontmatter, ...). Each tool call triggers one or more Yjs
* transactions. Without batching, the user would have to press Cmd+Z three
* times to revert a single agent turn. We bracket the turn with
* `startAgentBatch()` / `endAgentBatch()` so it collapses into one undo step.
*
* These tests target `createAgentBatch` against a real `Y.Doc` +
* `UndoManager`, mirroring what `useAgentChat` wires at runtime. We don't
* spin up TipTap or React because the contract being verified ("3
* collaborative edits inside the batch revert with a single undo()") is
* purely a Yjs concern.
*/
import { describe, it, expect } from "vitest";
import * as Y from "yjs";
import { createAgentBatch } from "../src/editor/agent-undo-batch";
const AGENT_ORIGIN = Symbol("agent");
const USER_ORIGIN = Symbol("user");
interface Fixture {
ydoc: Y.Doc;
body: Y.XmlFragment;
frontmatter: Y.Map<unknown>;
undoManager: Y.UndoManager;
batch: ReturnType<typeof createAgentBatch>;
}
function makeFixture(): Fixture {
const ydoc = new Y.Doc();
const body = ydoc.getXmlFragment("default");
const frontmatter = ydoc.getMap("frontmatter");
// Mirror `@tiptap/extension-collaboration`'s setup: one UndoManager that
// watches both the doc body and the frontmatter map, so a single undo()
// can revert text edits AND metadata mutations together.
const undoManager = new Y.UndoManager([body, frontmatter], {
captureTimeout: 500,
trackedOrigins: new Set([AGENT_ORIGIN, USER_ORIGIN]),
});
return { ydoc, body, frontmatter, undoManager, batch: createAgentBatch(undoManager) };
}
/**
* Helper that mimics a TipTap text insertion: appends a paragraph element
* containing the given text to the document body.
*/
function appendParagraph(ydoc: Y.Doc, body: Y.XmlFragment, text: string, origin: symbol) {
ydoc.transact(() => {
const p = new Y.XmlElement("paragraph");
p.insert(0, [new Y.XmlText(text)]);
body.push([p]);
}, origin);
}
/**
* Helper that mimics agent `applyDiff`: rewrites the text content of a
* specific paragraph in place.
*/
function rewriteParagraph(
ydoc: Y.Doc,
body: Y.XmlFragment,
index: number,
newText: string,
origin: symbol,
) {
ydoc.transact(() => {
const p = body.get(index) as Y.XmlElement;
const t = p.get(0) as Y.XmlText;
t.delete(0, t.length);
t.insert(0, newText);
}, origin);
}
function bodyAsText(body: Y.XmlFragment): string[] {
const out: string[] = [];
for (let i = 0; i < body.length; i++) {
const node = body.get(i) as Y.XmlElement;
const t = node.get(0) as Y.XmlText;
out.push(t.toString());
}
return out;
}
describe("agent-undo-batch — primitive contract", () => {
it("startAgentBatch + endAgentBatch are idempotent", () => {
const { batch } = makeFixture();
expect(batch.isActive()).toBe(false);
batch.startAgentBatch();
expect(batch.isActive()).toBe(true);
// Calling start again is a no-op.
batch.startAgentBatch();
expect(batch.isActive()).toBe(true);
batch.endAgentBatch();
expect(batch.isActive()).toBe(false);
// Calling end again is a no-op.
batch.endAgentBatch();
expect(batch.isActive()).toBe(false);
});
it("is a noop when the UndoManager is null", () => {
const batch = createAgentBatch(null);
expect(() => batch.startAgentBatch()).not.toThrow();
expect(() => batch.endAgentBatch()).not.toThrow();
expect(batch.isActive()).toBe(false);
});
});
describe("agent-undo-batch — 5.3.1 batched undo (P0)", () => {
it("reverts 3 chained agent edits with a single undo()", () => {
const { ydoc, body, frontmatter, undoManager, batch } = makeFixture();
// Simulate a prior user edit that should NOT be touched by the agent's
// single undo step. We give it a separate origin so its capture window
// is closed before the agent batch starts.
appendParagraph(ydoc, body, "User wrote this first.", USER_ORIGIN);
expect(bodyAsText(body)).toEqual(["User wrote this first."]);
// --- Agent turn ---------------------------------------------------------
batch.startAgentBatch();
// Tool 1: replaceSelection-style rewrite of the existing paragraph.
rewriteParagraph(ydoc, body, 0, "User wrote this first. (refined by agent)", AGENT_ORIGIN);
// Tool 2: insertAtCursor-style append of a new paragraph.
appendParagraph(ydoc, body, "Agent added a follow-up.", AGENT_ORIGIN);
// Tool 3: updateFrontmatter-style metadata change.
ydoc.transact(() => {
frontmatter.set("title", "New title from agent");
}, AGENT_ORIGIN);
batch.endAgentBatch();
// Sanity check: all three changes landed.
expect(bodyAsText(body)).toEqual([
"User wrote this first. (refined by agent)",
"Agent added a follow-up.",
]);
expect(frontmatter.get("title")).toBe("New title from agent");
// --- Undo once ----------------------------------------------------------
undoManager.undo();
// All three agent edits revert together; the user paragraph stays.
expect(bodyAsText(body)).toEqual(["User wrote this first."]);
expect(frontmatter.get("title")).toBeUndefined();
});
it("does not lump the previous user edit into the agent step", () => {
const { ydoc, body, undoManager, batch } = makeFixture();
// Two distinct user edits, then an agent batch.
appendParagraph(ydoc, body, "First user paragraph.", USER_ORIGIN);
appendParagraph(ydoc, body, "Second user paragraph.", USER_ORIGIN);
batch.startAgentBatch();
appendParagraph(ydoc, body, "Agent paragraph.", AGENT_ORIGIN);
batch.endAgentBatch();
expect(bodyAsText(body)).toEqual([
"First user paragraph.",
"Second user paragraph.",
"Agent paragraph.",
]);
// First undo: only the agent edit reverts.
undoManager.undo();
expect(bodyAsText(body)).toEqual([
"First user paragraph.",
"Second user paragraph.",
]);
});
});
|