Author-once plan protocol: single mermaid plan_init + derived statuses + live quiz nodeId validation

- plan_init is now the ONLY plan tool: a mermaid 'graph TD' block parsed by a
  constrained-subset parser (mermaid-plan.ts) with line-precise errors; the
  seven mutation/tracking tools are gone by design (2026-09-03 design pass).
- Re-call plan_init = sanctioned wholesale rewrite; slugs are permanent.
- derivePlanStatuses (contracts/plan.ts): done = passed check tagged with a
  quiz nodeId; current = open check's node else first topo-ready node — the
  tracking the teacher no longer declares by hand.
- questionToolsFor(ctx) (tools.ts): the quiz schema chains a dynamic plan-
  nodeId validation against the LIVE record — an invalid nodeId is rejected
  at the call boundary and the teacher retries mid-turn, before any UI.
  Static questionTools stay validator-less for the default path.
- Prompts rewritten for mermaid authoring + derived position.
- Out-of-lane surgical touches (flagged for master review):
  src/core/question-core.ts (quizInputObject export + nodeId description),
  src/core/contracts/plan.ts (derivePlanStatuses + doc).
This commit is contained in:
amosblomqvist
2026-09-09 03:04:16 +03:00
parent fce477dd24
commit 67fbc0575f
10 changed files with 691 additions and 297 deletions
+190 -96
View File
@@ -12,7 +12,7 @@ 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 { derivePlanStatuses, derivedNodeStatus, type PlanTree, type PlanToolResult } from "../src/core/contracts.ts";
import {
addEdge,
addNode,
@@ -26,6 +26,7 @@ import {
updateNode,
} from "../src/core/plan-tree.ts";
import { planTools } from "../src/agent/tools/plan.ts";
import { parseMermaidPlan } from "../src/agent/tools/mermaid-plan.ts";
import { chatStore } from "../src/store/chats.ts";
import { PATHS } from "../config.ts";
@@ -233,138 +234,231 @@ function newChat(title: string): string {
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");
describe("plan_init — the author-once mermaid tool (execute against the real store)", () => {
it("parses a mermaid block, builds the tree, persists, returns the full tree", async () => {
const chatId = newChat("Plan: init");
const tools = planTools({ chatId, chatStore });
const res = await exec(tools, "plan_init", {
nodes: [
{ id: "truth", title: "Core truth" },
{ id: "goal", title: "The goal" },
],
edges: [{ from: "truth", to: "goal" }],
mermaid: "graph TD\n primitives[Number primitives] --> operators[Arithmetic operators]\n operators --> expressions[Evaluating expressions]\n detour[Side quest]\n",
});
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" }]);
assert.deepEqual(
res.tree.nodes.map((n) => n.id),
["primitives", "operators", "expressions", "detour"],
);
assert.deepEqual(res.tree.edges, [
{ from: "primitives", to: "operators" },
{ from: "operators", to: "expressions" },
]);
const saved = chatStore.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");
it("RE-CALLABLE: a rewrite replaces the tree wholesale (the mutation tools are gone)", async () => {
const chatId = newChat("Plan: rewrite");
const tools = planTools({ chatId, chatStore });
await exec(tools, "plan_init", { nodes: [{ id: "a", title: "A" }] });
await exec(tools, "plan_init", {
mermaid: "graph TD\n a[First attempt] --> b[Early shape]\n",
});
const res = await exec(tools, "plan_init", {
nodes: [{ id: "totally", title: "Different tree" }],
mermaid: "graph TD\n truth[The core truth]\n goal[The sink] --> truth\n",
});
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 = chatStore.getChat(chatId)!.tree!;
assert.deepEqual(saved.nodes.map((n) => n.id), ["a"]);
assert.equal(res.ok, true, "re-init is the sanctioned rewrite path now");
assert.deepEqual(
res.tree.nodes.map((n) => n.id),
["truth", "goal"],
);
assert.deepEqual(
chatStore.getChat(chatId)!.tree!.nodes.map((n) => n.id),
["truth", "goal"],
"rewrite persisted",
);
});
it("plan_init returns an error result on invalid input (cycle) without persisting", async () => {
const chatId = newChat("Plan tools: bad init");
it("parse failures collect EVERY problem with line numbers and persist nothing", async () => {
const chatId = newChat("Plan: bad parse");
const tools = planTools({ chatId, chatStore });
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" },
],
mermaid: "graph TD\n Foo Bar[x] --> y\n a --> a\n subgraph hi\n",
});
assert.equal(res.ok, false);
assert.match(res.error!, /cycle/);
const err = res.error!;
assert.match(err, /line 2/);
assert.match(err, /line 3/);
assert.match(err, /line 4/);
assert.deepEqual(res.tree, emptyTree());
assert.equal(chatStore.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");
it("structural failures (cycle) become ok:false without persisting", async () => {
const chatId = newChat("Plan: cycle");
const tools = planTools({ chatId, chatStore });
await exec(tools, "plan_init", {
nodes: [
{ id: "ratelimit", title: "Token bucket" },
{ id: "burst", title: "Burst vs steady" },
],
edges: [{ from: "ratelimit", to: "burst" }],
const res = await exec(tools, "plan_init", {
mermaid: "graph TD\n a[A] --> b[B]\n b --> a\n",
});
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(chatStore.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(chatStore.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(chatStore.getChat(chatId)!.tree!.doneNodeIds, ["ratelimit"], "mark_done persisted");
assert.equal(done.tree.version, 4);
assert.equal(res.ok, false);
assert.match(res.error!, /cycle/);
assert.deepEqual(res.tree, emptyTree());
});
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, chatStore });
await exec(tools, "plan_init", { nodes: [{ id: "a", title: "A" }] });
const before = chatStore.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(chatStore.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, chatStore });
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(chatStore.getChat(chatId)!.tree, "tree seeded without plan_init");
it("only plan_init exists — the seven mutation/tracking tools are gone by design", () => {
const tools = planTools({ chatId: "chat_x", chatStore });
assert.deepEqual(Object.keys(tools), ["plan_init"]);
});
it("unknown chat returns ok:false with the empty tree", async () => {
const tools = planTools({ chatId: "chat_nonexistent", chatStore });
const res = await exec(tools, "plan_mark_done", { id: "a" });
const res = await exec(tools, "plan_init", { mermaid: "graph TD\n a[A]\n" });
assert.equal(res.ok, false);
assert.match(res.error!, /Chat not found/);
assert.deepEqual(res.tree, emptyTree());
});
});
// ─── the mermaid subset parser ────────────────────────────────────────────────
describe("parseMermaidPlan (the constrained mermaid subset)", () => {
const parse = (m: string) => parseMermaidPlan(m);
it("accepts graph TD and flowchart TB headers (case-insensitive), comments, and blank lines", () => {
for (const header of ["graph TD", "graph td", "flowchart TB", "Flowchart td"]) {
const res = parse(`${header}\n%% a comment\n\n a[Alpha]\n`);
assert.equal(res.ok, true, header);
assert.deepEqual(res.plan!.nodes, [{ id: "a", title: "Alpha" }]);
}
});
it("bare ids fall back to id-as-title; bracket and paren labels both work", () => {
const res = parse("graph TD\n bare\n bracket[Bracket title]\n paren(\"Paren title\")\n");
assert.deepEqual(res.plan!.nodes, [
{ id: "bare", title: "bare" },
{ id: "bracket", title: "Bracket title" },
{ id: "paren", title: "Paren title" },
]);
});
it("edges register bare endpoints as nodes and dedupe duplicate edges", () => {
const res = parse("graph TD\n a --> b[Bee]\n a --> b[Bee]\n");
assert.deepEqual(res.plan!.nodes, [
{ id: "a", title: "a" },
{ id: "b", title: "Bee" },
]);
assert.deepEqual(res.plan!.edges, [{ from: "a", to: "b" }]);
});
it("labels on both edge endpoints survive; forward references are fine", () => {
const res = parse("graph TD\n downstream[Later thing] --> upstream[Earlier thing]\n");
assert.deepEqual(res.plan!.edges, [{ from: "downstream", to: "upstream" }]);
assert.deepEqual(res.plan!.nodes, [
{ id: "downstream", title: "Later thing" },
{ id: "upstream", title: "Earlier thing" },
]);
});
it("rejects: non-slug ids, self-loops, chained edges, subgraphs, missing header, empty", () => {
assert.ok(!parse("graph TD\n Foo Bar[x]\n").ok, "id with a space");
assert.match(parse("graph TD\n Foo Bar[x]\n").problems[0]!, /line 2.*slug/);
assert.ok(!parse("graph TD\n a --> a\n").ok, "self-loop");
assert.ok(!parse("graph TD\n a --> b --> c\n").ok, "chained edge");
assert.match(parse("graph TD\n a --> b --> c\n").problems[0]!, /one edge per line/);
assert.ok(!parse("graph TD\n subgraph cluster\n").ok, "subgraph");
assert.ok(!parse("digraph G\n a\n").ok, "wrong header");
assert.ok(!parse("%% only a comment\n").ok, "empty plan");
});
it("the first label wins when a node line repeats (relabeling mid-block is ignored)", () => {
const res = parse("graph TD\n a[First label]\n a[Second label]\n");
assert.deepEqual(res.plan!.nodes, [{ id: "a", title: "First label" }]);
});
});
// ─── derived statuses (the tracking the teacher no longer does) ───────────────
describe("derivePlanStatuses (statuses derive from checks, never declarations)", () => {
const tree: PlanTree = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "c", title: "C" },
],
edges: [
{ from: "a", to: "b" },
{ from: "b", to: "c" },
],
currentNodeId: null,
doneNodeIds: [],
version: 1,
};
const quizPart = (state: string, nodeId: string | undefined, correct?: boolean) => ({
type: "tool-quiz",
state,
input: nodeId === undefined ? {} : { nodeId },
output: correct === undefined ? undefined : { correct },
});
type QuizPart = { type: string; state: string; input: unknown; output?: unknown };
const msg = (...parts: QuizPart[]) => ({ parts });
it("empty transcript → current = first node (authoring order, no prereqs), nothing done", () => {
const st = derivePlanStatuses(tree, []);
assert.equal(st.current, "a");
assert.equal(st.done.size, 0);
});
it("a passed check on a tags it done; current advances to the next topologically-ready node", () => {
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", true))]);
assert.deepEqual([...st.done], ["a"]);
assert.equal(st.current, "b");
});
it("a pending check OWNS current, wherever the topo frontier is", () => {
const st = derivePlanStatuses(tree, [
msg(quizPart("output-available", "a", true)),
msg(quizPart("input-available", "c")), // a jump check on the sink
]);
assert.equal(st.current, "c", "the open check is where we are");
assert.deepEqual([...st.done], ["a"]);
});
it("a FAILED check marks nothing done (explaining is yours, done is theirs)", () => {
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", false))]);
assert.equal(st.done.size, 0);
assert.equal(st.current, "a", "frontier unmoved — resume teaching");
});
it("untagged checks and unknown nodeIds are lost signal — ignored", () => {
const st = derivePlanStatuses(tree, [
msg(quizPart("output-available", undefined, true)),
msg(quizPart("output-available", "ghost", true)),
]);
assert.equal(st.done.size, 0);
assert.equal(st.current, "a");
});
it("prereqs gate the frontier: c isn't next until b is demonstrated", () => {
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", true))]);
assert.equal(st.current, "b", "c requires b");
const st2 = derivePlanStatuses(tree, [
msg(quizPart("output-available", "a", true)),
msg(quizPart("output-available", "b", true)),
]);
assert.equal(st2.current, "c");
assert.equal(derivedNodeStatus(st2, "a"), "done");
assert.equal(derivedNodeStatus(st2, "c"), "current");
assert.equal(derivedNodeStatus(st2, "b"), "done");
});
it("a rewritten map keeps honest progress for reused slugs (derivation matches the CURRENT tree)", () => {
const rewritten: PlanTree = { ...tree, nodes: [tree.nodes[0]!, tree.nodes[1]!] };
const st = derivePlanStatuses(rewritten, [msg(quizPart("output-available", "a", true))]);
assert.equal(st.current, "b");
const orphaned = derivePlanStatuses(rewritten, [msg(quizPart("output-available", "c", true))]);
assert.equal(orphaned.done.size, 0, "a slug removed from the rewrite stops counting");
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* questionToolsFor — the per-session question tools with dynamic plan-nodeId
* validation (tools.ts). The grading contract is question-core's and is
* UNTOUCHED; the For variant chains one session-relative check on top:
* a quiz whose nodeId doesn't match a node of the CURRENT plan tree is
* rejected at the call boundary (never reaches the learner — the SDK
* returns the validation error to the teacher as a tool result, and it
* retries).
*/
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 { questionTools, questionToolsFor } from "../src/agent/tools.ts";
import { chatStore } from "../src/store/chats.ts";
import { PATHS } from "../config.ts";
const dataDir = path.join(PATHS.root, "data");
fs.mkdirSync(dataDir, { recursive: true });
const createdChats: string[] = [];
after(() => {
for (const id of createdChats) {
try {
fs.unlinkSync(path.join(PATHS.chats, `${id}.json`));
} catch {
/* scratch cleanup, best effort */
}
}
});
function newChat(title: string): string {
const chat = chatStore.createChat(title);
createdChats.push(chat.id);
return chat.id;
}
const validQuiz = {
question: "What is 2 + 2?",
options: [
{ label: "3" },
{ label: "4" },
],
correctAnswer: "4",
explanation: "Arithmetic.",
};
interface QuizToolSpec {
inputSchema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ message: string }> } } };
}
const quizSchemaOf = (tools: Record<string, unknown>) => (tools.quiz as QuizToolSpec).inputSchema;
describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
it("accepts a nodeId that matches a node of the session's tree", () => {
const chatId = newChat("NodeId: valid");
chatStore.saveTree(chatId, {
nodes: [
{ id: "arith", title: "Arithmetic" },
{ id: "expressions", title: "Expressions" },
],
edges: [{ from: "arith", to: "expressions" }],
currentNodeId: null,
doneNodeIds: [],
version: 1,
});
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
...validQuiz,
nodeId: "expressions",
});
assert.equal(res.success, true);
});
it("rejects a nodeId that matches no node — with a message listing the slugs", () => {
const chatId = newChat("NodeId: typo");
chatStore.saveTree(chatId, {
nodes: [{ id: "arith", title: "Arithmetic" }],
edges: [],
currentNodeId: null,
doneNodeIds: [],
version: 1,
});
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
...validQuiz,
nodeId: "arithmetic", // the typo — slug is 'arith'
});
assert.equal(res.success, false);
const msg = res.error!.issues.map((i) => i.message).join("\n");
assert.match(msg, /matches no node/);
assert.match(msg, /arith/);
});
it("no tree yet (probe phase): ANY nodeId is rejected — ad-hoc checks must omit it", () => {
const chatId = newChat("NodeId: probe");
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
...validQuiz,
nodeId: "anything",
});
assert.equal(res.success, false);
assert.match(res.error!.issues.at(-1)!.message, /No plan map exists yet/);
// …while an untagged probe check is fine
const plain = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse(validQuiz);
assert.equal(plain.success, true);
});
it("reads the LIVE record — a plan_init mutation mid-turn is honored immediately", () => {
const chatId = newChat("NodeId: live record");
const tools = questionToolsFor({ chatId, chatStore });
const schema = quizSchemaOf(tools);
const before = schema.safeParse({ ...validQuiz, nodeId: "fresh-node" });
assert.equal(before.success, false, "no tree yet → rejected");
chatStore.saveTree(chatId, {
nodes: [{ id: "fresh-node", title: "Just initialized" }],
edges: [],
currentNodeId: null,
doneNodeIds: [],
version: 1,
});
const after = schema.safeParse({ ...validQuiz, nodeId: "fresh-node" });
assert.equal(after.success, true, "the same id validates once the tree exists — same turn");
});
it("the shared grading rules still chain FIRST (schema-valid ⇔ gradeable)", () => {
const chatId = newChat("NodeId: grading rules intact");
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
...validQuiz,
correctAnswer: "42", // matches no option — question-core's rule
nodeId: "ghost", // also bad — BOTH problems surface
});
assert.equal(res.success, false);
const msgs = res.error!.issues.map((i) => i.message).join("\n");
assert.match(msgs, /correctAnswer/);
assert.match(msgs, /matches no node/);
});
it("the STATIC questionTools stay validator-less (the teacher-agent default path)", () => {
const res = quizSchemaOf(questionTools).safeParse({ ...validQuiz, nodeId: "anything" });
assert.equal(res.success, true, "no session context → no nodeId check");
});
it("ask_user_question is untouched (never carries nodeId — it isn't a check)", () => {
const chatId = newChat("NodeId: ask untouched");
const ask = questionToolsFor({ chatId, chatStore }).ask_user_question as QuizToolSpec;
const res = ask.inputSchema.safeParse({ question: "Free-form?", options: [{ label: "A" }, { label: "B" }] });
assert.equal(res.success, true);
});
});