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
+5 -29
View File
@@ -15,7 +15,7 @@ import {
import type { PlanBlockData, PlanTree } from "../src/core/contracts.ts";
// Plan blocks: the pure layered layout, the frozen-snapshot rendering
// (full = borderless + column-filling, neighborhood = labeled fragment
// (full = borderless + column-filling
// with the glowing current), and the popup's LIVE derivation.
const CHAIN: PlanTree = {
@@ -28,8 +28,6 @@ const CHAIN: PlanTree = {
{ from: "a", to: "b" },
{ from: "b", to: "c" },
],
currentNodeId: null,
doneNodeIds: [],
version: 1,
};
@@ -75,7 +73,7 @@ describe("plan block render", () => {
at: "2026-09-09T12:00:00Z",
current: null,
nodes: [
{ id: "a", title: "Alpha node", status: "done" },
{ id: "a", title: "Alpha node", status: "visited" },
{ id: "b", title: "Beta node", status: "upcoming" },
],
edges: [{ from: "a", to: "b" }],
@@ -90,33 +88,11 @@ describe("plan block render", () => {
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 natural height, width = one node column.
assert.match(html, /viewBox="0 0 160 136"/);
// Snapshot statuses render as carried.
assert.match(html, /data-testid="plan-node-a" data-status="done"/);
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/);
assert.match(html, /Open the full plan/);
});
it("neighborhood block: own small container, labeled fragment, glowing current", () => {
const data: PlanBlockData = {
kind: "neighborhood",
at: "2026-09-09T12:00:00Z",
current: "mid",
nodes: [
{ id: "up", title: "Upstream", status: "done" },
{ id: "mid", title: "Current node", status: "current" },
{ id: "down", title: "Downstream", status: "upcoming" },
],
edges: [
{ from: "up", to: "mid" },
],
};
const html = renderToStaticMarkup(<PlanBlock data={data} onOpen={() => {}} />);
assert.match(html, /data-testid="plan-block-neighborhood"/);
assert.match(html, /class="[^"]*border/, "the fragment has its own container");
assert.match(html, /Plan fragment/);
// The glow: a pulsing primary halo around the current node.
assert.match(html, /stroke-primary\/70 animate-pulse/);
assert.match(html, /data-testid="plan-node-mid" data-status="current"/);
});
});
describe("plan popup (live derivation)", () => {
@@ -153,10 +129,10 @@ describe("plan popup (live derivation)", () => {
];
}
it("derives statuses at click time: passed → done, live check → current (glowing)", () => {
it("derives statuses at click time: latest tagged call → current, earlier → visited (glowing)", () => {
const html = renderToStaticMarkup(<PlanPopup messages={messages()} onClose={() => {}} />);
assert.match(html, /data-testid="plan-popup"/);
assert.match(html, /data-testid="plan-node-a" data-status="done"/);
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
assert.match(html, /data-testid="plan-node-b" data-status="current"/);
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
// The current node is the glow target.