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:
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user