/** * Editing the plan the way an agent has to. * * An agent cannot hold a cursor, so it edits by position in the list of * top-level blocks. Positions move under concurrent editing, which is why a * batch names the revision it was written against and is refused outright if * the plan has moved on — a half-applied batch would be worse than none. */ import { beforeEach, describe, expect, it } from "bun:test"; import * as edit from "./edit"; import * as room from "./room"; import * as Questions from "../questions/store"; import type { Plan } from "./service"; const SOURCE = `# Title First paragraph. Second paragraph. `; const DIAGRAM = '```seecode\n{"type":"architecture","nodes":[{"id":"web","label":"Browser","row":0,"col":0},{"id":"api","label":"API","row":0,"col":1}],"edges":[["web","api"]]}\n```\n'; const MALFORMED_DIAGRAM = '```seecode\n{"type":\n```\n'; /** Enough of a plan for the edit engine, which only needs these fields. */ async function plan(source = SOURCE): Promise { return { document: await room.create(source), revision: 1, outlines: new Map(), questions: Questions.create(), } as Plan; } let subject: Plan; beforeEach(async () => { subject = await plan(); }); describe("reading", () => { it("describes each top-level block with a preview and a digest", () => { let blocks = edit.outline(subject); expect(blocks.map(block => block.type)).toEqual(["heading", "paragraph", "paragraph"]); expect(blocks[0]?.preview).toBe("Title"); expect(blocks[0]?.digest).toMatch(/^sha256:[0-9a-f]{64}$/); }); }); describe("applying a batch", () => { it("authors a valid diagram through the revision-checked edit path", () => { let outcome = edit.apply(subject, 1, [{ op: "insert", index: 1, source: DIAGRAM }]); expect(outcome).toMatchObject({ ok: true, touched: [2] }); expect(room.project(subject.document)).toContain(DIAGRAM.trim()); }); it("revises a diagram only when its new spec renders", async () => { let held = await plan(`# Title\n\n${DIAGRAM}`); expect(edit.apply(held, 1, [{ op: "replace", index: 1, source: MALFORMED_DIAGRAM }])) .toMatchObject({ ok: false, reason: "invalid" }); expect(edit.source(held)).toContain(DIAGRAM.trim()); expect( edit.apply(held, 1, [{ op: "replace", index: 1, source: DIAGRAM.replace("Browser", "Reader"), }]).ok, ).toBe(true); expect(edit.source(held)).toContain('"label":"Reader"'); }); it("moves a diagram without changing its canonical source", async () => { let held = await plan(`# Title\n\n${DIAGRAM}\nSurrounding prose.\n`); let outcome = edit.apply(held, 1, [{ op: "move", index: 1, to: 2 }]); expect(outcome.ok).toBe(true); expect(edit.source(held)).toContain(DIAGRAM.trim()); expect(edit.source(held).indexOf("Surrounding prose.")).toBeLessThan( edit.source(held).indexOf("```seecode"), ); }); it("inserts after the block it names", () => { let outcome = edit.apply(subject, 1, [{ op: "insert", index: 0, source: "Inserted.\n" }]); expect(outcome.ok).toBe(true); expect(room.project(subject.document)).toBe( "# Title\n\nInserted.\n\nFirst paragraph.\n\nSecond paragraph.\n", ); }); it("replaces, deletes and moves", () => { expect(edit.apply(subject, 1, [{ op: "replace", index: 1, source: "Rewritten.\n" }]).ok) .toBe(true); expect(room.project(subject.document)).toContain("Rewritten."); expect(edit.apply(subject, 1, [{ op: "delete", index: 2 }]).ok).toBe(true); expect(room.project(subject.document)).not.toContain("Second paragraph."); expect(edit.apply(subject, 1, [{ op: "move", index: 0, to: 1 }]).ok).toBe(true); expect(room.project(subject.document)).toBe("Rewritten.\n\n# Title\n"); }); /** * Indices are resolved against the document as it was read, so two * operations in one batch cannot shift each other's targets out from under * them. Without this, `delete 0` followed by `replace 1` would silently hit * the wrong block. */ it("resolves every index against the revision that was read", () => { let outcome = edit.apply(subject, 1, [ { op: "delete", index: 0 }, { op: "replace", index: 1, source: "Replaced first paragraph.\n" }, ]); expect(outcome.ok).toBe(true); expect(room.project(subject.document)) .toBe("Replaced first paragraph.\n\nSecond paragraph.\n"); }); it("mints identity for a component the agent writes without one", () => { let outcome = edit.apply(subject, 1, [{ op: "insert", index: 0, source: '\n\tMind this.\n\n', }]); expect(outcome.ok).toBe(true); expect(room.project(subject.document)).toMatch(/ { /** * Which blocks a turn authored is what lets a decision be pointed at the * prose it produced, even when the agent forgets to say so. Object identity * is what tells them apart: a node carried over from the parsed base is the * same object, a staged one is not. */ it("names the blocks it authored and not the ones it left alone", () => { let outcome = edit.apply(subject, 1, [{ op: "replace", index: 1, source: "Rewritten.\n" }]); expect(outcome).toMatchObject({ ok: true, touched: [1] }); }); it("names each of several", () => { let outcome = edit.apply(subject, 1, [ { op: "replace", index: 1, source: "One.\n" }, { op: "replace", index: 2, source: "Two.\n" }, ]); expect(outcome).toMatchObject({ ok: true, touched: [1, 2] }); }); it("names an insertion where it landed", () => { let outcome = edit.apply(subject, 1, [{ op: "insert", index: 0, source: "Added.\n" }]); expect(outcome).toMatchObject({ ok: true, touched: [1] }); }); /** Moving a block writes nothing: it is the same prose, further down. */ it("names nothing when a batch only moves blocks", () => { let outcome = edit.apply(subject, 1, [{ op: "move", index: 2, to: 0 }]); expect(outcome).toMatchObject({ ok: true, touched: [] }); }); it("names nothing when a batch only deletes", () => { let outcome = edit.apply(subject, 1, [{ op: "delete", index: 2 }]); expect(outcome).toMatchObject({ ok: true, touched: [] }); }); }); describe("refusing a batch", () => { it("rejects a malformed new diagram without applying surrounding prose", () => { let outcome = edit.apply(subject, 1, [ { op: "insert", index: 0, source: "New explanation.\n" }, { op: "insert", index: 1, source: MALFORMED_DIAGRAM }, ]); expect(outcome).toMatchObject({ ok: false, reason: "invalid" }); if (outcome.ok || outcome.reason !== "invalid") return; expect(outcome.message).toContain("valid JSON"); expect(outcome.message.length).toBeLessThan(300); expect(room.project(subject.document)).toBe(SOURCE); }); it("reports a diagram field error before changing the document", () => { let outcome = edit.apply(subject, 1, [{ op: "insert", index: 0, source: '```seecode\n{"type":"architecture"}\n```\n', }]); expect(outcome).toMatchObject({ ok: false, reason: "invalid" }); if (outcome.ok || outcome.reason !== "invalid") return; expect(outcome.message).toContain("nodes"); expect(room.project(subject.document)).toBe(SOURCE); }); it("allows unrelated edits and moves around an existing malformed diagram", async () => { let held = await plan(`# Title\n\n${MALFORMED_DIAGRAM}\nSurrounding prose.\n`); expect(edit.apply(held, 1, [{ op: "insert", index: 0, source: "Context.\n" }]).ok) .toBe(true); expect(edit.apply(held, 1, [{ op: "move", index: 1, to: 2 }]).ok).toBe(true); expect(room.project(held.document)).toContain(MALFORMED_DIAGRAM.trim()); }); it("refuses one aimed at a revision that has moved, and says what changed", () => { let outcome = edit.apply(subject, 0, [{ op: "delete", index: 0 }]); expect(outcome).toMatchObject({ ok: false, reason: "stale", revision: 1 }); // Untouched. expect(room.project(subject.document)).toBe(SOURCE); }); it("refuses a batch that targets one block twice", () => { let outcome = edit.apply(subject, 1, [ { op: "replace", index: 1, source: "One.\n" }, { op: "delete", index: 1 }, ]); expect(outcome).toMatchObject({ ok: false, reason: "invalid" }); expect(room.project(subject.document)).toBe(SOURCE); }); /** * `\n", }]); expect(outcome).toMatchObject({ ok: false, reason: "invalid" }); expect(room.project(subject.document)).toBe(SOURCE); }); it("refuses to leave the plan empty", () => { let outcome = edit.apply(subject, 1, [ { op: "delete", index: 0 }, { op: "delete", index: 1 }, { op: "delete", index: 2 }, ]); expect(outcome).toMatchObject({ ok: false, reason: "invalid" }); }); /** * A model that copies a block it read copies the id with it, and durable * state hangs off that id. Two blocks claiming one is worse than refusing. */ it("refuses a duplicated component id", async () => { let withCallout = await plan( '\n\tOne.\n\n', ); let outcome = edit.apply(withCallout, 1, [{ op: "insert", index: 0, source: '\n\tTwo.\n\n', }]); expect(outcome).toMatchObject({ ok: false, reason: "invalid" }); if (outcome.ok) return; expect(outcome.reason === "invalid" && outcome.message).toContain("appears twice"); }); it("refuses to author a questionnaire, which is asked rather than written", () => { let outcome = edit.apply(subject, 1, [{ op: "insert", index: 0, source: '\n\tx\n\n', }]); expect(outcome).toMatchObject({ ok: false, reason: "invalid" }); }); it("refuses to author a decision, which is accepted rather than written", () => { let outcome = edit.apply(subject, 1, [{ op: "insert", index: 0, source: '\n' + '\n\n', }]); expect(outcome).toMatchObject({ ok: false, reason: "invalid" }); }); it("refuses to author inline research owned by its request record", () => { let outcome = edit.apply(subject, 1, [{ op: "insert", index: 0, source: '\n', }]); expect(outcome).toMatchObject({ ok: false, reason: "invalid" }); if (outcome.ok || outcome.reason !== "invalid") return; expect(outcome.message).toContain("inline research"); }); /** * A decision is what the room settled, and the record it projects has no * way to hear that the plan no longer shows it. `detach_question` exists * because removing a questionnaire had to be deliberate; a decision is less * removable than a question, not more. */ it("refuses to delete or rewrite a decision while tidying around it", async () => { let held = await plan(); room.insertDecision(held.document, { id: "01K0N4TR8K7JGM4R1J7PW4R8YJ", quote: "First paragraph.", by: "ana", at: "2026-07-28T10:14:00Z", notes: [{ by: "ana", text: "Tighten this." }], }); let at = edit.outline(held).findIndex(block => block.type === "Decision"); expect(at).toBeGreaterThan(-1); expect(edit.apply(held, 1, [{ op: "delete", index: at }])) .toMatchObject({ ok: false, reason: "invalid" }); expect(edit.apply(held, 1, [{ op: "replace", index: at, source: "Gone.\n" }])) .toMatchObject({ ok: false, reason: "invalid" }); // Still there, and still saying what it said. expect(room.project(held.document)).toContain("Tighten this."); }); it("protects inline research while allowing unrelated Planner edits", async () => { let held = await plan( '# Title\n\n\n\nAfter.\n', ); let at = edit.outline(held).findIndex(block => block.type === "Research"); expect(at).toBe(1); let attempts: edit.Operation[][] = [ [{ op: "delete", index: at }], [{ op: "replace", index: at, source: "Gone.\n" }], [{ op: "move", index: at, to: 0 }], [{ op: "replace_root", source: "# Replacement\n" }], ]; for (let operations of attempts) { let outcome = edit.apply(held, 1, operations); expect(outcome).toMatchObject({ ok: false, reason: "invalid" }); if (!outcome.ok && outcome.reason === "invalid") { expect(outcome.message).toContain("inline research"); } } expect(edit.apply(held, 1, [{ op: "insert", index: 0, source: "Context.\n" }]).ok) .toBe(true); expect(room.project(held.document)).toContain(" { let held = await plan( '\n' + '\n' + "\n\nAfter.\n", ); let attempts: edit.Operation[][] = [ [{ op: "delete", index: 0 }], [{ op: "replace", index: 0, source: "Gone.\n" }], [{ op: "move", index: 0, to: 1 }], [{ op: "replace_root", source: "# Replacement\n" }], ]; for (let operations of attempts) { let outcome = edit.apply(held, 1, operations); expect(outcome).toMatchObject({ ok: false, reason: "invalid" }); if (!outcome.ok && outcome.reason === "invalid") { expect(outcome.message).toContain("inline research"); } } }); it("refuses an index the plan does not have", () => { expect(edit.apply(subject, 1, [{ op: "delete", index: 9 }])) .toMatchObject({ ok: false, reason: "invalid" }); }); }); describe("reconciliation", () => { /** * The reason an edit is reconciled rather than replacing the document: * blocks the agent did not touch keep their identity, so anyone editing * alongside it keeps their cursor, their selection and their undo history. */ it("leaves untouched blocks with the identity they had", () => { let keys = () => { let out: string[] = []; subject.document.editor.getEditorState().read(() => { // eslint-disable-next-line out = [...subject.document.editor.getEditorState()._nodeMap.keys()]; }); return out; }; let before = keys(); expect(edit.apply(subject, 1, [{ op: "replace", index: 2, source: "Rewritten.\n" }]).ok) .toBe(true); let after = keys(); // The heading and first paragraph survive; only the replaced block and // the root differ. let survived = before.filter(key => after.includes(key)); expect(survived.length).toBeGreaterThan(3); }); it("produces a delta to relay, not a whole document", () => { let outcome = edit.apply(subject, 1, [{ op: "insert", index: 0, source: "Added.\n" }]); expect(outcome.ok).toBe(true); if (!outcome.ok) return; expect(outcome.mutation).toBeDefined(); expect(outcome.mutation!.source).toContain("Added."); }); }); /** * What a batch did, so a reader can be shown where. * * The two halves are derived differently on purpose, and the difference is * what these pin: identity answers what was written, and cannot be fooled; * the operations answer what moved or went, which identity cannot tell apart * from a block merely pushed down by an insert above it. */ describe("reporting what a batch did", () => { /** Every change, in the order the reader will meet them. */ function changes(outcome: edit.Result): edit.Change[] { expect(outcome.ok).toBe(true); return outcome.ok ? outcome.changes : []; } it("reports a block it wrote, with enough of it to recognise", () => { let found = changes( edit.apply(subject, 1, [{ op: "insert", index: 0, source: "Inserted.\n" }]), ); expect(found).toEqual([ { kind: "added", index: 1, type: "paragraph", preview: "Inserted." }, ]); }); /** * The case index comparison gets wrong. Everything below an insert shifts * down by one, and a diff that read position as movement would light up * the whole document for an edit that touched one block. */ it("does not report blocks an insert pushed down as having moved", () => { let found = changes( edit.apply(subject, 1, [{ op: "insert", index: 0, source: "Inserted.\n" }]), ); expect(found.filter(change => change.kind === "moved")).toEqual([]); }); it("reports a rewrite as what it wrote, and leaves no hole", () => { let found = changes( edit.apply(subject, 1, [{ op: "replace", index: 1, source: "Rewritten.\n" }]), ); expect(found).toEqual([ { kind: "added", index: 1, type: "paragraph", preview: "Rewritten." }, ]); }); it("names the hole a deletion left by the block that followed it", () => { let found = changes(edit.apply(subject, 1, [{ op: "delete", index: 1 }])); expect(found).toEqual([ { kind: "removed", at: { index: 1, side: "before" }, blocks: [{ type: "paragraph", preview: "First paragraph." }], }, ]); }); it("names a hole at the end by the block before it", () => { let found = changes(edit.apply(subject, 1, [{ op: "delete", index: 2 }])); expect(found).toEqual([ { kind: "removed", at: { index: 1, side: "after" }, blocks: [{ type: "paragraph", preview: "Second paragraph." }], }, ]); }); /** Three blocks deleted in a row is one hole in the prose, not three. */ it("collapses deletions that left the same space into one hole", () => { let found = changes( edit.apply(subject, 1, [{ op: "delete", index: 1 }, { op: "delete", index: 2 }]), ); expect(found).toEqual([ { kind: "removed", at: { index: 0, side: "after" }, blocks: [ { type: "paragraph", preview: "First paragraph." }, { type: "paragraph", preview: "Second paragraph." }, ], }, ]); }); it("reports a move as one change, at both ends", () => { let found = changes(edit.apply(subject, 1, [{ op: "move", index: 0, to: 2 }])); expect(found).toEqual([ { kind: "moved", index: 2, from: { index: 0, side: "before" }, type: "heading", preview: "Title", }, ]); }); it("reports nothing for a move that goes nowhere", () => { expect(changes(edit.apply(subject, 1, [{ op: "move", index: 1, to: 1 }]))).toEqual([]); }); /** * The exclusion that stops a hole pointing at the wrong prose. * * The deleted paragraph sat between the heading and the block after it, * and that block is the one this batch sends to the top. Naming the hole * by it would put the mark above the whole document, nowhere near where * the content was, so the search has to pass over anything that vacated * and settle on the heading instead. */ it("does not name a hole by a block that has itself gone elsewhere", () => { let found = changes( edit.apply(subject, 1, [{ op: "delete", index: 1 }, { op: "move", index: 2, to: 0 }]), ); expect(room.project(subject.document)).toBe("Second paragraph.\n\n# Title\n"); // The heading, which ended up last — not the paragraph now at the top. expect(found.find(change => change.kind === "removed")) .toMatchObject({ at: { index: 1, side: "after" } }); }); /** A document marked from end to end says nothing at all. */ it("reports nothing when the whole plan was replaced", () => { expect(changes(edit.apply(subject, 1, [{ op: "replace_root", source: "# New\n" }]))) .toEqual([]); }); it("reads in document order", () => { let found = changes( edit.apply(subject, 1, [ { op: "insert", index: 2, source: "Last.\n" }, { op: "insert", index: 0, source: "Early.\n" }, ]), ); expect(found.map(change => change.kind === "removed" ? change.at.index : change.index)) .toEqual([1, 4]); }); }); describe("replacing canonical source", () => { it("validates changed diagrams but carries unchanged malformed ones", async () => { let held = await plan(`# Title\n\n${MALFORMED_DIAGRAM}\nSurrounding prose.\n`); let current = edit.source(held); expect(edit.replace(held, 1, current.replace("Surrounding", "Revised")).ok).toBe(true); let before = edit.source(held); let invalid = edit.replace(held, 1, `${before}\n${MALFORMED_DIAGRAM}`); expect(invalid).toMatchObject({ ok: false, reason: "invalid" }); expect(edit.source(held)).toBe(before); expect(edit.replace(held, 1, `${before}\n${DIAGRAM}`).ok).toBe(true); }); it("rewrites prose while keeping unchanged block identity", () => { let keys = () => { let out: string[] = []; subject.document.editor.getEditorState().read(() => { out = [...subject.document.editor.getEditorState()._nodeMap.keys()]; }); return out; }; let before = keys(); let outcome = edit.replace(subject, 1, "# Title\n\nFirst paragraph.\n\nRewritten.\n"); expect(outcome.ok).toBe(true); if (!outcome.ok) return; expect(room.project(subject.document)).toContain("Rewritten."); expect(outcome.changes).toEqual([ { kind: "added", index: 2, type: "paragraph", preview: "Rewritten." }, ]); let survived = before.filter(key => keys().includes(key)); expect(survived.length).toBeGreaterThan(3); }); /** A stored link the newer URL rules refuse stays; a new one is not let in. */ it("lets the Planner edit around a stored link only the newer URL rules refuse", async () => { let stored = "# Title\n\nRead [the notes](docs\\notes.md).\n"; let held = await plan(stored); let current = room.project(held.document); expect(edit.replace(held, 1, `${current}\nAdded.\n`).ok).toBe(true); let next = room.project(held.document); expect(edit.replace(held, 1, `${next}\nSee [this](//evil.com).\n`)) .toMatchObject({ ok: false, reason: "invalid" }); expect(edit.replace(held, 1, `${next}\nSee [this](https://example.com).\n`).ok).toBe(true); }); it("refuses dropping or forging a decision projection", async () => { let held = await plan(); room.insertDecision(held.document, { id: "01K0N4TR8K7JGM4R1J7PW4R8YJ", quote: "First paragraph.", by: "ana", at: "2026-07-28T10:14:00Z", notes: [{ by: "ana", text: "Tighten this." }], }); let current = room.project(held.document); expect(edit.replace(held, 1, "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n")) .toMatchObject({ ok: false, reason: "invalid" }); expect(edit.replace( held, 1, `${current}\n\n\n` + `\n\n`, )).toMatchObject({ ok: false, reason: "invalid", }); expect(room.project(held.document)).toBe(current); }); it("keeps an unchanged decision while rewriting surrounding prose", async () => { let held = await plan(); room.insertDecision(held.document, { id: "01K0N4TR8K7JGM4R1J7PW4R8YJ", quote: "First paragraph.", by: "ana", at: "2026-07-28T10:14:00Z", notes: [{ by: "ana", text: "Tighten this." }], }); let current = room.project(held.document); let next = current.replace("First paragraph.", "Updated first paragraph."); let outcome = edit.replace(held, 1, next); expect(outcome.ok).toBe(true); expect(room.project(held.document)).toContain("Updated first paragraph."); expect(room.project(held.document)).toContain("Tighten this."); }); it("preserves questionnaire answers and nested research projections", async () => { let projections = [ ` `, ` `, ]; for (let projection of projections) { let held = await plan(`# Title\n\n${projection}\n\nAfter.\n`); let current = room.project(held.document); for ( let next of [ "# Title\n\nAfter.\n", current.replace("01K0N4TR8K7JGM4R1J7PW4R8YJ", "01K0N4TR8K7JGM4R1J7PW4R8YM"), ] ) { expect(edit.replace(held, 1, next)).toMatchObject({ ok: false, reason: "invalid" }); expect(room.project(held.document)).toBe(current); } if (current.includes("Settled")) { expect(edit.replace(held, 1, current.replace("Settled", "Changed"))) .toMatchObject({ ok: false, reason: "invalid" }); } expect(edit.replace(subject, 1, `${SOURCE}\n${projection}\n`)) .toMatchObject({ ok: false, reason: "invalid" }); expect(edit.replace(held, 1, current.replace("After.", "Revised prose.")).ok).toBe(true); expect(room.project(held.document)).toContain("Revised prose."); } }); it("leaves identical source and duplicate blocks untouched", async () => { let held = await plan("# Title\n\nSame paragraph.\n\nSame paragraph.\n"); let current = room.project(held.document); let result = edit.replace(held, 1, current); expect(result).toMatchObject({ ok: true, mutation: undefined, touched: [], changes: [] }); }); });