Files
learnharness/tests/plan-blocks.test.ts
T
amosblomqvist 5653c77a29 feat: probe/teach agent split — explicit phase markers, position-only plan map
Diagnosis (2026-09-11 user testing, minimal-effort GLM): sessions stuck on
'plan' forever. Two causes: (1) the three-phase view read the stored tree's
currentNodeId, which nothing ever writes post author-once (2026-09-03) — a
structural dead seam; (2) the teacher stochastically left quiz.nodeId
untagged, and done-by-quiz-result semantics would have falsely completed
nodes anyway.

The redesign (docs/probe-teach-split.md) abolishes derived occupancy in
favor of explicit events:

- Two agents over one transcript: createProberAgent (quiz without a
  nodeId field, ask, probe_done) and createTeacherAgent (full toolset,
  plan_init onward). The chat-handler construction branch keys on a
  probe_done part in the transcript. MODELS.prober added.
- probe_done: server-side seam tool — carries the learner assessment
  (strands + free-form edge read), refuses while a question is open or
  the probe is already concluded, and queues a handoff steer through the
  generalized wake pipeline (steers now dedupe by id, not jobId; job
  finalization only for job steers). The teacher auto-wakes and authors
  the plan; the learner sees the assessment via the probe-done card.
- phases: probe ends at a concluded probe_done, plan ends at a
  successful plan_init — strictly sequential, no skipping, no legacy
  compatibility (data wiped at the pivot per doctrine).
- Plan map = position, not completion: derivePlanStatuses reads only
  tagged quiz CALLS (state/outcome feed nothing); current = latest
  tagged node with consecutive duplicates collapsed, visited = earlier
  distinct stays (a revisit stays in both — current wins at render).
  PlanTree is structure-only; currentNodeId/doneNodeIds and the dead
  mutation helpers are gone. No fallback current.
- Prompts split per agent (prompts/prober/, prompts/teacher/ over the
  trimmed shared base); graph-context injection removed entirely
  (shelved, docs/probe-teach-split.md §5); knowledge-graph context no
  longer reaches either agent.
- Plan blocks are full-only snapshots at plan_init (neighborhood
  machinery retired; the persistent DAG window is the future UI wave);
  usage capture unchanged.

Gates: tsc clean, 294/294 tests (phases/plan-tree/plan-blocks/question-
tools rewritten to the new semantics; probe-done + handoff-wake tests
added), lint, build. Live GLM smoke PASS: prober concluded with an honest
assessment, teacher auto-woke, authored a 10-node plan with the block
attached, then tagged checks on slope-instinct → secant derived current/
visited exactly as designed; grading honest both ways (driver off-by-one
proved a wrong answer grades false).
2026-09-11 06:44:20 +03:00

122 lines
4.6 KiB
TypeScript

/**
* plan-blocks — the INTERIM plan presentation seam (docs/probe-teach-split.md
* §6): the full block renders IN FLOW exactly once, attached to the
* assistant message carrying the successful plan_init call. The
* neighborhood-block machinery is retired (position derives live from
* derivePlanStatuses); the persistent DAG window (UI wave) succeeds this.
*/
import assert from "node:assert";
import test from "node:test";
import type { UIMessage } from "ai";
import { withPlanBlocks, type PlanBlockPart } from "../src/core/plan-blocks.ts";
import type { PlanTree } from "../src/core/contracts.ts";
// 5-node fixture: a → b → c, b → d, d → e.
const TREE: PlanTree = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "c", title: "C" },
{ id: "d", title: "D" },
{ id: "e", title: "E" },
],
edges: [
{ from: "a", to: "b" },
{ from: "b", to: "c" },
{ from: "b", to: "d" },
{ from: "d", to: "e" },
],
version: 1,
};
/** An assistant message carrying a successful plan_init with the fixture tree. */
function planInitMessage(id: string, tree: PlanTree = TREE): UIMessage {
return {
id,
role: "assistant",
parts: [
{ type: "text", text: "Here is the plan." },
{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree } },
],
} as unknown as UIMessage;
}
function blocksOf(messages: UIMessage[]): PlanBlockPart[] {
return messages.flatMap((m) => m.parts.filter((p) => p.type === "data-plan-block") as PlanBlockPart[]);
}
test("withPlanBlocks: a successful plan_init message gains ONE full block (all nodes, statuses from the transcript)", () => {
const out = withPlanBlocks([planInitMessage("m1")], TREE);
const blocks = blocksOf(out);
assert.equal(blocks.length, 1);
const data = blocks[0]!.data;
assert.equal(data.kind, "full");
assert.equal(data.current, null, "at authoring nothing is current");
assert.equal(data.nodes.length, 5);
assert.ok(data.nodes.every((n) => n.status === "upcoming"));
assert.equal(data.edges.length, 4);
// attached to the plan_init message itself
assert.equal(blocksOf([out[0]!]).length, 1);
});
test("withPlanBlocks: a FAILED plan_init gains nothing", () => {
const failed: UIMessage = {
id: "m1",
role: "assistant",
parts: [
{ type: "text", text: "oops" },
{ type: "tool-plan_init", state: "output-error", output: { ok: false, error: "bad" } },
],
} as unknown as UIMessage;
assert.equal(blocksOf(withPlanBlocks([failed], TREE)).length, 0);
});
test("withPlanBlocks: idempotent — a message that already carries a block is never given another", () => {
const once = withPlanBlocks([planInitMessage("m1")], TREE);
const twice = withPlanBlocks(once, TREE);
assert.equal(blocksOf(twice).length, 1, "no duplicate block on persist replay");
});
test("withPlanBlocks: a plan REWRITE (second successful plan_init) gets its own full block; the old stays frozen", () => {
const rewrittenTree: PlanTree = { nodes: TREE.nodes.slice(0, 3), edges: [{ from: "a", to: "b" }], version: 2 };
const out = withPlanBlocks([planInitMessage("m1"), planInitMessage("m2", rewrittenTree)], rewrittenTree);
const blocks = blocksOf(out);
assert.equal(blocks.length, 2, "one full block per authoring event");
assert.equal(blocks[1]!.data.nodes.length, 3);
});
test("withPlanBlocks: quizzes alone emit nothing (no neighborhood blocks anymore)", () => {
const quizTurn: UIMessage = {
id: "m1",
role: "assistant",
parts: [
{ type: "text", text: "check" },
{ type: "tool-quiz", state: "output-available", input: { nodeId: "b" }, output: { correct: true } },
],
} as unknown as UIMessage;
assert.equal(blocksOf(withPlanBlocks([quizTurn], TREE)).length, 0);
});
test("withPlanBlocks: no tree / empty tree / no messages are no-ops", () => {
assert.equal(withPlanBlocks([planInitMessage("m1")], null).length, 1, "passthrough");
const emptyTree: PlanTree = { nodes: [], edges: [], version: 0 };
assert.equal(blocksOf(withPlanBlocks([planInitMessage("m1")], emptyTree)).length, 0);
assert.equal(withPlanBlocks([], TREE).length, 0);
});
test("withPlanBlocks: mid-lesson rewrite snapshot derives statuses from the whole transcript", () => {
// A tagged quiz call on node b happened before the rewrite: b is current.
const quizBefore: UIMessage = {
id: "m0",
role: "assistant",
parts: [
{ type: "text", text: "about b" },
{ type: "tool-quiz", state: "output-available", input: { nodeId: "b" }, output: { correct: true } },
],
} as unknown as UIMessage;
const out = withPlanBlocks([quizBefore, planInitMessage("m1")], TREE);
const data = blocksOf(out)[0]!.data;
assert.equal(data.current, "b");
const b = data.nodes.find((n) => n.id === "b")!;
assert.equal(b.status, "current");
});