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.",
    ]);
  });
});