Merge feat/sub-agents: author-once plan protocol, steer-delivery starvation fixes, research-steer affordance

Wave 3: the plan protocol collapses to one re-callable mermaid plan_init
(seven mutation tools deleted by design; statuses derive from quiz checks;
live quiz-nodeId validation with mid-turn retry). The steer-starvation arc:
steers deliver coalesced with the learner's answer (claimSteers), persist
dedupes same-id continuation copies, jobs settle-sync defers while a turn
is busy. Research steers gain the collapsed full-brief affordance. Model
reasoning effort set to minimal across roles.
This commit is contained in:
amosblomqvist
2026-09-09 06:46:34 +03:00
19 changed files with 966 additions and 330 deletions
+9 -3
View File
@@ -342,7 +342,7 @@ describe("steer card rendering (the delivered artifact)", () => {
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
});
it("renders a quiet Research-complete badge — the full brief stays out of the transcript (but persisted)", () => {
it("renders a quiet Research-complete one-liner whose collapsed <details> carries the full brief", () => {
const steer = buildSubagentSteer(
{ id: "jr", kind: "research", toolCallId: "cr" },
{
@@ -357,8 +357,14 @@ describe("steer card rendering (the delivered artifact)", () => {
assert.match(html, /Research/);
assert.match(html, /complete/);
assert.match(html, /2 sources/);
assert.ok(!html.includes("founded the Academy"), "the long brief never renders inline");
assert.ok(!html.includes("britannica"), "source links stay out too — the wake turn cites them");
assert.match(html, /view brief/, "the expansion affordance is offered");
// the one-liner is a native <details> — collapsed by default, so the
// brief + sources ride in the DOM but never clutter the transcript
// until the learner opens them (the wave-1 "view full research" ask)
assert.match(html, /<details[^>]*data-testid="research-complete-badge"/, "a real disclosure");
assert.ok(!/<details[^>]*open/.test(html), "collapsed by default — no `open` attribute");
assert.ok(html.includes("founded the Academy"), "the persisted brief rides inside the details");
assert.ok(html.includes("britannica.com/plato"), "source links ride inside the details");
});
it("renders the visual failure note with no svg when the build FAILED", () => {
+120 -1
View File
@@ -27,6 +27,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 { UIMessage } from "ai";
import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
import {
@@ -41,7 +42,7 @@ import {
import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { createTeacherTools } from "../src/agent/tools.ts";
import { handleChatRequest } from "../src/server/chat-handler.ts";
import { deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
import { claimSteers, deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts";
@@ -641,6 +642,97 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
assert.equal(jobStore.getJob(jobId)!.status, "done", "the job still flips — recovery completes");
});
// ─── continuation coalescing (the delivery-starvation fix) ───────────────────
describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
it("claims queued undelivered steers: resolves deliver(), flips the job done, empties the queue", async () => {
const { chatId, jobId, steer } = setup();
let fired = 0;
setTurnRunner(async () => {
fired++;
return 200;
});
const delivery = deliverJobResult(steer);
await delay(300); // the pump is inside waitForIdle's grace (750ms)
const claimed = claimSteers(chatId);
assert.equal(claimed.length, 1, "the queued steer is claimed");
assert.equal(claimed[0]!.id, steer.id);
// The handler's write-ahead persist lands the claim immediately after
// claiming (the very next statement in handleChatRequest).
const record = chatStore.getChat(chatId)!;
chatStore.saveMessages(chatId, [...record.messages, ...claimed] as never);
await delivery; // the runner's deliver() promise resolves on claim
assert.equal(jobStore.getJob(jobId)!.status, "done", "job flips done at claim");
assert.deepEqual(claimSteers(chatId), [], "queue is empty after the claim");
await delay(800);
assert.equal(fired, 0, "the pump never fires a wake for a claimed steer");
});
it("finalizes already-delivered steers without returning them (a wake raced in)", async () => {
const { chatId, jobId, steer } = setup();
const record = chatStore.getChat(chatId)!;
chatStore.saveMessages(chatId, [...record.messages, steer] as never);
const delivery = deliverJobResult(steer);
await delay(100); // let the pump see it first (its top-of-loop idempotent skip)
const claimed = claimSteers(chatId);
assert.equal(claimed.length, 0, "delivered steers are not re-claimed");
assert.equal(jobStore.getJob(jobId)!.status, "done");
await delivery;
});
it("claimSteers on a chat with no queue returns [] and touches nothing", () => {
assert.deepEqual(claimSteers("chat_never_existed"), []);
});
it("pump duplicate-guard: a steer coalesced into the record while the gates were closed is never double-fired", async () => {
const { chatId, jobId, steer } = setup();
let fired = 0;
setTurnRunner(async () => {
fired++;
return 200;
});
// Gates closed: a pending quiz parks the pump in waitForIdle.
chatStore.saveMessages(chatId, [
{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] },
{
id: "a1",
role: "assistant",
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "input-available", input: { question: "q", choices: ["a", "b"] } }],
},
] as never);
const delivery = deliverJobResult(steer);
await delay(1200);
assert.equal(fired, 0, "parked behind the pending quiz");
// The client turn coalesces: it claims the steer and lands it in the record.
const claimed = claimSteers(chatId);
assert.equal(claimed.length, 1, "coalescing claims the parked steer");
const record = chatStore.getChat(chatId)!;
chatStore.saveMessages(chatId, [...record.messages, steer] as never); // write-ahead persist
// The learner answers: the gates open — the parked pump must see the
// delivered steer via its post-idle re-check, NOT fire a second turn.
const answered = chatStore.getChat(chatId)!.messages.map((m) =>
m.id === "a1"
? {
...m,
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "output-available", input: { question: "q", choices: ["a", "b"] }, output: { answer: "a", correct: true, explanation: "" } }],
}
: m,
);
chatStore.saveMessages(chatId, answered as never);
await delivery;
await delay(1500);
assert.equal(fired, 0, "no duplicate wake after coalescing");
assert.equal(jobStore.getJob(jobId)!.status, "done");
});
});
it("fails the job (error status) when the wake turn errors unrecoverably", async () => {
const { jobId, steer } = setup();
setTurnRunner(async () => 500);
@@ -673,6 +765,33 @@ describe("persistTurn id hygiene", () => {
assert.notEqual(filled[0]!.id, filled[1]!.id);
});
it("persistTurn dedupes same-id continuation copies, keeping the LAST (the SDK's merged superset)", () => {
// The coalescing shape: a steer (user-role) rides a quiz-answer
// continuation AFTER the assistant message being continued, so the
// SDK's onFinish list carries the input copy AND the extended
// response copy under one id — the record must keep only the merge.
const chat = chatStore.createChat("Test: dedupe continuation copies");
trackChat(chat.id);
const short: UIMessage = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }] };
const long: UIMessage = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }, { type: "text", text: "step 2 (continued)" }] };
persistTurn(chatStore, chat.id, [
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
short,
{ id: "steer_x", role: "user", parts: [{ type: "text", text: "research complete" }] },
long,
], false);
const record = chatStore.getChat(chat.id)!;
assert.equal(record.messages.length, 3, "the duplicate entry is dropped");
assert.deepEqual(
record.messages.map((m) => m.id),
["u1", "steer_x", "msg_dup_1"],
"order preserved, steer before the merged assistant copy",
);
const merged = record.messages[2]!;
assert.equal(merged.parts.length, 2, "the KEPT copy is the extended one");
assert.equal((merged.parts[1] as { text: string }).text, "step 2 (continued)");
});
it("persistTurn saves every message with a unique non-empty id (wake turns never re-POSTed)", () => {
const chat = chatStore.createChat("Test: persist ids");
trackChat(chat.id);
+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);
});
});