diff --git a/src/agent/prompts/10-plan-tree.md b/src/agent/prompts/10-plan-tree.md new file mode 100644 index 0000000..f8c3db9 --- /dev/null +++ b/src/agent/prompts/10-plan-tree.md @@ -0,0 +1,32 @@ +# The plan tree + +Your sessions have a formal dependency tree — the session's map, one object with two faces: you author it with the `plan_*` tools, and the learner SEES it, live, in a side panel. Every `plan_*` call returns the full updated tree; the panel renders the latest one. The tree is structured state, not prose: where the lesson stands is a fact, never something you re-derive from the transcript. + +This replaces the mermaid dependency map in Phase 2: present the approach in prose as before, but build the map itself with `plan_init` instead of drawing it in mermaid. Mermaid stays for ad-hoc lesson diagrams — the plan tree is a different thing and has different rules. + +## When + +Probe first — that never changes. Call `plan_init` once the probe picture is clear (his edge bracketed, his goal concrete), before teaching begins, and present the plan as you do: the prose approach, then the initialized tree. Then teach through it. + +## Grain + +The tree is a **map, not a syllabus**. + +- One reasoning step per node. If a node needs three explanations to teach, it's three nodes. +- `title` ≤ 6 words, `summary` 1–2 sentences. +- Typically 5–15 nodes per session. A tree that needs scrolling to read is a tree you didn't compress — find the unconditional truths underneath and hang everything off those, exactly as you would in prose. +- Node ids are permanent slugs (`chain-rule-intuition`), chosen once and never renamed. Edges are `requires` edges: from = must be understood before to. Direction is a teaching fact, not decoration. + +## Discipline + +- `plan_set_current` the moment you START teaching a node. The learner should always see where they are. +- `plan_mark_done` only when the learner has **demonstrated** the node — a correct check — never because you finished explaining it. Explaining is yours; done is theirs. +- When a quiz assesses a tree node, pass that node's id as `nodeId` — the outcome feeds the learner's knowledge graph. Untagged outcomes are lost signal. + +## Freedom + +The tree is yours to restructure mid-session, at your own initiative. This is not an exception — it's the point. + +- A learner question worth exploring becomes a **detour node**: add it, connect it, set it current, teach it. +- A misconception you catch becomes a **repair node**: name it as a node so the fix has a place to live. +- Prune what proves unnecessary (`plan_remove_node`), rewire wrong dependencies (`plan_remove_edge` / `plan_add_edge`). A map that can't change is a wrong map. diff --git a/src/agent/tools/plan.ts b/src/agent/tools/plan.ts index a1df1e2..d18cac9 100644 --- a/src/agent/tools/plan.ts +++ b/src/agent/tools/plan.ts @@ -1,16 +1,176 @@ -import type { ToolSet } from "ai"; +import { tool, type ToolSet } from "ai"; +import { z } from "zod"; +import type { PlanToolResult, PlanTree } from "../../core/contracts.ts"; +import { + addEdge, + addNode, + createTree, + emptyTree, + markDone, + PlanTreeError, + removeEdge, + removeNode, + setCurrent, + updateNode, +} from "../../core/plan-tree.ts"; +import { getChat, saveTree } from "../../store/chats.ts"; import type { TeacherToolContext } from "../tools.ts"; /** * Plan-tree tools — W1 workstream (docs/plan.md Wave 1; src/core/contracts.ts - * "Plan tree" section). Server-side tools WITH execute that mutate the chat's - * stored PlanTree and return the full updated tree (PlanToolResult). + * "Plan tree"). SERVER-SIDE tools WITH execute: read the chat record, apply a + * pure validated op from src/core/plan-tree.ts, persist via saveTree, and + * return PlanToolResult — the FULL updated tree every time, so the UI tree + * panel renders the latest state straight from the transcript's tool outputs. * - * Tool names to implement: plan_init, plan_add_node, plan_update_node, - * plan_remove_node, plan_add_edge, plan_remove_edge, plan_set_current, - * plan_mark_done. + * Edges are `requires` edges: from = prerequisite, to = dependent + * ("from must be understood before to"). */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars + +const nodeId = z + .string() + .min(1) + .describe("Stable node slug, e.g. 'chain-rule-intuition'. Ids are permanent — never reuse or rename one mid-session."); + +const nodeSchema = z.object({ + id: nodeId.describe("Stable node slug, e.g. 'chain-rule-intuition'. Ids are permanent — never reuse or rename one mid-session."), + title: z.string().min(1).describe("Short label for the tree panel — at most ~6 words."), + summary: z.string().optional().describe("1-2 sentences: what this node covers."), +}); + +const edgeSchema = z.object({ + from: nodeId.describe("Prerequisite node id (must be understood before `to`)."), + to: nodeId.describe("Node id that depends on `from`."), +}); + +/** + * Shared execute wrapper: load record → apply pure op → persist → PlanToolResult. + * PlanTreeError and missing chats become {ok:false} with the current (or + * empty) tree, so the model can see what it's working with and fix the call. + */ +async function runPlanOp( + chatId: string, + op: (tree: PlanTree) => PlanTree, +): Promise { + const record = getChat(chatId); + if (!record) return { ok: false, error: `Chat not found: ${chatId}`, tree: emptyTree() }; + const current = record.tree ?? emptyTree(); + try { + const next = op(current); + saveTree(chatId, next); + return { ok: true, tree: next }; + } catch (err) { + if (err instanceof PlanTreeError) return { ok: false, error: err.message, tree: current }; + throw err; + } +} + export function planTools(ctx: TeacherToolContext): ToolSet { - return {}; + return { + plan_init: tool({ + description: + "Initialize this session's plan tree — the dependency map of the lesson, which the learner sees live in a side panel. " + + "Call ONCE, after probing, before teaching begins: pass the full initial node set and the `requires` edges between them " + + "(edge direction: from = prerequisite, to = dependent — from must be understood before to). The tree must be a DAG (no cycles). " + + "Refuses to run if a tree already exists — after that, use the other plan_* tools to evolve it.", + inputSchema: z.object({ + nodes: z.array(nodeSchema).min(1).describe("The initial nodes. A map, not a syllabus: one reasoning step per node."), + edges: z + .array(edgeSchema) + .optional() + .describe("Initial `requires` edges between the nodes. Edge direction: from = prerequisite, to = dependent."), + }), + execute: async ({ nodes, edges }): Promise => { + const record = getChat(ctx.chatId); + if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}`, tree: emptyTree() }; + const existing = record.tree; + if (existing && existing.nodes.length > 0) { + return { + ok: false, + error: "Tree already initialized — use the mutation tools", + tree: existing, + }; + } + return runPlanOp(ctx.chatId, () => createTree(nodes, edges ?? [])); + }, + }), + + plan_add_node: tool({ + description: + "Add one node to the plan tree. Optionally pass `parents`: node ids this new node depends on (a `requires` edge parent → this node is " + + "created for each; a parent must be understood before this node). Parents must already exist; the result must stay a DAG.", + inputSchema: z.object({ + id: nodeId, + title: z.string().min(1).describe("Short label for the tree panel — at most ~6 words."), + summary: z.string().optional().describe("1-2 sentences: what this node covers."), + parents: z + .array(nodeId) + .optional() + .describe("Ids of prerequisite nodes — each gets a `requires` edge pointing to this node. All must already exist."), + }), + execute: async ({ id, title, summary, parents }): Promise => + runPlanOp(ctx.chatId, (tree) => addNode(tree, { id, title, ...(summary !== undefined ? { summary } : {}) }, parents)), + }), + + plan_update_node: tool({ + description: + "Update a node's `title` and/or `summary`. The node must exist; the id never changes. Use this to sharpen a vague node, not to change its meaning.", + inputSchema: z + .object({ + id: nodeId, + title: z.string().min(1).optional().describe("New short label (at most ~6 words)."), + summary: z.string().optional().describe("New 1-2 sentence summary."), + }) + .refine((v) => v.title !== undefined || v.summary !== undefined, { + message: "Pass at least one of `title` or `summary` — an update with neither changes nothing.", + }), + execute: async ({ id, title, summary }): Promise => + runPlanOp(ctx.chatId, (tree) => updateNode(tree, id, { ...(title !== undefined ? { title } : {}), ...(summary !== undefined ? { summary } : {}) })), + }), + + plan_remove_node: tool({ + description: + "Remove a node from the plan tree — prune what proves unnecessary. Cascades: every edge touching the node (both directions), its current " + + "status, and its done status are removed with it.", + inputSchema: z.object({ id: nodeId }), + execute: async ({ id }): Promise => runPlanOp(ctx.chatId, (tree) => removeNode(tree, id)), + }), + + plan_add_edge: tool({ + description: + "Add a `requires` edge to the plan tree. Direction: from = prerequisite, to = dependent — `from` must be understood before `to`. " + + "Both nodes must exist; self-loops, duplicate edges, and anything that would create a cycle are rejected.", + inputSchema: z.object({ + from: nodeId.describe("Prerequisite node id (must be understood before `to`)."), + to: nodeId.describe("Dependent node id (requires `from`)."), + }), + execute: async ({ from, to }): Promise => runPlanOp(ctx.chatId, (tree) => addEdge(tree, from, to)), + }), + + plan_remove_edge: tool({ + description: + "Remove an existing `requires` edge from the plan tree (the nodes stay). Use when a dependency turns out to be wrong or unnecessary.", + inputSchema: z.object({ + from: nodeId.describe("Prerequisite end of the edge to remove."), + to: nodeId.describe("Dependent end of the edge to remove."), + }), + execute: async ({ from, to }): Promise => runPlanOp(ctx.chatId, (tree) => removeEdge(tree, from, to)), + }), + + plan_set_current: tool({ + description: + "Set the CURRENT node — where the lesson is right now. Call it the moment you START teaching a node, so the learner sees their position " + + "on the map. The node must exist. This marks position only; it does not mark anything done.", + inputSchema: z.object({ id: nodeId.describe("The node you are starting to teach now.") }), + execute: async ({ id }): Promise => runPlanOp(ctx.chatId, (tree) => setCurrent(tree, id)), + }), + + plan_mark_done: tool({ + description: + "Mark a node DONE — and only when the learner has DEMONSTRATED it (a correct check), not when you finished explaining it. " + + "The node must exist. Idempotent.", + inputSchema: z.object({ id: nodeId.describe("The node the learner just demonstrated.") }), + execute: async ({ id }): Promise => runPlanOp(ctx.chatId, (tree) => markDone(tree, id)), + }), + }; } diff --git a/src/core/plan-tree.ts b/src/core/plan-tree.ts new file mode 100644 index 0000000..536b806 --- /dev/null +++ b/src/core/plan-tree.ts @@ -0,0 +1,226 @@ +import type { PlanEdge, PlanNode, PlanTree } from "./contracts.ts"; + +/** + * Pure, validated plan-tree operations (vision §2 "the formal plan tree"). + * + * Every function takes a tree and returns a NEW tree — never mutates its + * input. All validation throws PlanTreeError with a message written for the + * agent (it flows straight back as a tool-result error), stating exactly what + * is wrong and which id/edge is at fault. + * + * Invariants enforced everywhere: + * - Node ids are unique and non-empty; every edge endpoint references an + * existing node. + * - The structure is a DAG: no self-loops, no duplicate edges, no cycles. + * - `version` increments on every successful STATE-CHANGING op. True no-ops + * (setCurrent to the already-current node, markDone of an already-done + * node) return the input tree unchanged — version drives UI re-animation, + * so a non-change must not tick it. createTree always starts at version 1. + */ + +/** Error thrown by every op on invalid input. Message is agent-readable. */ +export class PlanTreeError extends Error { + constructor(message: string) { + super(message); + this.name = "PlanTreeError"; + } +} + +/** An empty, never-stored tree used as the seed before plan_init runs. */ +export function emptyTree(): PlanTree { + return { nodes: [], edges: [], currentNodeId: null, doneNodeIds: [], version: 0 }; +} + +// ─── validation helpers ────────────────────────────────────────────────────── + +function assertValidNode(node: PlanNode): void { + if (!node || typeof node.id !== "string" || node.id.trim() === "") { + throw new PlanTreeError("Every node needs a non-empty `id` (a stable slug like 'chain-rule-intuition')."); + } + if (typeof node.title !== "string" || node.title.trim() === "") { + throw new PlanTreeError(`Node "${node.id}" needs a non-empty \`title\`.`); + } +} + +function nodeMap(nodes: PlanNode[]): Map { + const map = new Map(); + for (const node of nodes) { + assertValidNode(node); + if (map.has(node.id)) { + throw new PlanTreeError(`Duplicate node id "${node.id}" — node ids must be unique.`); + } + map.set(node.id, node); + } + return map; +} + +function requireNode(map: Map, id: string, what: string): void { + if (!map.has(id)) { + throw new PlanTreeError(`${what} references unknown node id "${id}" — it is not in the tree.`); + } +} + +function edgeKey(from: string, to: string): string { + return `${from}\u0000${to}`; +} + +/** + * Would adding edge from→to create a cycle? Walk forward from `to` along + * existing edges; if we can reach `from`, the new edge closes a loop. + */ +function createsCycle(edges: PlanEdge[], from: string, to: string): boolean { + const adjacency = new Map(); + for (const e of edges) { + const list = adjacency.get(e.from); + if (list) list.push(e.to); + else adjacency.set(e.from, [e.to]); + } + const stack = [to]; + const seen = new Set(); + while (stack.length > 0) { + const current = stack.pop()!; + if (current === from) return true; + if (seen.has(current)) continue; + seen.add(current); + for (const next of adjacency.get(current) ?? []) stack.push(next); + } + return false; +} + +/** Full edge validation against existing nodes/edges: endpoints exist, no self-loop, no duplicate, acyclic. */ +function assertValidEdge(from: string, to: string, nodes: PlanNode[], existingEdges: PlanEdge[]): void { + const map = nodeMap(nodes); // also re-validates nodes on createTree + requireNode(map, from, "Edge `from`"); + requireNode(map, to, "Edge `to`"); + if (from === to) { + throw new PlanTreeError(`Edge "${from}" → "${to}" is a self-loop — a node can't require itself.`); + } + if (existingEdges.some((e) => e.from === from && e.to === to)) { + throw new PlanTreeError(`Edge "${from}" → "${to}" already exists — duplicate edges are not allowed.`); + } + if (createsCycle(existingEdges, from, to)) { + throw new PlanTreeError( + `Edge "${from}" → "${to}" would create a cycle: "${to}" already leads (directly or transitively) back to "${from}". ` + + `"from" must be understood before "to" — the tree is a DAG.`, + ); + } +} + +// ─── ops ───────────────────────────────────────────────────────────────────── + +/** Build a fresh tree (version 1) from node and edge lists, validating all invariants. */ +export function createTree(nodes: PlanNode[], edges: PlanEdge[]): PlanTree { + const map = nodeMap(nodes); + for (const e of edges) { + requireNode(map, e.from, "Edge `from`"); + requireNode(map, e.to, "Edge `to`"); + } + const accepted: PlanEdge[] = []; + for (const e of edges) { + // Each edge is checked against the ones already accepted, so any + // violation names the culprit edge. + assertValidEdge(e.from, e.to, nodes, accepted); + accepted.push({ from: e.from, to: e.to }); + } + return { nodes: nodes.map((n) => ({ ...n })), edges: accepted, currentNodeId: null, doneNodeIds: [], version: 1 }; +} + +/** Add a node, optionally creating edges parent → new node for each id in `parents`. */ +export function addNode(tree: PlanTree, node: PlanNode, parents?: string[]): PlanTree { + assertValidNode(node); + if (tree.nodes.some((n) => n.id === node.id)) { + throw new PlanTreeError(`Duplicate node id "${node.id}" — a node with that id already exists in the tree.`); + } + const parentList = parents ?? []; + if (parentList.includes(node.id)) { + throw new PlanTreeError(`Node "${node.id}" can't list itself as a parent.`); + } + const newEdges: PlanEdge[] = []; + for (const parent of parentList) { + // Validates existence (including the new node itself as `to`), + // self-loop, duplicate (against tree edges plus the new edges being + // added), and acyclicity. + assertValidEdge(parent, node.id, [...tree.nodes, node], [...tree.edges, ...newEdges]); + newEdges.push({ from: parent, to: node.id }); + } + return { + ...tree, + nodes: [...tree.nodes, { ...node }], + edges: [...tree.edges, ...newEdges], + version: tree.version + 1, + }; +} + +/** Update a node's title and/or summary. The id must exist; ids are immutable. */ +export function updateNode(tree: PlanTree, id: string, patch: { title?: string; summary?: string }): PlanTree { + const index = tree.nodes.findIndex((n) => n.id === id); + if (index === -1) { + throw new PlanTreeError(`Cannot update node "${id}" — it is not in the tree.`); + } + const current = tree.nodes[index]!; + const next: PlanNode = { ...current }; + if (patch.title !== undefined) next.title = patch.title; + if (patch.summary !== undefined) next.summary = patch.summary; + assertValidNode(next); + const nodes = tree.nodes.slice(); + nodes[index] = next; + return { ...tree, nodes, version: tree.version + 1 }; +} + +/** + * Remove a node and cascade: every edge touching it (both directions), + * currentNodeId (→ null if it pointed here), and its doneNodeIds entry. + */ +export function removeNode(tree: PlanTree, id: string): PlanTree { + if (!tree.nodes.some((n) => n.id === id)) { + throw new PlanTreeError(`Cannot remove node "${id}" — it is not in the tree.`); + } + return { + ...tree, + nodes: tree.nodes.filter((n) => n.id !== id), + edges: tree.edges.filter((e) => e.from !== id && e.to !== id), + currentNodeId: tree.currentNodeId === id ? null : tree.currentNodeId, + doneNodeIds: tree.doneNodeIds.filter((d) => d !== id), + version: tree.version + 1, + }; +} + +/** Add a `requires` edge from → to. Rejects self-loops, duplicates, and cycles. */ +export function addEdge(tree: PlanTree, from: string, to: string): PlanTree { + assertValidEdge(from, to, tree.nodes, tree.edges); + return { ...tree, edges: [...tree.edges, { from, to }], version: tree.version + 1 }; +} + +/** Remove an existing edge. Removing a nonexistent edge is an error. */ +export function removeEdge(tree: PlanTree, from: string, to: string): PlanTree { + const index = tree.edges.findIndex((e) => e.from === from && e.to === to); + if (index === -1) { + throw new PlanTreeError(`Cannot remove edge "${from}" → "${to}" — no such edge exists.`); + } + return { ...tree, edges: tree.edges.filter((e) => e.from !== from || e.to !== to), version: tree.version + 1 }; +} + +/** + * Set currentNodeId ("where we are"). Requires the node exists. Does NOT + * touch doneNodeIds. Setting the already-current node is a true no-op (no + * version bump). + */ +export function setCurrent(tree: PlanTree, id: string): PlanTree { + if (!tree.nodes.some((n) => n.id === id)) { + throw new PlanTreeError(`Cannot set current node "${id}" — it is not in the tree.`); + } + if (tree.currentNodeId === id) return tree; + return { ...tree, currentNodeId: id, version: tree.version + 1 }; +} + +/** + * Mark a node done. Requires the node exists; idempotent — marking an + * already-done node is a true no-op (no version bump). + */ +export function markDone(tree: PlanTree, id: string): PlanTree { + if (!tree.nodes.some((n) => n.id === id)) { + throw new PlanTreeError(`Cannot mark node "${id}" done — it is not in the tree.`); + } + if (tree.doneNodeIds.includes(id)) return tree; + return { ...tree, doneNodeIds: [...tree.doneNodeIds, id], version: tree.version + 1 }; +} diff --git a/tests/plan-tree.test.ts b/tests/plan-tree.test.ts new file mode 100644 index 0000000..d19a43d --- /dev/null +++ b/tests/plan-tree.test.ts @@ -0,0 +1,370 @@ +/** + * Plan-tree tests (W1) — two layers: + * + * 1. Pure ops (src/core/plan-tree.ts): every validation error path, cascade + * remove, version semantics, setCurrent/markDone behavior. + * 2. Tool execute (src/agent/tools/plan.ts): against the REAL chat store — + * plan_init happy path + refusal to re-init, a mutation round-trip that + * persists to the record, error results on invalid ops. + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; +import type { PlanTree, PlanToolResult } from "../src/core/contracts.ts"; +import { + addEdge, + addNode, + createTree, + emptyTree, + markDone, + PlanTreeError, + removeEdge, + removeNode, + setCurrent, + updateNode, +} from "../src/core/plan-tree.ts"; +import { planTools } from "../src/agent/tools/plan.ts"; +import { createChat, getChat } from "../src/store/chats.ts"; +import { PATHS } from "../config.ts"; + +const N = (id: string, title = id, summary?: string) => ({ id, title, ...(summary ? { summary } : {}) }); +const E = (from: string, to: string) => ({ from, to }); + +function tree(ids: string[], edges: Array<[string, string]> = []): PlanTree { + return createTree( + ids.map((id) => N(id)), + edges.map(([from, to]) => E(from, to)), + ); +} + +/** Assert the op throws a PlanTreeError matching the given regex. */ +function assertReject(fn: () => unknown, needle: RegExp): void { + try { + fn(); + } catch (err) { + assert.ok(err instanceof PlanTreeError, `expected PlanTreeError, got ${err}`); + assert.match(err.message, needle); + return; + } + assert.fail(`expected a PlanTreeError matching ${needle}, but the op succeeded`); +} + +// ─── 1. pure ops ───────────────────────────────────────────────────────────── + +describe("plan-tree pure ops", () => { + it("createTree builds version 1 with no mutation of its inputs", () => { + const nodes = [N("a", "A"), N("b", "B")]; + const edges = [E("a", "b")]; + const t = createTree(nodes, edges); + assert.equal(t.version, 1); + assert.equal(t.currentNodeId, null); + assert.deepEqual(t.doneNodeIds, []); + assert.deepEqual(t.nodes.map((n) => n.id), ["a", "b"]); + assert.deepEqual(t.edges, [E("a", "b")]); + // Input arrays untouched (deep-copied into the tree). + assert.deepEqual(nodes, [N("a", "A"), N("b", "B")]); + assert.deepEqual(edges, [E("a", "b")]); + }); + + it("createTree rejects duplicate node ids", () => { + assertReject(() => createTree([N("a"), N("a")], []), /Duplicate node id "a"/); + }); + + it("createTree rejects edges with missing endpoints", () => { + assertReject(() => createTree([N("a")], [E("a", "ghost")]), /unknown node id "ghost"/); + assertReject(() => createTree([N("a")], [E("ghost", "a")]), /unknown node id "ghost"/); + }); + + it("createTree rejects self-loops, duplicates, and cycles", () => { + assertReject(() => createTree([N("a")], [E("a", "a")]), /self-loop/); + assertReject(() => createTree([N("a"), N("b")], [E("a", "b"), E("a", "b")]), /already exists/); + // a→b→c→a is a cycle. + assertReject( + () => createTree([N("a"), N("b"), N("c")], [E("a", "b"), E("b", "c"), E("c", "a")]), + /cycle/, + ); + }); + + it("createTree rejects invalid node shapes", () => { + assertReject(() => createTree([N("")], []), /non-empty `id`/); + assertReject(() => createTree([{ id: "a", title: "" }], []), /non-empty `title`/); + }); + + it("addNode appends and increments version; parents become requires edges", () => { + const t = addNode(tree(["a", "b"], [["a", "b"]]), N("c", "C", "the goal"), ["a", "b"]); + assert.equal(t.version, 2); // createTree=1, plus this op + assert.deepEqual(t.nodes.map((n) => n.id), ["a", "b", "c"]); + assert.deepEqual(t.edges, [E("a", "b"), E("a", "c"), E("b", "c")]); + }); + + it("addNode rejects duplicate ids, unknown parents, self-parent, and cycle-creating parents", () => { + const base = tree(["a", "b"], [["a", "b"]]); + assertReject(() => addNode(base, N("a")), /already exists/); + assertReject(() => addNode(base, N("c"), ["ghost"]), /unknown node id "ghost"/); + assertReject(() => addNode(base, N("c"), ["c"]), /itself as a parent/); + // a→b exists; adding node x with parent b gives b→x — fine. But a NEW + // node whose parents would create a cycle can't happen (new nodes have no + // incoming edges yet) — the cycle case is covered via addEdge below. + }); + + it("updateNode patches title/summary and requires an existing node", () => { + let t = tree(["a"]); + t = updateNode(t, "a", { title: "A2" }); + assert.equal(t.nodes[0]!.title, "A2"); + assert.equal(t.version, 2); + t = updateNode(t, "a", { summary: "what it covers" }); + assert.equal(t.nodes[0]!.summary, "what it covers"); + assert.equal(t.nodes[0]!.title, "A2"); + assert.equal(t.version, 3); + assertReject(() => updateNode(t, "ghost", { title: "x" }), /Cannot update node "ghost"/); + }); + + it("removeNode cascades: edges both directions, currentNodeId, doneNodeIds", () => { + // a→b→c, b current, a+b done. + let t = tree(["a", "b", "c"], [["a", "b"], ["b", "c"]]); + t = setCurrent(t, "b"); + t = markDone(t, "a"); + t = markDone(t, "b"); + t = removeNode(t, "b"); + assert.deepEqual(t.nodes.map((n) => n.id), ["a", "c"]); + assert.deepEqual(t.edges, []); // both edges touched b + assert.equal(t.currentNodeId, null); // b was current + assert.deepEqual(t.doneNodeIds, ["a"]); // b's done entry gone + assert.equal(t.version, 5); + assertReject(() => removeNode(t, "b"), /Cannot remove node "b"/); + }); + + it("addEdge validates: unknown endpoints, self-loop, duplicate, cycle", () => { + const t = tree(["a", "b", "c"], [["a", "b"]]); + assertReject(() => addEdge(t, "a", "ghost"), /unknown node id "ghost"/); + assertReject(() => addEdge(t, "ghost", "a"), /unknown node id "ghost"/); + assertReject(() => addEdge(t, "a", "a"), /self-loop/); + assertReject(() => addEdge(t, "a", "b"), /already exists/); + // a→b exists; b→a closes the loop. + assertReject(() => addEdge(t, "b", "a"), /cycle/); + }); + + it("addEdge rejects an indirect cycle (A→B→C then C→A)", () => { + const t = tree(["a", "b", "c"], [["a", "b"], ["b", "c"]]); + assertReject(() => addEdge(t, "c", "a"), /cycle/); + // The reverse direction is fine (c already depends on a transitively). + const ok = addEdge(t, "a", "c"); + assert.deepEqual(ok.edges, [E("a", "b"), E("b", "c"), E("a", "c")]); + assert.equal(ok.version, 2); + }); + + it("removeEdge requires the edge to exist", () => { + const t = tree(["a", "b"], [["a", "b"]]); + const next = removeEdge(t, "a", "b"); + assert.deepEqual(next.edges, []); + assert.equal(next.version, 2); + assertReject(() => removeEdge(next, "a", "b"), /no such edge/); + }); + + it("setCurrent requires an existing node and never touches doneNodeIds", () => { + let t = tree(["a", "b"]); + t = markDone(t, "a"); + t = setCurrent(t, "b"); + assert.equal(t.currentNodeId, "b"); + assert.deepEqual(t.doneNodeIds, ["a"]); // untouched + assert.equal(t.version, 3); + assertReject(() => setCurrent(t, "ghost"), /Cannot set current node "ghost"/); + }); + + it("setCurrent on the already-current node is a true no-op (no version bump)", () => { + let t = tree(["a"]); + t = setCurrent(t, "a"); + const version = t.version; + const again = setCurrent(t, "a"); + assert.equal(again.version, version); + assert.equal(again, t); // same object: nothing changed + }); + + it("markDone requires an existing node and is idempotent", () => { + let t = tree(["a"]); + t = markDone(t, "a"); + assert.deepEqual(t.doneNodeIds, ["a"]); + const version = t.version; + const again = markDone(t, "a"); + assert.equal(again.version, version); + assert.equal(again, t); // same object: nothing changed + assertReject(() => markDone(t, "ghost"), /Cannot mark node "ghost" done/); + }); + + it("every op returns a new tree object (no mutation of the input)", () => { + const t = tree(["a", "b", "c"], [["a", "b"]]); + const snapshot = JSON.stringify(t); + addNode(t, N("d"), ["b"]); + updateNode(t, "a", { title: "X" }); + removeNode(t, "a"); + addEdge(t, "b", "c"); + removeEdge(t, "a", "b"); + setCurrent(t, "b"); + markDone(t, "b"); + assert.equal(JSON.stringify(t), snapshot); + }); +}); + +// ─── 2. tool execute against the real store ───────────────────────────────── + +const createdChats: string[] = []; +after(() => { + for (const id of createdChats) { + try { + fs.unlinkSync(path.join(PATHS.chats, `${id}.json`)); + } catch { + /* already gone */ + } + } +}); + +/** Call a plan tool's execute directly with a minimal execution-options object. */ +async function exec(tools: ReturnType, name: string, input: unknown): Promise { + const t = tools[name]!; + assert.ok(typeof t.execute === "function", `${name} must be a server-side tool with execute`); + return t.execute(input as never, { toolCallId: "test", messages: [] } as never) as Promise; +} + +function newChat(title: string): string { + const chat = createChat(title); + createdChats.push(chat.id); + return chat.id; +} + +describe("plan tools (execute against the real store)", () => { + it("plan_init creates and persists the tree; result is ok with the full tree", async () => { + const chatId = newChat("Plan tools: init"); + const tools = planTools({ chatId }); + + const res = await exec(tools, "plan_init", { + nodes: [ + { id: "truth", title: "Core truth" }, + { id: "goal", title: "The goal" }, + ], + edges: [{ from: "truth", to: "goal" }], + }); + assert.equal(res.ok, true); + assert.equal(res.error, undefined); + assert.equal(res.tree.version, 1); + assert.equal(res.tree.nodes.length, 2); + assert.deepEqual(res.tree.edges, [{ from: "truth", to: "goal" }]); + + const saved = getChat(chatId)!.tree; + assert.ok(saved, "tree persisted to the chat record"); + assert.deepEqual(saved, res.tree); + }); + + it("plan_init refuses to re-init and never silently wipes the existing tree", async () => { + const chatId = newChat("Plan tools: re-init"); + const tools = planTools({ chatId }); + await exec(tools, "plan_init", { nodes: [{ id: "a", title: "A" }] }); + + const res = await exec(tools, "plan_init", { + nodes: [{ id: "totally", title: "Different tree" }], + }); + assert.equal(res.ok, false); + assert.equal(res.error, "Tree already initialized — use the mutation tools"); + assert.deepEqual(res.tree.nodes.map((n: { id: string }) => n.id), ["a"], "existing tree returned, not wiped"); + + const saved = getChat(chatId)!.tree!; + assert.deepEqual(saved.nodes.map((n) => n.id), ["a"]); + }); + + it("plan_init returns an error result on invalid input (cycle) without persisting", async () => { + const chatId = newChat("Plan tools: bad init"); + const tools = planTools({ chatId }); + const res = await exec(tools, "plan_init", { + nodes: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + edges: [ + { from: "a", to: "b" }, + { from: "b", to: "a" }, + ], + }); + assert.equal(res.ok, false); + assert.match(res.error!, /cycle/); + assert.deepEqual(res.tree, emptyTree()); + assert.equal(getChat(chatId)!.tree, undefined, "nothing persisted on failure"); + }); + + it("mutation round-trip: add node with parents, set current, mark done — each persists", async () => { + const chatId = newChat("Plan tools: round-trip"); + const tools = planTools({ chatId }); + + await exec(tools, "plan_init", { + nodes: [ + { id: "ratelimit", title: "Token bucket" }, + { id: "burst", title: "Burst vs steady" }, + ], + edges: [{ from: "ratelimit", to: "burst" }], + }); + + const added = await exec(tools, "plan_add_node", { + id: "detour", + title: "Why leaky bucket differs", + summary: "Learner asked about the leaky-bucket variant.", + parents: ["ratelimit"], + }); + assert.equal(added.ok, true); + assert.deepEqual( + added.tree.edges.map((e) => `${e.from}→${e.to}`), + ["ratelimit→burst", "ratelimit→detour"], + ); + assert.equal(added.tree.version, 2); + assert.deepEqual(getChat(chatId)!.tree!, added.tree, "add_node persisted"); + + const current = await exec(tools, "plan_set_current", { id: "detour" }); + assert.equal(current.ok, true); + assert.equal(current.tree.currentNodeId, "detour"); + assert.equal(getChat(chatId)!.tree!.currentNodeId, "detour", "set_current persisted"); + + const done = await exec(tools, "plan_mark_done", { id: "ratelimit" }); + assert.equal(done.ok, true); + assert.deepEqual(done.tree.doneNodeIds, ["ratelimit"]); + assert.deepEqual(getChat(chatId)!.tree!.doneNodeIds, ["ratelimit"], "mark_done persisted"); + assert.equal(done.tree.version, 4); + }); + + it("invalid mutations return ok:false with the current tree and persist nothing", async () => { + const chatId = newChat("Plan tools: invalid ops"); + const tools = planTools({ chatId }); + await exec(tools, "plan_init", { nodes: [{ id: "a", title: "A" }] }); + const before = getChat(chatId)!.tree!; + + const dup = await exec(tools, "plan_add_node", { id: "a", title: "Impostor" }); + assert.equal(dup.ok, false); + assert.match(dup.error!, /already exists/); + assert.deepEqual(dup.tree, before); + + const missing = await exec(tools, "plan_set_current", { id: "ghost" }); + assert.equal(missing.ok, false); + assert.match(missing.error!, /unknown node id "ghost"|not in the tree/); + + const badEdge = await exec(tools, "plan_add_edge", { from: "a", to: "a" }); + assert.equal(badEdge.ok, false); + assert.match(badEdge.error!, /self-loop/); + + assert.deepEqual(getChat(chatId)!.tree!, before, "record untouched by failed ops"); + }); + + it("mutation tools work on a fresh (uninitialized) chat: the tree seeds from empty", async () => { + const chatId = newChat("Plan tools: no init"); + const tools = planTools({ chatId }); + const res = await exec(tools, "plan_add_node", { id: "a", title: "A" }); + assert.equal(res.ok, true); + assert.equal(res.tree.nodes.length, 1); + assert.ok(getChat(chatId)!.tree, "tree seeded without plan_init"); + }); + + it("unknown chat returns ok:false with the empty tree", async () => { + const tools = planTools({ chatId: "chat_nonexistent" }); + const res = await exec(tools, "plan_mark_done", { id: "a" }); + assert.equal(res.ok, false); + assert.match(res.error!, /Chat not found/); + assert.deepEqual(res.tree, emptyTree()); + }); +});