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
+1
View File
@@ -13,3 +13,4 @@ export * from "./contracts/plan.ts";
export * from "./contracts/graph.ts";
export * from "./contracts/chat.ts";
export * from "./contracts/jobs.ts";
export * from "./contracts/probe.ts";
+9 -6
View File
@@ -124,12 +124,15 @@ export interface TurnUsage {
* small container, the current node glowing — clearly a fragment, not the
* whole plan. Clicking either opens the full plan in a popup. */
export interface PlanBlockData {
kind: "full" | "neighborhood";
/** Only "full" survives (the 2026-09-11 pivot): the plan renders IN
* FLOW once, at authoring. The neighborhood fragments are retired —
* position derives live from the transcript (the persistent DAG
* window is the successor). */
kind: "full";
at: string;
/** The current node at snapshot time (the glow target; null = none). */
current: string | null;
/** Included nodes: all of them (full) or the one-level-up/one-level-down
* neighborhood of `current` (neighborhood). Statuses are the snapshot's,
/** All nodes of the authored tree. Statuses are the snapshot's,
* derived at emission time — the block never re-derives at render. */
nodes: Array<{ id: string; title: string; status: PlanNodeStatus }>;
edges: Array<{ from: string; to: string }>;
@@ -141,9 +144,9 @@ export interface PlanBlockData {
* them in listChats. Liveness (`busy`) is NOT record-derived — the API
* route merges it in (see ChatListItem).
*
* Phase semantics (three phases, data-only): probe = no plan yet, plan =
* tree exists but no node occupied, teach = a node is occupied
* (core/phases.ts phaseOfTree).
* Phase semantics (three phases, event-driven — docs/probe-teach-split.md
* §4): probe ends at the concluded probe_done, plan ends at the successful
* plan_init; derived from the transcript (core/phases.ts phaseAt3).
*/
export interface ChatSummary {
id: string;
+35 -6
View File
@@ -242,7 +242,7 @@ export interface AbortSteerMessage extends UIMessage {
}
/** Anything the wake pipeline may queue and deliver. */
export type SteerMessage = SubagentSteerMessage | AbortSteerMessage;
export type SteerMessage = SubagentSteerMessage | AbortSteerMessage | ProbeHandoffSteerMessage;
/** Whether a result is the failure shape (html/svg empty, failure brief). */
export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean {
@@ -326,10 +326,39 @@ function abortText(kind: JobKind): string {
return "The figure job was aborted by the learner — no figure is coming. Acknowledge it in one line and teach without it (a re-dispatch is fine if the learner wants it).";
}
/** True when the transcript already carries this job's steer (idempotent delivery). */
export function steerDelivered(messages: UIMessage[], jobId: string): boolean {
return messages.some(
(m) => (m.metadata as { subagentResult?: { jobId?: string } } | undefined)?.subagentResult?.jobId === jobId,
);
// ─── the probe handoff steer (the probe→teach split, docs/probe-teach-split.md §2) ──
//
// probe_done's execute queues this through the same wake pipeline; the
// pump fires it once the prober's turn ends (gates + grace). It carries no
// artifact — the assessment it references is durable in the transcript as
// the probe_done tool part. Deterministic id per chat: exactly one
// handoff, idempotent re-delivery.
export interface ProbeHandoffSteerMessage extends UIMessage {
id: `steer_probe_${string}`;
role: "user";
metadata: { probeHandoff: { chatId: string } };
parts: [{ type: "text"; text: string }];
}
export function buildProbeHandoffSteer(chatId: string): ProbeHandoffSteerMessage {
return {
id: `steer_probe_${chatId}`,
role: "user",
metadata: { probeHandoff: { chatId } },
parts: [
{
type: "text",
text: "The probe is concluded — the learner assessment is recorded. Author the session's plan map now (call plan_init) and present it, then begin teaching.",
},
],
};
}
/** True when the transcript already carries this steer — idempotent
* delivery for ANY steer kind: every steer id is deterministic per its
* event (steer_<jobId> for jobs, steer_probe_<chatId> for the handoff). */
export function steerDelivered(messages: UIMessage[], steerId: string): boolean {
return messages.some((m) => m.id === steerId);
}
+36 -51
View File
@@ -29,39 +29,35 @@ export interface PlanEdge {
export interface PlanTree {
nodes: PlanNode[];
edges: PlanEdge[];
currentNodeId: string | null;
doneNodeIds: string[];
/** Increments on every mutation — lets the UI animate/react to changes. */
version: number;
}
// NOTE (2026-09-11 pivot, docs/probe-teach-split.md): the tree once carried
// `currentNodeId` / `doneNodeIds` — progress fields. They are GONE by
// design: the tree carries structure ONLY; position and visited-ness derive
// from the transcript at render time (derivePlanStatuses below). There is no
// done ledger of any kind. No backwards compatibility for old records.
/** Derived, never stored: status of a node for the panel (vision §2). */
export type PlanNodeStatus = "done" | "current" | "upcoming";
export type PlanNodeStatus = "current" | "visited" | "upcoming";
export function planNodeStatus(tree: PlanTree, nodeId: string): PlanNodeStatus {
if (tree.currentNodeId === nodeId) return "current";
if (tree.doneNodeIds.includes(nodeId)) return "done";
return "upcoming";
}
// ─── derived statuses: the author-once protocol (2026-09-03) ─────────────────────
// ─── derived statuses: position from CALLS, never from results ─────────────
//
// The teacher AUTHORS the map (plan_init, mermaid — once at the seam,
// re-callable to rewrite) and never tracks progress: statuses derive from
// the transcript, pure and at render time. A check names its node (quiz
// input nodeId — validated at the call boundary against the live tree);
// the derivation reads outcomes:
// done — any check on the node that the learner PASSED (demonstration-
// only: "explaining is yours, done is theirs")
// current — the node of the last UNANSWERED check; if none, the first
// node in authoring order whose prerequisites are all done and
// that isn't done itself (forward-looking: the panel leads the
// lesson — during teaching it points at what's coming, and holds
// through the check that follows)
// upcoming — everything else
// Untagged checks and nodeIds missing from the current tree are lost signal
// (the standing doctrine); a rewritten map keeps honest progress for reused
// slugs because the derivation matches ids against the CURRENT tree.
// (2026-09-11 pivot — docs/probe-teach-split.md §3.) The teacher AUTHORS the
// map (plan_init, mermaid — re-callable rewrite) and never tracks progress.
// The quiz `nodeId` means "this question is ABOUT this node" — a position
// signal, never a completion signal. Derivation reads CALLS only:
// current — the node of the most RECENT tagged quiz call (any state)
// visited — every node referenced by an EARLIER tagged call
// Consequences: a node spans multiple turns/checks naturally (several
// questions may tag the same node — the map holds still); moving to a new
// node is the teacher's judgment the previous one is finished, regardless of
// check outcomes (a failed check then a move-on is still visited); results
// feed nothing; there is NO fallback current (before the first tagged check
// nothing is current). Untagged checks are legal ad-hoc checks — they move
// nothing. NodeIds missing from the current tree are ignored (a rewrite
// may have dropped the node).
/** Minimal structural message slice — anything with typed parts (mirrors
* phases.ts: pure, no runtime deps, safe on server, client, and in tests). */
@@ -70,48 +66,37 @@ interface PartsCarrier {
}
export interface DerivedPlanStatuses {
/** The node the most recent tagged quiz call is about; null before any. */
current: string | null;
/** Node ids demonstrated by the learner (passed checks). */
done: ReadonlySet<string>;
/** Nodes referenced by earlier tagged calls — been there, monotonic. */
visited: ReadonlySet<string>;
}
/** Statuses of a node: the derived view the plan panel should render. */
export function derivedNodeStatus(statuses: DerivedPlanStatuses, nodeId: string): PlanNodeStatus {
if (statuses.current === nodeId) return "current";
if (statuses.done.has(nodeId)) return "done";
if (statuses.visited.has(nodeId)) return "visited";
return "upcoming";
}
export function derivePlanStatuses(tree: PlanTree, messages: readonly PartsCarrier[]): DerivedPlanStatuses {
const ids = new Set(tree.nodes.map((n) => n.id));
const done = new Set<string>();
let current: string | null = null;
const runs: string[] = []; // distinct tagged nodes in call order, CONSECUTIVE
// duplicates collapsed (several checks on one node = one stay on it)
for (const m of messages) {
for (const part of m.parts) {
if (part.type !== "tool-quiz") continue;
const nodeId = (part.input as { nodeId?: unknown } | undefined)?.nodeId;
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; // untagged/unknown = lost signal
if (part.state === "input-available") {
current = nodeId; // a check is out — that's where we are
} else if (part.state === "output-available") {
if ((part.output as { correct?: unknown } | undefined)?.correct === true) done.add(nodeId);
// failed checks change nothing: resume teaching, re-check later
}
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; // untagged/unknown — moves nothing
if (runs.at(-1) !== nodeId) runs.push(nodeId);
}
}
if (current === null) {
// No check out: the first node (authoring order) whose prerequisites
// are all demonstrated and that isn't done itself — the next lesson.
const prereqs = new Map<string, string[]>();
for (const e of tree.edges) prereqs.set(e.to, [...(prereqs.get(e.to) ?? []), e.from]);
current =
tree.nodes.find(
(n) => !done.has(n.id) && (prereqs.get(n.id) ?? []).every((p) => done.has(p)),
)?.id ?? null;
}
return { current, done };
const current = runs.at(-1) ?? null;
// Visited = been there in an EARLIER stay: everything before the current
// run. A revisit (a→b→a) puts a in visited AND current — current wins at
// render (derivedNodeStatus), visited records it has been left before.
const visited = new Set(runs.slice(0, -1));
return { current, visited };
}
/**
+45
View File
@@ -0,0 +1,45 @@
/**
* Probe-phase contracts (the probe→teach split, docs/probe-teach-split.md).
*
* The prober concludes its phase with `probe_done` — a server-side tool
* whose INPUT is the learner assessment (authored by the model, streamed to
* the learner live, durable in the transcript by the tool result itself).
* The first `probe_done` in the transcript is the probe→planning seam: the
* teacher agent is constructed from that point on, and the tool's execute
* queues the handoff steer through the wake pipeline so the teacher starts
* immediately after the prober's turn ends (auto-wake, user-decided).
*
* Assessment shape (v1 — refine later): the probe was a binary search per
* dependency strand; the assessment formalizes exactly that. A list of the
* strands the lesson will depend on, each with a free-form read of where
* the learner's edge sits.
*/
/** One dependency strand the planned lesson will rest on. */
export interface ProbeStrand {
/** Name/description of the strand (e.g. "the derivative as a limit"). */
strand: string;
/** Free-form: where the learner's edge sits on this strand — floor,
* ceiling, misconceptions found, what to build from. */
assessment: string;
}
/** The prober's read of the learner, carried by probe_done. */
export interface ProbeAssessment {
strands: ProbeStrand[];
/** Optional overall synthesis: the learner's apparent goal and edge. */
summary?: string;
}
/** probe_done tool input (the assessment the prober authors). */
export interface ProbeDoneInput {
assessment: ProbeAssessment;
}
/** probe_done tool output. */
export interface ProbeDoneResult {
ok: boolean;
/** Refusal reason when ok === false (open question, already concluded). */
error?: string;
assessment?: ProbeAssessment;
}
-172
View File
@@ -1,172 +0,0 @@
/**
* Graph-as-context (W8; vision §1 "The graph as session context", DECIDED
* 2026-08-22).
*
* renderGraphContext() turns the derived graph into a compact markdown block
* the teacher sees as part of its instructions — this is what makes probing
* precise instead of generic: probe questions can target the boundary of
* what the learner actually holds, and personalization compounds across
* sessions.
*
* buildGraphContext() is the per-request entry point the chat-handler calls
* (stateless: it re-reads the ledgers, so it self-updates as the graph grows
* mid-session). An empty graph returns null — nothing is injected, and the
* prompt fragment (50-graph-context.md) tells the teacher how to behave when
* the block is absent (new learner).
*
* Selection strategy (§1 "the selection problem", simplest first): the WHOLE
* graph while it is small; past MAX_NODES, hotspots (missed / dont-know
* nodes) plus their 1-hop `follows` neighborhood — the frontier is where
* the next session should probe.
*/
import type { GraphEdge, GraphNode, LedgerEventKind } from "./contracts.ts";
import { latestQuizOutcomes, type DerivedGraph } from "./graph.ts";
/** The section header the teacher is taught to recognize (50-graph-context.md). */
export const GRAPH_CONTEXT_HEADER = "LEARNER KNOWLEDGE GRAPH (auto-generated)";
/** Above this node count only hotspots + their 1-hop neighborhood are shown. */
export const MAX_CONTEXT_NODES = 150;
/**
* Group order for node lines: diagnostic value first (vision §1 hotspots).
* No node-state classes (removed 2026-09-08) — the grouping is the raw
* event fact: the node's LATEST quiz outcome (miss/dont-know = hotspot).
*/
type Outcome = LedgerEventKind | "never-probed";
function groupOf(outcome: Outcome): number {
if (outcome === "miss" || outcome === "dont-know") return 0;
if (outcome === "correct") return 1;
return 2; // never probed
}
const GROUP_LABELS = [
"Hotspots (last answer wrong or don't-know) — likely misconception sites and unexplored ground:",
"Answered correctly:",
"Never probed (assume nothing):",
] as const;
/** A hotspot is a node worth showing even under truncation (§1: diagnostic hotspots). */
function isHotspot(outcome: Outcome): boolean {
return groupOf(outcome) === 0;
}
/**
* Render the context block, or null when there is nothing worth injecting
* (no nodes; or a truncated graph whose selection came out empty).
*
* Pure: callers hand in exactly what getGraph(graphStore) produces; the
* outcomes are the raw latest-quiz-event facts (core/graph.ts).
*/
export function renderGraphContext(
nodes: GraphNode[],
edges: GraphEdge[],
outcomes: Map<string, LedgerEventKind>,
): string | null {
if (nodes.length === 0) return null;
// ── selection ───────────────────────────────────────────────────────────
const outcomeOf = (node: GraphNode): Outcome => outcomes.get(node.id) ?? "never-probed";
let selected = nodes;
let truncated = false;
if (nodes.length > MAX_CONTEXT_NODES) {
truncated = true;
const hotspots = nodes.filter((n) => isHotspot(outcomeOf(n)));
const keep = new Set(hotspots.map((n) => n.id));
// 1-hop follows neighborhood: premises AND derivations.
for (const edge of edges) {
if (edge.type !== "follows") continue;
if (keep.has(edge.from) || keep.has(edge.to)) {
keep.add(edge.from);
keep.add(edge.to);
}
}
selected = nodes.filter((n) => keep.has(n.id));
if (selected.length === 0) return null; // nothing diagnostic to show
}
// ── node lines, grouped/sorted by diagnostic priority ──────────────────────────
const lines: string[] = [
`===== ${GRAPH_CONTEXT_HEADER} =====`,
`${truncated ? `showing ${selected.length} of ${nodes.length} nodes (hotspots + their 1-hop neighborhood)` : `${nodes.length} nodes`}, ${edges.filter((e) => e.type === "follows").length} follows edges`,
];
for (const [group, label] of GROUP_LABELS.entries()) {
const groupNodes = selected
.filter((n) => groupOf(outcomeOf(n)) === group)
.sort((a, b) => (a.statement < b.statement ? -1 : a.statement > b.statement ? 1 : 0));
if (groupNodes.length === 0) continue;
lines.push("", label);
for (const node of groupNodes) {
const outcome = outcomeOf(node);
const tag = outcome === "never-probed" ? "never probed" : outcome;
lines.push(`• [${tag}] ${node.statement} (${node.id})`);
}
}
// ── requires edges, merged into chains ──────────────────────────────────
const byId = new Map(selected.map((n) => [n.id, n.statement]));
const follows = edges.filter(
(e) => e.type === "follows" && byId.has(e.from) && byId.has(e.to),
);
if (follows.length > 0) {
lines.push("", "Follows (premise → derivation):");
for (const chain of edgeChains(follows)) {
lines.push(chain.map((id) => byId.get(id) ?? id).join(" → "));
}
}
lines.push("===== end of knowledge graph =====");
return lines.join("\n");
}
/**
* Merge a set of requires edges into maximal simple chains: a→b, b→c, a→d
* renders as "a → b → c" and "a → d". Each edge appears in exactly one chain.
*/
function edgeChains(edges: GraphEdge[]): string[][] {
const chains: string[][] = [];
const used = new Set<GraphEdge>();
const sorted = [...edges].sort((a, b) =>
`${a.from}\u0000${a.to}` < `${b.from}\u0000${b.to}` ? -1 : 1,
);
for (const edge of sorted) {
if (used.has(edge)) continue;
used.add(edge);
let chain = [edge.from, edge.to];
// Extend forward along unused outgoing edges (branching yields sibling
// chains; a cycle stops the walk).
for (;;) {
const tail = chain[chain.length - 1]!;
const next = sorted.find((e) => !used.has(e) && e.from === tail);
if (!next || chain.includes(next.to)) break; // branching or a cycle — stop
used.add(next);
chain.push(next.to);
}
// Extend backward along unused incoming edges, symmetrically.
for (;;) {
const head = chain[0]!;
const prev = sorted.find((e) => !used.has(e) && e.to === head);
if (!prev || chain.includes(prev.from)) break;
used.add(prev);
chain.unshift(prev.from);
}
chains.push(chain);
}
return chains;
}
// ─── the per-request entry point ─────────────────────────────────────────────
/**
* Build the graph context block for one request. Pure: the caller hands in
* the derived graph (production: getGraph(graphStore) per request in the
* chat-handler, so it self-updates as the graph grows mid-session).
*/
export function buildGraphContext(graph: DerivedGraph): string | null {
const outcomes = latestQuizOutcomes(graph.nodes, graph.events);
return renderGraphContext(graph.nodes, graph.edges, outcomes);
}
+2 -1
View File
@@ -373,7 +373,8 @@ const QUIZ_EVENT_KINDS: ReadonlySet<string> = new Set(["correct", "miss", "dont-
* colors nodes by quiz results anymore). Used by the teacher's
* graph-as-context selection (graph-context.ts) to find diagnostic
* hotspots; the home renderer does not consume it. Nodes with no quiz
* events have no entry.
* events have no entry. (The graph-as-context consumer is shelved —
* docs/probe-teach-split.md §5 — but the event fact stays.)
*/
export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map<string, LedgerEventKind> {
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
+4 -5
View File
@@ -9,11 +9,10 @@
* components/** → "@/src/core" (this barrel, ONLY)
* app/** → "@/src/core" + "@/src/server"
*
* Server-only core modules (core/graph.ts emission/derivation,
* core/graph-context.ts) are pure but need storage ports passed in by a
* server caller, so they are deliberately NOT re-exported here — the
* server imports them directly. The storage PORT TYPES are types-only and
* browser-safe, hence re-exported.
* Server-only core modules (core/graph.ts emission/derivation) are pure
* but need storage ports passed in by a server caller, so they are
* deliberately NOT re-exported here — the server imports them directly.
* The storage PORT TYPES are types-only and browser-safe, hence re-exported.
*/
export * from "./contracts.ts";
+49 -117
View File
@@ -1,128 +1,69 @@
/**
* Probe/teach phase derivation (vision §2 "Two phases, clearly separated").
* Phase derivation (docs/probe-teach-split.md §4) — EXPLICIT EVENTS, never
* occupancy. The probe→teach agent split (2026-09-11) gave every phase
* boundary a transcript marker:
*
* The session's phase is DERIVED, never stored: `plan_init` IS the transition
* marker — the call already fires exactly once at the seam (verified live in
* Gate 1), so no new agent tool exists. Phase = "probe" until the first
* message containing a `plan_init` tool part; from that message onward,
* phase = "teach". Everything after it (checks, follow-up asks) is teaching.
* probe — until the first message containing a `tool-probe_done` part
* (the prober concludes; the assessment rides the tool part)
* plan — probe concluded, until the first successful `plan_init`
* (the teacher presents the map; interim rule until the
* accept-plan UI lands — then acceptance becomes the seam)
* teach — a plan exists
*
* Pure: no imports, no runtime deps — safe on server, client, and in tests.
* The former derived-occupancy seam (phase-of-tree via `currentNodeId`)
* is GONE: nothing ever writes currentNodeId (the stuck-on-plan bug — a
* session could never leave "plan"), and the stored tree no longer carries
* progress fields at all. No backwards compatibility: legacy sessions
* without probe_done simply read as probe (they are wiped data).
*
* Pure: no imports, no runtime deps — safe on server, client, and tests.
*/
/** The two phases of a session. */
export type Phase = "probe" | "teach";
/** Minimal structural slice of a UI message — anything with typed parts. */
export interface PhaseMessage {
parts: ReadonlyArray<{ type: string }>;
}
const PLAN_INIT_PART = "tool-plan_init";
/**
* The phase at `upToIndex` (inclusive; default: the last message).
*
* Scans messages[0..upToIndex] for the FIRST message containing a
* `tool-plan_init` part — any part state counts (the call itself is the
* transition; teaching has begun even while the call's input streams). The
* message that contains plan_init is already teach: the divider renders
* exactly there, so probe content sits above it, teaching below.
*
* Boundary semantics:
* - no plan_init anywhere → "probe" at every index (pre-plan sessions keep
* the old auto-open behavior for every question — backward compatible).
* - first plan_init in message i → phaseAt(m, i-1) === "probe",
* phaseAt(m, i) === "teach", and every index after.
* - multiple plan_init parts → the FIRST wins (a re-init attempt later in
* the transcript never moves the seam; the tool refuses re-init anyway).
*/
export function phaseAt(messages: readonly PhaseMessage[], upToIndex?: number): Phase {
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
for (let i = 0; i <= last; i++) {
const parts = messages[i]?.parts;
if (parts?.some((p) => p.type === PLAN_INIT_PART)) return "teach";
}
return "probe";
}
// ─── The three-phase view (probe / plan / teach) ──────────────────────────────
//
// The session picker's color strips, the header chip, and the transcript
// dividers all speak THREE phases, derived data-only from the plan tree:
//
// probe — no plan output yet (the tree is empty or absent);
// plan — a tree exists but no node is occupied (plan_set_current not fired);
// teach — a node is occupied.
//
// The teacher's invisible PRE-plan planning (reasoning before plan_init
// fires) reads as probe until the plan lands — an accepted limitation: no
// observable signal marks "I'm now planning" (a teacher-side marker would be
// an agent change; see phases.md discussion in the transcript divider work).
/** The three phases of a session, as the picker / header / dividers see it. */
/** The three phases of a session, as the picker / header / dividers see them. */
export type Phase3 = "probe" | "plan" | "teach";
/** The advance-only order of phases: a session never visibly regresses. */
const PHASE3_RANK: Record<Phase3, number> = { probe: 0, plan: 1, teach: 2 };
/** Structural slice of a plan tree — anything with nodes + a current node. */
export interface PhaseTree {
nodes: ReadonlyArray<unknown>;
currentNodeId: string | null;
}
/**
* The phase of a tree, point-in-time: no nodes → probe, no current node →
* plan, an occupied node → teach. Used for the chats summary (the stored
* tree IS the session state there). Empty trees (emptyTree(), a failed
* plan_init) read as probe — a seam only counts once a plan exists.
* Minimal structural slice of a UI message for the phase scan: parts with
* a state and (for concluded calls) an output.
*/
export function phaseOfTree(tree: PhaseTree | null | undefined): Phase3 {
if (!tree || tree.nodes.length === 0) return "probe";
return tree.currentNodeId === null ? "plan" : "teach";
}
/**
* Minimal structural slice of a UI message for the three-phase scan: parts
* with a state and (when a plan op completed) its output.
*/
export interface Phase3Message {
export interface PhaseMessage {
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>;
}
/** A successful plan-op output as it appears on the part (`PlanToolResult`). */
interface PlanOutput {
tree?: { nodes?: unknown };
}
/**
* The three-phase boundary semantics over the TRANSCRIPT (the same data the
* tree panel reads): scanning forward from 0 to `upToIndex` (default: the
* last message), the phase advances probe → plan at the first plan part
* with a NON-EMPTY tree available (a failed plan_init carries an empty tree
* and never marks the seam), and plan → teach at the first tree output with
* an occupied node. MONOTONIC by design: the phase only ever advances — a
* node removal mid-teaching (currentNodeId → null) is an edit, not a phase
* regression, so no "Plan" divider ever appears below teaching content.
*
* `phaseAt` (two-phase) stays as-is: the plan_init call is its seam and the
* question dock's reveal logic leans on it. The two views differ only in
* the window between plan_init and the first plan_set_current, where
* three-phase reads "plan".
*/
export function phaseAt3(messages: readonly Phase3Message[], upToIndex?: number): Phase3 {
/** The phase at `upToIndex` (inclusive; default: the last message). */
export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number): Phase3 {
let phase: Phase3 = "probe";
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
for (let i = 0; i <= last; i++) {
const parts = messages[i]?.parts;
if (!parts) continue;
for (const part of parts) {
if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue;
const tree = (part.output as PlanOutput | undefined)?.tree as PhaseTree | undefined;
if (!tree || !Array.isArray(tree.nodes)) continue;
const candidate = phaseOfTree(tree);
if (PHASE3_RANK[candidate] > PHASE3_RANK[phase]) phase = candidate;
phase = advance(phase, messages[i]?.parts);
}
return phase;
}
/** One message's advance of the phase (pure fold step). */
function advance(
phase: Phase3,
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }> | undefined,
): Phase3 {
if (!parts) return phase;
for (const part of parts) {
// STRICTLY SEQUENTIAL: probe → plan only via a concluded probe_done,
// plan → teach only via a successful plan_init. A marker out of order
// (e.g. a legacy plan_init with no probe) advances nothing — the chain
// never skips, and the rank table below only guards regressions.
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
phase = "plan";
}
if (phase === "plan" && part.type === "tool-plan_init" && part.state === "output-available") {
const tree = (part.output as PlanOutput | undefined)?.tree;
if (tree && Array.isArray(tree.nodes) && tree.nodes.length > 0) phase = "teach";
}
}
return phase;
@@ -130,24 +71,15 @@ export function phaseAt3(messages: readonly Phase3Message[], upToIndex?: number)
/**
* The phase at EVERY message index, in one forward fold — the transcript
* dividers render one per boundary, so phaseAt3(m, i) per index would
* rescan 0..i each time (quadratic); this is the same derivation, linear.
* out[i] === phaseAt3(messages, i) for every index.
* dividers render one per boundary, so phaseAt3(m, i) per index would rescan
* 0..i each time (quadratic); this is the same derivation, linear.
* out[i] === phaseAt3(messages, i) for every i.
*/
export function phaseTimeline(messages: readonly Phase3Message[]): Phase3[] {
export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
const out: Phase3[] = [];
let phase: Phase3 = "probe";
for (let i = 0; i < messages.length; i++) {
const parts = messages[i]?.parts;
if (parts) {
for (const part of parts) {
if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue;
const tree = (part.output as PlanOutput | undefined)?.tree as PhaseTree | undefined;
if (!tree || !Array.isArray(tree.nodes)) continue;
const candidate = phaseOfTree(tree);
if (PHASE3_RANK[candidate] > PHASE3_RANK[phase]) phase = candidate;
}
}
phase = advance(phase, messages[i]?.parts);
out.push(phase);
}
return out;
+28 -123
View File
@@ -1,28 +1,23 @@
/**
* Plan-block emission (wave 4, the seam the dock/popup render against):
* the PURE builder that turns a plan tree + transcript into the persisted
* `data-plan-block` parts the chat renders in flow. Emission happens ONCE,
* at persist time (turn-service seam) — blocks are frozen transcript
* content (user decision, 2026-09-11), never derived at render:
* Plan-block emission (docs/probe-teach-split.md §6): the INTERIM plan
* presentation seam. The plan renders IN FLOW once, at authoring — a full
* `data-plan-block` attached to the assistant message that carries the
* successful plan_init call. It is the presentation moment for the plan
* (and the popup's entry point) — statuses on it are accurate at that
* instant (current = null, everything upcoming — unless the authoring is a
* mid-lesson rewrite, in which case the snapshot shows what was true then).
*
* - FULL block: attached to the assistant message carrying a successful
* plan_init call — the whole DAG, snapshot statuses. Always emitted on
* (re)authoring, even an identical rewrite: authoring is the event.
* - NEIGHBORHOOD block: attached to the LAST assistant message when the
* derived plan state MOVED since the newest existing block — current
* shifted or a check passed (done grew). A resolved FAILED check also
* moves: done doesn't grow, but the check is no longer out, so current
* falls back to the first eligible node — the block reflects that
* standing. The as-of state is read from the newest block's FROZEN
* SNAPSHOT (not re-derived from the transcript, which the client
* re-posts with answered quizzes mutated in place) and compared with
* the state derived now — no extra state channel exists.
* The neighborhood-block machinery (frozen fragments on plan movement) is
* RETIRED: position derives live from the transcript (derivePlanStatuses —
* calls, not results), and frozen snapshots can't represent live movement.
* The persistent DAG window (the UI wave) is the successor — always
* present, auto-moving with the lesson.
*
* Idempotent by construction: a message that already carries a plan-block
* part is never given another (persist replays / retry paths are safe).
*/
import type { PlanBlockData, PlanNode, PlanTree } from "./contracts.ts";
import type { PlanBlockData, PlanTree } from "./contracts.ts";
import { derivePlanStatuses } from "./contracts.ts";
import type { UIMessage } from "ai";
@@ -34,19 +29,7 @@ export interface PlanBlockPart {
data: PlanBlockData;
}
/** Nodes/edges of the one-level neighborhood: current + direct parents +
* direct children, and the edges among exactly those nodes. */
export function neighborhood(tree: PlanTree, current: string): { nodes: PlanNode[]; edges: Array<{ from: string; to: string }> } {
const parents = tree.edges.filter((e) => e.to === current).map((e) => e.from);
const children = tree.edges.filter((e) => e.from === current).map((e) => e.to);
const ids = new Set([current, ...parents, ...children]);
return {
nodes: tree.nodes.filter((n) => ids.has(n.id)),
edges: tree.edges.filter((e) => ids.has(e.from) && ids.has(e.to)),
};
}
/** Does this message carry a successful plan_init (the full-block trigger)? */
/** Does this message carry a successful plan_init (the trigger)? */
function hasSuccessfulPlanInit(message: UIMessage): boolean {
for (const part of message.parts) {
if (part.type !== "tool-plan_init") continue;
@@ -56,114 +39,36 @@ function hasSuccessfulPlanInit(message: UIMessage): boolean {
return false;
}
/** The plan state as derived from a message slice — the diff key. `checked`
* = every node with a tagged quiz out or answered (a new check IS a plan
* event: the block's glowing current follows it). */
function planState(messages: readonly UIMessage[], tree: PlanTree) {
function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData {
const statuses = derivePlanStatuses(tree, messages);
const checked = new Set<string>();
for (const m of messages) {
for (const part of m.parts) {
if (part.type !== "tool-quiz") continue;
const nodeId = (part as { input?: { nodeId?: unknown } }).input?.nodeId;
if (typeof nodeId === "string" && nodeId !== "") checked.add(nodeId);
}
}
return {
current: statuses.current,
done: [...statuses.done].sort().join(","),
checked: [...checked].sort().join(","),
};
}
/** The messages up to and including the newest plan-block carrier — the
* "as of last block" slice. Null when no block exists yet. */
function sliceAsOfLastBlock(messages: readonly UIMessage[]): UIMessage[] | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]!.parts.some((p) => p.type === "data-plan-block")) return messages.slice(0, i + 1);
}
return null;
}
/** The as-of state read from the newest existing block's FROZEN SNAPSHOT —
* not re-derived from the transcript (which the client re-posts with quiz
* parts mutated in place: an answered check's part flips from
* input-available to output-available, so a re-derivation would read the
* post-answer state and the move would be invisible). The block is the
* as-of record; that is what frozen snapshots are for. */
function stateAsOfLastBlock(messages: readonly UIMessage[]): { current: string | null; done: string } | null {
for (let i = messages.length - 1; i >= 0; i--) {
for (const part of messages[i]!.parts) {
if (part.type !== "data-plan-block") continue;
const data = (part as { data?: PlanBlockData }).data;
if (!data) return null;
return {
current: data.current,
done: data.nodes
.filter((n) => n.status === "done")
.map((n) => n.id)
.sort()
.join(","),
};
}
}
return null;
}
function snapshot(kind: PlanBlockData["kind"], tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData | null {
const statuses = derivePlanStatuses(tree, messages);
if (kind === "neighborhood" && statuses.current === null) return null;
const scope = kind === "full" || statuses.current === null ? tree : neighborhood(tree, statuses.current);
return {
kind,
kind: "full",
at: new Date().toISOString(),
current: statuses.current,
nodes: scope.nodes.map((n) => ({
nodes: tree.nodes.map((n) => ({
id: n.id,
title: n.title,
status: statuses.current === n.id ? "current" : statuses.done.has(n.id) ? "done" : "upcoming",
status: statuses.current === n.id ? "current" : statuses.visited.has(n.id) ? "visited" : "upcoming",
})),
edges: scope.edges.map((e) => ({ from: e.from, to: e.to })),
edges: tree.edges.map((e) => ({ from: e.from, to: e.to })),
};
}
/**
* Enrich a message list with plan-block parts — THE emission seam's pure
* half. `messages` is the deduped list persistTurn is about to write;
* `tree` is the record's current tree (the plan tool saved it during the
* turn). Messages already carrying a block are never touched.
* Enrich a message list with plan-presentation blocks — THE emission
* seam's pure half. `messages` is the deduped list persistTurn is about to
* write; `tree` is the record's current tree (the plan tool saved it during
* the turn). Messages already carrying a block are never touched.
*/
export function withPlanBlocks(messages: UIMessage[], tree: PlanTree | null | undefined): UIMessage[] {
if (!tree || tree.nodes.length === 0 || messages.length === 0) return messages;
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
if (!lastAssistant) return messages;
// neighborhood needed? the plan state moved since the newest existing block
let moved = false;
const asOf = stateAsOfLastBlock(messages);
if (asOf === null) {
moved = planState(messages, tree).checked !== ""; // first block: any plan event ever
} else {
const now = planState(messages, tree);
moved = asOf.current !== now.current || asOf.done !== now.done;
}
const blockId = `pb_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
let fullAttached = false;
return messages.map((m) => {
if (m.role !== "assistant" || m.parts.some((p) => p.type === "data-plan-block")) return m;
if (hasSuccessfulPlanInit(m)) {
const data = snapshot("full", tree, messages);
if (!data) return m;
fullAttached = true;
return { ...m, parts: [...m.parts, { type: "data-plan-block", id: `${blockId}_f`, data } satisfies PlanBlockPart] };
}
if (moved && !fullAttached && m.id === lastAssistant.id) {
const data = snapshot("neighborhood", tree, messages);
if (!data) return m;
return { ...m, parts: [...m.parts, { type: "data-plan-block", id: `${blockId}_n`, data } satisfies PlanBlockPart] };
}
return m;
if (!hasSuccessfulPlanInit(m)) return m;
return {
...m,
parts: [...m.parts, { type: "data-plan-block", id: blockId, data: snapshot(tree, messages) } satisfies PlanBlockPart],
};
});
}
+7 -101
View File
@@ -28,7 +28,7 @@ export class PlanTreeError extends Error {
/** An empty, never-stored tree used as the seed before plan_init runs. */
export function emptyTree(): PlanTree {
return { nodes: [], edges: [], currentNodeId: null, doneNodeIds: [], version: 0 };
return { nodes: [], edges: [], version: 0 };
}
// ─── validation helpers ──────────────────────────────────────────────────────
@@ -122,105 +122,11 @@ export function createTree(nodes: PlanNode[], edges: PlanEdge[]): PlanTree {
assertValidEdge(e.from, e.to, nodes, accepted);
accepted.push({ from: e.from, to: e.to });
}
return { nodes: nodes.map((n) => ({ ...n })), edges: accepted, currentNodeId: null, doneNodeIds: [], version: 1 };
return { nodes: nodes.map((n) => ({ ...n })), edges: accepted, version: 1 };
}
/** Add a node, optionally creating edges parent → new node for each id in `parents`. */
export function addNode(tree: PlanTree, node: PlanNode, parents?: string[]): PlanTree {
assertValidNode(node);
if (tree.nodes.some((n) => n.id === node.id)) {
throw new PlanTreeError(`Duplicate node id "${node.id}" — a node with that id already exists in the tree.`);
}
const parentList = parents ?? [];
if (parentList.includes(node.id)) {
throw new PlanTreeError(`Node "${node.id}" can't list itself as a parent.`);
}
const newEdges: PlanEdge[] = [];
for (const parent of parentList) {
// Validates existence (including the new node itself as `to`),
// self-loop, duplicate (against tree edges plus the new edges being
// added), and acyclicity.
assertValidEdge(parent, node.id, [...tree.nodes, node], [...tree.edges, ...newEdges]);
newEdges.push({ from: parent, to: node.id });
}
return {
...tree,
nodes: [...tree.nodes, { ...node }],
edges: [...tree.edges, ...newEdges],
version: tree.version + 1,
};
}
/** Update a node's title and/or summary. The id must exist; ids are immutable. */
export function updateNode(tree: PlanTree, id: string, patch: { title?: string; summary?: string }): PlanTree {
const index = tree.nodes.findIndex((n) => n.id === id);
if (index === -1) {
throw new PlanTreeError(`Cannot update node "${id}" — it is not in the tree.`);
}
const current = tree.nodes[index]!;
const next: PlanNode = { ...current };
if (patch.title !== undefined) next.title = patch.title;
if (patch.summary !== undefined) next.summary = patch.summary;
assertValidNode(next);
const nodes = tree.nodes.slice();
nodes[index] = next;
return { ...tree, nodes, version: tree.version + 1 };
}
/**
* Remove a node and cascade: every edge touching it (both directions),
* currentNodeId (→ null if it pointed here), and its doneNodeIds entry.
*/
export function removeNode(tree: PlanTree, id: string): PlanTree {
if (!tree.nodes.some((n) => n.id === id)) {
throw new PlanTreeError(`Cannot remove node "${id}" — it is not in the tree.`);
}
return {
...tree,
nodes: tree.nodes.filter((n) => n.id !== id),
edges: tree.edges.filter((e) => e.from !== id && e.to !== id),
currentNodeId: tree.currentNodeId === id ? null : tree.currentNodeId,
doneNodeIds: tree.doneNodeIds.filter((d) => d !== id),
version: tree.version + 1,
};
}
/** Add a `requires` edge from → to. Rejects self-loops, duplicates, and cycles. */
export function addEdge(tree: PlanTree, from: string, to: string): PlanTree {
assertValidEdge(from, to, tree.nodes, tree.edges);
return { ...tree, edges: [...tree.edges, { from, to }], version: tree.version + 1 };
}
/** Remove an existing edge. Removing a nonexistent edge is an error. */
export function removeEdge(tree: PlanTree, from: string, to: string): PlanTree {
const index = tree.edges.findIndex((e) => e.from === from && e.to === to);
if (index === -1) {
throw new PlanTreeError(`Cannot remove edge "${from}" → "${to}" — no such edge exists.`);
}
return { ...tree, edges: tree.edges.filter((e) => e.from !== from || e.to !== to), version: tree.version + 1 };
}
/**
* Set currentNodeId ("where we are"). Requires the node exists. Does NOT
* touch doneNodeIds. Setting the already-current node is a true no-op (no
* version bump).
*/
export function setCurrent(tree: PlanTree, id: string): PlanTree {
if (!tree.nodes.some((n) => n.id === id)) {
throw new PlanTreeError(`Cannot set current node "${id}" — it is not in the tree.`);
}
if (tree.currentNodeId === id) return tree;
return { ...tree, currentNodeId: id, version: tree.version + 1 };
}
/**
* Mark a node done. Requires the node exists; idempotent — marking an
* already-done node is a true no-op (no version bump).
*/
export function markDone(tree: PlanTree, id: string): PlanTree {
if (!tree.nodes.some((n) => n.id === id)) {
throw new PlanTreeError(`Cannot mark node "${id}" done — it is not in the tree.`);
}
if (tree.doneNodeIds.includes(id)) return tree;
return { ...tree, doneNodeIds: [...tree.doneNodeIds, id], version: tree.version + 1 };
}
// The per-node mutation ops (addNode/updateNode/removeNode/addEdge/removeEdge/
// setCurrent/markDone) were deleted with the author-once protocol
// (2026-09-03) and their dead exports removed with the 2026-09-11 pivot: the
// tree carries structure ONLY — no progress fields exist to mutate
// (docs/probe-teach-split.md §3). Restructuring is a plan_init rewrite.
+66 -45
View File
@@ -107,47 +107,59 @@ export function quizInputProblems(input: {
return problems;
}
export const quizInputObject = z
.object({
question: z.string().describe("The single quiz question to ask. Ask exactly one question per tool call."),
options: z
.array(optionSchema)
.min(2)
.describe(
"The answer options (2 or more). Options only — there is no free-text mode. " +
"Give each option a stable `value`; you reference the correct one by that value in correctAnswer. " +
"Never add your own 'I don't know' option — that choice is added for you.",
),
multiSelect: z
.boolean()
.optional()
.describe("Set to true when more than one option is correct and the learner must select all of them."),
correctAnswer: z
.union([z.string(), z.array(z.string())])
.describe(
"REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. " +
'Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); ' +
"the learner is only correct if their selection matches this set exactly. " +
"Always pass the value, not a position number — this is self-checking and prevents miscounting. " +
"A value that matches no option fails validation and the call is returned to you to fix.",
),
explanation: z
.string()
.describe(
"REQUIRED. Explanation revealed AFTER the learner answers (shown whether they got it right or wrong). " +
"Use it to reinforce why the correct answer is correct.",
),
details: z.string().optional().describe("Optional extra context or instructions shown under the question."),
nodeId: z
.string()
.optional()
.describe(
"Optional plan-tree node id this question assesses — must EXACTLY match one of this session's " +
"plan_init node slugs (validated at call time; a mismatched id is rejected back to you). " +
"Set it whenever the question targets a map node — the outcome drives the learner's live session map. " +
"Omit for ad-hoc checks.",
),
})
// The SHARED quiz authoring fields — everything except `nodeId`. The probe
// and teach agents use DIFFERENT tools under the same name (docs/probe-teach-
// split.md §1): the prober's quiz has NO nodeId field at all (there is no
// map to position on), the teacher's adds it (the position signal — the
// per-session tree validation chains on top in tools.ts). Grading rules
// are identical, single-sourced here.
export const quizInputBase = z.object({
question: z.string().describe("The single quiz question to ask. Ask exactly one question per tool call."),
options: z
.array(optionSchema)
.min(2)
.describe(
"The answer options (2 or more). Options only — there is no free-text mode. " +
"Give each option a stable `value`; you reference the correct one by that value in correctAnswer. " +
"Never add your own 'I don't know' option — that choice is added for you.",
),
multiSelect: z
.boolean()
.optional()
.describe("Set to true when more than one option is correct and the learner must select all of them."),
correctAnswer: z
.union([z.string(), z.array(z.string())])
.describe(
"REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. " +
'Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); ' +
"the learner is only correct if their selection matches this set exactly. " +
"Always pass the value, not a position number — this is self-checking and prevents miscounting. " +
"A value that matches no option fails validation and the call is returned to you to fix.",
),
explanation: z
.string()
.describe(
"REQUIRED. Explanation revealed AFTER the learner answers (shown whether they got it right or wrong). " +
"Use it to reinforce why the correct answer is correct.",
),
details: z.string().optional().describe("Optional extra context or instructions shown under the question."),
});
// The TEACH-side quiz object: the shared base + the plan-position field.
// `nodeId` means "this question is ABOUT this node" — a position signal,
// never a completion signal (docs/probe-teach-split.md §3).
export const quizInputObject = quizInputBase.extend({
nodeId: z
.string()
.optional()
.describe(
"Optional plan-tree node id this question is about — must EXACTLY match one of this session's " +
"plan_init node slugs (validated at call time; a mismatched id is rejected back to you). " +
"Tag every question that targets a map node: the lesson's position on the map derives from these tags " +
"(the node the latest tagged question is about is where the map stands; earlier ones count as visited). " +
"Omit for quick ad-hoc checks that don't target a node.",
),
})
// The cross-field authoring contract (the Gate-3 hardening): a quiz whose
// correctAnswer can't be graded NEVER reaches the learner. The SDK validates
// client-side tool inputs against this schema at the call boundary and, on
@@ -155,10 +167,11 @@ export const quizInputObject = z
// the teacher retries with a fixed quiz in the same turn. Schema-valid ⇔
// gradeable: the rules are exactly what the grading path accepts.
//
// NOTE the split: the raw object (pre-refinement) is exported for
// composition — tools.ts questionToolsFor chains the per-session plan-nodeId
// validation (it reads the live chat record, which this core never touches)
// on top of this shared refinement.
// NOTE the split: the raw objects (pre-refinement) are exported for
// composition — tools.ts teachQuestionTools chains the per-session
// plan-nodeId validation (it reads the live chat record, which this core
// never touches) on top of this shared refinement, and the PROBE quiz
// (no nodeId at all) refines quizInputBase directly.
export const quizInputSchema = quizInputObject
.superRefine((input, ctx) => {
for (const problem of quizInputProblems(input)) {
@@ -166,6 +179,14 @@ export const quizInputSchema = quizInputObject
}
});
/** The PROBE-side quiz schema: the shared authoring contract, no nodeId
* field at all — the session's map doesn't exist yet. */
export const probeQuizInputSchema = quizInputBase.superRefine((input, ctx) => {
for (const problem of quizInputProblems(input)) {
ctx.addIssue({ code: "custom", message: problem });
}
});
export const askInputSchema = z
.object({
// No `mode` param — the pi original has none: the mode is inferred from
+12 -1
View File
@@ -1,5 +1,13 @@
import type { UIMessage } from "ai";
import type { DemoToolInput, PlanBlockData, ResearchToolInput, SubagentArtifactData, VisualToolInput } from "./contracts.ts";
import type {
DemoToolInput,
PlanBlockData,
ProbeDoneInput,
ProbeDoneResult,
ResearchToolInput,
SubagentArtifactData,
VisualToolInput,
} from "./contracts.ts";
import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question-core.ts";
/**
@@ -17,6 +25,9 @@ import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question
export type TeacherUITools = {
quiz: { input: QuizToolInput; output: QuestionAnswerData };
ask_user_question: { input: AskToolInput; output: QuestionAnswerData };
/** The prober's conclusion tool (docs/probe-teach-split.md §2): the
* input IS the learner assessment; the output confirms the handoff. */
probe_done: { input: ProbeDoneInput; output: ProbeDoneResult };
research: { input: ResearchToolInput; output: string };
demo: { input: DemoToolInput; output: string };
visual: { input: VisualToolInput; output: string };