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).
This commit is contained in:
amosblomqvist
2026-09-11 06:44:20 +03:00
parent 741ff416d6
commit 5653c77a29
49 changed files with 1538 additions and 2038 deletions
+65 -169
View File
@@ -1,11 +1,11 @@
/**
* 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.
* 1. Pure ops (src/core/plan-tree.ts): every validation error path and
* version semantics (structure only — progress derives from the
* transcript, docs/probe-teach-split.md §3).
* 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.
* plan_init happy path, the re-callable rewrite, error results.
*/
import assert from "node:assert/strict";
@@ -13,18 +13,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import { derivePlanStatuses, derivedNodeStatus, type PlanTree, type PlanToolResult } from "../src/core/contracts.ts";
import {
addEdge,
addNode,
createTree,
emptyTree,
markDone,
PlanTreeError,
removeEdge,
removeNode,
setCurrent,
updateNode,
} from "../src/core/plan-tree.ts";
import { createTree, emptyTree, PlanTreeError } 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";
@@ -60,8 +49,8 @@ describe("plan-tree pure ops", () => {
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.ok(!("currentNodeId" in t), "no stored progress — structure only");
assert.ok(!("doneNodeIds" in t), "no done ledger of any kind");
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).
@@ -93,119 +82,6 @@ describe("plan-tree pure ops", () => {
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 ─────────────────────────────────
@@ -379,9 +255,9 @@ describe("parseMermaidPlan (the constrained mermaid subset)", () => {
});
});
// ─── derived statuses (the tracking the teacher no longer does) ───────────────
// ─── derived statuses: position from CALLS, never results (§3) ─────────────────
describe("derivePlanStatuses (statuses derive from checks, never declarations)", () => {
describe("derivePlanStatuses (the map tracks position, not completion)", () => {
const tree: PlanTree = {
nodes: [
{ id: "a", title: "A" },
@@ -392,73 +268,93 @@ describe("derivePlanStatuses (statuses derive from checks, never declarations)",
{ 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 },
...(correct === undefined ? {} : { output: { 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", () => {
it("empty transcript → no current, nothing visited (no fallback current)", () => {
const st = derivePlanStatuses(tree, []);
assert.equal(st.current, "a");
assert.equal(st.done.size, 0);
assert.equal(st.current, null);
assert.equal(st.visited.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("current = the node of the most recent tagged CALL — regardless of state or outcome", () => {
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "b", false))]);
assert.equal(st.current, "b", "even a FAILED check positions the map: the tag is about, not completion");
assert.equal(st.visited.size, 0);
const open = derivePlanStatuses(tree, [msg(quizPart("input-available", "c"))]);
assert.equal(open.current, "c", "a merely ASKED question is already the position");
});
it("a pending check OWNS current, wherever the topo frontier is", () => {
it("visited = earlier tagged nodes, sticky; results feed nothing", () => {
const st = derivePlanStatuses(tree, [
msg(quizPart("output-available", "a", true)),
msg(quizPart("input-available", "c")), // a jump check on the sink
msg(quizPart("output-available", "b", true)),
]);
assert.equal(st.current, "c", "the open check is where we are");
assert.deepEqual([...st.done], ["a"]);
assert.equal(st.current, "b");
assert.deepEqual([...st.visited], ["a"]);
// a passed check never marks anything finished — only moving on does
const again = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", true))]);
assert.equal(again.current, "a");
assert.equal(again.visited.size, 0, "one correct answer on a does not finish it");
});
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("revisits keep both: A→B→A gives current A, visited {A,B}", () => {
const st = derivePlanStatuses(tree, [
msg(quizPart("output-available", "a", true)),
msg(quizPart("output-available", "b", true)),
msg(quizPart("input-available", "a")),
]);
assert.equal(st.current, "a");
assert.deepEqual([...st.visited].sort(), ["a", "b"]);
});
it("untagged checks and unknown nodeIds are lost signal — ignored", () => {
it("several checks on one node hold the map still", () => {
const st = derivePlanStatuses(tree, [
msg(quizPart("output-available", "a", true)),
msg(quizPart("output-available", "a", false)),
msg(quizPart("input-available", "a")),
]);
assert.equal(st.current, "a");
assert.equal(st.visited.size, 0);
});
it("untagged checks and unknown nodeIds are ad-hoc — they move nothing", () => {
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");
assert.equal(st.current, null);
assert.equal(st.visited.size, 0);
});
it("prereqs gate the frontier: c isn't next until b is demonstrated", () => {
it("a rewritten map derives against the CURRENT tree — dropped slugs stop counting", () => {
const rewritten: PlanTree = { ...tree, nodes: [tree.nodes[0]!, tree.nodes[1]!] };
const st = derivePlanStatuses(rewritten, [
msg(quizPart("output-available", "a", true)),
msg(quizPart("output-available", "c", true)), // c removed by the rewrite
]);
assert.equal(st.current, "a", "the orphaned tag is ignored");
assert.equal(st.visited.size, 0);
});
it("derivedNodeStatus: current wins over visited; upcoming otherwise", () => {
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", true))]);
assert.equal(st.current, "b", "c requires b");
const st2 = derivePlanStatuses(tree, [
assert.equal(derivedNodeStatus(st, "a"), "current");
assert.equal(derivedNodeStatus(st, "b"), "upcoming");
const later = 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");
assert.equal(derivedNodeStatus(later, "a"), "visited");
assert.equal(derivedNodeStatus(later, "b"), "current");
assert.equal(derivedNodeStatus(later, "c"), "upcoming");
});
});