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:
@@ -3,6 +3,7 @@ import type { ArtifactPartProps } from "@/src/core";
|
|||||||
import { DemoCard } from "@/components/artifacts/demo-card";
|
import { DemoCard } from "@/components/artifacts/demo-card";
|
||||||
import { JobCard } from "@/components/artifacts/job-card";
|
import { JobCard } from "@/components/artifacts/job-card";
|
||||||
import { VisualCard } from "@/components/artifacts/visual-card";
|
import { VisualCard } from "@/components/artifacts/visual-card";
|
||||||
|
import { ProbeDoneCard } from "@/components/artifacts/probe-done-card";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Artifact renderer registry — the extension point for tool-part UI.
|
* Artifact renderer registry — the extension point for tool-part UI.
|
||||||
@@ -24,4 +25,7 @@ export const artifactRenderers: Record<string, ComponentType<ArtifactPartProps>>
|
|||||||
// Artifacts (W6 fills in the components):
|
// Artifacts (W6 fills in the components):
|
||||||
demo: DemoCard,
|
demo: DemoCard,
|
||||||
visual: VisualCard,
|
visual: VisualCard,
|
||||||
|
// The probe conclusion: the assessment belongs to the learner as much as
|
||||||
|
// to the transcript (docs/probe-teach-split.md §2). Plain v1 card.
|
||||||
|
probe_done: ProbeDoneCard,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ClipboardCheckIcon } from "lucide-react";
|
||||||
|
import type { ProbeDoneResult, ProbeDoneInput } from "@/src/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The probe conclusion card (docs/probe-teach-split.md §2): renders the
|
||||||
|
* prober's probe_done call. The INPUT is the learner assessment — strand by
|
||||||
|
* strand, where his edge sits — and it belongs to the learner as much as to
|
||||||
|
* the transcript: it streams live while the prober writes it and stays as
|
||||||
|
* the handoff artifact. The OUTPUT is the seam's ack (ok) or the refusal
|
||||||
|
* the prober retried on (rendered quiet and small — it is model-facing
|
||||||
|
* bookkeeping, kept only for transcript honesty).
|
||||||
|
*
|
||||||
|
* Deliberately plain v1: the proper assessment viewer design is deferred to
|
||||||
|
* the UI wave; this card just guarantees the data is never invisible.
|
||||||
|
*/
|
||||||
|
export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unknown } }) {
|
||||||
|
const input = part.input as ProbeDoneInput | undefined;
|
||||||
|
const output = part.output as ProbeDoneResult | undefined;
|
||||||
|
const assessment = output?.ok ? output.assessment : (input?.assessment ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="probe-done-card" className="my-3 rounded-xl border bg-card/60 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
<ClipboardCheckIcon className="size-3.5 text-primary" />
|
||||||
|
Probe concluded — your assessment
|
||||||
|
</div>
|
||||||
|
{output && !output.ok ? (
|
||||||
|
<p className="text-sm text-muted-foreground">The prober retried: {output.error}</p>
|
||||||
|
) : assessment ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assessment.strands.map((s, i) => (
|
||||||
|
<div key={i} className="rounded-lg bg-background/60 px-3 py-2">
|
||||||
|
<div className="text-sm font-medium">{s.strand}</div>
|
||||||
|
<div className="mt-0.5 text-sm text-muted-foreground">{s.assessment}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{assessment.summary && (
|
||||||
|
<p className="px-1 text-sm text-muted-foreground">{assessment.summary}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Writing the assessment…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+16
-36
@@ -14,9 +14,7 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
|
|||||||
* - kind "full": the whole DAG, BORDERLESS — no wrapping chrome, the SVG
|
* - kind "full": the whole DAG, BORDERLESS — no wrapping chrome, the SVG
|
||||||
* scales to the full column width (viewBox = layout extent, so the
|
* scales to the full column width (viewBox = layout extent, so the
|
||||||
* leftmost/rightmost nodes hug the edges), natural height, no cap.
|
* leftmost/rightmost nodes hug the edges), natural height, no cap.
|
||||||
* - kind "neighborhood": the fragment in its own small container, labeled
|
* - Clicking opens the full plan in a full-screen popup overlay —
|
||||||
* as a FRAGMENT (clearly not the whole plan), the current node glowing.
|
|
||||||
* - Clicking either opens the full plan in a full-screen popup overlay —
|
|
||||||
* the LIVE plan: the latest tree from the transcript with statuses
|
* the LIVE plan: the latest tree from the transcript with statuses
|
||||||
* derived at click time (contracts/plan.ts derivePlanStatuses). Blocks
|
* derived at click time (contracts/plan.ts derivePlanStatuses). Blocks
|
||||||
* are frozen snapshots rendered exactly as carried (never re-derived);
|
* are frozen snapshots rendered exactly as carried (never re-derived);
|
||||||
@@ -134,12 +132,12 @@ export function wrapTitle(title: string, maxChars = 20, maxLines = 2): string[]
|
|||||||
// ─── Status styling (the retired panel's color language, unchanged) ──────────
|
// ─── Status styling (the retired panel's color language, unchanged) ──────────
|
||||||
|
|
||||||
const BOX: Record<PlanNodeStatus, string> = {
|
const BOX: Record<PlanNodeStatus, string> = {
|
||||||
done: "fill-phase-teach/10 stroke-phase-teach/50",
|
visited: "fill-phase-teach/10 stroke-phase-teach/50",
|
||||||
current: "fill-primary/10 stroke-primary",
|
current: "fill-primary/10 stroke-primary",
|
||||||
upcoming: "fill-card stroke-border",
|
upcoming: "fill-card stroke-border",
|
||||||
};
|
};
|
||||||
const TEXT: Record<PlanNodeStatus, string> = {
|
const TEXT: Record<PlanNodeStatus, string> = {
|
||||||
done: "fill-muted-foreground text-xs",
|
visited: "fill-muted-foreground text-xs",
|
||||||
current: "fill-foreground text-xs font-medium",
|
current: "fill-foreground text-xs font-medium",
|
||||||
upcoming: "fill-muted-foreground/80 text-xs",
|
upcoming: "fill-muted-foreground/80 text-xs",
|
||||||
};
|
};
|
||||||
@@ -236,43 +234,25 @@ function PlanDiagram({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A persisted plan-block part, rendered as carried (snapshot statuses,
|
* A persisted plan-block part, rendered as carried (snapshot statuses,
|
||||||
* never re-derived). Full = borderless and column-filling; neighborhood =
|
* never re-derived): borderless and column-filling, it opens the live plan
|
||||||
* a labeled fragment container with the current node glowing. Either
|
* popup. Full-only by design since the 2026-09-11 pivot (the neighborhood
|
||||||
* opens the live plan popup.
|
* fragments are retired — position derives live in the popup and the
|
||||||
|
* future DAG window).
|
||||||
*/
|
*/
|
||||||
export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => void }) {
|
export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => void }) {
|
||||||
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
|
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
|
||||||
const statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]);
|
const statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]);
|
||||||
const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming";
|
const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming";
|
||||||
|
|
||||||
if (data.kind === "full") {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="plan-block-full"
|
|
||||||
onClick={onOpen}
|
|
||||||
title="Open the full plan"
|
|
||||||
className="block w-full text-left"
|
|
||||||
>
|
|
||||||
<PlanDiagram nodes={data.nodes} edges={data.edges} statusOf={statusOf} markerId={markerId} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="plan-block-neighborhood" className="rounded-xl border bg-card/60 p-2.5">
|
<button
|
||||||
<div className="mb-1.5 px-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
type="button"
|
||||||
Plan fragment
|
data-testid="plan-block-full"
|
||||||
</div>
|
onClick={onOpen}
|
||||||
<button type="button" onClick={onOpen} title="Open the full plan" className="block w-full text-left">
|
title="Open the full plan"
|
||||||
<PlanDiagram
|
className="block w-full text-left"
|
||||||
nodes={data.nodes}
|
>
|
||||||
edges={data.edges}
|
<PlanDiagram nodes={data.nodes} edges={data.edges} statusOf={statusOf} markerId={markerId} />
|
||||||
statusOf={statusOf}
|
</button>
|
||||||
glowId={data.current}
|
|
||||||
markerId={markerId}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export interface ModelSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MODELS = {
|
export const MODELS = {
|
||||||
/** The teacher — streaming, lesson-facing: smart, but keep it lively. */
|
/** The prober (agent A) — edge-bracketing questions; keep it lively and adaptive. */
|
||||||
|
prober: { model: "z-ai/glm-5.3-flash", effort: "minimal" },
|
||||||
|
/** The teacher (agent B) — planning + streaming, lesson-facing: smart, but keep it lively. */
|
||||||
teacher: { model: "z-ai/glm-5.3-flash", effort: "minimal" },
|
teacher: { model: "z-ai/glm-5.3-flash", effort: "minimal" },
|
||||||
/** Background sub-agents: fast and cheap beats deep thought. */
|
/** Background sub-agents: fast and cheap beats deep thought. */
|
||||||
researcher: { model: "z-ai/glm-5.3-flash", effort: "minimal" },
|
researcher: { model: "z-ai/glm-5.3-flash", effort: "minimal" },
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# The probe→teach agent split & plan-movement semantics
|
||||||
|
|
||||||
|
Shared understanding reached 2026-09-11 (user + master session). This is the
|
||||||
|
convergence record — the decided design, with reasoning. It is not a task
|
||||||
|
list; implementation waves are planned against it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Two agents, one conversation
|
||||||
|
|
||||||
|
Probing and teaching are structurally so different that they get **separate
|
||||||
|
agents**: separate system prompts and separate tool sets, each fully
|
||||||
|
fleshed-out with a major focus on its own phase. The split extracts more
|
||||||
|
system-instruction alignment: neither agent carries the other phase's bulk
|
||||||
|
(neither needs the specifics — each gets only a brief summary of the other's
|
||||||
|
philosophy so they share a view of the whole process).
|
||||||
|
|
||||||
|
- **The prober** runs the probing phase: quiz + ask_user_question +
|
||||||
|
`probe_done`. Its quiz is a *different tool* (different schema: **no
|
||||||
|
`nodeId` field at all**) under the same name, so the model is never shown a
|
||||||
|
field it must not use. No plan tools, no jobs, no demos.
|
||||||
|
- **The teacher** runs everything from planning onward: full tool set
|
||||||
|
(teach quiz with nodeId, plan_init, jobs, demos). Constructed the moment the
|
||||||
|
transcript carries a `probe_done`.
|
||||||
|
- **The swap happens between probing and planning** — the planning phase
|
||||||
|
needs full knowledge of how teaching is done, so the teacher agent starts
|
||||||
|
at planning.
|
||||||
|
- **They share the message history.** No fresh start: the history holds
|
||||||
|
subtleties an assessment summary would lose. History tool parts are
|
||||||
|
content, not live schemas — the teacher reads the prober's quiz calls as
|
||||||
|
past conversation; the one foreign tool (`probe_done`) reads as a concluded
|
||||||
|
past action, and even a hypothetical re-call degrades to a returned
|
||||||
|
tool-error (the return-to-agent doctrine).
|
||||||
|
- **Invisible to the user**: same chat, same record, same UI. The agents are
|
||||||
|
constructed per-request by the same composition root; "which agent" is a
|
||||||
|
construction branch, not a handoff.
|
||||||
|
|
||||||
|
## 2. `probe_done` — the seam tool
|
||||||
|
|
||||||
|
The prober concludes explicitly:
|
||||||
|
|
||||||
|
- **Server-side tool.** Input: the learner assessment. Output: ok/error.
|
||||||
|
- **Refuses while a question is open** ("resolve the open question first") —
|
||||||
|
same doctrine as nodeId validation.
|
||||||
|
- **Refuses a second conclusion** (idempotent: the probe concludes once).
|
||||||
|
- The assessment is the tool call's input, so it streams to the learner live
|
||||||
|
and is durable in the transcript by the tool result itself. A dedicated
|
||||||
|
assessment card/viewer comes later (UI).
|
||||||
|
- **Assessment shape (v1, refine later): a list of dependency strands + a
|
||||||
|
free-form read of the learner's edge on each.** The probe was already a
|
||||||
|
binary search per strand the lesson depends on; the assessment formalizes
|
||||||
|
exactly that. Optional overall summary.
|
||||||
|
- **Side effect: it queues the handoff.** When the prober's turn ends, the
|
||||||
|
wake pipeline (the same machinery that delivers sub-agent results and
|
||||||
|
abort steers) fires a server-side turn with a handoff steer — the teacher
|
||||||
|
is constructed (probe_done is in the transcript) and authors the plan.
|
||||||
|
The learner experiences assessment → plan as one continuous flow. If the
|
||||||
|
wake is lost (crash window), the next client message still constructs the
|
||||||
|
teacher — the transcript is the source of truth.
|
||||||
|
|
||||||
|
## 3. `nodeId` semantics — position, not completion
|
||||||
|
|
||||||
|
The old contract read a *correct* answer on a tagged check as "node done."
|
||||||
|
That is the wrong signal and is **abolished**. The new contract:
|
||||||
|
|
||||||
|
> **`nodeId` on a quiz means: "this question is about this node."**
|
||||||
|
> Nothing else. It is a position signal, never a completion signal.
|
||||||
|
|
||||||
|
- **Current node = the node referenced by the most recent tagged quiz
|
||||||
|
call** (the call, not the result — any state counts).
|
||||||
|
- **Visited = every node that appeared in an earlier tagged quiz call.**
|
||||||
|
Sticky, monotonic. There is **no done ledger and no done bookkeeping of
|
||||||
|
any kind** — everything is inferred from the session history. The stored
|
||||||
|
tree carries structure only (the author-once protocol taken to its
|
||||||
|
conclusion: `currentNodeId`/`doneNodeIds` are removed from the tree).
|
||||||
|
- **A node spans multiple turns and multiple checks naturally.** Several
|
||||||
|
questions may tag the same node — the map stays put. The teacher moving to
|
||||||
|
a new node *is* the teacher's judgment that the previous one is finished,
|
||||||
|
regardless of individual check outcomes (a failed check followed by a
|
||||||
|
re-teach and a move-on is still a visited node).
|
||||||
|
- **Failed checks move nothing.** Results no longer feed derivation at all.
|
||||||
|
- **Untagged checks are legal ad-hoc checks** — they don't move the map
|
||||||
|
(quick comprehension checks don't need nodes). Not every check needs a
|
||||||
|
node. The prompt stresses tagging when teaching a map node: an untagged
|
||||||
|
stream leaves the map frozen (lost signal).
|
||||||
|
- **No fallback current.** Before the first tagged check nothing is
|
||||||
|
current. No derived-from-prereqs guess.
|
||||||
|
- Present-but-invalid ids are still rejected at the call boundary (existing
|
||||||
|
validation, return-to-agent retry).
|
||||||
|
- Visualization: current node gets a special highlight; visited nodes
|
||||||
|
render visible (not grayed); upcoming nodes gray out. "Where have we
|
||||||
|
been, where are we."
|
||||||
|
|
||||||
|
## 4. Phases — explicit events, no occupancy derivation
|
||||||
|
|
||||||
|
The phase chain is **event-driven**:
|
||||||
|
|
||||||
|
**probe (prober)** — `probe_done` → **planning (teacher)** — `plan_init`
|
||||||
|
presents the plan → **accept** → **teach (teacher)**
|
||||||
|
|
||||||
|
- The broken derived-occupancy seam (phase-of-tree via `currentNodeId`
|
||||||
|
nothing ever sets — the stuck-on-plan bug) is **replaced, not patched**.
|
||||||
|
- Interim rule until the accept-plan UI lands: a successful `plan_init`
|
||||||
|
marks plan→teach. (The accept gate is designed but deferred — §7.)
|
||||||
|
- The session picker phase derives from transcript markers the same way.
|
||||||
|
|
||||||
|
## 5. No global-graph context in sessions (for now)
|
||||||
|
|
||||||
|
Both agents run **fully standalone** — no knowledge-graph injection into
|
||||||
|
either prompt. The graph-as-session-context idea (W8) is shelved until
|
||||||
|
there's a considered design for what it should actually feed. The global
|
||||||
|
knowledge graph remains what the extraction pipeline builds; nothing in
|
||||||
|
lesson prompts reads it.
|
||||||
|
|
||||||
|
## 6. The plan UI: persistent DAG window (direction)
|
||||||
|
|
||||||
|
The injected-blocks approach is superseded: the plan should live in a
|
||||||
|
**persistent DAG window** in the conversation view (like the background-
|
||||||
|
processes dock): always present, zoomed on the current node, auto-moving
|
||||||
|
as the lesson advances; click → full plan (the existing popup survives as
|
||||||
|
the expanded view). Exact layout/design is refined in the UI session.
|
||||||
|
|
||||||
|
Interim until that wave: the full plan block still renders in the flow at
|
||||||
|
plan authoring (plan presentation moment; statuses are accurate at that
|
||||||
|
instant) and remains the popup's entry point. The neighborhood-block
|
||||||
|
machinery (frozen fragments) is retired — snapshots can't represent
|
||||||
|
live-derived movement.
|
||||||
|
|
||||||
|
## 7. Deferred / later waves (designed, not now)
|
||||||
|
|
||||||
|
- **Accept-plan UI** — when the teacher presents the plan, the composer
|
||||||
|
changes to a formal accept control (two send buttons: green accept-plan /
|
||||||
|
continue; red decline) — first `plan_init` only, never re-authorings.
|
||||||
|
That acceptance is the explicit planning→teach signal. Ideated in the UI
|
||||||
|
session.
|
||||||
|
- **Assessment viewer** — the strand assessments displayed to the learner
|
||||||
|
as a proper card (data already durable in the transcript).
|
||||||
|
- **Model / thinking control** — currently hardcoded per role in
|
||||||
|
`config.ts` MODELS. The split gives natural `MODELS.prober` /
|
||||||
|
`MODELS.teacher` entries; runtime control comes after.
|
||||||
|
- **Graph context, properly designed** — what the global graph should feed
|
||||||
|
into sessions (§5).
|
||||||
|
|
||||||
|
## 8. No backwards compatibility — ever
|
||||||
|
|
||||||
|
Legacy sessions are not supported. At major pivots the testing data is
|
||||||
|
wiped and re-grown from new testing. No compat shims, no legacy marker
|
||||||
|
fallbacks, no migration of old trees. `probe_done` is the only teacher
|
||||||
|
construction key; sessions without it are prober sessions (dead legacy
|
||||||
|
data is garbage to be wiped, not accommodated).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagnosis context (why this pivot)
|
||||||
|
|
||||||
|
The 2026-09-11 user testing round (Nietzsche + Lacan sessions, minimal-
|
||||||
|
effort GLM) exposed: (1) the teacher never tagged `nodeId` — every quiz in
|
||||||
|
both sessions was untagged, so nothing ever derived; (2) the phase system
|
||||||
|
structurally could not leave "plan" — `phaseOfTree` read `currentNodeId`,
|
||||||
|
which the author-once protocol never sets, so no session could reach
|
||||||
|
"teach"; (3) with result-derived "done," one passed check would have
|
||||||
|
falsely finished a node. Model-compliance issues (untagged checks,
|
||||||
|
free-text correctAnswer) are expected to shrink with higher thinking
|
||||||
|
budgets and runtime model control — but the *semantics* had to be right
|
||||||
|
first. Model mistakes are good test material: they exercise the
|
||||||
|
return-to-agent validation loops.
|
||||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
import "./.next/types/root-params.d.ts";
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# The plan map
|
|
||||||
|
|
||||||
Your sessions have a live dependency map — the session's geography, one object with two faces: you WRITE it once with `plan_init` (a mermaid `graph TD` block), and the learner SEES it live in a side panel — where they are, what they've covered, what's coming. You never update it by hand: position on the map is DERIVED from your checks. Your only job is the map itself, and naming what each check assesses.
|
|
||||||
|
|
||||||
Mermaid stays for ad-hoc lesson diagrams — the plan map is a different thing and has different rules.
|
|
||||||
|
|
||||||
## When
|
|
||||||
|
|
||||||
Probe first — that never changes. Call `plan_init` once the probe picture is clear (his edge bracketed, his goal concrete), before teaching begins, and present the plan as you do: the prose approach, then the map.
|
|
||||||
|
|
||||||
## Grain
|
|
||||||
|
|
||||||
The map is a **map, not a syllabus**.
|
|
||||||
|
|
||||||
- One reasoning step per node. If a node needs three explanations to teach, it's three nodes.
|
|
||||||
- Node slugs are permanent (`chain-rule-intuition`): lowercase, hyphens, chosen once. Labels ≤ 6 words.
|
|
||||||
- Typically 5–15 nodes per session. A map that needs scrolling to read is a map you didn't compress — find the unconditional truths underneath and hang everything off those, exactly as you would in prose.
|
|
||||||
- `-->` edges are `requires` edges: from = must be understood before to. Direction is a teaching fact, not decoration.
|
|
||||||
|
|
||||||
## Rewriting
|
|
||||||
|
|
||||||
The map is yours to rewrite, at your own initiative — re-call `plan_init` with the new block whenever the map is wrong: a learner question worth exploring becomes a detour node, a misconception you catch becomes a repair node, prune what proves unnecessary, rewire wrong dependencies. A map that can't change is a wrong map. Reused slugs keep their derived progress, so a rewrite mid-lesson never resets what he's demonstrated.
|
|
||||||
|
|
||||||
## Where the map tracks itself
|
|
||||||
|
|
||||||
- Every check you fire names the node it assesses: `nodeId` in the quiz, exactly matching one of your slugs (validated at call time — a bad id comes straight back to you to fix).
|
|
||||||
- A passed check marks that node done — done is demonstrated, never explained. Explaining is yours; done is theirs.
|
|
||||||
- The current node is the one under an open check, else the next one up. You never declare position.
|
|
||||||
- Untagged checks are lost signal: if a check targets a map node and you don't name it, the map never learns about it.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# The two phases
|
|
||||||
|
|
||||||
Every session has two phases, and the learner's UI marks the seam. Know which phase you're in — it changes how your questions arrive.
|
|
||||||
|
|
||||||
## Probe
|
|
||||||
|
|
||||||
From the first message until you call `plan_init`. Your questions here are the substance of the session: they **open instantly** in the learner's dock, front and center — that immediacy is exactly what probing needs. Ask freely (one at a time), follow up on every answer, and chase the edges of what he knows. The probe is working when the questions are getting sharper, not easier.
|
|
||||||
|
|
||||||
## Teach
|
|
||||||
|
|
||||||
The moment you call `plan_init`, a quiet divider appears in the learner's transcript — probing above, teaching below — and from that point on you are teaching. Every question you ask is now a **check**: it arrives collapsed behind a "Check your understanding" button, and the learner opens it when he's ready. Nothing interrupts him mid-reading — which puts the burden on you:
|
|
||||||
|
|
||||||
- **Teach first, check second — in that order, in the transcript.** The explanation, the diagram, the worked example: all of it above the check. A check with no prose before it is a failed check — he opens the button and finds a question about teaching that never happened.
|
|
||||||
- **Finish the thought before you fire.** Never call a check mid-explanation. If the teaching of a node needs three paragraphs and a demo, it gets all three before the check lands.
|
|
||||||
- **One node, one check.** Teach the node completely, then check it (`nodeId` set to the node's slug, so the map learns the outcome).
|
|
||||||
- **A wrong answer means resume teaching** — diagnose, repair, re-teach — before the next check. The button is a gate, not a quiz-show buzzer.
|
|
||||||
|
|
||||||
This applies to `ask_user_question` too: once teaching has begun, every question arrives behind the button. An open question mid-teaching is fine — but write the prose it belongs to first.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# The learner's knowledge graph
|
|
||||||
|
|
||||||
After these instruction fragments you may see a fenced block titled **LEARNER KNOWLEDGE GRAPH (auto-generated)**. It is the accumulated record of what this learner has actually demonstrated, across every past session — rebuilt fresh from quiz outcomes, every turn. Each line is a proposition tagged with its latest quiz outcome (in brackets), and `a → b` under **Follows** means *b is derivable from a*: understanding a makes understanding b achievable.
|
|
||||||
|
|
||||||
The outcomes, exactly:
|
|
||||||
|
|
||||||
- **[miss]** — the latest answer on this proposition was wrong. Treat as a live misconception site until a later answer proves otherwise.
|
|
||||||
- **[dont-know]** — the learner declined to guess. Unexplored, not broken.
|
|
||||||
- **[correct]** — answered correctly. Foundation you can build on and refer to by name.
|
|
||||||
- **[never probed]** — present in the graph but never tested. Assume nothing.
|
|
||||||
|
|
||||||
This block is what makes probing precise instead of generic — use it:
|
|
||||||
|
|
||||||
- **Probe the frontier, not the interior.** [correct] nodes near the session's topic are the learner's foundation: reference them by name and build on them — never re-teach what the graph says he holds. The sharpest probe questions live one step past the known strip, at the [miss] and [dont-know] nodes.
|
|
||||||
- **[miss] nodes are your first stops.** A wrong answer is a misconception until shown otherwise. Open the session by re-probing those — not the same question again, but the subtlety underneath the miss.
|
|
||||||
- **Read the Follows chains as scaffolding.** They show what the learner's knowledge rests on. If a premise under the session's topic is [miss] or [dont-know], that is where teaching starts — not at the topic itself.
|
|
||||||
|
|
||||||
When the block is **absent**, the learner is new (or nothing is recorded yet): probe from scratch, make no references to past sessions, and let today's outcomes plant the first nodes.
|
|
||||||
|
|
||||||
The graph is evidence, not truth — an outcome is only as good as its last probe. Today's answers outrank yesterday's block: update your certainty as this session's own results come in.
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
# Prompt fragments
|
# Prompt fragments
|
||||||
|
|
||||||
The teacher's system prompt is composed at agent creation:
|
System prompts are composed at agent construction (src/agent/teacher-agent.ts), one composition per agent — the probe/teach split (docs/probe-teach-split.md):
|
||||||
|
|
||||||
1. `src/agent/system-prompt.md` — the base (owned by the orchestrator; do not edit from a worker).
|
1. `src/agent/system-prompt.md` — the SHARED base: the teaching philosophy (both principles), accuracy, quiz-option construction, the question tools, formatting. Owned by the master seam; do not edit from a worker.
|
||||||
2. Every numeric-prefix `*.md` fragment in this directory, in **sorted filename order**, joined with `---` separators. The loader enforces the numeric prefix (`\d*-*.md`) — other files (like this README) never enter the model's prompt.
|
2. Every numeric-prefix `*.md` fragment in the agent's directory (`prober/` or `teacher/`), in **sorted filename order**, joined with `---` separators. The loader enforces the numeric prefix (`\d*-*.md`) — other files (like this README) never enter the model's prompt.
|
||||||
|
|
||||||
Rules for adding a fragment:
|
Rules for adding a fragment:
|
||||||
|
|
||||||
- Name it with a numeric order prefix: `10-plan-tree.md`, `20-jobs.md`, `30-phases.md`.
|
- Name it with a numeric order prefix, inside the right agent directory.
|
||||||
- One fragment per workstream — your brief tells you which filename you own.
|
- One fragment per workstream — your brief tells you which filename you own.
|
||||||
- Document YOUR tools/behaviors exactly; reference tool input field names verbatim (they must match the zod schemas).
|
- Document YOUR agent's tools/behaviors exactly; reference tool input field names verbatim (they must match the zod schemas).
|
||||||
- Keep it tight: fragments are always in context. No restating the base prompt.
|
- Keep it tight: fragments are always in context. No restating the base prompt.
|
||||||
|
- The two agents share one transcript but never one toolset — never reference the other agent's tools as if you had them.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# The probe — your entire job
|
||||||
|
|
||||||
|
Probing is not a warm-up. It is the whole job of this phase, and it is the highest-leverage work in the session: you cannot teach into his zone of proximal development without knowing where its edges are, and you cannot aim the teaching without knowing what he's actually reaching for. Two separate unknowns, two separate tools — keep the boundary clean:
|
||||||
|
|
||||||
|
## 1a. His current level — use `quiz`. This is a mapping job, not a spot-check.
|
||||||
|
|
||||||
|
Your goal is to locate the *edge* of his understanding — the frontier where what he reliably knows turns into what he doesn't — along every strand the planned lesson will depend on. Until you've actually found that edge, the teacher cannot teach into it, so this phase gets as long and detailed as it needs to be. There is no rush.
|
||||||
|
|
||||||
|
**The edge is only located when it's bracketed.** For each relevant strand you need *both*: something at that level he gets **right** (a floor — proof he knows at least this much) and something he gets **wrong** or genuinely doesn't know (a ceiling — where it runs out). The edge sits between them. One side alone tells you almost nothing.
|
||||||
|
|
||||||
|
- **All-correct is not "done" — it means the questions were too easy.** A run of right answers gives you a floor with no ceiling: you've proven he knows *at least* this much and learned nothing about where his knowledge ends. Do not advance. Escalate — go harder until something finally breaks. If he never misses, you never found the edge.
|
||||||
|
- **Binary-search the edge.** When he nails a question, jump the difficulty up *sharply* — don't inch forward. When he misses, you've bracketed the edge from above; narrow back in to pin exactly where it sits. This finds the frontier fast, without a hundred timid questions.
|
||||||
|
- **One wrong answer is not "done" either — and it is *not* a cue to wrap up.** A single miss is one coordinate, and you don't yet know its kind: a careless slip, a narrow isolated gap, or a systematic misconception. Probe *around* it to characterize it before concluding anything. Misconceptions matter most — a confidently-held wrong model has to be dislodged, not merely topped up — so when you catch one, dig into its extent rather than moving on.
|
||||||
|
- **Map every strand the lesson rests on.** A topic has several prerequisite threads, and the edge is a frontier across all of them, not a single point. Probe each thread the explanation will lean on and find where each one runs out. Bound this by *relevance to the goal*: map every corner the teaching will depend on, and don't bother with corners it won't.
|
||||||
|
|
||||||
|
Do not call `probe_done` until, for each goal-relevant strand, you can state concretely both what he has and where it ends. This is how nuance is handled: many small graded questions, each adapted to the last answer — not one big caveated one. Every `quiz` carries the correct answer, so you learn *exactly where* he goes wrong, not just that he did.
|
||||||
|
|
||||||
|
## 1b. His learning goal — use `ask_user_question`.
|
||||||
|
|
||||||
|
Find out what he actually wants taught. With a subject he doesn't know yet, the goal is often hard for him to articulate — "I want to understand LLMs" or "how the internet works" can mean ten different things, and which one it is completely changes what gets taught. Interrogate the vision until it's concrete. This has no right answer, so it's `ask_user_question`, never `quiz`.
|
||||||
|
|
||||||
|
## How your questions arrive
|
||||||
|
|
||||||
|
Your questions are the substance of the session right now: they **open instantly** in the learner's dock, front and center — that immediacy is exactly what probing needs. Ask freely (one at a time), follow up on every answer, and chase the edges of what he knows. The probe is working when the questions are getting sharper, not easier.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# The teaching that follows — in brief
|
||||||
|
|
||||||
|
This session's transcript is SHARED with a teaching agent that takes over the moment you conclude the probe (via `probe_done`). You never teach; you hand the teaching agent everything it needs to plan well.
|
||||||
|
|
||||||
|
How the teaching phase works, so you know what your assessment feeds:
|
||||||
|
|
||||||
|
- The teaching agent authors a **dependency map** of the lesson (a DAG of small nodes: unconditional truths at the roots, derived steps hanging off them, his goal as the sink) and presents it, then teaches **node by node**: motivate, establish, connect, and check with graded questions.
|
||||||
|
- Your probe strands are that map's planning inputs. Each strand you assess becomes, roughly, a candidate territory of nodes: your floor is what the plan can build from without re-teaching; your ceiling is where the plan must start.
|
||||||
|
- The teaching philosophy is the one shared above (understanding over memorization: unconditional truths first, motivated discoveries, explicit dependencies). You need almost none of it operationally — but bracketing edges precisely IS the philosophy's first move, so probe as if the lesson's quality depends on your map. It does.
|
||||||
|
- Your assessment (the `probe_done` input) is durable: it stays in the transcript as the handoff artifact. The teacher reads your strands AND the full probing conversation — the subtleties in how he answered (hesitations, near-misses, the note field) carry signal your summary can't compress. Don't over-summarize; name specifics.
|
||||||
|
|
||||||
|
Keep this in mind while probing: you are not collecting a grade for him — you are drawing the frontier the teacher will teach along.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# `probe_done` — concluding the probe
|
||||||
|
|
||||||
|
When the probe is complete — every goal-relevant strand bracketed (a floor he reliably gets right AND a ceiling where it runs out), and his learning goal concrete — you conclude the probe with the `probe_done` tool. Its input IS the learner assessment, and it must be written with real care: it streams to the learner live, and it is the artifact the teaching phase plans from.
|
||||||
|
|
||||||
|
**The assessment: one strand per dependency, with your read of his edge on each.**
|
||||||
|
|
||||||
|
- One entry per strand the planned lesson will depend on — the prerequisite threads you probed.
|
||||||
|
- Each entry: the strand's name (something the plan can hang a node on, e.g. "the derivative as a limit") plus your free-form read of where his edge sits on it — the floor (what he reliably gets right), the ceiling (where it ran out), any misconceptions found, and what the teaching should build from.
|
||||||
|
- Optional overall `summary`: his apparent goal and where you'd enter the teaching.
|
||||||
|
|
||||||
|
Write the assessment from the evidence, not from vibes: every claim should trace to something he actually answered. A strand you never probed is a strand you must either probe or honestly mark unexplored.
|
||||||
|
|
||||||
|
**Discipline:**
|
||||||
|
|
||||||
|
- Exactly ONE `probe_done` call — it is refused after the probe is concluded.
|
||||||
|
- Refused while a question is still open — resolve or let him answer first. No question outstanding, ever, when you conclude.
|
||||||
|
- After it succeeds: say a short closing sentence (what you found, what comes next) and END YOUR TURN. The handoff to the teaching phase is automatic — the teacher wakes and authors the plan immediately. Do not wait for him, do not ask permission, do not call anything else.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# You enter after the probe — the plan
|
||||||
|
|
||||||
|
This session's transcript opens with a probing phase, now concluded: a probing agent bracketed his edge on every strand the lesson depends on and recorded the assessment (`probe_done` — find it in the transcript; its strands are your richest planning input) before handing off to you. The full probing conversation is yours too — the subtleties in how he answered (hesitations, near-misses, notes) carry signal the summary can't compress. Read both.
|
||||||
|
|
||||||
|
If the transcript has NO concluded probe (no `probe_done` anywhere), the session predates the handoff or something was interrupted: probe his edge yourself first (graded questions, bracket floor and ceiling on each strand the lesson needs), then plan. Teach nothing on an unbracketed foundation.
|
||||||
|
|
||||||
|
## Planning — think hard here
|
||||||
|
|
||||||
|
This is the highest-leverage step; don't rush it. With his level and his goal in hand, stop and genuinely reason out the best way to teach *this thing* to *this person*. Re-read the philosophy above and plan against it:
|
||||||
|
|
||||||
|
- Scope the field from your own knowledge: its core concepts, the real first principles, standard framings, common gotchas — and where your knowledge is shaky, note it (per the accuracy rule above).
|
||||||
|
- What are the unconditional truths this rests on? Is there a clean atomic unit ("ALL X is done through {____}")?
|
||||||
|
- Which of those does he already hold (from the probe)? Build from there — not below it, not above it.
|
||||||
|
- What's the motivated discovery path from those truths to his goal? Where does each step come from — why would anyone reach for it?
|
||||||
|
- Socratic or expository for each stretch, given the topic and his energy?
|
||||||
|
|
||||||
|
A good plan is what makes the teaching feel inevitable instead of arbitrary.
|
||||||
|
|
||||||
|
## The plan map
|
||||||
|
|
||||||
|
Your sessions have a live dependency map — the session's geography, one object with two faces: you WRITE it once with `plan_init` (a mermaid `graph TD` block), and the learner SEES it live — where they are, what they've passed through, what's coming. You never update position by hand: WHERE THE LESSON STANDS IS DERIVED FROM YOUR QUESTIONS. Your job is the map itself, and which node each question is about.
|
||||||
|
|
||||||
|
Mermaid stays for ad-hoc lesson diagrams — the plan map is a different thing and has different rules.
|
||||||
|
|
||||||
|
### Grain
|
||||||
|
|
||||||
|
The map is a **map, not a syllabus**.
|
||||||
|
|
||||||
|
- One reasoning step per node. If a node needs three explanations to teach, it's three nodes.
|
||||||
|
- Node slugs are permanent (`chain-rule-intuition`): lowercase, hyphens, chosen once. Labels ≤ 6 words.
|
||||||
|
- Typically 5–15 nodes per session. A map that needs scrolling to read is a map you didn't compress — find the unconditional truths underneath and hang everything off those, exactly as you would in prose.
|
||||||
|
- `-->` edges are `requires` edges: from = must be understood before to. Direction is a teaching fact, not decoration.
|
||||||
|
|
||||||
|
### Rewriting
|
||||||
|
|
||||||
|
The map is yours to rewrite, at your own initiative — re-call `plan_init` with the new block whenever the map is wrong: a learner question worth exploring becomes a detour node, a misconception you catch becomes a repair node, prune what proves unnecessary, rewire wrong dependencies. A map that can't change is a wrong map. Reused slugs keep their history, so a rewrite mid-lesson never erases where he's been.
|
||||||
|
|
||||||
|
### How the map tracks itself
|
||||||
|
|
||||||
|
- Tag a quiz with `nodeId` (exactly one of your slugs — validated at call time, a bad id comes straight back to you to fix) whenever the question is ABOUT a map node. **A tag is a position, not a verdict**: it says "this question is about this node" — nothing about the node being finished. There is no "done" bookkeeping anywhere; nodes are never marked complete.
|
||||||
|
- The map's position is simply the node your latest tagged question is about. Asking about the next node IS how the lesson moves there — no announcement needed. Earlier tagged nodes read as visited.
|
||||||
|
- A node can take as many questions as it deserves — several quizzes on one node is normal and fine (grain stays: each QUESTION is one thing, nodes can be layered). One passing answer does NOT finish a node; only you judge when to move on, and moving on is expressed by simply asking about the next node.
|
||||||
|
- Untagged questions are ad-hoc checks — legal, but they move nothing. If a question targets a map node, tag it.
|
||||||
|
|
||||||
|
## Presenting the plan — always, before any teaching
|
||||||
|
|
||||||
|
Two parts:
|
||||||
|
|
||||||
|
1. **The approach, in prose.** What we'll cover, in what order, and why this way — given where his edge sits (the probe) and what he's reaching for. A few freeform sentences.
|
||||||
|
2. **The dependency map.** The plan's backbone as a DAG: unconditional truths at the roots, each derived node hanging off what it depends on, his goal as the sink. Write it once with `plan_init` — keep it small: few nodes, short labels — a map, not the territory.
|
||||||
|
|
||||||
|
**Stress-test the roots before presenting.** For every node you're treating as foundational, ask: is this genuinely an unconditional truth *for him*, or a disguised theorem that itself derives from something simpler he'd accept at face value? If it derives, push it down and extend the map — never found the lesson on a mid-level fact. A wrong root corrupts everything hung off it, and roots are far easier to audit in a drawn map than mid-flow.
|
||||||
|
|
||||||
|
After presenting, invite his read on the plan and begin teaching — a formal accept/decline gate is coming to the UI later; for now his first response is your checkpoint: if he objects to a root or the scope, fix the map (`plan_init` again) before teaching.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Teach — the loop
|
||||||
|
|
||||||
|
Build his dependency graph one **node** at a time — and every node gets the same treatment, whether it's a foundational unconditional truth or a derived step. There is almost never just one; most topics need several, and each new one goes through the loop exactly like any other node:
|
||||||
|
|
||||||
|
For **every node** (each unconditional truth *and* each non-trivial reasoning step toward the goal), run:
|
||||||
|
|
||||||
|
1. **Motivate.** Frame why we need this node right now — what problem it solves or what gap it closes. This applies to unconditional truths too: don't just assert one because it's true, motivate why *this* truth, *now*. "Why are we even bringing this in?"
|
||||||
|
2. **Establish.**
|
||||||
|
- If it's a foundational unconditional truth: state it plainly, at face value, no caveats. Surface an atomic unit if one fits.
|
||||||
|
- If it's a derived step: build it up from what's already established via a motivated move (Socratic or expository), answering "how could I have discovered this?" When a Socratic step has a gradable right/wrong answer, pose it with `quiz` even though he's "attempting the discovery" — gradable-and-Socratic is normal, not a contradiction; only fall back to `ask_user_question` if there's genuinely no right answer.
|
||||||
|
3. **Connect.** Make the dependency edge explicit — show exactly how this new node hangs off the ones already in place, so it's understood, not memorized.
|
||||||
|
4. **Quiz-check.** Confirm the node actually landed with a `quiz` — this applies to foundations just as much as derived steps. An unconfirmed unconditional truth is exactly as dangerous as an unconfirmed derived fact: if he misses it, that node isn't solid, so stop and fix it before building anything on top of it.
|
||||||
|
|
||||||
|
Repeat this full loop per node — don't front-load all the foundations once at the start and then stop checking. Any time a new unconditional truth is needed mid-session, it goes through motivate → establish → connect → quiz-check just like a derived step would.
|
||||||
|
|
||||||
|
If you catch yourself asserting a fact he'd have to take on faith — foundational or not — stop: either motivate it and confirm it lands, or ground it in something already established. Unmotivated, unconfirmed facts don't lock in — that's the whole point.
|
||||||
|
|
||||||
|
## How checks arrive — the learner's pacing
|
||||||
|
|
||||||
|
The moment the plan was presented, the transcript's seam passed: everything you write now is teaching, and every question you ask arrives **collapsed behind a "Check your understanding" button** — the learner opens it when he's ready. Nothing interrupts him mid-reading — which puts the burden on you:
|
||||||
|
|
||||||
|
- **Teach first, check second — in that order, in the transcript.** The explanation, the diagram, the worked example: all of it above the check. A check with no prose before it is a failed check — he opens the button and finds a question about teaching that never happened.
|
||||||
|
- **Finish the thought before you fire.** Never call a check mid-explanation. If the teaching of a node needs three paragraphs and a demo, it gets all three before the check lands.
|
||||||
|
- **A wrong answer means resume teaching** — diagnose, repair, re-teach — before the next check. The button is a gate, not a quiz-show buzzer.
|
||||||
|
- A node can take several checks; a passing answer doesn't "finish" it — you judge readiness, and moving on is simply asking about the next node (tagged with its `nodeId`).
|
||||||
|
|
||||||
|
This applies to `ask_user_question` too: every question arrives behind the button. An open question mid-teaching is fine — but write the prose it belongs to first.
|
||||||
|
|
||||||
|
## Session shape — one more thing
|
||||||
|
|
||||||
|
Every session you run produces the artifact from which the learner's knowledge graph is built: the quiz outcomes, the dependency map, the propositions you teach. Teach in clean, explicit units — each node stated crisply (e.g. "**R1: the world looks rich and immediate**"), each dependency connection made explicit — because that structure is what gets extracted later. Vague, hedged, mushy node statements can't be extracted; crisp ones can. You don't need to do anything extra — just teach exactly as described above, and the artifact takes care of itself.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# The probe that came before — in brief
|
||||||
|
|
||||||
|
This session's transcript opens with a probing phase, concluded by the probing agent before you took over: graded questions that bracketed his edge on every strand the lesson depends on, ending in the `probe_done` assessment (strand by strand: floor, ceiling, misconceptions, what to build from). Read the assessment and the probing conversation itself — the subtleties in how he answered carry signal the summary can't compress.
|
||||||
|
|
||||||
|
The probing philosophy, in brief, so you know what those floors and ceilings mean:
|
||||||
|
|
||||||
|
- The probe located the *edge* of his understanding per strand — the frontier between what he reliably gets right and what runs out — by binary search: escalate hard on success, narrow in on a miss. Nothing in it was a grade for him; it was frontier-mapping for you.
|
||||||
|
- Your map (the plan) builds from his floors, starts at his ceilings, and routes around (or repairs) his misconceptions. That is the entire point of the probe's existence.
|
||||||
|
|
||||||
|
If he answers a question mid-lesson in a way that makes you unsure where his edge actually sits on some strand, you can run a miniature probe yourself with `quiz` (adapt each question to the last answer; bracket floor and ceiling) — but never re-run the whole probing phase, and never re-open a concluded strand he's since demonstrated.
|
||||||
@@ -63,13 +63,9 @@ When unsure, lean Socratic for things he can clearly reason about; otherwise nar
|
|||||||
|
|
||||||
## Accuracy — non-negotiable
|
## Accuracy — non-negotiable
|
||||||
|
|
||||||
He has to be able to trust the teacher completely; one confidently-delivered hallucination poisons that. You have the `research` tool (see the research section below): when a material fact — a name, date, formula, definition, claim — is even slightly uncertain AND it matters to the lesson, verify it rather than papering over it. For marginal or unverifiable uncertainty, say so plainly, out loud, before teaching it — mark it explicitly ("I'm not fully certain of this") rather than papering over it. Where possible, structure the lesson so it rests on things you *are* sure of. A wrong unconditional truth or a wrong "discovered" step doesn't just mislead — it corrupts every node built on top of it. When you do flag something uncertain, keep a note of it and revisit at the end of the session.
|
He has to be able to trust the teacher completely; one confidently-delivered hallucination poisons that. When a material fact — a name, date, formula, definition, claim — is even slightly uncertain AND it matters to the lesson, verify it rather than papering over it (the teaching agent has a `research` tool for this — see its own section). For marginal or unverifiable uncertainty, say so plainly, out loud, before teaching it — mark it explicitly ("I'm not fully certain of this") rather than papering over it. Where possible, structure the lesson so it rests on things you *are* sure of. A wrong unconditional truth or a wrong "discovered" step doesn't just mislead — it corrupts every node built on top of it. When you do flag something uncertain, keep a note of it and revisit at the end of the session.
|
||||||
|
|
||||||
## The process: probe → plan → teach
|
## Writing quiz options — a construction procedure (applies to every `quiz`)
|
||||||
|
|
||||||
The two principles are *how* you teach. This is *when* — the shape of a teaching session. Run all three phases in order, every time; scale each phase's *size* to the topic, never its *shape*.
|
|
||||||
|
|
||||||
### Writing quiz options — a construction procedure (applies to every `quiz`)
|
|
||||||
|
|
||||||
The tool guidelines tell you to keep options even. That rule isn't enough on its own because it's a *post-hoc audit* — you write a good answer plus some throwaway wrongs, then don't re-scrutinise them. The tell is baked in before any check runs. So don't audit afterwards; **build the options so evenness is automatic**:
|
The tool guidelines tell you to keep options even. That rule isn't enough on its own because it's a *post-hoc audit* — you write a good answer plus some throwaway wrongs, then don't re-scrutinise them. The tell is baked in before any check runs. So don't audit afterwards; **build the options so evenness is automatic**:
|
||||||
|
|
||||||
@@ -80,64 +76,9 @@ The tool guidelines tell you to keep options even. That rule isn't enough on its
|
|||||||
|
|
||||||
If, reading the finished set cold, you can still tell which is right without knowing the material, you skipped step 1 or 2 — regenerate, don't patch.
|
If, reading the finished set cold, you can still tell which is right without knowing the material, you skipped step 1 or 2 — regenerate, don't patch.
|
||||||
|
|
||||||
### Phase 1 — Probe (never skip this)
|
## Tools — the question tools
|
||||||
|
|
||||||
You can't teach into his zone of proximal development without knowing where its edges are, and you can't aim the teaching without knowing what he's actually reaching for. Two separate unknowns, two separate tools — keep the boundary clean:
|
`quiz` and `ask_user_question` are the two shared question instruments — precise, with distinct jobs. Your agent-specific tools (if any) are described in their own sections after this base.
|
||||||
|
|
||||||
**1a. His current level — use `quiz`. This is a mapping job, not a spot-check.** Your goal is to locate the *edge* of his understanding — the frontier where what he reliably knows turns into what he doesn't — along every strand the planned lesson will depend on. Until you've actually found that edge, you cannot teach into it, so this phase gets as long and detailed as it needs to be. There is no rush.
|
|
||||||
|
|
||||||
**The edge is only located when it's bracketed.** For each relevant strand you need *both*: something at that level he gets **right** (a floor — proof he knows at least this much) and something he gets **wrong** or genuinely doesn't know (a ceiling — where it runs out). The edge sits between them. One side alone tells you almost nothing.
|
|
||||||
|
|
||||||
- **All-correct is not "done" — it means the questions were too easy.** A run of right answers gives you a floor with no ceiling: you've proven he knows *at least* this much and learned nothing about where his knowledge ends. Do not advance. Escalate — go harder until something finally breaks. If he never misses, you never found the edge.
|
|
||||||
- **Binary-search the edge.** When he nails a question, jump the difficulty up *sharply* — don't inch forward. When he misses, you've bracketed the edge from above; narrow back in to pin exactly where it sits. This finds the frontier fast, without a hundred timid questions.
|
|
||||||
- **One wrong answer is not "done" either — and it is *not* a cue to start teaching.** A single miss is one coordinate, and you don't yet know its kind: a careless slip, a narrow isolated gap, or a systematic misconception. Probe *around* it to characterize it before concluding anything. Misconceptions matter most — a confidently-held wrong model has to be dislodged, not merely topped up — so when you catch one, dig into its extent rather than moving on.
|
|
||||||
- **Map every strand the lesson rests on.** A topic has several prerequisite threads, and the edge is a frontier across all of them, not a single point. Probe each thread the explanation will lean on and find where each one runs out. Bound this by *relevance to the goal*: map every corner the teaching will depend on, and don't bother with corners it won't.
|
|
||||||
|
|
||||||
Do not advance to Phase 2 until, for each goal-relevant strand, you can state concretely both what he has and where it ends. This is how nuance is handled: many small graded questions, each adapted to the last answer — not one big caveated one. Every `quiz` carries the correct answer, so you learn *exactly where* he goes wrong, not just that he did.
|
|
||||||
|
|
||||||
**1b. His learning goal — use `ask_user_question`.** Find out what he actually wants taught. With a subject he doesn't know yet, the goal is often hard for him to articulate — "I want to understand LLMs" or "how the internet works" can mean ten different things, and which one it is completely changes what you teach. Interrogate the vision until it's concrete. This has no right answer, so it's `ask_user_question`, never `quiz`.
|
|
||||||
|
|
||||||
### Phase 2 — Plan (think hard here)
|
|
||||||
|
|
||||||
This is the highest-leverage step; don't rush it. With his level and his goal now in hand, stop and genuinely reason out the best way to teach *this thing* to *this person*. Re-read the philosophy above and plan against it:
|
|
||||||
|
|
||||||
- Scope the field from your own knowledge: its core concepts, the real first principles, standard framings, common gotchas — and where your knowledge is shaky, note it (per the accuracy rule above).
|
|
||||||
- What are the unconditional truths this rests on? Is there a clean atomic unit ("ALL X is done through {____}")?
|
|
||||||
- Which of those does he already hold (from Phase 1a)? Build from there — not below it, not above it.
|
|
||||||
- What's the motivated discovery path from those truths to his goal? Where does each step come from — why would anyone reach for it?
|
|
||||||
- Socratic or expository for each stretch, given the topic and his energy?
|
|
||||||
|
|
||||||
A good plan is what makes the teaching feel inevitable instead of arbitrary.
|
|
||||||
|
|
||||||
**Then present the plan in chat — always, before any teaching.** Two parts:
|
|
||||||
|
|
||||||
1. **The approach, in prose.** What we'll cover, in what order, and why this way — given where his edge sits (Phase 1a) and what he's reaching for (Phase 1b). A few freeform sentences.
|
|
||||||
2. **The dependency map.** The plan's backbone as a DAG: unconditional truths at the roots, each derived node hanging off what it depends on, his goal as the sink. Write it once with the `plan_init` tool (see the plan-map section below) — the learner SEES it live in a side panel, and where the lesson stands derives from your checks (`nodeId`), not from your bookkeeping. Keep it small: few nodes, short labels — a map, not the territory.
|
|
||||||
|
|
||||||
**Stress-test the roots before presenting.** For every node you're treating as foundational, ask: is this genuinely an unconditional truth *for him*, or a disguised theorem that itself derives from something simpler he'd accept at face value? If it derives, push it down and extend the map — never found the lesson on a mid-level fact. A wrong root corrupts everything hung off it, and roots are far easier to audit in a drawn map than mid-flow.
|
|
||||||
|
|
||||||
**Then stop and wait for his go-ahead.** The presented plan is his checkpoint: a wrong root or wrong scope is cheap to fix now, expensive mid-lesson. Do not begin Phase 3 until he okays the plan.
|
|
||||||
|
|
||||||
### Phase 3 — Teach (the loop)
|
|
||||||
|
|
||||||
Build his dependency graph one **node** at a time — and every node gets the same treatment, whether it's a foundational unconditional truth or a derived step. There is almost never just one; most topics need several, and each new one goes through the loop exactly like any other node:
|
|
||||||
|
|
||||||
For **every node** (each unconditional truth *and* each non-trivial reasoning step toward the goal), run:
|
|
||||||
|
|
||||||
1. **Motivate.** Frame why we need this node right now — what problem it solves or what gap it closes. This applies to unconditional truths too: don't just assert one because it's true, motivate why *this* truth, *now*. "Why are we even bringing this in?"
|
|
||||||
2. **Establish.**
|
|
||||||
- If it's a foundational unconditional truth: state it plainly, at face value, no caveats. Surface an atomic unit if one fits.
|
|
||||||
- If it's a derived step: build it up from what's already established via a motivated move (Socratic or expository), answering "how could I have discovered this?" When a Socratic step has a gradable right/wrong answer, pose it with `quiz` even though he's "attempting the discovery" — gradable-and-Socratic is normal, not a contradiction; only fall back to `ask_user_question` if there's genuinely no right answer.
|
|
||||||
3. **Connect.** Make the dependency edge explicit — show exactly how this new node hangs off the ones already in place, so it's understood, not memorized.
|
|
||||||
4. **Quiz-check.** Confirm the node actually landed with a quick `quiz` — this applies to foundations just as much as derived steps. An unconfirmed unconditional truth is exactly as dangerous as an unconfirmed derived fact: if he misses it, that node isn't solid, so stop and fix it before building anything on top of it.
|
|
||||||
|
|
||||||
Repeat this full loop per node — don't front-load all the foundations once at the start and then stop checking. Any time a new unconditional truth is needed mid-session, it goes through motivate → establish → connect → quiz-check just like a derived step would.
|
|
||||||
|
|
||||||
If you catch yourself asserting a fact he'd have to take on faith — foundational or not — stop: either motivate it and confirm it lands, or ground it in something already established. Unmotivated, unconfirmed facts don't lock in — that's the whole point.
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
You have exactly two teaching instruments: `quiz` and `ask_user_question`. They are precise instruments with distinct jobs. (The plan-tree, research, demo, and visual tools are described in their own sections below — they exist to serve the teaching, never to interrupt it.)
|
|
||||||
|
|
||||||
**How answering works (both tools):** depending on the interface, a question either blocks until he answers (terminal) or is displayed to him and the tool returns immediately (web). In BOTH cases the behavior rule is the same: after calling `quiz` or `ask_user_question`, END YOUR TURN and wait. His answer arrives as the next user message (in the web interface it always arrives this way, formatted with the grading result). Do not call any other tool while a question is outstanding, and never stack multiple questions in one turn — ask, wait for the answer, then continue. When you call a question tool, the question becomes visible to him the moment the tool runs; there is nothing to "wait for" on your side, so just finish your turn with a short natural sentence (or nothing) and stop.
|
**How answering works (both tools):** depending on the interface, a question either blocks until he answers (terminal) or is displayed to him and the tool returns immediately (web). In BOTH cases the behavior rule is the same: after calling `quiz` or `ask_user_question`, END YOUR TURN and wait. His answer arrives as the next user message (in the web interface it always arrives this way, formatted with the grading result). Do not call any other tool while a question is outstanding, and never stack multiple questions in one turn — ask, wait for the answer, then continue. When you call a question tool, the question becomes visible to him the moment the tool runs; there is nothing to "wait for" on your side, so just finish your turn with a short natural sentence (or nothing) and stop.
|
||||||
|
|
||||||
@@ -185,7 +126,3 @@ NEVER use `$` or `$$` for math — a bare `$` is always literal currency in this
|
|||||||
If LaTeX can be used, it should be. Write \(f(x) = x^2\), not `f(x) = x^2`. The session transcript is durable data that will be rendered properly later; keep the notation clean.
|
If LaTeX can be used, it should be. Write \(f(x) = x^2\), not `f(x) = x^2`. The session transcript is durable data that will be rendered properly later; keep the notation clean.
|
||||||
|
|
||||||
For diagrams (flows, cycles, comparisons, small DAGs), write a ```mermaid fenced block — it renders as an integrated, zoomable diagram, not a code block. Diagram freely: a good diagram beats three paragraphs. Mermaid syntax discipline: node labels containing spaces, `=`, or symbols MUST be quoted bracket form (`B["u = x^3"]`), e.g. `flowchart LR` then `x -->|inner: cube| B["u = x^3"]`; bare `u = x³` node ids break the lexer.
|
For diagrams (flows, cycles, comparisons, small DAGs), write a ```mermaid fenced block — it renders as an integrated, zoomable diagram, not a code block. Diagram freely: a good diagram beats three paragraphs. Mermaid syntax discipline: node labels containing spaces, `=`, or symbols MUST be quoted bracket form (`B["u = x^3"]`), e.g. `flowchart LR` then `x -->|inner: cube| B["u = x^3"]`; bare `u = x³` node ids break the lexer.
|
||||||
|
|
||||||
## Session shape — one more thing
|
|
||||||
|
|
||||||
Every session you run produces the artifact from which the learner's knowledge graph is built: the quiz outcomes, the dependency map, the propositions you teach. Teach in clean, explicit units — each node stated crisply (e.g. "**R1: the world looks rich and immediate**"), each dependency connection made explicit — because that structure is what gets extracted later. Vague, hedged, mushy node statements can't be extracted; crisp ones can. You don't need to do anything extra — just teach exactly as described above, and the artifact takes care of itself.
|
|
||||||
|
|||||||
+94
-42
@@ -1,84 +1,117 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { ToolLoopAgent, stepCountIs, type LanguageModel, type LanguageModelUsage, type ToolSet } from "ai";
|
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type LanguageModelUsage, type ToolSet } from "ai";
|
||||||
import { PATHS } from "../../config.ts";
|
import { PATHS } from "../../config.ts";
|
||||||
import { modelFor } from "./models.ts";
|
import { modelFor } from "./models.ts";
|
||||||
import { questionTools } from "./tools.ts";
|
import {
|
||||||
|
askUserQuestionTool,
|
||||||
|
createProberTools,
|
||||||
|
createTeacherTools,
|
||||||
|
type ProbeDoneContext,
|
||||||
|
type TeacherToolContext,
|
||||||
|
} from "./tools.ts";
|
||||||
|
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The teacher agent. Everything pi used to provide (agent loop, tool calling,
|
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
|
||||||
* streaming, multi-step) is these few lines now.
|
* the TEACHER. Same harness (agent loop, tool calling, streaming,
|
||||||
|
* multi-step — the few lines ToolLoopAgent provides), same transcript, but
|
||||||
|
* different prompts and different tool sets; the chat-handler constructs
|
||||||
|
* one per request off the transcript's seam (probe_done = teacher).
|
||||||
*
|
*
|
||||||
* The model is injectable so tests can pass a MockLanguageModelV4 and drive
|
* Models are injectable so tests can pass a MockLanguageModelV4 and drive
|
||||||
* deterministic scripted lessons (see tests/chat-flow.test.ts).
|
* deterministic scripted lessons (see tests/chat-flow.test.ts).
|
||||||
*
|
*
|
||||||
* The optional graphContext (buildGraphContext() in src/core/graph-context.ts,
|
* System prompts are COMPOSED per agent: system-prompt.md (the shared base:
|
||||||
* wired by the chat-handler per request) is appended after the composed
|
* philosophy, accuracy, quiz construction, question tools, formatting) +
|
||||||
* fragments; absent/empty means a new learner — nothing is injected.
|
* every numeric-prefix fragment in that agent's directory
|
||||||
*
|
* (src/agent/prompts/prober/ or src/agent/prompts/teacher/), in sorted
|
||||||
* The system prompt is COMPOSED: system-prompt.md is the base, then every
|
* filename order, joined with `---` separators.
|
||||||
* src/agent/prompts/*.md fragment in sorted filename order (workers add
|
|
||||||
* fragments — nobody edits shared sections of the base prompt). Prefix
|
|
||||||
* fragments with a numeric order key: 10-plan-tree.md, 20-jobs.md, …
|
|
||||||
*
|
*
|
||||||
* ONLY numeric-prefix files load — the loader enforces the convention, so
|
* ONLY numeric-prefix files load — the loader enforces the convention, so
|
||||||
* non-fragment documentation (README.md, …) can live in the directory
|
* non-fragment documentation (README.md, …) can live in the directories
|
||||||
* without ever leaking into the model's prompt.
|
* without ever leaking into the model's prompt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Which agent's prompt directory to compose. */
|
||||||
|
export type AgentKind = "prober" | "teacher";
|
||||||
|
|
||||||
const PROMPTS_DIR = path.join(PATHS.root, "src", "agent", "prompts");
|
const PROMPTS_DIR = path.join(PATHS.root, "src", "agent", "prompts");
|
||||||
|
|
||||||
/** A prompt fragment: numeric order key + .md (e.g. 20-jobs.md). Nothing else loads. */
|
/** A prompt fragment: numeric order key + .md (e.g. 20-teaching.md). Nothing else loads. */
|
||||||
const FRAGMENT_RE = /^\d+-[\w.-]+\.md$/;
|
const FRAGMENT_RE = /^\d+-[\w.-]+\.md$/;
|
||||||
|
|
||||||
/** Exported for tests: assert what composes into the model's prompt. */
|
/** Exported for tests: assert what composes into the model's prompt. */
|
||||||
export function composeInstructions(): string {
|
export function composeInstructions(kind: AgentKind): string {
|
||||||
const base = fs.readFileSync(PATHS.teacherPrompt, "utf-8");
|
const base = fs.readFileSync(PATHS.teacherPrompt, "utf-8");
|
||||||
if (!fs.existsSync(PROMPTS_DIR)) return base;
|
const dir = path.join(PROMPTS_DIR, kind);
|
||||||
|
if (!fs.existsSync(dir)) return base;
|
||||||
const fragments = fs
|
const fragments = fs
|
||||||
.readdirSync(PROMPTS_DIR)
|
.readdirSync(dir)
|
||||||
.filter((f) => FRAGMENT_RE.test(f))
|
.filter((f) => FRAGMENT_RE.test(f))
|
||||||
.sort()
|
.sort()
|
||||||
.map((f) => fs.readFileSync(path.join(PROMPTS_DIR, f), "utf-8").trim())
|
.map((f) => fs.readFileSync(path.join(dir, f), "utf-8").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return [base.trim(), ...fragments].join("\n\n---\n\n");
|
return [base.trim(), ...fragments].join("\n\n---\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
const instructions = composeInstructions();
|
const composedInstructions: Record<AgentKind, string> = {
|
||||||
|
prober: composeInstructions("prober"),
|
||||||
|
teacher: composeInstructions("teacher"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Money channel (wave 4): the chat-handler passes a per-turn collector;
|
||||||
|
// every model call's usage lands in the chat record's turns log
|
||||||
|
// (ChatStore.appendTurnUsage). The step carries far more than usage —
|
||||||
|
// the narrow param keeps the seam honest about what we consume.
|
||||||
|
export type StepUsageCollector = (step: { usage: LanguageModelUsage }) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compose the full instruction string: base prompt + fragments, then — when
|
* The PROBER (agent A): quiz (no nodeId) + ask + probe_done, lean on
|
||||||
* the session carries graph context (W8, vision §1 "the graph as session
|
* probing doctrine. Its whole job is bracketing the learner's edge and
|
||||||
* context") — the auto-generated knowledge-graph block appended after them as
|
* concluding with the assessment (probe_done) — which also queues the
|
||||||
* a clearly-delimited section (the 50-graph-context.md fragment teaches the
|
* handoff wake (see tools/probe.ts) and is the transcript seam the
|
||||||
* teacher how to read it). The fragment mechanism itself stays untouched.
|
* handler keys teacher construction on.
|
||||||
*/
|
*/
|
||||||
function withGraphContext(graphContext: string | null | undefined): string {
|
export function createProberAgent(ctx: ProbeDoneContext, model?: LanguageModel, onStepFinish?: StepUsageCollector) {
|
||||||
if (!graphContext) return instructions;
|
return new ToolLoopAgent({
|
||||||
return `${instructions}\n\n---\n\n${graphContext}`;
|
// Model choice + thinking budget live in config.ts (MODELS.prober).
|
||||||
|
// Reasoning tokens stream as reasoning parts into the UI's collapsible block.
|
||||||
|
model: model ?? modelFor("prober"),
|
||||||
|
instructions: composedInstructions.prober,
|
||||||
|
tools: createProberTools(ctx),
|
||||||
|
// Question tools are client-side (a call ends the server turn);
|
||||||
|
// probe_done resolves immediately (it queues the handoff and acks).
|
||||||
|
// stepCountIs is just the safety net.
|
||||||
|
stopWhen: stepCountIs(20),
|
||||||
|
...(onStepFinish ? { onStepFinish } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TEACHER (agent B): enters at planning (the probe is already
|
||||||
|
* concluded — its assessment is in the transcript), owns plan_init and the
|
||||||
|
* full toolset (teach questions with validated nodeId, the map, research,
|
||||||
|
* demos).
|
||||||
|
*/
|
||||||
export function createTeacherAgent(
|
export function createTeacherAgent(
|
||||||
|
// Optional so tests can construct a bare mock-driven agent (no storage
|
||||||
|
// wiring): without ctx the tools fall back to the validation-free
|
||||||
|
// question set. The handler ALWAYS passes ctx (the composition root).
|
||||||
|
ctx?: TeacherToolContext,
|
||||||
model?: LanguageModel,
|
model?: LanguageModel,
|
||||||
|
onStepFinish?: StepUsageCollector,
|
||||||
|
// Tests may pass a bare tools set to drive deterministic flows; when
|
||||||
|
// present it overrides the ctx-composed registry.
|
||||||
tools?: ToolSet,
|
tools?: ToolSet,
|
||||||
graphContext?: string | null,
|
|
||||||
// Money channel (wave 4): the chat-handler passes a per-turn collector;
|
|
||||||
// every model call's usage lands in the chat record's turns log
|
|
||||||
// (ChatStore.appendTurnUsage). The step carries far more than usage —
|
|
||||||
// the narrow param keeps the seam honest about what we consume.
|
|
||||||
onStepFinish?: (step: { usage: LanguageModelUsage }) => void,
|
|
||||||
) {
|
) {
|
||||||
return new ToolLoopAgent({
|
return new ToolLoopAgent({
|
||||||
// Model choice + thinking budget live in config.ts (MODELS.teacher).
|
// Model choice + thinking budget live in config.ts (MODELS.teacher).
|
||||||
// Reasoning tokens stream as reasoning parts into the UI's collapsible block.
|
|
||||||
model: model ?? modelFor("teacher"),
|
model: model ?? modelFor("teacher"),
|
||||||
instructions: withGraphContext(graphContext),
|
instructions: composedInstructions.teacher,
|
||||||
// Tools are per-request (plan tools close over the injected ctx; job
|
// Tools are per-request (they close over the injected ctx — chat-
|
||||||
// tools close over the job store + spawn seam) — chat-handler passes
|
// handler passes the composition root's stores + spawn seam).
|
||||||
// createTeacherTools({ chatId, chatStore, jobStore, spawn }). The
|
tools: tools ?? (ctx ? createTeacherTools(ctx) : bareQuestionTools()),
|
||||||
// dependency-free default is the question-only set (client-side tools,
|
|
||||||
// no storage needed) — used when tests pass a bare mock model.
|
|
||||||
tools: tools ?? { ...questionTools },
|
|
||||||
// Question tools are client-side (a call ends the server turn);
|
// Question tools are client-side (a call ends the server turn);
|
||||||
// job tools resolve immediately (fire-and-forget ack). stepCountIs is
|
// job tools resolve immediately (fire-and-forget ack). stepCountIs is
|
||||||
// just the safety net.
|
// just the safety net.
|
||||||
@@ -87,4 +120,23 @@ export function createTeacherAgent(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The validation-free teach question set (tests, bare agents): quiz with
|
||||||
|
* the shared authoring refinement but NO per-session nodeId check (no
|
||||||
|
* record to read), plus ask. */
|
||||||
|
export function bareQuestionTools(): ToolSet {
|
||||||
|
return {
|
||||||
|
quiz: tool({
|
||||||
|
description: "Ask the learner a graded question.",
|
||||||
|
inputSchema: quizInputObject.superRefine((input, add) => {
|
||||||
|
for (const problem of quizInputProblems(input)) {
|
||||||
|
add.addIssue({ code: "custom", message: problem });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
ask_user_question: askUserQuestionTool,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProberAgent = ReturnType<typeof createProberAgent>;
|
||||||
export type TeacherAgent = ReturnType<typeof createTeacherAgent>;
|
export type TeacherAgent = ReturnType<typeof createTeacherAgent>;
|
||||||
|
export type TeachingAgent = ProberAgent | TeacherAgent;
|
||||||
+76
-67
@@ -1,52 +1,38 @@
|
|||||||
import { tool, type ToolSet } from "ai";
|
import { tool, type ToolSet } from "ai";
|
||||||
import { askInputSchema, quizInputObject, quizInputProblems, quizInputSchema } from "../core/question-core.ts";
|
import {
|
||||||
|
askInputSchema,
|
||||||
|
probeQuizInputSchema,
|
||||||
|
quizInputObject,
|
||||||
|
quizInputProblems,
|
||||||
|
} from "../core/question-core.ts";
|
||||||
import type { JobRecord } from "../core/contracts.ts";
|
import type { JobRecord } from "../core/contracts.ts";
|
||||||
import type { JobStore } from "../core/ports.ts";
|
import type { JobStore } from "../core/ports.ts";
|
||||||
import { jobTools, type DemoInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts";
|
import { jobTools, type DemoInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts";
|
||||||
import { planTools, type PlanToolContext } from "./tools/plan.ts";
|
import { planTools, type PlanToolContext } from "./tools/plan.ts";
|
||||||
|
import { probeDoneTool, type ProbeDoneContext } from "./tools/probe.ts";
|
||||||
|
|
||||||
export { jobTools };
|
export { jobTools };
|
||||||
|
export type { ProbeDoneContext } from "./tools/probe.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The teacher's tools, composed per request by createTeacherTools.
|
* The agent-split tool composition (docs/probe-teach-split.md §1): the
|
||||||
|
* PROBER and the TEACHER are different agents with different tool sets.
|
||||||
|
* The question tools share a NAME but are genuinely DIFFERENT tools —
|
||||||
|
* the prober's quiz has NO nodeId field at all (its schema is built from
|
||||||
|
* quizInputBase; zod strips the key if the model tries anyway), the
|
||||||
|
* teacher's carries the validated position field.
|
||||||
*
|
*
|
||||||
* - questionTools / questionToolsFor (quiz / ask_user_question): CLIENT-SIDE
|
* - Question tools (quiz / ask_user_question): CLIENT-SIDE (no `execute`).
|
||||||
* tools (no `execute`). The tool call streams to the browser in
|
* The call streams to the browser in `input-available` state; the
|
||||||
* `input-available` state; the learner answers in the dock; the client
|
* learner answers in the dock; the client grades and sends the verdict
|
||||||
* grades via question-core and sends the verdict with addToolOutput. The
|
* with addToolOutput. Input schemas live in core/question-core.
|
||||||
* input schemas live in core/question-core (the question contract) —
|
* - probe_done (src/agent/tools/probe.ts): SERVER-SIDE seam tool.
|
||||||
* parameter names mirror the prompt docs exactly; keep them in sync.
|
* - planTools (src/agent/tools/plan.ts): SERVER-SIDE — author-once
|
||||||
* questionToolsFor adds the per-session plan-nodeId validation: an invalid
|
* plan_init. Teacher only.
|
||||||
* nodeId NEVER reaches the learner — the SDK returns the validation error
|
* - jobTools (src/agent/tools/jobs.ts): SERVER-EXECUTED dispatch. Teacher
|
||||||
* to the teacher as a tool result and it retries (same loop as the
|
* only.
|
||||||
* grading rules). It reads the LIVE record at validation time, so a
|
|
||||||
* plan_init earlier in the same turn already counts.
|
|
||||||
* - planTools (src/agent/tools/plan.ts): SERVER-SIDE — the single author-once
|
|
||||||
* plan_init (mermaid → structured tree; re-callable rewrite). Needs the
|
|
||||||
* chatId closure — hence the per-request factory.
|
|
||||||
* - jobTools (src/agent/tools/jobs.ts): SERVER-EXECUTED dispatch — the call
|
|
||||||
* resolves immediately with a fire-and-forget ack, the background runner
|
|
||||||
* does the work, and the result arrives later as a steer message
|
|
||||||
* (contracts.ts "THE ASYNC PROTOCOL").
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const quizTool = tool({
|
|
||||||
// Description ported from the pi original (the learning system this app
|
|
||||||
// grew out of) — that one never produced invalid correctAnswer values.
|
|
||||||
description:
|
|
||||||
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
|
|
||||||
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
|
|
||||||
"correct answer supplied by you, marks the learner's selection right/wrong, reveals the correct answer, and " +
|
|
||||||
"can show an explanation. Use it to (1) assess what the learner already understands before teaching, and " +
|
|
||||||
"(2) run tight practice/retrieval loops after explaining, or probe understanding whenever you're unsure " +
|
|
||||||
"they've got it. Options-only: single-select or multi-select, plus an automatic 'I don't know' choice so the " +
|
|
||||||
"learner can signal a genuine gap instead of guessing. An always-present optional note field lets the learner " +
|
|
||||||
"attach a free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for " +
|
|
||||||
"non-graded questions use ask_user_question instead. Ask exactly one question per tool call; after calling, " +
|
|
||||||
"end your turn and wait for the graded result.",
|
|
||||||
inputSchema: quizInputSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const askUserQuestionTool = tool({
|
export const askUserQuestionTool = tool({
|
||||||
description:
|
description:
|
||||||
"Ask the learner a single question and pause until they answer. NOT graded — there is no correct answer. " +
|
"Ask the learner a single question and pause until they answer. NOT graded — there is no correct answer. " +
|
||||||
@@ -55,21 +41,41 @@ export const askUserQuestionTool = tool({
|
|||||||
inputSchema: askInputSchema,
|
inputSchema: askInputSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const questionTools = {
|
// ─── the prober's question tools (no nodeId — no map exists yet) ─────────────
|
||||||
quiz: quizTool,
|
|
||||||
ask_user_question: askUserQuestionTool,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
export const probeQuizTool = tool({
|
||||||
* Per-session question tools: the quiz schema gains the dynamic plan-nodeId
|
description:
|
||||||
* validation on top of question-core's shared authoring rules (chained
|
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
|
||||||
* superRefines — the grading contract is untouched). The check reads the
|
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
|
||||||
* LIVE chat record, so rewrites mid-turn are honored, and with no tree yet
|
"correct answer supplied by you, marks the learner's selection right/wrong, reveals the correct answer, and " +
|
||||||
* (probe phase) every nodeId is rejected — ad-hoc checks must omit it.
|
"can show an explanation. Use it to locate the edge of his understanding: bracket each strand the lesson will " +
|
||||||
* ask_user_question is untouched: it never carries nodeId (not a check).
|
"depend on — something he gets right (a floor), something he gets wrong or doesn't know (a ceiling). " +
|
||||||
*/
|
"Options-only: single-select or multi-select, plus an automatic 'I don't know' choice so the learner can " +
|
||||||
export function questionToolsFor(ctx: Pick<PlanToolContext, "chatId" | "chatStore">): ToolSet {
|
"signal a genuine gap instead of guessing. An always-present optional note field lets the learner attach a " +
|
||||||
const quizInputWithNodeValidation = quizInputObject
|
"free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded " +
|
||||||
|
"questions use ask_user_question instead. Ask exactly one question per tool call; after calling, end your " +
|
||||||
|
"turn and wait for the graded result.",
|
||||||
|
inputSchema: probeQuizInputSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The prober's tool set: probe quiz (no nodeId) + ask + probe_done. */
|
||||||
|
export function createProberTools(ctx: ProbeDoneContext): ToolSet {
|
||||||
|
return {
|
||||||
|
quiz: probeQuizTool,
|
||||||
|
ask_user_question: askUserQuestionTool,
|
||||||
|
...probeDoneTool(ctx),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── the teacher's question tools (quiz carries the position field) ──────────
|
||||||
|
|
||||||
|
/** Build the teach-side quiz tool for one request (exported for tests): the authoring contract
|
||||||
|
* + the per-session plan-nodeId validation (chained superRefines — the
|
||||||
|
* grading contract is untouched). Reads the LIVE chat record, so rewrites
|
||||||
|
* mid-turn are honored, and with no tree yet every nodeId is rejected.
|
||||||
|
* Omitting nodeId is legal — an ad-hoc check that moves nothing. */
|
||||||
|
export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStore">) {
|
||||||
|
const inputSchema = quizInputObject
|
||||||
.superRefine((input, add) => {
|
.superRefine((input, add) => {
|
||||||
for (const problem of quizInputProblems(input)) {
|
for (const problem of quizInputProblems(input)) {
|
||||||
add.addIssue({ code: "custom", message: problem });
|
add.addIssue({ code: "custom", message: problem });
|
||||||
@@ -78,29 +84,32 @@ export function questionToolsFor(ctx: Pick<PlanToolContext, "chatId" | "chatStor
|
|||||||
.superRefine((input, add) => {
|
.superRefine((input, add) => {
|
||||||
if (typeof input.nodeId !== "string" || input.nodeId.length === 0) return;
|
if (typeof input.nodeId !== "string" || input.nodeId.length === 0) return;
|
||||||
const record = ctx.chatStore.getChat(ctx.chatId);
|
const record = ctx.chatStore.getChat(ctx.chatId);
|
||||||
const tree = record?.tree;
|
const known = record?.tree?.nodes.map((n) => n.id) ?? [];
|
||||||
const known = tree?.nodes.map((n) => n.id) ?? [];
|
|
||||||
if (!known.includes(input.nodeId)) {
|
if (!known.includes(input.nodeId)) {
|
||||||
add.addIssue({
|
add.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message:
|
message:
|
||||||
`nodeId "${input.nodeId}" matches no node in this session's plan map. ` +
|
`nodeId "${input.nodeId}" matches no node in this session's plan map. ` +
|
||||||
(known.length > 0
|
(known.length > 0
|
||||||
? `Copy one of your plan_init slugs exactly (${known.slice(0, 5).join(", ")}${known.length > 5 ? ", …" : ""}), or omit nodeId for an ad-hoc check.`
|
? `Copy one of your plan_init slugs exactly (${known.slice(0, 5).join(", ")}${known.length > 5 ? ", …" : ""}), or omit nodeId for an ad-hoc check.`
|
||||||
: "No plan map exists yet — omit nodeId (ad-hoc checks only until you call plan_init)."),
|
: "No plan map exists yet — omit nodeId (ad-hoc checks only until you call plan_init)."),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return tool({
|
||||||
const quizWithNodeValidation = tool({
|
description:
|
||||||
description: quizTool.description,
|
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
|
||||||
inputSchema: quizInputWithNodeValidation,
|
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
|
||||||
|
"correct answer supplied by you, marks the learner's selection right/wrong, reveals the correct answer, and " +
|
||||||
|
"can show an explanation. Use it to (1) confirm a taught node actually landed, or (2) probe understanding " +
|
||||||
|
"whenever you're unsure. Set `nodeId` whenever the question targets a map node — the lesson's position on the " +
|
||||||
|
"map derives from those tags; omit it only for quick ad-hoc checks. Options-only: single-select or multi-select, " +
|
||||||
|
"plus an automatic 'I don't know' choice so the learner can signal a genuine gap instead of guessing. " +
|
||||||
|
"An always-present optional note field lets the learner attach a free-text note to ANY answer; it reaches you " +
|
||||||
|
"only when non-empty. No free-text answers — for non-graded questions use ask_user_question instead. " +
|
||||||
|
"Ask exactly one question per tool call; after calling, end your turn and wait for the graded result.",
|
||||||
|
inputSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
quiz: quizWithNodeValidation,
|
|
||||||
ask_user_question: askUserQuestionTool,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeacherToolContext extends PlanToolContext {
|
export interface TeacherToolContext extends PlanToolContext {
|
||||||
@@ -114,13 +123,13 @@ export interface TeacherToolContext extends PlanToolContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-request tool registry. Plan tools and question tools close over the
|
* Per-request teacher tool registry: teach questions (validated nodeId) +
|
||||||
* ctx (chatId + the injected ChatStore — the latter also powers the quiz
|
* plan tools + job tools, all closing over the ctx.
|
||||||
* nodeId validation); job tools close over the job store + spawn seam.
|
|
||||||
*/
|
*/
|
||||||
export function createTeacherTools(ctx: TeacherToolContext): ToolSet {
|
export function createTeacherTools(ctx: TeacherToolContext): ToolSet {
|
||||||
return {
|
return {
|
||||||
...questionToolsFor(ctx),
|
quiz: teachQuizToolFor(ctx),
|
||||||
|
ask_user_question: askUserQuestionTool,
|
||||||
...planTools(ctx),
|
...planTools(ctx),
|
||||||
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }),
|
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { tool, type ToolSet } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { ProbeDoneInput, ProbeDoneResult } from "../../core/contracts.ts";
|
||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import { findPendingToolCall } from "../../core/pending.ts";
|
||||||
|
import type { ChatStore } from "../../core/ports.ts";
|
||||||
|
import { buildProbeHandoffSteer, type ProbeHandoffSteerMessage } from "../../core/contracts.ts";
|
||||||
|
|
||||||
|
/** What the probe_done tool closes over. */
|
||||||
|
export interface ProbeDoneContext {
|
||||||
|
chatId: string;
|
||||||
|
/** The storage port (open-question + already-concluded checks). */
|
||||||
|
chatStore: ChatStore;
|
||||||
|
/** Queue the handoff steer (the wake pipeline — src/server/jobs.ts).
|
||||||
|
* Injected so this module never imports the server seam. */
|
||||||
|
deliver: (steer: ProbeHandoffSteerMessage) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does the transcript already carry a concluded probe (any state)? */
|
||||||
|
function probeConcluded(messages: UIMessage[]): boolean {
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.parts.some((p) => p.type === "tool-probe_done")) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* probe_done — the probe→planning seam (docs/probe-teach-split.md §2).
|
||||||
|
*
|
||||||
|
* SERVER-SIDE. The prober concludes the probe by authoring the learner
|
||||||
|
* assessment (the tool INPUT — it streams to the learner live and is
|
||||||
|
* durable in the transcript as the tool part itself). Three outcomes:
|
||||||
|
*
|
||||||
|
* ok:false + error — a question is still open ("resolve it first"), or
|
||||||
|
* the probe is already concluded. The refusal returns to the prober as
|
||||||
|
* a tool result; it fixes course and retries (the return-to-agent
|
||||||
|
* doctrine, same as nodeId validation).
|
||||||
|
* ok:true — the assessment is recorded, and the HANDOFF is queued: a
|
||||||
|
* steer through the wake pipeline that fires the teacher's first turn
|
||||||
|
* (plan authoring) the moment the prober's turn ends. Auto-wake is the
|
||||||
|
* user-decided trigger — the learner experiences assessment → plan as
|
||||||
|
* one continuous flow.
|
||||||
|
*
|
||||||
|
* The first probe_done in the transcript is also the construction seam:
|
||||||
|
* from that message on, every request builds the TEACHER agent.
|
||||||
|
*/
|
||||||
|
export function probeDoneTool(ctx: ProbeDoneContext): ToolSet {
|
||||||
|
return {
|
||||||
|
probe_done: tool({
|
||||||
|
description:
|
||||||
|
"Conclude the probing phase and hand off to the teaching phase. Call this ONCE, when the probe is " +
|
||||||
|
"complete: every strand the lesson depends on is bracketed (a floor he reliably gets right AND a " +
|
||||||
|
"ceiling where it runs out), and his learning goal is concrete. The input IS the learner assessment — " +
|
||||||
|
"one strand per dependency the lesson will rest on, with your read of where his edge sits on it " +
|
||||||
|
"(floor, ceiling, misconceptions, what to build from). It streams to the learner live and becomes " +
|
||||||
|
"the handoff artifact the teacher plans from. Refused while a question is still open, and after " +
|
||||||
|
"the probe is already concluded. After it succeeds, say your closing sentence and end your turn.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
assessment: z
|
||||||
|
.object({
|
||||||
|
strands: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
strand: z.string().describe("The dependency strand (e.g. 'the derivative as a limit') — a name the plan can hang a node on."),
|
||||||
|
assessment: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Where his edge sits on this strand: the floor (what he reliably gets right), the ceiling (where it runs out), " +
|
||||||
|
"misconceptions found, and what the lesson should build from.",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.describe("Every strand the planned lesson will depend on, each with your read of his edge on it."),
|
||||||
|
summary: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Optional overall synthesis: his apparent goal and where the teaching should enter."),
|
||||||
|
})
|
||||||
|
.describe("The learner assessment — the probe's distilled output."),
|
||||||
|
}) satisfies z.ZodType<ProbeDoneInput>,
|
||||||
|
execute: async (input): Promise<ProbeDoneResult> => {
|
||||||
|
const record = ctx.chatStore.getChat(ctx.chatId);
|
||||||
|
if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}` };
|
||||||
|
|
||||||
|
if (findPendingToolCall(record.messages) !== null) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
"A question is still open — resolve it (or let him answer) before concluding the probe. " +
|
||||||
|
"probe_done only fires with no question outstanding.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (probeConcluded(record.messages)) {
|
||||||
|
return { ok: false, error: "The probe is already concluded — probe_done fires exactly once." };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ctx.deliver(buildProbeHandoffSteer(ctx.chatId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[chat ${ctx.chatId}] probe handoff queueing failed:`, err);
|
||||||
|
// The handoff is recoverable-by-design (the next client message
|
||||||
|
// constructs the teacher off the transcript seam) — record the
|
||||||
|
// assessment anyway; the wake is an optimization, not a gate.
|
||||||
|
}
|
||||||
|
return { ok: true, assessment: input.assessment };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,3 +13,4 @@ export * from "./contracts/plan.ts";
|
|||||||
export * from "./contracts/graph.ts";
|
export * from "./contracts/graph.ts";
|
||||||
export * from "./contracts/chat.ts";
|
export * from "./contracts/chat.ts";
|
||||||
export * from "./contracts/jobs.ts";
|
export * from "./contracts/jobs.ts";
|
||||||
|
export * from "./contracts/probe.ts";
|
||||||
|
|||||||
@@ -124,12 +124,15 @@ export interface TurnUsage {
|
|||||||
* small container, the current node glowing — clearly a fragment, not the
|
* small container, the current node glowing — clearly a fragment, not the
|
||||||
* whole plan. Clicking either opens the full plan in a popup. */
|
* whole plan. Clicking either opens the full plan in a popup. */
|
||||||
export interface PlanBlockData {
|
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;
|
at: string;
|
||||||
/** The current node at snapshot time (the glow target; null = none). */
|
/** The current node at snapshot time (the glow target; null = none). */
|
||||||
current: string | null;
|
current: string | null;
|
||||||
/** Included nodes: all of them (full) or the one-level-up/one-level-down
|
/** All nodes of the authored tree. Statuses are the snapshot's,
|
||||||
* neighborhood of `current` (neighborhood). Statuses are the snapshot's,
|
|
||||||
* derived at emission time — the block never re-derives at render. */
|
* derived at emission time — the block never re-derives at render. */
|
||||||
nodes: Array<{ id: string; title: string; status: PlanNodeStatus }>;
|
nodes: Array<{ id: string; title: string; status: PlanNodeStatus }>;
|
||||||
edges: Array<{ from: string; to: string }>;
|
edges: Array<{ from: string; to: string }>;
|
||||||
@@ -141,9 +144,9 @@ export interface PlanBlockData {
|
|||||||
* them in listChats. Liveness (`busy`) is NOT record-derived — the API
|
* them in listChats. Liveness (`busy`) is NOT record-derived — the API
|
||||||
* route merges it in (see ChatListItem).
|
* route merges it in (see ChatListItem).
|
||||||
*
|
*
|
||||||
* Phase semantics (three phases, data-only): probe = no plan yet, plan =
|
* Phase semantics (three phases, event-driven — docs/probe-teach-split.md
|
||||||
* tree exists but no node occupied, teach = a node is occupied
|
* §4): probe ends at the concluded probe_done, plan ends at the successful
|
||||||
* (core/phases.ts phaseOfTree).
|
* plan_init; derived from the transcript (core/phases.ts phaseAt3).
|
||||||
*/
|
*/
|
||||||
export interface ChatSummary {
|
export interface ChatSummary {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ export interface AbortSteerMessage extends UIMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Anything the wake pipeline may queue and deliver. */
|
/** 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). */
|
/** Whether a result is the failure shape (html/svg empty, failure brief). */
|
||||||
export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean {
|
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).";
|
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). */
|
// ─── the probe handoff steer (the probe→teach split, docs/probe-teach-split.md §2) ──
|
||||||
export function steerDelivered(messages: UIMessage[], jobId: string): boolean {
|
//
|
||||||
return messages.some(
|
// probe_done's execute queues this through the same wake pipeline; the
|
||||||
(m) => (m.metadata as { subagentResult?: { jobId?: string } } | undefined)?.subagentResult?.jobId === jobId,
|
// 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
@@ -29,39 +29,35 @@ export interface PlanEdge {
|
|||||||
export interface PlanTree {
|
export interface PlanTree {
|
||||||
nodes: PlanNode[];
|
nodes: PlanNode[];
|
||||||
edges: PlanEdge[];
|
edges: PlanEdge[];
|
||||||
currentNodeId: string | null;
|
|
||||||
doneNodeIds: string[];
|
|
||||||
/** Increments on every mutation — lets the UI animate/react to changes. */
|
/** Increments on every mutation — lets the UI animate/react to changes. */
|
||||||
version: number;
|
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). */
|
/** 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 {
|
// ─── derived statuses: position from CALLS, never from results ─────────────
|
||||||
if (tree.currentNodeId === nodeId) return "current";
|
|
||||||
if (tree.doneNodeIds.includes(nodeId)) return "done";
|
|
||||||
return "upcoming";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── derived statuses: the author-once protocol (2026-09-03) ─────────────────────
|
|
||||||
//
|
//
|
||||||
// The teacher AUTHORS the map (plan_init, mermaid — once at the seam,
|
// (2026-09-11 pivot — docs/probe-teach-split.md §3.) The teacher AUTHORS the
|
||||||
// re-callable to rewrite) and never tracks progress: statuses derive from
|
// map (plan_init, mermaid — re-callable rewrite) and never tracks progress.
|
||||||
// the transcript, pure and at render time. A check names its node (quiz
|
// The quiz `nodeId` means "this question is ABOUT this node" — a position
|
||||||
// input nodeId — validated at the call boundary against the live tree);
|
// signal, never a completion signal. Derivation reads CALLS only:
|
||||||
// the derivation reads outcomes:
|
// current — the node of the most RECENT tagged quiz call (any state)
|
||||||
// done — any check on the node that the learner PASSED (demonstration-
|
// visited — every node referenced by an EARLIER tagged call
|
||||||
// only: "explaining is yours, done is theirs")
|
// Consequences: a node spans multiple turns/checks naturally (several
|
||||||
// current — the node of the last UNANSWERED check; if none, the first
|
// questions may tag the same node — the map holds still); moving to a new
|
||||||
// node in authoring order whose prerequisites are all done and
|
// node is the teacher's judgment the previous one is finished, regardless of
|
||||||
// that isn't done itself (forward-looking: the panel leads the
|
// check outcomes (a failed check then a move-on is still visited); results
|
||||||
// lesson — during teaching it points at what's coming, and holds
|
// feed nothing; there is NO fallback current (before the first tagged check
|
||||||
// through the check that follows)
|
// nothing is current). Untagged checks are legal ad-hoc checks — they move
|
||||||
// upcoming — everything else
|
// nothing. NodeIds missing from the current tree are ignored (a rewrite
|
||||||
// Untagged checks and nodeIds missing from the current tree are lost signal
|
// may have dropped the node).
|
||||||
// (the standing doctrine); a rewritten map keeps honest progress for reused
|
|
||||||
// slugs because the derivation matches ids against the CURRENT tree.
|
|
||||||
|
|
||||||
/** Minimal structural message slice — anything with typed parts (mirrors
|
/** Minimal structural message slice — anything with typed parts (mirrors
|
||||||
* phases.ts: pure, no runtime deps, safe on server, client, and in tests). */
|
* phases.ts: pure, no runtime deps, safe on server, client, and in tests). */
|
||||||
@@ -70,48 +66,37 @@ interface PartsCarrier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DerivedPlanStatuses {
|
export interface DerivedPlanStatuses {
|
||||||
|
/** The node the most recent tagged quiz call is about; null before any. */
|
||||||
current: string | null;
|
current: string | null;
|
||||||
/** Node ids demonstrated by the learner (passed checks). */
|
/** Nodes referenced by earlier tagged calls — been there, monotonic. */
|
||||||
done: ReadonlySet<string>;
|
visited: ReadonlySet<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Statuses of a node: the derived view the plan panel should render. */
|
/** Statuses of a node: the derived view the plan panel should render. */
|
||||||
export function derivedNodeStatus(statuses: DerivedPlanStatuses, nodeId: string): PlanNodeStatus {
|
export function derivedNodeStatus(statuses: DerivedPlanStatuses, nodeId: string): PlanNodeStatus {
|
||||||
if (statuses.current === nodeId) return "current";
|
if (statuses.current === nodeId) return "current";
|
||||||
if (statuses.done.has(nodeId)) return "done";
|
if (statuses.visited.has(nodeId)) return "visited";
|
||||||
return "upcoming";
|
return "upcoming";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function derivePlanStatuses(tree: PlanTree, messages: readonly PartsCarrier[]): DerivedPlanStatuses {
|
export function derivePlanStatuses(tree: PlanTree, messages: readonly PartsCarrier[]): DerivedPlanStatuses {
|
||||||
const ids = new Set(tree.nodes.map((n) => n.id));
|
const ids = new Set(tree.nodes.map((n) => n.id));
|
||||||
const done = new Set<string>();
|
const runs: string[] = []; // distinct tagged nodes in call order, CONSECUTIVE
|
||||||
let current: string | null = null;
|
// duplicates collapsed (several checks on one node = one stay on it)
|
||||||
|
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
for (const part of m.parts) {
|
for (const part of m.parts) {
|
||||||
if (part.type !== "tool-quiz") continue;
|
if (part.type !== "tool-quiz") continue;
|
||||||
const nodeId = (part.input as { nodeId?: unknown } | undefined)?.nodeId;
|
const nodeId = (part.input as { nodeId?: unknown } | undefined)?.nodeId;
|
||||||
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; // untagged/unknown = lost signal
|
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; // untagged/unknown — moves nothing
|
||||||
if (part.state === "input-available") {
|
if (runs.at(-1) !== nodeId) runs.push(nodeId);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const current = runs.at(-1) ?? null;
|
||||||
if (current === null) {
|
// Visited = been there in an EARLIER stay: everything before the current
|
||||||
// No check out: the first node (authoring order) whose prerequisites
|
// run. A revisit (a→b→a) puts a in visited AND current — current wins at
|
||||||
// are all demonstrated and that isn't done itself — the next lesson.
|
// render (derivedNodeStatus), visited records it has been left before.
|
||||||
const prereqs = new Map<string, string[]>();
|
const visited = new Set(runs.slice(0, -1));
|
||||||
for (const e of tree.edges) prereqs.set(e.to, [...(prereqs.get(e.to) ?? []), e.from]);
|
return { current, visited };
|
||||||
current =
|
|
||||||
tree.nodes.find(
|
|
||||||
(n) => !done.has(n.id) && (prereqs.get(n.id) ?? []).every((p) => done.has(p)),
|
|
||||||
)?.id ?? null;
|
|
||||||
}
|
|
||||||
return { current, done };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
* colors nodes by quiz results anymore). Used by the teacher's
|
||||||
* graph-as-context selection (graph-context.ts) to find diagnostic
|
* graph-as-context selection (graph-context.ts) to find diagnostic
|
||||||
* hotspots; the home renderer does not consume it. Nodes with no quiz
|
* 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> {
|
export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map<string, LedgerEventKind> {
|
||||||
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
|
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
|
||||||
|
|||||||
+4
-5
@@ -9,11 +9,10 @@
|
|||||||
* components/** → "@/src/core" (this barrel, ONLY)
|
* components/** → "@/src/core" (this barrel, ONLY)
|
||||||
* app/** → "@/src/core" + "@/src/server"
|
* app/** → "@/src/core" + "@/src/server"
|
||||||
*
|
*
|
||||||
* Server-only core modules (core/graph.ts emission/derivation,
|
* Server-only core modules (core/graph.ts emission/derivation) are pure
|
||||||
* core/graph-context.ts) are pure but need storage ports passed in by a
|
* but need storage ports passed in by a server caller, so they are
|
||||||
* server caller, so they are deliberately NOT re-exported here — the
|
* deliberately NOT re-exported here — the server imports them directly.
|
||||||
* server imports them directly. The storage PORT TYPES are types-only and
|
* The storage PORT TYPES are types-only and browser-safe, hence re-exported.
|
||||||
* browser-safe, hence re-exported.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./contracts.ts";
|
export * from "./contracts.ts";
|
||||||
|
|||||||
+49
-117
@@ -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
|
* probe — until the first message containing a `tool-probe_done` part
|
||||||
* marker — the call already fires exactly once at the seam (verified live in
|
* (the prober concludes; the assessment rides the tool part)
|
||||||
* Gate 1), so no new agent tool exists. Phase = "probe" until the first
|
* plan — probe concluded, until the first successful `plan_init`
|
||||||
* message containing a `plan_init` tool part; from that message onward,
|
* (the teacher presents the map; interim rule until the
|
||||||
* phase = "teach". Everything after it (checks, follow-up asks) is teaching.
|
* 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. */
|
/** The three phases of a session, as the picker / header / dividers see them. */
|
||||||
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. */
|
|
||||||
export type Phase3 = "probe" | "plan" | "teach";
|
export type Phase3 = "probe" | "plan" | "teach";
|
||||||
|
|
||||||
/** The advance-only order of phases: a session never visibly regresses. */
|
/** The advance-only order of phases: a session never visibly regresses. */
|
||||||
const PHASE3_RANK: Record<Phase3, number> = { probe: 0, plan: 1, teach: 2 };
|
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 →
|
* Minimal structural slice of a UI message for the phase scan: parts with
|
||||||
* plan, an occupied node → teach. Used for the chats summary (the stored
|
* a state and (for concluded calls) an output.
|
||||||
* tree IS the session state there). Empty trees (emptyTree(), a failed
|
|
||||||
* plan_init) read as probe — a seam only counts once a plan exists.
|
|
||||||
*/
|
*/
|
||||||
export function phaseOfTree(tree: PhaseTree | null | undefined): Phase3 {
|
export interface PhaseMessage {
|
||||||
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 {
|
|
||||||
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>;
|
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A successful plan-op output as it appears on the part (`PlanToolResult`). */
|
|
||||||
interface PlanOutput {
|
interface PlanOutput {
|
||||||
tree?: { nodes?: unknown };
|
tree?: { nodes?: unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The phase at `upToIndex` (inclusive; default: the last message). */
|
||||||
* The three-phase boundary semantics over the TRANSCRIPT (the same data the
|
export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number): Phase3 {
|
||||||
* 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 {
|
|
||||||
let phase: Phase3 = "probe";
|
let phase: Phase3 = "probe";
|
||||||
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
|
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
|
||||||
for (let i = 0; i <= last; i++) {
|
for (let i = 0; i <= last; i++) {
|
||||||
const parts = messages[i]?.parts;
|
phase = advance(phase, messages[i]?.parts);
|
||||||
if (!parts) continue;
|
}
|
||||||
for (const part of parts) {
|
return phase;
|
||||||
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;
|
/** One message's advance of the phase (pure fold step). */
|
||||||
const candidate = phaseOfTree(tree);
|
function advance(
|
||||||
if (PHASE3_RANK[candidate] > PHASE3_RANK[phase]) phase = candidate;
|
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;
|
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
|
* The phase at EVERY message index, in one forward fold — the transcript
|
||||||
* dividers render one per boundary, so phaseAt3(m, i) per index would
|
* dividers render one per boundary, so phaseAt3(m, i) per index would rescan
|
||||||
* rescan 0..i each time (quadratic); this is the same derivation, linear.
|
* 0..i each time (quadratic); this is the same derivation, linear.
|
||||||
* out[i] === phaseAt3(messages, i) for every index.
|
* 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[] = [];
|
const out: Phase3[] = [];
|
||||||
let phase: Phase3 = "probe";
|
let phase: Phase3 = "probe";
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const parts = messages[i]?.parts;
|
phase = advance(phase, 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push(phase);
|
out.push(phase);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
+28
-123
@@ -1,28 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Plan-block emission (wave 4, the seam the dock/popup render against):
|
* Plan-block emission (docs/probe-teach-split.md §6): the INTERIM plan
|
||||||
* the PURE builder that turns a plan tree + transcript into the persisted
|
* presentation seam. The plan renders IN FLOW once, at authoring — a full
|
||||||
* `data-plan-block` parts the chat renders in flow. Emission happens ONCE,
|
* `data-plan-block` attached to the assistant message that carries the
|
||||||
* at persist time (turn-service seam) — blocks are frozen transcript
|
* successful plan_init call. It is the presentation moment for the plan
|
||||||
* content (user decision, 2026-09-11), never derived at render:
|
* (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
|
* The neighborhood-block machinery (frozen fragments on plan movement) is
|
||||||
* plan_init call — the whole DAG, snapshot statuses. Always emitted on
|
* RETIRED: position derives live from the transcript (derivePlanStatuses —
|
||||||
* (re)authoring, even an identical rewrite: authoring is the event.
|
* calls, not results), and frozen snapshots can't represent live movement.
|
||||||
* - NEIGHBORHOOD block: attached to the LAST assistant message when the
|
* The persistent DAG window (the UI wave) is the successor — always
|
||||||
* derived plan state MOVED since the newest existing block — current
|
* present, auto-moving with the lesson.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Idempotent by construction: a message that already carries a plan-block
|
* Idempotent by construction: a message that already carries a plan-block
|
||||||
* part is never given another (persist replays / retry paths are safe).
|
* 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 { derivePlanStatuses } from "./contracts.ts";
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
|
|
||||||
@@ -34,19 +29,7 @@ export interface PlanBlockPart {
|
|||||||
data: PlanBlockData;
|
data: PlanBlockData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nodes/edges of the one-level neighborhood: current + direct parents +
|
/** Does this message carry a successful plan_init (the trigger)? */
|
||||||
* 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)? */
|
|
||||||
function hasSuccessfulPlanInit(message: UIMessage): boolean {
|
function hasSuccessfulPlanInit(message: UIMessage): boolean {
|
||||||
for (const part of message.parts) {
|
for (const part of message.parts) {
|
||||||
if (part.type !== "tool-plan_init") continue;
|
if (part.type !== "tool-plan_init") continue;
|
||||||
@@ -56,114 +39,36 @@ function hasSuccessfulPlanInit(message: UIMessage): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The plan state as derived from a message slice — the diff key. `checked`
|
function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData {
|
||||||
* = 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) {
|
|
||||||
const statuses = derivePlanStatuses(tree, messages);
|
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 {
|
return {
|
||||||
current: statuses.current,
|
kind: "full",
|
||||||
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,
|
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
current: statuses.current,
|
current: statuses.current,
|
||||||
nodes: scope.nodes.map((n) => ({
|
nodes: tree.nodes.map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
title: n.title,
|
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
|
* Enrich a message list with plan-presentation blocks — THE emission
|
||||||
* half. `messages` is the deduped list persistTurn is about to write;
|
* seam's pure half. `messages` is the deduped list persistTurn is about to
|
||||||
* `tree` is the record's current tree (the plan tool saved it during the
|
* write; `tree` is the record's current tree (the plan tool saved it during
|
||||||
* turn). Messages already carrying a block are never touched.
|
* the turn). Messages already carrying a block are never touched.
|
||||||
*/
|
*/
|
||||||
export function withPlanBlocks(messages: UIMessage[], tree: PlanTree | null | undefined): UIMessage[] {
|
export function withPlanBlocks(messages: UIMessage[], tree: PlanTree | null | undefined): UIMessage[] {
|
||||||
if (!tree || tree.nodes.length === 0 || messages.length === 0) return messages;
|
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)}`;
|
const blockId = `pb_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
||||||
let fullAttached = false;
|
|
||||||
return messages.map((m) => {
|
return messages.map((m) => {
|
||||||
if (m.role !== "assistant" || m.parts.some((p) => p.type === "data-plan-block")) return m;
|
if (m.role !== "assistant" || m.parts.some((p) => p.type === "data-plan-block")) return m;
|
||||||
if (hasSuccessfulPlanInit(m)) {
|
if (!hasSuccessfulPlanInit(m)) return m;
|
||||||
const data = snapshot("full", tree, messages);
|
return {
|
||||||
if (!data) return m;
|
...m,
|
||||||
fullAttached = true;
|
parts: [...m.parts, { type: "data-plan-block", id: blockId, data: snapshot(tree, messages) } satisfies PlanBlockPart],
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-101
@@ -28,7 +28,7 @@ export class PlanTreeError extends Error {
|
|||||||
|
|
||||||
/** An empty, never-stored tree used as the seed before plan_init runs. */
|
/** An empty, never-stored tree used as the seed before plan_init runs. */
|
||||||
export function emptyTree(): PlanTree {
|
export function emptyTree(): PlanTree {
|
||||||
return { nodes: [], edges: [], currentNodeId: null, doneNodeIds: [], version: 0 };
|
return { nodes: [], edges: [], version: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── validation helpers ──────────────────────────────────────────────────────
|
// ─── validation helpers ──────────────────────────────────────────────────────
|
||||||
@@ -122,105 +122,11 @@ export function createTree(nodes: PlanNode[], edges: PlanEdge[]): PlanTree {
|
|||||||
assertValidEdge(e.from, e.to, nodes, accepted);
|
assertValidEdge(e.from, e.to, nodes, accepted);
|
||||||
accepted.push({ from: e.from, to: e.to });
|
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`. */
|
// The per-node mutation ops (addNode/updateNode/removeNode/addEdge/removeEdge/
|
||||||
export function addNode(tree: PlanTree, node: PlanNode, parents?: string[]): PlanTree {
|
// setCurrent/markDone) were deleted with the author-once protocol
|
||||||
assertValidNode(node);
|
// (2026-09-03) and their dead exports removed with the 2026-09-11 pivot: the
|
||||||
if (tree.nodes.some((n) => n.id === node.id)) {
|
// tree carries structure ONLY — no progress fields exist to mutate
|
||||||
throw new PlanTreeError(`Duplicate node id "${node.id}" — a node with that id already exists in the tree.`);
|
// (docs/probe-teach-split.md §3). Restructuring is a plan_init rewrite.
|
||||||
}
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
+66
-45
@@ -107,47 +107,59 @@ export function quizInputProblems(input: {
|
|||||||
return problems;
|
return problems;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const quizInputObject = z
|
// The SHARED quiz authoring fields — everything except `nodeId`. The probe
|
||||||
.object({
|
// and teach agents use DIFFERENT tools under the same name (docs/probe-teach-
|
||||||
question: z.string().describe("The single quiz question to ask. Ask exactly one question per tool call."),
|
// split.md §1): the prober's quiz has NO nodeId field at all (there is no
|
||||||
options: z
|
// map to position on), the teacher's adds it (the position signal — the
|
||||||
.array(optionSchema)
|
// per-session tree validation chains on top in tools.ts). Grading rules
|
||||||
.min(2)
|
// are identical, single-sourced here.
|
||||||
.describe(
|
export const quizInputBase = z.object({
|
||||||
"The answer options (2 or more). Options only — there is no free-text mode. " +
|
question: z.string().describe("The single quiz question to ask. Ask exactly one question per tool call."),
|
||||||
"Give each option a stable `value`; you reference the correct one by that value in correctAnswer. " +
|
options: z
|
||||||
"Never add your own 'I don't know' option — that choice is added for you.",
|
.array(optionSchema)
|
||||||
),
|
.min(2)
|
||||||
multiSelect: z
|
.describe(
|
||||||
.boolean()
|
"The answer options (2 or more). Options only — there is no free-text mode. " +
|
||||||
.optional()
|
"Give each option a stable `value`; you reference the correct one by that value in correctAnswer. " +
|
||||||
.describe("Set to true when more than one option is correct and the learner must select all of them."),
|
"Never add your own 'I don't know' option — that choice is added for you.",
|
||||||
correctAnswer: z
|
),
|
||||||
.union([z.string(), z.array(z.string())])
|
multiSelect: z
|
||||||
.describe(
|
.boolean()
|
||||||
"REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. " +
|
.optional()
|
||||||
'Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); ' +
|
.describe("Set to true when more than one option is correct and the learner must select all of them."),
|
||||||
"the learner is only correct if their selection matches this set exactly. " +
|
correctAnswer: z
|
||||||
"Always pass the value, not a position number — this is self-checking and prevents miscounting. " +
|
.union([z.string(), z.array(z.string())])
|
||||||
"A value that matches no option fails validation and the call is returned to you to fix.",
|
.describe(
|
||||||
),
|
"REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. " +
|
||||||
explanation: z
|
'Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); ' +
|
||||||
.string()
|
"the learner is only correct if their selection matches this set exactly. " +
|
||||||
.describe(
|
"Always pass the value, not a position number — this is self-checking and prevents miscounting. " +
|
||||||
"REQUIRED. Explanation revealed AFTER the learner answers (shown whether they got it right or wrong). " +
|
"A value that matches no option fails validation and the call is returned to you to fix.",
|
||||||
"Use it to reinforce why the correct answer is correct.",
|
),
|
||||||
),
|
explanation: z
|
||||||
details: z.string().optional().describe("Optional extra context or instructions shown under the question."),
|
.string()
|
||||||
nodeId: z
|
.describe(
|
||||||
.string()
|
"REQUIRED. Explanation revealed AFTER the learner answers (shown whether they got it right or wrong). " +
|
||||||
.optional()
|
"Use it to reinforce why the correct answer is correct.",
|
||||||
.describe(
|
),
|
||||||
"Optional plan-tree node id this question assesses — must EXACTLY match one of this session's " +
|
details: z.string().optional().describe("Optional extra context or instructions shown under the question."),
|
||||||
"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 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
|
// The cross-field authoring contract (the Gate-3 hardening): a quiz whose
|
||||||
// correctAnswer can't be graded NEVER reaches the learner. The SDK validates
|
// 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
|
// 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 ⇔
|
// the teacher retries with a fixed quiz in the same turn. Schema-valid ⇔
|
||||||
// gradeable: the rules are exactly what the grading path accepts.
|
// gradeable: the rules are exactly what the grading path accepts.
|
||||||
//
|
//
|
||||||
// NOTE the split: the raw object (pre-refinement) is exported for
|
// NOTE the split: the raw objects (pre-refinement) are exported for
|
||||||
// composition — tools.ts questionToolsFor chains the per-session plan-nodeId
|
// composition — tools.ts teachQuestionTools chains the per-session
|
||||||
// validation (it reads the live chat record, which this core never touches)
|
// plan-nodeId validation (it reads the live chat record, which this core
|
||||||
// on top of this shared refinement.
|
// never touches) on top of this shared refinement, and the PROBE quiz
|
||||||
|
// (no nodeId at all) refines quizInputBase directly.
|
||||||
export const quizInputSchema = quizInputObject
|
export const quizInputSchema = quizInputObject
|
||||||
.superRefine((input, ctx) => {
|
.superRefine((input, ctx) => {
|
||||||
for (const problem of quizInputProblems(input)) {
|
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
|
export const askInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
// No `mode` param — the pi original has none: the mode is inferred from
|
// No `mode` param — the pi original has none: the mode is inferred from
|
||||||
|
|||||||
+12
-1
@@ -1,5 +1,13 @@
|
|||||||
import type { UIMessage } from "ai";
|
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";
|
import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question-core.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,6 +25,9 @@ import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question
|
|||||||
export type TeacherUITools = {
|
export type TeacherUITools = {
|
||||||
quiz: { input: QuizToolInput; output: QuestionAnswerData };
|
quiz: { input: QuizToolInput; output: QuestionAnswerData };
|
||||||
ask_user_question: { input: AskToolInput; 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 };
|
research: { input: ResearchToolInput; output: string };
|
||||||
demo: { input: DemoToolInput; output: string };
|
demo: { input: DemoToolInput; output: string };
|
||||||
visual: { input: VisualToolInput; output: string };
|
visual: { input: VisualToolInput; output: string };
|
||||||
|
|||||||
+33
-16
@@ -1,12 +1,12 @@
|
|||||||
import { createAgentUIStreamResponse, InvalidToolInputError, NoSuchToolError, type LanguageModelUsage, type UIMessage } from "ai";
|
import { createAgentUIStreamResponse, InvalidToolInputError, NoSuchToolError, type LanguageModelUsage, type UIMessage } from "ai";
|
||||||
import { createTeacherAgent, type TeacherAgent } from "../agent/teacher-agent.ts";
|
import { createProberAgent, createTeacherAgent, type TeachingAgent } from "../agent/teacher-agent.ts";
|
||||||
import { createJobRunner } from "../agent/subagents/runner.ts";
|
import { createJobRunner } from "../agent/subagents/runner.ts";
|
||||||
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||||
import { createTeacherTools } from "../agent/tools.ts";
|
import { MODELS } from "../../config.ts";
|
||||||
import { MODELS } from "../../config.ts";import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
|
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
|
||||||
import { stores } from "./stores.ts";
|
import { stores } from "./stores.ts";
|
||||||
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||||
import { buildTeacherContext, persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
|
import { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
|
||||||
import type { CallUsage } from "../core/contracts.ts";
|
import type { CallUsage } from "../core/contracts.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,7 +48,7 @@ setTurnRunner(async (chatId, messages) => {
|
|||||||
});
|
});
|
||||||
setJobRespawn((jobId) => void jobRunner.runJob(jobId));
|
setJobRespawn((jobId) => void jobRunner.runJob(jobId));
|
||||||
|
|
||||||
export async function handleChatRequest(req: Request, agent?: TeacherAgent): Promise<Response> {
|
export async function handleChatRequest(req: Request, agent?: TeachingAgent): Promise<Response> {
|
||||||
let body: { chatId?: unknown; messages?: unknown };
|
let body: { chatId?: unknown; messages?: unknown };
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
@@ -111,21 +111,38 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
// AGENT CONSTRUCTION BRANCH (docs/probe-teach-split.md §2): the transcript
|
||||||
return await createAgentUIStreamResponse({
|
// is the seam — a probe_done tool part (any state) means the probe is
|
||||||
agent:
|
// concluded: build the TEACHER (planning + the full toolset). Otherwise
|
||||||
agent ??
|
// the session is still probing: build the PROBER (quiz/ask + probe_done).
|
||||||
createTeacherAgent(
|
// Both share this whole composition (write-ahead persist, liveness,
|
||||||
undefined,
|
// steers, usage, extraction) — only the agent differs.
|
||||||
createTeacherTools({
|
const teaching = outgoing.some((m) => m.parts.some((p) => p.type === "tool-probe_done"));
|
||||||
|
const modelId = teaching ? MODELS.teacher.model : MODELS.prober.model;
|
||||||
|
const constructed: TeachingAgent = teaching
|
||||||
|
? createTeacherAgent(
|
||||||
|
{
|
||||||
chatId,
|
chatId,
|
||||||
chatStore: stores.chatStore,
|
chatStore: stores.chatStore,
|
||||||
jobStore: stores.jobStore,
|
jobStore: stores.jobStore,
|
||||||
spawn: (job, input) => void jobRunner.runJob(job.id, input),
|
spawn: (job, input) => void jobRunner.runJob(job.id, input),
|
||||||
}),
|
},
|
||||||
buildTeacherContext(stores.graphStore, chatId),
|
undefined,
|
||||||
collectUsage,
|
collectUsage,
|
||||||
),
|
)
|
||||||
|
: createProberAgent(
|
||||||
|
{
|
||||||
|
chatId,
|
||||||
|
chatStore: stores.chatStore,
|
||||||
|
deliver: (steer) => void deliverJobResult(steer),
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
collectUsage,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await createAgentUIStreamResponse({
|
||||||
|
agent: agent ?? constructed,
|
||||||
uiMessages: outgoing,
|
uiMessages: outgoing,
|
||||||
// Tool-call validation failures are messages OUR schemas authored (the
|
// Tool-call validation failures are messages OUR schemas authored (the
|
||||||
// quiz authoring contract, unknown tools) — safe and useful in the
|
// quiz authoring contract, unknown tools) — safe and useful in the
|
||||||
@@ -152,7 +169,7 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro
|
|||||||
id: turnUsageId,
|
id: turnUsageId,
|
||||||
messageId: response.id,
|
messageId: response.id,
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
model: MODELS.teacher.model,
|
model: modelId,
|
||||||
steps,
|
steps,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-23
@@ -110,15 +110,28 @@ function delay(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
function jobIdOf(steer: SteerMessage): string {
|
/** The job a steer belongs to, if it is a job steer (result/abort);
|
||||||
return (steer.metadata as { subagentResult: { jobId: string } }).subagentResult.jobId;
|
* probe-handoff steers have no job — null. */
|
||||||
|
function jobIdOf(steer: SteerMessage): string | null {
|
||||||
|
const meta = steer.metadata as { subagentResult?: { jobId: string } };
|
||||||
|
return meta.subagentResult?.jobId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Finalize a delivered steer's job bookkeeping — job steers only. */
|
||||||
* Hand a finished job's steer to the pipeline. Resolves when the wake turn
|
function finalizeSteer(steer: SteerMessage): void {
|
||||||
* has completed (and the job status flipped); rejects when delivery fails
|
const jobId = jobIdOf(steer);
|
||||||
* unrecoverably. Never rejects for queueing itself.
|
if (jobId) finalizeJob(jobId);
|
||||||
*/
|
}
|
||||||
|
|
||||||
|
/** Fail a steer's job on unrecoverable delivery failure — job steers only. */
|
||||||
|
function failSteer(steer: SteerMessage, err: Error): void {
|
||||||
|
const jobId = jobIdOf(steer);
|
||||||
|
if (jobId) failJob(jobId, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue ANY steer (result, abort, probe handoff) through the pipeline.
|
||||||
|
* Resolves when the wake turn has completed; rejects when delivery fails
|
||||||
|
* unrecoverably. Never rejects for queueing itself. */
|
||||||
export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const s = state();
|
const s = state();
|
||||||
@@ -155,13 +168,12 @@ export function claimSteers(chatId: string): SteerMessage[] {
|
|||||||
if (!record) return []; // the handler validated the chat; defensive only
|
if (!record) return []; // the handler validated the chat; defensive only
|
||||||
const claimed: SteerMessage[] = [];
|
const claimed: SteerMessage[] = [];
|
||||||
for (const entry of queue) {
|
for (const entry of queue) {
|
||||||
const jobId = jobIdOf(entry.steer);
|
if (steerDelivered(record.messages, entry.steer.id)) {
|
||||||
if (steerDelivered(record.messages, jobId)) {
|
|
||||||
// A wake already landed it — just finish the bookkeeping.
|
// A wake already landed it — just finish the bookkeeping.
|
||||||
finalizeJob(jobId);
|
finalizeSteer(entry.steer);
|
||||||
} else {
|
} else {
|
||||||
claimed.push(entry.steer);
|
claimed.push(entry.steer);
|
||||||
finalizeJob(jobId); // delivery is now the client turn's job
|
finalizeSteer(entry.steer); // delivery is now the client turn's job
|
||||||
}
|
}
|
||||||
entry.resolve();
|
entry.resolve();
|
||||||
}
|
}
|
||||||
@@ -229,10 +241,13 @@ export function abortJob(jobId: string): AbortOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function chatIdOf(steer: SteerMessage): string {
|
function chatIdOf(steer: SteerMessage): string {
|
||||||
|
// The probe handoff carries its chat directly (no job record exists).
|
||||||
|
const handoff = (steer.metadata as { probeHandoff?: { chatId: string } }).probeHandoff;
|
||||||
|
if (handoff) return handoff.chatId;
|
||||||
// The chat is resolvable from the job record; the steer's metadata
|
// The chat is resolvable from the job record; the steer's metadata
|
||||||
// carries jobId, which indexes the store.
|
// carries jobId, which indexes the store.
|
||||||
const jobId = jobIdOf(steer);
|
const jobId = jobIdOf(steer);
|
||||||
const job = stores.jobStore.getJob(jobId);
|
const job = jobId ? stores.jobStore.getJob(jobId) : undefined;
|
||||||
return job?.chatId ?? "";
|
return job?.chatId ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,21 +268,21 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entry = queue[0]!;
|
const entry = queue[0]!;
|
||||||
const jobId = jobIdOf(entry.steer);
|
const steerId = entry.steer.id;
|
||||||
|
|
||||||
// Gone chat: nothing to deliver into.
|
// Gone chat: nothing to deliver into.
|
||||||
const record = stores.chatStore.getChat(chatId);
|
const record = stores.chatStore.getChat(chatId);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
queue.shift();
|
queue.shift();
|
||||||
entry.reject(new Error(`chat ${chatId} not found — cannot deliver job ${jobId}`));
|
entry.reject(new Error(`chat ${chatId} not found — cannot deliver steer ${steerId}`));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent delivery: the transcript already carries this steer
|
// Idempotent delivery: the transcript already carries this steer
|
||||||
// (a previous wake completed, or a duplicate deliver raced in).
|
// (a previous wake completed, or a duplicate deliver raced in).
|
||||||
if (steerDelivered(record.messages, jobId)) {
|
if (steerDelivered(record.messages, steerId)) {
|
||||||
queue.shift();
|
queue.shift();
|
||||||
finalizeJob(jobId);
|
finalizeSteer(entry.steer);
|
||||||
entry.resolve();
|
entry.resolve();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -280,7 +295,7 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
const fresh = stores.chatStore.getChat(chatId);
|
const fresh = stores.chatStore.getChat(chatId);
|
||||||
if (!fresh) {
|
if (!fresh) {
|
||||||
queue.shift();
|
queue.shift();
|
||||||
entry.reject(new Error(`chat ${chatId} not found — cannot deliver job ${jobId}`));
|
entry.reject(new Error(`chat ${chatId} not found — cannot deliver steer ${steerId}`));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,16 +304,16 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
if (!s.turnRunner) {
|
if (!s.turnRunner) {
|
||||||
queue.shift();
|
queue.shift();
|
||||||
const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)");
|
const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)");
|
||||||
failJob(jobId, err);
|
failSteer(entry.steer, err);
|
||||||
entry.reject(err);
|
entry.reject(err);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A client continuation may have COALESCED this steer (claimSteers) while
|
// A client continuation may have COALESCED this steer (claimSteers) while
|
||||||
// the gates were closed — re-check before firing: exactly one delivery.
|
// the gates were closed — re-check before firing: exactly one delivery.
|
||||||
if (steerDelivered(fresh.messages, jobId)) {
|
if (steerDelivered(fresh.messages, steerId)) {
|
||||||
queue.shift();
|
queue.shift();
|
||||||
finalizeJob(jobId);
|
finalizeSteer(entry.steer);
|
||||||
entry.resolve();
|
entry.resolve();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -306,7 +321,7 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
// Fire the server-side teacher turn. A 409 = a client turn raced
|
// Fire the server-side teacher turn. A 409 = a client turn raced
|
||||||
// in between idle and firing — back off and retry the same steer.
|
// in between idle and firing — back off and retry the same steer.
|
||||||
const status = await s.turnRunner(chatId, [...fresh.messages, entry.steer]).catch((err: unknown) => {
|
const status = await s.turnRunner(chatId, [...fresh.messages, entry.steer]).catch((err: unknown) => {
|
||||||
console.error(`[job ${jobId}] wake turn crashed:`, err);
|
console.error(`[steer ${steerId}] wake turn crashed:`, err);
|
||||||
return -1;
|
return -1;
|
||||||
});
|
});
|
||||||
if (status === 409) {
|
if (status === 409) {
|
||||||
@@ -317,11 +332,11 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
|
|
||||||
queue.shift();
|
queue.shift();
|
||||||
if (status === 200) {
|
if (status === 200) {
|
||||||
finalizeJob(jobId);
|
finalizeSteer(entry.steer);
|
||||||
entry.resolve();
|
entry.resolve();
|
||||||
} else {
|
} else {
|
||||||
const err = new Error(`wake turn failed with status ${status}`);
|
const err = new Error(`wake turn failed with status ${status}`);
|
||||||
failJob(jobId, err);
|
failSteer(entry.steer, err);
|
||||||
entry.reject(err);
|
entry.reject(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* implementing. Each takes its dependencies explicitly (storage ports, the
|
* implementing. Each takes its dependencies explicitly (storage ports, the
|
||||||
* job runner) — no module here imports an adapter.
|
* job runner) — no module here imports an adapter.
|
||||||
*
|
*
|
||||||
* buildTeacherContext — graph → instructions block (the W8 read channel)
|
|
||||||
* persistTurn — the canonical state write (client + agent messages)
|
* persistTurn — the canonical state write (client + agent messages)
|
||||||
* triggerExtraction — the graph write channel: fires the extraction
|
* triggerExtraction — the graph write channel: fires the extraction
|
||||||
* pipeline (the sole graph writer) once a chat
|
* pipeline (the sole graph writer) once a chat
|
||||||
@@ -18,28 +17,11 @@
|
|||||||
|
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import { withPlanBlocks } from "../core/plan-blocks.ts";
|
import { withPlanBlocks } from "../core/plan-blocks.ts";
|
||||||
import { buildGraphContext } from "../core/graph-context.ts";
|
import { pendingQuizAnswers } from "../core/graph.ts";
|
||||||
import { getGraph, pendingQuizAnswers } from "../core/graph.ts";
|
|
||||||
import type { ChatStore, GraphStore } from "../core/ports.ts";
|
import type { ChatStore, GraphStore } from "../core/ports.ts";
|
||||||
import { EXTRACTION } from "../../config.ts";
|
import { EXTRACTION } from "../../config.ts";
|
||||||
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||||
|
|
||||||
/**
|
|
||||||
* W8 (vision §1 "the graph as session context"): the learner's knowledge
|
|
||||||
* graph rides along as part of the teacher's instructions. Built per request
|
|
||||||
* — stateless, and self-updating as the graph grows mid-session. A read
|
|
||||||
* failure must never take the lesson down: the block is context, not a
|
|
||||||
* dependency.
|
|
||||||
*/
|
|
||||||
export function buildTeacherContext(graphStore: GraphStore, chatId: string): string | null {
|
|
||||||
try {
|
|
||||||
return buildGraphContext(getGraph(graphStore));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[chat ${chatId}] graph context build failed:`, err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The canonical state write: the client owns the message list between turns
|
* The canonical state write: the client owns the message list between turns
|
||||||
* (useChat); this persists client messages + the agent response. Persists
|
* (useChat); this persists client messages + the agent response. Persists
|
||||||
|
|||||||
+6
-5
@@ -14,7 +14,7 @@ import type { ChatStore } from "../core/ports.ts";
|
|||||||
import type { ChatRecord, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
import type { ChatRecord, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import { findPendingToolCall } from "../core/pending.ts";
|
import { findPendingToolCall } from "../core/pending.ts";
|
||||||
import { phaseOfTree } from "../core/phases.ts";
|
import { phaseAt3 } from "../core/phases.ts";
|
||||||
|
|
||||||
function chatFile(id: string): string {
|
function chatFile(id: string): string {
|
||||||
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`);
|
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`);
|
||||||
@@ -58,10 +58,11 @@ function listChats(): ChatSummary[] {
|
|||||||
...summary,
|
...summary,
|
||||||
messageCount: messages.length,
|
messageCount: messages.length,
|
||||||
// Record-derived status for the session picker: phase from the
|
// Record-derived status for the session picker: phase from the
|
||||||
// stored plan tree, pending question from the transcript's last
|
// transcript's explicit markers (probe_done / plan_init — the
|
||||||
// unanswered quiz/ask part. Liveness (busy) is NOT record-derived —
|
// event-driven phases, docs/probe-teach-split.md §4), pending
|
||||||
// the API route merges it (contracts.ts ChatListItem).
|
// question from the last unanswered quiz/ask part. Liveness (busy)
|
||||||
phase: phaseOfTree(tree),
|
// is NOT record-derived — the API route merges it (ChatListItem).
|
||||||
|
phase: phaseAt3(messages),
|
||||||
pendingQuestion: findPendingToolCall(messages) !== null,
|
pendingQuestion: findPendingToolCall(messages) !== null,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ after(() => {
|
|||||||
describe("teacher chat flow (scripted model)", () => {
|
describe("teacher chat flow (scripted model)", () => {
|
||||||
it("quiz tool call persists as a pending part; the answer continues the lesson", async () => {
|
it("quiz tool call persists as a pending part; the answer continues the lesson", async () => {
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
const agent = createTeacherAgent(scriptedModel(calls));
|
const agent = createTeacherAgent(undefined, scriptedModel(calls));
|
||||||
const chat = chatStore.createChat("Test: solar system");
|
const chat = chatStore.createChat("Test: solar system");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
// 'input-streaming', which the stream processor sets from the
|
// 'input-streaming', which the stream processor sets from the
|
||||||
// tool-input-start chunk. If a transport change ever stopped emitting
|
// tool-input-start chunk. If a transport change ever stopped emitting
|
||||||
// it, the indicator would silently die — pin the protocol.
|
// it, the indicator would silently die — pin the protocol.
|
||||||
const agent = createTeacherAgent(scriptedModel([]));
|
const agent = createTeacherAgent(undefined, scriptedModel([]));
|
||||||
const chat = chatStore.createChat("Test: protocol pin");
|
const chat = chatStore.createChat("Test: protocol pin");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
|
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
|
||||||
@@ -214,7 +214,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
|
|
||||||
it("persists even when the stream is aborted (resume keeps partial state)", async () => {
|
it("persists even when the stream is aborted (resume keeps partial state)", async () => {
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
const agent = createTeacherAgent(scriptedModel(calls));
|
const agent = createTeacherAgent(undefined, scriptedModel(calls));
|
||||||
const chat = chatStore.createChat("Test: abort");
|
const chat = chatStore.createChat("Test: abort");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const agent = createTeacherAgent(retryModel);
|
const agent = createTeacherAgent(undefined, retryModel);
|
||||||
const chat = chatStore.createChat("Test: bad quiz retry");
|
const chat = chatStore.createChat("Test: bad quiz retry");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
|
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
|
||||||
@@ -331,7 +331,7 @@ describe("chat store", () => {
|
|||||||
describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
||||||
it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => {
|
it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => {
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
const agent = createTeacherAgent(scriptedModel(calls));
|
const agent = createTeacherAgent(undefined, scriptedModel(calls));
|
||||||
const chat = chatStore.createChat("Test: turn guard");
|
const chat = chatStore.createChat("Test: turn guard");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
|||||||
|
|
||||||
it("write-ahead: the learner's answer is durable BEFORE the turn finishes", async () => {
|
it("write-ahead: the learner's answer is durable BEFORE the turn finishes", async () => {
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
const agent = createTeacherAgent(scriptedModel(calls));
|
const agent = createTeacherAgent(undefined, scriptedModel(calls));
|
||||||
const chat = chatStore.createChat("Test: write-ahead");
|
const chat = chatStore.createChat("Test: write-ahead");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
@@ -408,14 +408,14 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
|||||||
// A text part with no text: fails UIMessage validation before the stream
|
// A text part with no text: fails UIMessage validation before the stream
|
||||||
// exists — the path where no onFinish will ever run.
|
// exists — the path where no onFinish will ever run.
|
||||||
const bad = [{ id: "m1", role: "user", parts: [{ type: "text" }] }];
|
const bad = [{ id: "m1", role: "user", parts: [{ type: "text" }] }];
|
||||||
const res = await handleChatRequest(post(chat.id, bad), createTeacherAgent(scriptedModel([])));
|
const res = await handleChatRequest(post(chat.id, bad), createTeacherAgent(undefined, scriptedModel([])));
|
||||||
assert.equal(res.status, 500, "turn-start failure is a 500, not a hang");
|
assert.equal(res.status, 500, "turn-start failure is a 500, not a hang");
|
||||||
assert.equal(isTurnRunning(chat.id), false, "registry cleared when the stream never started");
|
assert.equal(isTurnRunning(chat.id), false, "registry cleared when the stream never started");
|
||||||
|
|
||||||
// And the chat is NOT locked: the next (valid) turn is accepted.
|
// And the chat is NOT locked: the next (valid) turn is accepted.
|
||||||
const ok = await handleChatRequest(
|
const ok = await handleChatRequest(
|
||||||
post(chat.id, [{ id: "m1", role: "user", parts: [{ type: "text", text: "teach me" }] }]),
|
post(chat.id, [{ id: "m1", role: "user", parts: [{ type: "text", text: "teach me" }] }]),
|
||||||
createTeacherAgent(scriptedModel([])),
|
createTeacherAgent(undefined, scriptedModel([])),
|
||||||
);
|
);
|
||||||
assert.equal(ok.status, 200);
|
assert.equal(ok.status, 200);
|
||||||
await drain(ok);
|
await drain(ok);
|
||||||
|
|||||||
@@ -732,7 +732,9 @@ describe("demo/visual job runners", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
createTeacherAgent(
|
createTeacherAgent(
|
||||||
|
undefined,
|
||||||
teacher,
|
teacher,
|
||||||
|
undefined,
|
||||||
createTeacherTools({
|
createTeacherTools({
|
||||||
chatId: chat.id,
|
chatId: chat.id,
|
||||||
chatStore,
|
chatStore,
|
||||||
|
|||||||
@@ -1,421 +0,0 @@
|
|||||||
/**
|
|
||||||
* Graph-as-context tests (vision §1 "the graph as session context", 2026-09-02
|
|
||||||
* redesign vocabulary: follows edges, no node kinds).
|
|
||||||
*
|
|
||||||
* renderGraphContext — pure: empty / small / cap-truncation rendering
|
|
||||||
* buildGraphContext — per-request entry point (derived-graph → block)
|
|
||||||
* injection plumbing — chat-flow style: the teacher's instructions carry
|
|
||||||
* the block (MockLanguageModelV4 captures the prompt)
|
|
||||||
* cross-chat edges — the merger (mergeFindings) appends one ASSERTION
|
|
||||||
* line per session; reads merge with unioned
|
|
||||||
* message-granular provenance
|
|
||||||
*
|
|
||||||
* Ledger isolation: NONE needed for the merge tests — the merger's ledger is
|
|
||||||
* injectable (in-memory), so the live data/graph files are only ever READ
|
|
||||||
* here (tests/graph.test.ts owns the backup-and-clear dance; both suites run
|
|
||||||
* concurrently as separate processes). Chat fixtures are real ChatRecords,
|
|
||||||
* created and deleted by this suite like tests/chat-flow.test.ts does.
|
|
||||||
*/
|
|
||||||
|
|
||||||
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 { MockLanguageModelV4, simulateReadableStream } from "ai/test";
|
|
||||||
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
|
|
||||||
import type {
|
|
||||||
AuditResult,
|
|
||||||
GraphEdge,
|
|
||||||
GraphNode,
|
|
||||||
LedgerEvent,
|
|
||||||
NodeMapping,
|
|
||||||
SessionCheckpoint,
|
|
||||||
} from "../src/core/contracts.ts";
|
|
||||||
import type { LedgerEventKind } from "../src/core/contracts.ts";
|
|
||||||
import { getGraph, mergeFindings } from "../src/core/graph.ts";
|
|
||||||
import type { GraphStore } from "../src/core/ports.ts";
|
|
||||||
import {
|
|
||||||
buildGraphContext,
|
|
||||||
GRAPH_CONTEXT_HEADER,
|
|
||||||
MAX_CONTEXT_NODES,
|
|
||||||
renderGraphContext,
|
|
||||||
} from "../src/core/graph-context.ts";
|
|
||||||
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
|
||||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
|
||||||
import { graphStore } from "../src/store/graph.ts";
|
|
||||||
import { PATHS } from "../config.ts";
|
|
||||||
|
|
||||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function node(id: string, statement: string, over: Partial<GraphNode> = {}): GraphNode {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: statement,
|
|
||||||
statement,
|
|
||||||
aliases: [],
|
|
||||||
createdAt: "2026-08-22T00:00:00.000Z",
|
|
||||||
provenance: [],
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function edge(from: string, to: string): GraphEdge {
|
|
||||||
return { from, to, type: "follows", provenance: [{ chatId: "chat_x", messageId: "m1" }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Latest-quiz-outcome map: nodeId → quiz event kind (the raw event fact). */
|
|
||||||
function outcomes(...entries: [string, LedgerEventKind][]): Map<string, LedgerEventKind> {
|
|
||||||
return new Map(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── renderGraphContext (pure) ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("renderGraphContext (pure)", () => {
|
|
||||||
it("returns null for an empty graph (inject nothing)", () => {
|
|
||||||
assert.equal(renderGraphContext([], [], outcomes()), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a header, outcome-grouped node lines (hotspots first), and chained follows edges", () => {
|
|
||||||
const nodes = [
|
|
||||||
node("n_a", "Chain rule intuition"),
|
|
||||||
node("n_b", "Quotient rule"),
|
|
||||||
node("n_c", "Product rule"),
|
|
||||||
node("n_d", "Composite functions"),
|
|
||||||
];
|
|
||||||
const edges = [edge("n_a", "n_b"), edge("n_b", "n_c"), edge("n_d", "n_a")];
|
|
||||||
const oc = outcomes(
|
|
||||||
["n_a", "correct"],
|
|
||||||
["n_b", "miss"],
|
|
||||||
["n_d", "dont-know"],
|
|
||||||
);
|
|
||||||
const out = renderGraphContext(nodes, edges, oc)!;
|
|
||||||
|
|
||||||
assert.ok(out.includes(GRAPH_CONTEXT_HEADER), "header line present");
|
|
||||||
assert.match(out, /4 nodes, 3 follows edges/);
|
|
||||||
|
|
||||||
// Node lines: "• [outcome] statement (id)".
|
|
||||||
assert.ok(out.includes("• [miss] Quotient rule (n_b)"));
|
|
||||||
assert.ok(out.includes("• [correct] Chain rule intuition (n_a)"));
|
|
||||||
assert.ok(out.includes("• [dont-know] Composite functions (n_d)"));
|
|
||||||
assert.ok(out.includes("• [never probed] Product rule (n_c)"));
|
|
||||||
|
|
||||||
// Groups in the decided order: hotspots, correct, never probed.
|
|
||||||
const order = ["Hotspots", "Answered correctly", "Never probed"].map((g) => out.indexOf(g));
|
|
||||||
assert.deepEqual([...order].sort((a, b) => a - b), order, "groups appear in priority order");
|
|
||||||
|
|
||||||
// Edges merged into chains: d → a → b → c (one line, three arrows).
|
|
||||||
assert.ok(out.includes("Composite functions → Chain rule intuition → Quotient rule → Product rule"));
|
|
||||||
assert.ok(out.includes("Follows (premise → derivation)"));
|
|
||||||
assert.ok(out.includes("===== end of knowledge graph ====="), "clear end delimiter");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders branching edges as sibling chains, not one false chain", () => {
|
|
||||||
const nodes = [node("n_a", "A"), node("n_b", "B"), node("n_c", "C")];
|
|
||||||
const out = renderGraphContext(nodes, [edge("n_a", "n_b"), edge("n_a", "n_c")], outcomes())!;
|
|
||||||
assert.ok(out.includes("A → B"));
|
|
||||||
assert.ok(out.includes("A → C"));
|
|
||||||
assert.ok(!out.includes("B → C"), "no edge was invented between siblings");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps at MAX_CONTEXT_NODES: hotspots + their 1-hop follows neighborhood only, with a truncation note", () => {
|
|
||||||
// 3 hotspots, each with a premise and a derivation (1-hop), plus a
|
|
||||||
// long tail of disconnected candidates that must drop out.
|
|
||||||
const nodes: GraphNode[] = [];
|
|
||||||
const edges: GraphEdge[] = [];
|
|
||||||
const oc = new Map<string, LedgerEventKind>();
|
|
||||||
const hotspot = (i: number) => {
|
|
||||||
const h = `n_h${i}`;
|
|
||||||
const pre = `n_p${i}`;
|
|
||||||
const dep = `n_d${i}`;
|
|
||||||
nodes.push(node(pre, `Premise ${i}`), node(h, `Hotspot ${i}`), node(dep, `Derivation ${i}`));
|
|
||||||
edges.push(edge(pre, h), edge(h, dep));
|
|
||||||
oc.set(pre, "correct");
|
|
||||||
oc.set(h, "miss");
|
|
||||||
};
|
|
||||||
hotspot(1);
|
|
||||||
hotspot(2);
|
|
||||||
hotspot(3);
|
|
||||||
for (let i = 0; i < MAX_CONTEXT_NODES; i++) {
|
|
||||||
const id = `n_f${i}`;
|
|
||||||
nodes.push(node(id, `Far away ${i}`));
|
|
||||||
}
|
|
||||||
assert.ok(nodes.length > MAX_CONTEXT_NODES, "fixture exceeds the cap");
|
|
||||||
|
|
||||||
const out = renderGraphContext(nodes, edges, oc)!;
|
|
||||||
assert.ok(out.includes("• [miss] Hotspot 1 (n_h1)"), "hotspots stay");
|
|
||||||
assert.ok(out.includes("• [correct] Premise 1 (n_p1)"), "1-hop premises stay");
|
|
||||||
assert.ok(out.includes("• [never probed] Derivation 1 (n_d1)"), "1-hop derivations stay");
|
|
||||||
assert.ok(!out.includes("Far away"), "disconnected nodes drop out");
|
|
||||||
assert.match(out, new RegExp(`showing 9 of ${nodes.length} nodes`), "truncation is noted");
|
|
||||||
assert.ok(out.includes(", 6 follows edges"), "edges among the selected nodes stay");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the full graph at exactly the cap (no truncation note)", () => {
|
|
||||||
const nodes = Array.from({ length: MAX_CONTEXT_NODES }, (_, i) => node(`n_${i}`, `Node ${i}`));
|
|
||||||
const out = renderGraphContext(nodes, [], outcomes())!;
|
|
||||||
assert.ok(out.includes(`• [never probed] Node ${MAX_CONTEXT_NODES - 1}`));
|
|
||||||
assert.ok(!out.includes("showing"), "at the cap nothing is dropped");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when a too-large graph has no hotspots at all", () => {
|
|
||||||
const nodes = Array.from({ length: MAX_CONTEXT_NODES + 1 }, (_, i) => node(`n_${i}`, `Node ${i}`));
|
|
||||||
assert.equal(renderGraphContext(nodes, [], outcomes()), null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── buildGraphContext (the per-request entry point) ────────────────────────
|
|
||||||
|
|
||||||
describe("buildGraphContext", () => {
|
|
||||||
it("returns null for an empty derived graph", () => {
|
|
||||||
assert.equal(buildGraphContext({ nodes: [], edges: [], events: [] }), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives latest quiz outcomes from events and renders the block", () => {
|
|
||||||
const nodes = [node("n_a", "Solo node"), node("n_b", "Checked node")];
|
|
||||||
const events: LedgerEvent[] = [
|
|
||||||
{ nodeId: "n_b", kind: "correct", chatId: "c1", at: "2026-08-22T00:00:00.000Z", ref: "r1" },
|
|
||||||
];
|
|
||||||
const out = buildGraphContext({ nodes, edges: [], events })!;
|
|
||||||
assert.ok(out.includes(GRAPH_CONTEXT_HEADER));
|
|
||||||
assert.ok(out.includes("• [correct] Checked node (n_b)"), "correct → answered correctly");
|
|
||||||
assert.ok(out.includes("• [never probed] Solo node (n_a)"), "no events → never probed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads the live ledgers without throwing (read-only integration)", () => {
|
|
||||||
// Other suites (graph.test.ts) mutate data/graph concurrently, so only
|
|
||||||
// assert the shape of the result, never its content. Structural note:
|
|
||||||
// the caller now hands in getGraph(graphStore) — the entry point is pure.
|
|
||||||
const out = buildGraphContext(getGraph(graphStore));
|
|
||||||
assert.ok(out === null || out.includes(GRAPH_CONTEXT_HEADER));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── injection plumbing (chat-flow style) ───────────────────────────────────
|
|
||||||
|
|
||||||
function usage() {
|
|
||||||
return {
|
|
||||||
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
outputTokens: { total: 5, text: 5, reasoning: 0 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A model that records every prompt it is given and replies with plain text. */
|
|
||||||
function capturingModel(prompts: string[]) {
|
|
||||||
return new MockLanguageModelV4({
|
|
||||||
doStream: async (options) => {
|
|
||||||
prompts.push(JSON.stringify(options.prompt));
|
|
||||||
const chunks: LanguageModelV4StreamPart[] = [
|
|
||||||
{ type: "stream-start", warnings: [] },
|
|
||||||
{ type: "text-start", id: "t1" },
|
|
||||||
{ type: "text-delta", id: "t1", delta: "Where shall we begin?" },
|
|
||||||
{ type: "text-end", id: "t1" },
|
|
||||||
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: usage() },
|
|
||||||
];
|
|
||||||
return { stream: simulateReadableStream({ chunks, initialDelayInMs: 0, chunkDelayInMs: 0 }) };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function drain(res: Response): Promise<void> {
|
|
||||||
if (res.status !== 200) {
|
|
||||||
assert.fail(`expected 200, got ${res.status}: ${await res.text()}`);
|
|
||||||
}
|
|
||||||
await res.text(); // consumes the SSE stream to completion (fires onFinish)
|
|
||||||
}
|
|
||||||
|
|
||||||
function post(chatId: string, messages: unknown[]): Request {
|
|
||||||
return new Request("http://test/api/chat", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ chatId, messages }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdChats: string[] = [];
|
|
||||||
after(() => {
|
|
||||||
for (const id of createdChats) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(path.join(PATHS.chats, `${id}.json`));
|
|
||||||
} catch {
|
|
||||||
/* already gone */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("graph-context injection (chat-flow style)", () => {
|
|
||||||
it("the teacher's instructions carry the block after the composed fragments", async () => {
|
|
||||||
const graph = {
|
|
||||||
nodes: [node("n_ctx", "Chain rule intuition"), node("n_ctx2", "Derivative as a limit")],
|
|
||||||
edges: [edge("n_ctx2", "n_ctx")],
|
|
||||||
events: [
|
|
||||||
{ nodeId: "n_ctx2", kind: "correct", chatId: "c1", at: "2026-08-22T00:00:00.000Z", ref: "r1" },
|
|
||||||
] as LedgerEvent[],
|
|
||||||
};
|
|
||||||
const context = buildGraphContext(graph);
|
|
||||||
assert.ok(context, "fixture graph renders a block");
|
|
||||||
|
|
||||||
// Exactly what chat-handler does per request when no agent is passed:
|
|
||||||
// createTeacherAgent(model, tools, buildGraphContext(getGraph(graphStore))).
|
|
||||||
const prompts: string[] = [];
|
|
||||||
const agent = createTeacherAgent(capturingModel(prompts), undefined, context);
|
|
||||||
const chat = chatStore.createChat("Test: graph context injection");
|
|
||||||
createdChats.push(chat.id);
|
|
||||||
|
|
||||||
await drain(
|
|
||||||
await handleChatRequest(
|
|
||||||
post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me calculus" }] }]),
|
|
||||||
agent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(prompts.length, 1, "one model call");
|
|
||||||
const system = prompts[0]!;
|
|
||||||
assert.ok(system.includes(GRAPH_CONTEXT_HEADER), "the block reached the model");
|
|
||||||
assert.ok(system.includes("Chain rule intuition"), "fixture node reached the model");
|
|
||||||
// The block sits AFTER the composed fragments (the phases fragment is
|
|
||||||
// part of the static instructions), and the fragment teaching the
|
|
||||||
// teacher to read it is present too.
|
|
||||||
const fragmentIdx = system.indexOf("The two phases");
|
|
||||||
const graphFragIdx = system.indexOf("The learner's knowledge graph");
|
|
||||||
const blockIdx = system.indexOf(GRAPH_CONTEXT_HEADER);
|
|
||||||
assert.ok(fragmentIdx !== -1 && graphFragIdx !== -1, "prompt fragments composed");
|
|
||||||
assert.ok(graphFragIdx > fragmentIdx, "50-graph-context.md sorts after 30-phases.md");
|
|
||||||
assert.ok(blockIdx > graphFragIdx, "the block is appended after the fragments");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("no block is injected when the context is null (new learner)", async () => {
|
|
||||||
const prompts: string[] = [];
|
|
||||||
const agent = createTeacherAgent(capturingModel(prompts));
|
|
||||||
const chat = chatStore.createChat("Test: no graph context");
|
|
||||||
createdChats.push(chat.id);
|
|
||||||
await drain(
|
|
||||||
await handleChatRequest(
|
|
||||||
post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] }]),
|
|
||||||
agent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const system = prompts[0]!;
|
|
||||||
assert.ok(system.includes("The learner's knowledge graph"), "the fragment is always composed");
|
|
||||||
assert.ok(!system.includes("===== LEARNER KNOWLEDGE GRAPH"), "no auto-generated block");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── cross-chat edge assertions (merger + read-time merging) ────────────────
|
|
||||||
|
|
||||||
/** An in-memory stand-in for the five ledgers (the GraphStore port, core/ports.ts). */
|
|
||||||
function memoryLedgers() {
|
|
||||||
const nodes: GraphNode[] = [];
|
|
||||||
const edges: GraphEdge[] = [];
|
|
||||||
const events: LedgerEvent[] = [];
|
|
||||||
const mappings: NodeMapping[] = [];
|
|
||||||
const checkpoints: SessionCheckpoint[] = [];
|
|
||||||
return {
|
|
||||||
appendNode: (n: GraphNode) => void nodes.push(n),
|
|
||||||
readNodes: () => nodes.map((n) => ({ ...n })),
|
|
||||||
appendEdge: (e: GraphEdge) => void edges.push(e),
|
|
||||||
readEdges: () => edges.map((e) => ({ ...e })),
|
|
||||||
appendEvent: (e: LedgerEvent) => void events.push(e),
|
|
||||||
readEvents: () => events.map((e) => ({ ...e })),
|
|
||||||
appendMapping: (m: NodeMapping) => void mappings.push(m),
|
|
||||||
readMappings: () => mappings.map((m) => ({ ...m })),
|
|
||||||
appendCheckpoint: (c: SessionCheckpoint) => void checkpoints.push(c),
|
|
||||||
readCheckpoints: () => checkpoints.map((c) => ({ ...c })),
|
|
||||||
// Test accessors (not part of the GraphStore surface).
|
|
||||||
counts: { get nodes() { return nodes.length; } },
|
|
||||||
} satisfies GraphStore & { counts: { nodes: number } };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A minimal chat record with one user message (the merger reads the real chat store). */
|
|
||||||
function fixtureChat(title: string) {
|
|
||||||
const chat = chatStore.createChat(title);
|
|
||||||
createdChats.push(chat.id);
|
|
||||||
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "go" }] }]);
|
|
||||||
return chatStore.getChat(chat.id)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("cross-chat edge assertions (merger + read-time merging)", () => {
|
|
||||||
it("a second session asserting the same edge appends a second line; reads merge with unioned provenance", () => {
|
|
||||||
const ledgers = memoryLedgers();
|
|
||||||
|
|
||||||
// Session 1: two fresh propositions + a follows edge between them.
|
|
||||||
const chat1 = fixtureChat("Edge A");
|
|
||||||
const verdict1: AuditResult = {
|
|
||||||
propositions: [
|
|
||||||
{
|
|
||||||
id: "p1",
|
|
||||||
name: "Derivative as a limit",
|
|
||||||
statement: "The derivative is the limit of difference quotients.",
|
|
||||||
aliases: [],
|
|
||||||
messages: ["u1"],
|
|
||||||
evidence: ["e"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p2",
|
|
||||||
name: "Chain rule",
|
|
||||||
statement: "Composed functions differentiate by multiplying local rates.",
|
|
||||||
aliases: [],
|
|
||||||
messages: ["u1"],
|
|
||||||
evidence: ["e"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
edges: [{ from: "p1", to: "p2", type: "follows", messages: ["u1"], evidence: ["e"] }],
|
|
||||||
attribution: { quiz: {} },
|
|
||||||
dropped: [],
|
|
||||||
};
|
|
||||||
let stats = mergeFindings(chat1.id, verdict1, { chatStore, graphStore: ledgers });
|
|
||||||
assert.deepEqual(stats, { nodes: 2, edges: 1, events: 0 });
|
|
||||||
const base = ledgers.readNodes()[0]!.id;
|
|
||||||
const chain = ledgers.readNodes()[1]!.id;
|
|
||||||
|
|
||||||
// Session 2: the auditor links both propositions to the SAME nodes
|
|
||||||
// (identity verdicts) and asserts the same relation.
|
|
||||||
const chat2 = fixtureChat("Edge B");
|
|
||||||
const verdict2: AuditResult = {
|
|
||||||
propositions: [
|
|
||||||
{
|
|
||||||
id: "q1",
|
|
||||||
name: "Derivative as a limit",
|
|
||||||
statement: "The derivative as a limit of quotients", // different phrasing
|
|
||||||
aliases: [],
|
|
||||||
messages: ["u1"],
|
|
||||||
evidence: ["e"],
|
|
||||||
linkTo: base,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "q2",
|
|
||||||
name: "Chain rule",
|
|
||||||
statement: "Chain rule, intuitively",
|
|
||||||
aliases: [],
|
|
||||||
messages: ["u1"],
|
|
||||||
evidence: ["e"],
|
|
||||||
linkTo: chain,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
edges: [{ from: "q1", to: "q2", type: "follows", messages: ["u1"], evidence: ["e"] }],
|
|
||||||
attribution: { quiz: {} },
|
|
||||||
dropped: [],
|
|
||||||
};
|
|
||||||
stats = mergeFindings(chat2.id, verdict2, { chatStore, graphStore: ledgers });
|
|
||||||
|
|
||||||
// No new nodes; ONE new assertion line (per-session honest provenance).
|
|
||||||
assert.deepEqual(stats, { nodes: 0, edges: 1, events: 0 }, "no new nodes, one re-assertion line");
|
|
||||||
assert.equal(ledgers.counts.nodes, 4, "2 defining lines + 2 link-appends for the linked propositions");
|
|
||||||
const lines = ledgers.readEdges();
|
|
||||||
assert.equal(lines.length, 2, "two assertion lines for the same edge");
|
|
||||||
assert.equal(lines[0]!.from, lines[1]!.from);
|
|
||||||
assert.equal(lines[0]!.to, lines[1]!.to);
|
|
||||||
assert.deepEqual(lines.map((l) => l.provenance), [
|
|
||||||
[{ chatId: chat1.id, messageId: "u1" }],
|
|
||||||
[{ chatId: chat2.id, messageId: "u1" }],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Read-time merging: one derived edge, both sessions in its provenance.
|
|
||||||
const derived = getGraph(ledgers).edges;
|
|
||||||
assert.equal(derived.length, 1);
|
|
||||||
assert.deepEqual(derived[0]!.provenance, [
|
|
||||||
{ chatId: chat1.id, messageId: "u1" },
|
|
||||||
{ chatId: chat2.id, messageId: "u1" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+66
-8
@@ -42,6 +42,8 @@ import {
|
|||||||
type SteerMessage,
|
type SteerMessage,
|
||||||
type SubagentSteerMessage,
|
type SubagentSteerMessage,
|
||||||
type VisualResult,
|
type VisualResult,
|
||||||
|
buildProbeHandoffSteer,
|
||||||
|
type ProbeHandoffSteerMessage,
|
||||||
} from "../src/core/contracts.ts";
|
} from "../src/core/contracts.ts";
|
||||||
import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||||
@@ -237,11 +239,17 @@ describe("steer helpers (contracts.ts)", () => {
|
|||||||
assert.equal(isSubagentFailure("visual", { ...visual, svg: "" }), true);
|
assert.equal(isSubagentFailure("visual", { ...visual, svg: "" }), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("steerDelivered: matches on metadata.subagentResult.jobId", () => {
|
it("steerDelivered: matches on the deterministic steer id (any steer kind)", () => {
|
||||||
const steer = buildSubagentSteer({ id: "job_y", kind: "demo", toolCallId: "c" }, demo);
|
const steer = buildSubagentSteer({ id: "job_y", kind: "demo", toolCallId: "c" }, demo);
|
||||||
assert.equal(steerDelivered([], "job_y"), false);
|
assert.equal(steer.id, "steer_job_y");
|
||||||
assert.equal(steerDelivered([steer], "job_y"), true);
|
assert.equal(steerDelivered([], steer.id), false);
|
||||||
assert.equal(steerDelivered([steer], "job_z"), false);
|
assert.equal(steerDelivered([steer], steer.id), true);
|
||||||
|
assert.equal(steerDelivered([steer], "steer_job_z"), false);
|
||||||
|
// the probe handoff shares the id-space contract
|
||||||
|
const handoff = buildProbeHandoffSteer("chat_q");
|
||||||
|
assert.equal(handoff.id, "steer_probe_chat_q");
|
||||||
|
assert.equal(steerDelivered([handoff], handoff.id), true);
|
||||||
|
assert.equal(steerDelivered([steer], handoff.id), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("failure steers tell the teacher how to recover (research/demo/visual wording)", () => {
|
it("failure steers tell the teacher how to recover (research/demo/visual wording)", () => {
|
||||||
@@ -325,7 +333,9 @@ describe("dispatch (async protocol §2)", () => {
|
|||||||
const delivery = fakeDeliver();
|
const delivery = fakeDeliver();
|
||||||
const testRunner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
|
const testRunner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
|
||||||
const agent = createTeacherAgent(
|
const agent = createTeacherAgent(
|
||||||
|
undefined,
|
||||||
researchCallingTeacher(),
|
researchCallingTeacher(),
|
||||||
|
undefined,
|
||||||
createTeacherTools({
|
createTeacherTools({
|
||||||
chatId: chat.id,
|
chatId: chat.id,
|
||||||
chatStore,
|
chatStore,
|
||||||
@@ -603,6 +613,50 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
|||||||
return { chatId: chat.id, jobId: job.id, steer };
|
return { chatId: chat.id, jobId: job.id, steer };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("delivers a PROBE HANDOFF steer the same way — the teacher's wake turn", async () => {
|
||||||
|
const chat = chatStore.createChat("Test: probe handoff wake");
|
||||||
|
trackChat(chat.id);
|
||||||
|
// The concluded probe: a persisted probe_done part (the seam the
|
||||||
|
// chat-handler's construction branch keys on) and no open question.
|
||||||
|
chatStore.saveMessages(chat.id, [
|
||||||
|
{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me calc" }] },
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [
|
||||||
|
{ type: "tool-probe_done", state: "output-available", output: { ok: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
const handoff = buildProbeHandoffSteer(chat.id);
|
||||||
|
let seen: { chatId: string; lastMessage: ProbeHandoffSteerMessage } | null = null;
|
||||||
|
setTurnRunner(async (cid, messages) => {
|
||||||
|
seen = { chatId: cid, lastMessage: messages[messages.length - 1] as ProbeHandoffSteerMessage };
|
||||||
|
return 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
await deliverJobResult(handoff);
|
||||||
|
await waitFor(() => seen !== null, 8000, "handoff wake fired");
|
||||||
|
assert.equal(seen!.chatId, chat.id);
|
||||||
|
assert.equal(seen!.lastMessage.id, `steer_probe_${chat.id}`, "the handoff is the wake turn's final input message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a redelivered handoff dedupes via the transcript (idempotent by steer id)", async () => {
|
||||||
|
const chat = chatStore.createChat("Test: handoff idempotent");
|
||||||
|
trackChat(chat.id);
|
||||||
|
const handoff = buildProbeHandoffSteer(chat.id);
|
||||||
|
// The transcript already carries the steer (a previous wake completed).
|
||||||
|
chatStore.saveMessages(chat.id, [handoff as never]);
|
||||||
|
let fired = 0;
|
||||||
|
setTurnRunner(async () => {
|
||||||
|
fired++;
|
||||||
|
return 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
await deliverJobResult(handoff);
|
||||||
|
assert.equal(fired, 0, "already delivered — the pump resolves without firing a wake");
|
||||||
|
});
|
||||||
|
|
||||||
it("delivers: fires the turn runner with the record + steer, then flips the job done", async () => {
|
it("delivers: fires the turn runner with the record + steer, then flips the job done", async () => {
|
||||||
const { chatId, jobId, steer } = setup();
|
const { chatId, jobId, steer } = setup();
|
||||||
let seen: { chatId: string; lastMessage: unknown } | null = null;
|
let seen: { chatId: string; lastMessage: unknown } | null = null;
|
||||||
@@ -867,7 +921,7 @@ describe("persistTurn id hygiene", () => {
|
|||||||
|
|
||||||
describe("prompt composition", () => {
|
describe("prompt composition", () => {
|
||||||
it("loads ONLY numeric-prefix fragments — README.md never enters the system prompt", () => {
|
it("loads ONLY numeric-prefix fragments — README.md never enters the system prompt", () => {
|
||||||
const instructions = composeInstructions();
|
const instructions = composeInstructions("teacher");
|
||||||
assert.ok(!instructions.includes("Prompt fragments"), "README content must not leak");
|
assert.ok(!instructions.includes("Prompt fragments"), "README content must not leak");
|
||||||
assert.ok(!instructions.includes("Rules for adding a fragment"), "README content must not leak (rules)");
|
assert.ok(!instructions.includes("Rules for adding a fragment"), "README content must not leak (rules)");
|
||||||
// every numeric fragment DOES compose, verbatim, after the base prompt
|
// every numeric fragment DOES compose, verbatim, after the base prompt
|
||||||
@@ -889,7 +943,7 @@ describe("prompt composition", () => {
|
|||||||
const body = fs.readFileSync(`src/agent/prompts/${f}`, "utf-8").trim();
|
const body = fs.readFileSync(`src/agent/prompts/${f}`, "utf-8").trim();
|
||||||
assert.ok(body.length <= 8_000, `fragment ${f} is ${body.length} chars — over the 8k guard`);
|
assert.ok(body.length <= 8_000, `fragment ${f} is ${body.length} chars — over the 8k guard`);
|
||||||
}
|
}
|
||||||
assert.ok(composeInstructions().length <= 60_000, "composed system prompt over the 60k guard");
|
assert.ok(composeInstructions("teacher").length <= 60_000, "composed system prompt over the 60k guard");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1001,7 +1055,7 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
|||||||
|
|
||||||
await waitFor(() => fired.length > 0, 8000, "abort wake fired");
|
await waitFor(() => fired.length > 0, 8000, "abort wake fired");
|
||||||
const last = fired[0]!;
|
const last = fired[0]!;
|
||||||
assert.equal(last.metadata.subagentResult.aborted, true);
|
assert.equal((last.metadata as { subagentResult: { aborted: boolean } }).subagentResult.aborted, true);
|
||||||
assert.equal(last.id, `steer_${jobId}`);
|
assert.equal(last.id, `steer_${jobId}`);
|
||||||
assert.equal(last.parts.length, 1, "text only — no artifact part");
|
assert.equal(last.parts.length, 1, "text only — no artifact part");
|
||||||
// aborted is terminal: the wake completing must not flip it
|
// aborted is terminal: the wake completing must not flip it
|
||||||
@@ -1067,7 +1121,11 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
|||||||
endTurn(chat.id); // gates open — whatever is left in the queue fires
|
endTurn(chat.id); // gates open — whatever is left in the queue fires
|
||||||
await waitFor(() => fired.length > 0, 8000, "wake fired");
|
await waitFor(() => fired.length > 0, 8000, "wake fired");
|
||||||
assert.equal(fired.length, 1, "exactly one delivery — the result steer was dropped");
|
assert.equal(fired.length, 1, "exactly one delivery — the result steer was dropped");
|
||||||
assert.equal(fired[0]!.metadata.subagentResult.aborted, true, "the abort steer, not the result");
|
assert.equal(
|
||||||
|
(fired[0]!.metadata as { subagentResult: { aborted: boolean } }).subagentResult.aborted,
|
||||||
|
true,
|
||||||
|
"the abort steer, not the result",
|
||||||
|
);
|
||||||
assert.equal(jobStore.getJob(job.id)!.status, "error", "terminal after the wake");
|
assert.equal(jobStore.getJob(job.id)!.status, "error", "terminal after the wake");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+67
-203
@@ -1,220 +1,84 @@
|
|||||||
/**
|
/**
|
||||||
* Phase derivation (W5, vision §2 "two phases") — the probe→teach boundary.
|
* phases — the event-driven phase chain (docs/probe-teach-split.md §4).
|
||||||
*
|
*
|
||||||
* phaseAt is the pure seam-finder: plan_init IS the transition marker, so the
|
* probe ends at the first CONCLUDED probe_done (output-available); planning
|
||||||
* first message containing a tool-plan_init part flips everything from it
|
* ends at the first successful plan_init (a tree with nodes). No stored-tree
|
||||||
* onward to "teach". The boundary cases here are the ones the UI leans on:
|
* seam: the phase is a pure fold over the transcript, so it can never stick
|
||||||
* teach checks collapse behind the reveal button, probe questions auto-open.
|
* (the old currentNodeId-based phaseOfTree read a field nothing ever set).
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert";
|
||||||
import { describe, it } from "node:test";
|
import test from "node:test";
|
||||||
import {
|
import type { UIMessage } from "ai";
|
||||||
phaseAt,
|
import { phaseAt3, phaseTimeline, type Phase3 } from "../src/core/phases.ts";
|
||||||
phaseAt3,
|
|
||||||
phaseOfTree,
|
|
||||||
phaseTimeline,
|
|
||||||
type Phase3Message,
|
|
||||||
type PhaseMessage,
|
|
||||||
} from "../src/core/phases.ts";
|
|
||||||
|
|
||||||
/** A minimal message: just typed parts, exactly the structural slice phaseAt reads. */
|
/** Text-only user message. */
|
||||||
function msg(...partTypes: string[]): PhaseMessage {
|
function user(text: string): UIMessage {
|
||||||
return { parts: partTypes.map((type) => ({ type })) };
|
return { id: `u_${text}`, role: "user", parts: [{ type: "text", text }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Realistic shape: full tool part objects with state fields (extra fields ignored). */
|
/** An assistant message carrying a tool part in the given state. */
|
||||||
function toolMsg(...parts: { type: string; state: string; toolCallId: string }[]): PhaseMessage {
|
function assistantTool(
|
||||||
return { parts };
|
name: string,
|
||||||
}
|
state: "input-available" | "output-available" | "output-error",
|
||||||
|
output?: unknown,
|
||||||
describe("phaseAt", () => {
|
): UIMessage {
|
||||||
it("no messages → probe", () => {
|
|
||||||
assert.equal(phaseAt([]), "probe");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("no plan_init anywhere → probe at every index (pre-plan sessions keep the old behavior)", () => {
|
|
||||||
const messages = [
|
|
||||||
msg("text"),
|
|
||||||
msg("text", "tool-quiz", "text"),
|
|
||||||
toolMsg({ type: "tool-quiz", state: "input-available", toolCallId: "q1" }),
|
|
||||||
msg("text"),
|
|
||||||
];
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
assert.equal(phaseAt(messages, i), "probe", `index ${i}`);
|
|
||||||
}
|
|
||||||
assert.equal(phaseAt(messages), "probe");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the boundary: probe before the plan_init message, teach from it onward", () => {
|
|
||||||
const messages = [
|
|
||||||
msg("text"), // probe chatter
|
|
||||||
msg("text", "tool-quiz"), // probe question
|
|
||||||
msg("text", "tool-plan_init", "text"), // ← the seam (divider renders here)
|
|
||||||
msg("text", "tool-quiz"), // a check — teach phase
|
|
||||||
msg("text"),
|
|
||||||
];
|
|
||||||
assert.equal(phaseAt(messages, 0), "probe");
|
|
||||||
assert.equal(phaseAt(messages, 1), "probe");
|
|
||||||
assert.equal(phaseAt(messages, 2), "teach", "the message containing plan_init is already teach");
|
|
||||||
assert.equal(phaseAt(messages, 3), "teach");
|
|
||||||
assert.equal(phaseAt(messages, 4), "teach");
|
|
||||||
assert.equal(phaseAt(messages), "teach", "default = current phase (last message)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("multiple plan_init parts → the FIRST wins (a later re-init never moves the seam)", () => {
|
|
||||||
const messages = [
|
|
||||||
msg("text"),
|
|
||||||
msg("tool-plan_init"), // first — the seam
|
|
||||||
msg("text"),
|
|
||||||
msg("tool-plan_init"), // a refused re-init attempt: must not matter
|
|
||||||
msg("text"),
|
|
||||||
];
|
|
||||||
assert.equal(phaseAt(messages, 0), "probe");
|
|
||||||
assert.equal(phaseAt(messages, 1), "teach");
|
|
||||||
assert.equal(phaseAt(messages, 2), "teach", "between the two plan_inits: already teach (first wins)");
|
|
||||||
assert.equal(phaseAt(messages, 3), "teach");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("any part state counts — the call itself is the transition, even mid-stream", () => {
|
|
||||||
const messages = [
|
|
||||||
msg("text"),
|
|
||||||
toolMsg({ type: "tool-plan_init", state: "input-streaming", toolCallId: "p1" }),
|
|
||||||
];
|
|
||||||
assert.equal(phaseAt(messages, 1), "teach");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("upToIndex past the end / negative → clamped, never throws", () => {
|
|
||||||
const messages = [msg("tool-plan_init")];
|
|
||||||
assert.equal(phaseAt(messages, 99), "teach");
|
|
||||||
assert.equal(phaseAt(messages, -1), "probe", "empty scan range → probe");
|
|
||||||
assert.equal(phaseAt([]), "probe");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── The three-phase view (picker strips, header chip, transcript dividers) ────
|
|
||||||
|
|
||||||
/** A minimal tree: nodes as opaque ids is all phaseOfTree reads. */
|
|
||||||
function tree(nodes: string[], currentNodeId: string | null): { nodes: unknown[]; currentNodeId: string | null } {
|
|
||||||
return { nodes: nodes.map((id) => ({ id })), currentNodeId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A message whose parts are full plan-op tool parts with outputs. */
|
|
||||||
function planMsg(
|
|
||||||
...ops: { tool: string; ok: boolean; tree: ReturnType<typeof tree> }[]
|
|
||||||
): Phase3Message {
|
|
||||||
return {
|
return {
|
||||||
parts: ops.map((op, i) => ({
|
id: `a_${name}_${state}_${Math.random().toString(36).slice(2, 6)}`,
|
||||||
type: `tool-plan_${op.tool}`,
|
role: "assistant",
|
||||||
state: "output-available",
|
parts: [{ type: `tool-${name}`, state, ...(output !== undefined ? { output } : {}) } as never],
|
||||||
toolCallId: `p${i}`,
|
|
||||||
output: { ok: op.ok, tree: op.tree },
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("phaseOfTree", () => {
|
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
|
||||||
it("no tree / empty tree → probe", () => {
|
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } };
|
||||||
assert.equal(phaseOfTree(undefined), "probe");
|
|
||||||
assert.equal(phaseOfTree(null), "probe");
|
|
||||||
assert.equal(phaseOfTree(tree([], null)), "probe", "emptyTree() (a failed init) reads probe");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tree with no current node → plan", () => {
|
test("phaseAt3: probe until a concluded probe_done; plan until a successful plan_init; teach after", () => {
|
||||||
assert.equal(phaseOfTree(tree(["a", "b"], null)), "plan");
|
const probe = phaseAt3([user("teach me calculus"), assistantTool("quiz", "output-available")]);
|
||||||
});
|
assert.equal(probe, "probe", "quiz answers alone never end the probe");
|
||||||
|
|
||||||
it("occupied node → teach", () => {
|
const concluded = phaseAt3([
|
||||||
assert.equal(phaseOfTree(tree(["a", "b"], "a")), "teach");
|
user("teach me"),
|
||||||
});
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||||
|
]);
|
||||||
|
assert.equal(concluded, "plan", "a concluded probe_done ends the probe (planning begins)");
|
||||||
|
|
||||||
|
const plan = phaseAt3([
|
||||||
|
user("teach me"),
|
||||||
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||||
|
assistantTool("plan_init", "output-available", PLAN_OK),
|
||||||
|
]);
|
||||||
|
assert.equal(plan, "teach", "a successful plan_init ends planning");
|
||||||
|
|
||||||
|
const failedPlan = phaseAt3([
|
||||||
|
user("teach me"),
|
||||||
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||||
|
assistantTool("plan_init", "output-error", { ok: false, error: "bad mermaid" }),
|
||||||
|
]);
|
||||||
|
assert.equal(failedPlan, "plan", "a failed plan_init does not end planning");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("phaseAt3", () => {
|
test("phaseAt3: a mere probe_done CALL (input-available, not yet concluded) keeps the probe open", () => {
|
||||||
it("no plan parts anywhere → probe at every index", () => {
|
const still = phaseAt3([user("teach me"), assistantTool("probe_done", "input-available")]);
|
||||||
const messages: Phase3Message[] = [msg("text"), msg("tool-quiz"), msg("text")];
|
assert.equal(still, "probe");
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
assert.equal(phaseAt3(messages, i), "probe", `index ${i}`);
|
|
||||||
}
|
|
||||||
assert.equal(phaseAt3([]), "probe");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the probe→plan seam: the first plan output with a non-empty tree", () => {
|
|
||||||
const messages: Phase3Message[] = [
|
|
||||||
msg("text"), // probe chatter
|
|
||||||
planMsg({ tool: "plan_init", ok: false, tree: tree([], null) }), // failed init: NOT a seam
|
|
||||||
msg("text"),
|
|
||||||
planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }), // ← the seam
|
|
||||||
msg("text"),
|
|
||||||
];
|
|
||||||
assert.equal(phaseAt3(messages, 0), "probe");
|
|
||||||
assert.equal(phaseAt3(messages, 1), "probe", "a failed init carries an empty tree — no seam");
|
|
||||||
assert.equal(phaseAt3(messages, 2), "probe");
|
|
||||||
assert.equal(phaseAt3(messages, 3), "plan");
|
|
||||||
assert.equal(phaseAt3(messages, 4), "plan");
|
|
||||||
assert.equal(phaseAt3(messages), "plan", "default = current phase");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the plan→teach seam: the first tree output with an occupied node", () => {
|
|
||||||
const messages: Phase3Message[] = [
|
|
||||||
msg("text"),
|
|
||||||
planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }),
|
|
||||||
msg("text"),
|
|
||||||
planMsg({ tool: "plan_set_current", ok: true, tree: tree(["a", "b"], "a") }), // ← the seam
|
|
||||||
msg("text"),
|
|
||||||
];
|
|
||||||
assert.equal(phaseAt3(messages, 2), "plan");
|
|
||||||
assert.equal(phaseAt3(messages, 3), "teach");
|
|
||||||
assert.equal(phaseAt3(messages, 4), "teach");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapsed seams: init and set_current in one message → straight to teach", () => {
|
|
||||||
const messages: Phase3Message[] = [
|
|
||||||
msg("text"),
|
|
||||||
planMsg(
|
|
||||||
{ tool: "plan_init", ok: true, tree: tree(["a"], null) },
|
|
||||||
{ tool: "plan_set_current", ok: true, tree: tree(["a"], "a") },
|
|
||||||
),
|
|
||||||
];
|
|
||||||
assert.equal(phaseAt3(messages, 0), "probe");
|
|
||||||
assert.equal(phaseAt3(messages, 1), "teach");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("monotonic: a mid-teach node removal (current → null) never regresses the phase", () => {
|
|
||||||
const messages: Phase3Message[] = [
|
|
||||||
planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }),
|
|
||||||
planMsg({ tool: "plan_set_current", ok: true, tree: tree(["a", "b"], "a") }),
|
|
||||||
msg("text"),
|
|
||||||
planMsg({ tool: "plan_remove_node", ok: true, tree: tree(["b"], null) }), // edit, not a regression
|
|
||||||
];
|
|
||||||
assert.equal(phaseAt3(messages, 2), "teach");
|
|
||||||
assert.equal(phaseAt3(messages, 3), "teach", "phase only ever advances");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("upToIndex past the end / negative → clamped, never throws", () => {
|
|
||||||
const messages: Phase3Message[] = [planMsg({ tool: "plan_init", ok: true, tree: tree(["a"], null) })];
|
|
||||||
assert.equal(phaseAt3(messages, 99), "plan");
|
|
||||||
assert.equal(phaseAt3(messages, -1), "probe", "empty scan range → probe");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("phaseTimeline", () => {
|
test("phaseAt3: legacy transcripts without probe_done read as probe (no backwards compat — data wiped)", () => {
|
||||||
it("equals phaseAt3 at every index (the linear fold is the same derivation)", () => {
|
const legacy = phaseAt3([user("teach me lacan"), assistantTool("plan_init", "output-available", PLAN_OK)]);
|
||||||
const messages: Phase3Message[] = [
|
assert.equal(legacy, "probe", "plan_init alone never flips a phase post-split");
|
||||||
msg("text"),
|
|
||||||
planMsg({ tool: "plan_init", ok: false, tree: tree([], null) }),
|
|
||||||
planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }),
|
|
||||||
msg("text"),
|
|
||||||
planMsg({ tool: "plan_set_current", ok: true, tree: tree(["a", "b"], "a") }),
|
|
||||||
msg("text"),
|
|
||||||
];
|
|
||||||
const timeline = phaseTimeline(messages);
|
|
||||||
assert.equal(timeline.length, messages.length);
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
assert.equal(timeline[i], phaseAt3(messages, i), `index ${i}`);
|
|
||||||
}
|
|
||||||
assert.deepEqual(timeline, ["probe", "probe", "plan", "plan", "teach", "teach"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty transcript → empty timeline", () => {
|
|
||||||
assert.deepEqual(phaseTimeline([]), []);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("phaseTimeline: folds messages into phase spans for the dividers", () => {
|
||||||
|
const timeline = phaseTimeline([
|
||||||
|
user("teach me"),
|
||||||
|
assistantTool("quiz", "output-available"),
|
||||||
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||||
|
assistantTool("plan_init", "output-available", PLAN_OK),
|
||||||
|
user("go on"),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "teach", "teach"]);
|
||||||
|
assert.equal(timeline.length, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("phaseAt3: empty transcript is probe", () => {
|
||||||
|
assert.equal(phaseAt3([]), "probe");
|
||||||
|
});
|
||||||
+110
-179
@@ -1,191 +1,122 @@
|
|||||||
import assert from "node:assert/strict";
|
/**
|
||||||
import { describe, it } from "node:test";
|
* 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 type { UIMessage } from "ai";
|
||||||
import { neighborhood, withPlanBlocks, type PlanBlockPart } from "../src/core/plan-blocks.ts";
|
import { withPlanBlocks, type PlanBlockPart } from "../src/core/plan-blocks.ts";
|
||||||
import type { PlanTree } from "../src/core/contracts.ts";
|
import type { PlanTree } from "../src/core/contracts.ts";
|
||||||
|
|
||||||
/** The 5-node DAG the neighborhood tests slice:
|
// 5-node fixture: a → b → c, b → d, d → e.
|
||||||
* A → B → C, B → D → E (B's children C,D; A's only child is B). */
|
const TREE: PlanTree = {
|
||||||
function 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 {
|
return {
|
||||||
nodes: [
|
id,
|
||||||
{ id: "a", title: "Foundations" },
|
role: "assistant",
|
||||||
{ id: "b", title: "Core" },
|
parts: [
|
||||||
{ id: "c", title: "Applied" },
|
{ type: "text", text: "Here is the plan." },
|
||||||
{ id: "d", title: "Deep dive" },
|
{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree } },
|
||||||
{ id: "e", title: "Frontier" },
|
|
||||||
],
|
],
|
||||||
edges: [
|
} as unknown as UIMessage;
|
||||||
{ from: "a", to: "b" },
|
|
||||||
{ from: "b", to: "c" },
|
|
||||||
{ from: "b", to: "d" },
|
|
||||||
{ from: "d", to: "e" },
|
|
||||||
],
|
|
||||||
currentNodeId: null,
|
|
||||||
doneNodeIds: [],
|
|
||||||
version: 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function msg(id: string, role: "user" | "assistant", parts: UIMessage["parts"] = []): UIMessage {
|
function blocksOf(messages: UIMessage[]): PlanBlockPart[] {
|
||||||
return { id, role, parts };
|
return messages.flatMap((m) => m.parts.filter((p) => p.type === "data-plan-block") as PlanBlockPart[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function blocksOf(m: UIMessage): PlanBlockPart[] {
|
test("withPlanBlocks: a successful plan_init message gains ONE full block (all nodes, statuses from the transcript)", () => {
|
||||||
return m.parts.filter((p): p is PlanBlockPart => p.type === "data-plan-block") as PlanBlockPart[];
|
const out = withPlanBlocks([planInitMessage("m1")], TREE);
|
||||||
}
|
const blocks = blocksOf(out);
|
||||||
|
assert.equal(blocks.length, 1);
|
||||||
function allBlocks(messages: UIMessage[]): PlanBlockPart[] {
|
const data = blocks[0]!.data;
|
||||||
return messages.flatMap((m) => blocksOf(m));
|
assert.equal(data.kind, "full");
|
||||||
}
|
assert.equal(data.current, null, "at authoring nothing is current");
|
||||||
|
assert.equal(data.nodes.length, 5);
|
||||||
/** tool-plan_init part in terminal state (the authoring call succeeded). */
|
assert.ok(data.nodes.every((n) => n.status === "upcoming"));
|
||||||
function planInitPart(tree: PlanTree, ok = true) {
|
assert.equal(data.edges.length, 4);
|
||||||
return { type: "tool-plan_init", state: "output-available", input: { mermaid: "graph TD" }, output: { ok, tree } } as unknown as UIMessage["parts"][number];
|
// attached to the plan_init message itself
|
||||||
}
|
assert.equal(blocksOf([out[0]!]).length, 1);
|
||||||
|
|
||||||
/** tool-quiz part tagged with a node. `answered` appends the learner's output. */
|
|
||||||
function quizPart(nodeId: string, answered?: { correct: boolean }) {
|
|
||||||
const part = {
|
|
||||||
type: "tool-quiz",
|
|
||||||
state: answered ? "output-available" : "input-available",
|
|
||||||
input: { nodeId, question: "…", options: [] },
|
|
||||||
...(answered ? { output: { correct: answered.correct } } : {}),
|
|
||||||
} as unknown as UIMessage["parts"][number];
|
|
||||||
return part;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("neighborhood (pure scope)", () => {
|
|
||||||
it("includes current + direct parents + direct children, nothing further", () => {
|
|
||||||
const { nodes, edges } = neighborhood(tree(), "b");
|
|
||||||
assert.deepEqual(
|
|
||||||
nodes.map((n) => n.id).sort(),
|
|
||||||
["a", "b", "c", "d"],
|
|
||||||
);
|
|
||||||
assert.deepEqual(edges.map((e) => `${e.from}>${e.to}`).sort(), ["a>b", "b>c", "b>d"]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("withPlanBlocks", () => {
|
test("withPlanBlocks: a FAILED plan_init gains nothing", () => {
|
||||||
it("attaches a FULL block to the assistant message with a successful plan_init", () => {
|
const failed: UIMessage = {
|
||||||
const t = tree();
|
id: "m1",
|
||||||
const out = withPlanBlocks(
|
role: "assistant",
|
||||||
[msg("m1", "user", [{ type: "text", text: "teach me" } as never]), msg("m2", "assistant", [planInitPart(t)])],
|
parts: [
|
||||||
t,
|
{ type: "text", text: "oops" },
|
||||||
);
|
{ type: "tool-plan_init", state: "output-error", output: { ok: false, error: "bad" } },
|
||||||
const blocks = allBlocks(out);
|
],
|
||||||
assert.equal(blocks.length, 1);
|
} as unknown as UIMessage;
|
||||||
assert.equal(blocks[0]!.data.kind, "full");
|
assert.equal(blocksOf(withPlanBlocks([failed], TREE)).length, 0);
|
||||||
assert.equal(blocks[0]!.data.current, "a"); // first prereq-free node
|
|
||||||
assert.equal(blocks[0]!.data.nodes.length, 5);
|
|
||||||
assert.equal(blocksOf(out[1]!).length, 1); // on the plan_init message itself
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores a failed plan_init (ok: false)", () => {
|
|
||||||
const t = tree();
|
|
||||||
const out = withPlanBlocks([msg("m1", "user"), msg("m2", "assistant", [planInitPart(t, false)])], t);
|
|
||||||
assert.equal(allBlocks(out).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attaches a NEIGHBORHOOD block on the last assistant message when a check goes out", () => {
|
|
||||||
const t = tree();
|
|
||||||
const out = withPlanBlocks(
|
|
||||||
[
|
|
||||||
msg("m1", "user"),
|
|
||||||
msg("m2", "assistant", [quizPart("a", { correct: true })]), // a done earlier
|
|
||||||
msg("m3", "assistant", [quizPart("b")]), // check on b now out
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const blocks = allBlocks(out);
|
|
||||||
assert.equal(blocks.length, 1);
|
|
||||||
assert.equal(blocks[0]!.data.kind, "neighborhood");
|
|
||||||
assert.equal(blocks[0]!.data.current, "b");
|
|
||||||
assert.deepEqual(
|
|
||||||
blocks[0]!.data.nodes.map((n) => n.id).sort(),
|
|
||||||
["a", "b", "c", "d"], // e excluded: grandchild of b via d
|
|
||||||
);
|
|
||||||
assert.equal(blocksOf(out[2]!).length, 1); // on the LAST assistant message
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not re-emit when the plan state has not moved", () => {
|
|
||||||
const t = tree();
|
|
||||||
const turn1 = withPlanBlocks([msg("m1", "user"), msg("m2", "assistant", [quizPart("b")])], t);
|
|
||||||
assert.equal(allBlocks(turn1).length, 1);
|
|
||||||
// next turn: plain exchange, plan untouched — the previous block IS the as-of slice
|
|
||||||
const turn2 = withPlanBlocks(
|
|
||||||
[...turn1, msg("m3", "user"), msg("m4", "assistant", [{ type: "text", text: "ok" } as never])],
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
assert.equal(allBlocks(turn2).length, 1); // nothing new
|
|
||||||
assert.equal(blocksOf(turn2[3]!).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-emits when a check resolves (done grows / current falls back)", () => {
|
|
||||||
const t = tree();
|
|
||||||
const asked = withPlanBlocks([msg("m1", "user"), msg("m2", "assistant", [quizPart("b")])], t);
|
|
||||||
// learner answers correctly: done grows, no check out → current falls to a
|
|
||||||
const answered = withPlanBlocks(
|
|
||||||
[...asked, msg("m3", "user"), msg("m4", "assistant", [{ type: "text", text: "good" } as never])].map((m) =>
|
|
||||||
m.id === "m2" ? { ...m, parts: m.parts.map((p) => (p.type === "tool-quiz" ? quizPart("b", { correct: true }) : p)) } : m,
|
|
||||||
),
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const blocks = allBlocks(answered);
|
|
||||||
assert.equal(blocks.length, 2);
|
|
||||||
const latest = blocks.at(-1)!.data;
|
|
||||||
assert.equal(latest.kind, "neighborhood");
|
|
||||||
assert.deepEqual(
|
|
||||||
latest.nodes.find((n) => n.id === "b")!.status,
|
|
||||||
"done",
|
|
||||||
);
|
|
||||||
assert.equal(latest.current, "a"); // b done, a never checked → first eligible
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-emits when a failed check resets the standing (no done growth)", () => {
|
|
||||||
const t = tree();
|
|
||||||
const asked = withPlanBlocks([msg("m1", "user"), msg("m2", "assistant", [quizPart("b")])], t);
|
|
||||||
// learner answers WRONG: done doesn't grow, but no check is out anymore
|
|
||||||
// → current falls back to the first eligible node
|
|
||||||
const failed = withPlanBlocks(
|
|
||||||
[...asked, msg("m3", "user"), msg("m4", "assistant", [{ type: "text", text: "let's review" } as never])].map((m) =>
|
|
||||||
m.id === "m2" ? { ...m, parts: m.parts.map((p) => (p.type === "tool-quiz" ? quizPart("b", { correct: false }) : p)) } : m,
|
|
||||||
),
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const blocks = allBlocks(failed);
|
|
||||||
assert.equal(blocks.length, 2);
|
|
||||||
const latest = blocks.at(-1)!.data;
|
|
||||||
assert.equal(latest.current, "a");
|
|
||||||
assert.equal(latest.nodes.find((n) => n.id === "b")!.status, "upcoming"); // not done
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent when the same list is persisted twice (retry replay)", () => {
|
|
||||||
const t = tree();
|
|
||||||
const once = withPlanBlocks([msg("m1", "user"), msg("m2", "assistant", [quizPart("b")])], t);
|
|
||||||
const twice = withPlanBlocks(once, t);
|
|
||||||
assert.equal(allBlocks(twice).length, 1);
|
|
||||||
assert.equal(twice[1]!.parts.length, once[1]!.parts.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attaches a new FULL block on a plan rewrite, keeping the old block frozen", () => {
|
|
||||||
const t = tree();
|
|
||||||
const authored = withPlanBlocks([msg("m1", "user"), msg("m2", "assistant", [planInitPart(t)])], t);
|
|
||||||
const rewrite = withPlanBlocks(
|
|
||||||
[...authored, msg("m3", "user"), msg("m4", "assistant", [planInitPart(t)])],
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const blocks = allBlocks(rewrite);
|
|
||||||
assert.equal(blocks.length, 2);
|
|
||||||
assert.deepEqual(blocks.map((b) => b.data.kind), ["full", "full"]);
|
|
||||||
assert.equal(blocksOf(rewrite[3]!).length, 1); // new block on the NEW authoring message
|
|
||||||
});
|
|
||||||
|
|
||||||
it("no-ops on null/empty tree and on messages without assistant turns", () => {
|
|
||||||
assert.equal(withPlanBlocks([msg("m1", "user")], tree()).length, 1);
|
|
||||||
const empty = withPlanBlocks([msg("m1", "user"), msg("m2", "assistant")], { ...tree(), nodes: [] });
|
|
||||||
assert.equal(allBlocks(empty).length, 0);
|
|
||||||
const noTree = withPlanBlocks([msg("m1", "user"), msg("m2", "assistant")], null);
|
|
||||||
assert.equal(allBlocks(noTree).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");
|
||||||
|
});
|
||||||
+65
-169
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Plan-tree tests (W1) — two layers:
|
* Plan-tree tests (W1) — two layers:
|
||||||
*
|
*
|
||||||
* 1. Pure ops (src/core/plan-tree.ts): every validation error path, cascade
|
* 1. Pure ops (src/core/plan-tree.ts): every validation error path and
|
||||||
* remove, version semantics, setCurrent/markDone behavior.
|
* 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 —
|
* 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
|
* plan_init happy path, the re-callable rewrite, error results.
|
||||||
* persists to the record, error results on invalid ops.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
@@ -13,18 +13,7 @@ import * as fs from "node:fs";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { after, describe, it } from "node:test";
|
import { after, describe, it } from "node:test";
|
||||||
import { derivePlanStatuses, derivedNodeStatus, type PlanTree, type PlanToolResult } from "../src/core/contracts.ts";
|
import { derivePlanStatuses, derivedNodeStatus, type PlanTree, type PlanToolResult } from "../src/core/contracts.ts";
|
||||||
import {
|
import { createTree, emptyTree, PlanTreeError } from "../src/core/plan-tree.ts";
|
||||||
addEdge,
|
|
||||||
addNode,
|
|
||||||
createTree,
|
|
||||||
emptyTree,
|
|
||||||
markDone,
|
|
||||||
PlanTreeError,
|
|
||||||
removeEdge,
|
|
||||||
removeNode,
|
|
||||||
setCurrent,
|
|
||||||
updateNode,
|
|
||||||
} from "../src/core/plan-tree.ts";
|
|
||||||
import { planTools } from "../src/agent/tools/plan.ts";
|
import { planTools } from "../src/agent/tools/plan.ts";
|
||||||
import { parseMermaidPlan } from "../src/agent/tools/mermaid-plan.ts";
|
import { parseMermaidPlan } from "../src/agent/tools/mermaid-plan.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
@@ -60,8 +49,8 @@ describe("plan-tree pure ops", () => {
|
|||||||
const edges = [E("a", "b")];
|
const edges = [E("a", "b")];
|
||||||
const t = createTree(nodes, edges);
|
const t = createTree(nodes, edges);
|
||||||
assert.equal(t.version, 1);
|
assert.equal(t.version, 1);
|
||||||
assert.equal(t.currentNodeId, null);
|
assert.ok(!("currentNodeId" in t), "no stored progress — structure only");
|
||||||
assert.deepEqual(t.doneNodeIds, []);
|
assert.ok(!("doneNodeIds" in t), "no done ledger of any kind");
|
||||||
assert.deepEqual(t.nodes.map((n) => n.id), ["a", "b"]);
|
assert.deepEqual(t.nodes.map((n) => n.id), ["a", "b"]);
|
||||||
assert.deepEqual(t.edges, [E("a", "b")]);
|
assert.deepEqual(t.edges, [E("a", "b")]);
|
||||||
// Input arrays untouched (deep-copied into the tree).
|
// 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`/);
|
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 ─────────────────────────────────
|
// ─── 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 = {
|
const tree: PlanTree = {
|
||||||
nodes: [
|
nodes: [
|
||||||
{ id: "a", title: "A" },
|
{ id: "a", title: "A" },
|
||||||
@@ -392,73 +268,93 @@ describe("derivePlanStatuses (statuses derive from checks, never declarations)",
|
|||||||
{ from: "a", to: "b" },
|
{ from: "a", to: "b" },
|
||||||
{ from: "b", to: "c" },
|
{ from: "b", to: "c" },
|
||||||
],
|
],
|
||||||
currentNodeId: null,
|
|
||||||
doneNodeIds: [],
|
|
||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
const quizPart = (state: string, nodeId: string | undefined, correct?: boolean) => ({
|
const quizPart = (state: string, nodeId: string | undefined, correct?: boolean) => ({
|
||||||
type: "tool-quiz",
|
type: "tool-quiz",
|
||||||
state,
|
state,
|
||||||
input: nodeId === undefined ? {} : { nodeId },
|
input: nodeId === undefined ? {} : { nodeId },
|
||||||
output: correct === undefined ? undefined : { correct },
|
...(correct === undefined ? {} : { output: { correct } }),
|
||||||
});
|
});
|
||||||
type QuizPart = { type: string; state: string; input: unknown; output?: unknown };
|
type QuizPart = { type: string; state: string; input: unknown; output?: unknown };
|
||||||
const msg = (...parts: QuizPart[]) => ({ parts });
|
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, []);
|
const st = derivePlanStatuses(tree, []);
|
||||||
assert.equal(st.current, "a");
|
assert.equal(st.current, null);
|
||||||
assert.equal(st.done.size, 0);
|
assert.equal(st.visited.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a passed check on a tags it done; current advances to the next topologically-ready node", () => {
|
it("current = the node of the most recent tagged CALL — regardless of state or outcome", () => {
|
||||||
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", true))]);
|
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "b", false))]);
|
||||||
assert.deepEqual([...st.done], ["a"]);
|
assert.equal(st.current, "b", "even a FAILED check positions the map: the tag is about, not completion");
|
||||||
assert.equal(st.current, "b");
|
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, [
|
const st = derivePlanStatuses(tree, [
|
||||||
msg(quizPart("output-available", "a", true)),
|
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.equal(st.current, "b");
|
||||||
assert.deepEqual([...st.done], ["a"]);
|
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)", () => {
|
it("revisits keep both: A→B→A gives current A, visited {A,B}", () => {
|
||||||
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", false))]);
|
const st = derivePlanStatuses(tree, [
|
||||||
assert.equal(st.done.size, 0);
|
msg(quizPart("output-available", "a", true)),
|
||||||
assert.equal(st.current, "a", "frontier unmoved — resume teaching");
|
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, [
|
const st = derivePlanStatuses(tree, [
|
||||||
msg(quizPart("output-available", undefined, true)),
|
msg(quizPart("output-available", undefined, true)),
|
||||||
msg(quizPart("output-available", "ghost", true)),
|
msg(quizPart("output-available", "ghost", true)),
|
||||||
]);
|
]);
|
||||||
assert.equal(st.done.size, 0);
|
assert.equal(st.current, null);
|
||||||
assert.equal(st.current, "a");
|
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))]);
|
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", true))]);
|
||||||
assert.equal(st.current, "b", "c requires b");
|
assert.equal(derivedNodeStatus(st, "a"), "current");
|
||||||
const st2 = derivePlanStatuses(tree, [
|
assert.equal(derivedNodeStatus(st, "b"), "upcoming");
|
||||||
|
const later = derivePlanStatuses(tree, [
|
||||||
msg(quizPart("output-available", "a", true)),
|
msg(quizPart("output-available", "a", true)),
|
||||||
msg(quizPart("output-available", "b", true)),
|
msg(quizPart("output-available", "b", true)),
|
||||||
]);
|
]);
|
||||||
assert.equal(st2.current, "c");
|
assert.equal(derivedNodeStatus(later, "a"), "visited");
|
||||||
assert.equal(derivedNodeStatus(st2, "a"), "done");
|
assert.equal(derivedNodeStatus(later, "b"), "current");
|
||||||
assert.equal(derivedNodeStatus(st2, "c"), "current");
|
assert.equal(derivedNodeStatus(later, "c"), "upcoming");
|
||||||
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,121 @@
|
|||||||
|
/**
|
||||||
|
* probe_done — the probe→planning seam tool (docs/probe-teach-split.md §2).
|
||||||
|
* Server-side; three outcomes: refusal while a question is open, refusal
|
||||||
|
* after the probe is already concluded, ok + handoff queued. The handoff
|
||||||
|
* rides the wake pipeline (deliver injected here — no server seam import).
|
||||||
|
*/
|
||||||
|
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 { probeDoneTool } from "../src/agent/tools/probe.ts";
|
||||||
|
import type { ProbeHandoffSteerMessage } from "../src/core/contracts.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 ASSESSMENT = {
|
||||||
|
strands: [
|
||||||
|
{ strand: "the derivative as a limit", assessment: "Floor: slope intuition. Ceiling: formal ε–δ." },
|
||||||
|
],
|
||||||
|
summary: "Enter at the formal definition.",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function execute(tools: ReturnType<typeof probeDoneTool>, input: unknown) {
|
||||||
|
const t = tools.probe_done!;
|
||||||
|
assert.ok(typeof t.execute === "function");
|
||||||
|
return t.execute(input as never, { toolCallId: "test", messages: [] } as never) as Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("probe_done: the probe→planning seam", () => {
|
||||||
|
it("ok path: records the assessment and queues the handoff steer (deterministic id)", async () => {
|
||||||
|
const chatId = newChat("Probe: ok");
|
||||||
|
const delivered: ProbeHandoffSteerMessage[] = [];
|
||||||
|
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
||||||
|
|
||||||
|
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||||
|
assert.equal(res.ok, true);
|
||||||
|
assert.equal(res.error, undefined);
|
||||||
|
assert.equal(delivered.length, 1, "the handoff is queued exactly once");
|
||||||
|
assert.equal(delivered[0]!.id, `steer_probe_${chatId}`);
|
||||||
|
assert.equal((delivered[0]!.metadata as { probeHandoff: { chatId: string } }).probeHandoff.chatId, chatId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES while a question is still open — resolve it first", async () => {
|
||||||
|
const chatId = newChat("Probe: open question");
|
||||||
|
chatStore.saveMessages(chatId, [
|
||||||
|
{
|
||||||
|
id: "m1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "tool-quiz", state: "input-available", input: { question: "q?" } }],
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
const delivered: ProbeHandoffSteerMessage[] = [];
|
||||||
|
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
||||||
|
|
||||||
|
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||||
|
assert.equal(res.ok, false);
|
||||||
|
assert.match(res.error!, /question is still open/i);
|
||||||
|
assert.equal(delivered.length, 0, "nothing queued on refusal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES a second conclusion — probe_done fires exactly once", async () => {
|
||||||
|
const chatId = newChat("Probe: twice");
|
||||||
|
chatStore.saveMessages(chatId, [
|
||||||
|
{
|
||||||
|
id: "m1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "tool-probe_done", state: "output-available", input: { assessment: ASSESSMENT }, output: { ok: true } }],
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
const delivered: ProbeHandoffSteerMessage[] = [];
|
||||||
|
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
||||||
|
|
||||||
|
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||||
|
assert.equal(res.ok, false);
|
||||||
|
assert.match(res.error!, /already concluded/i);
|
||||||
|
assert.equal(delivered.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES an unknown chat defensively", async () => {
|
||||||
|
const tools = probeDoneTool({ chatId: "chat_nonexistent", chatStore, deliver: () => {} });
|
||||||
|
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||||
|
assert.equal(res.ok, false);
|
||||||
|
assert.match(res.error!, /not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a deliver failure never fails the call — the assessment records; the wake is recoverable-by-design", async () => {
|
||||||
|
const chatId = newChat("Probe: deliver crash");
|
||||||
|
const tools = probeDoneTool({
|
||||||
|
chatId,
|
||||||
|
chatStore,
|
||||||
|
deliver: () => {
|
||||||
|
throw new Error("queue exploded");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||||
|
assert.equal(res.ok, true, "ok:true even when the queue is lost — the next client message constructs the teacher off the transcript seam");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* questionToolsFor — the per-session question tools with dynamic plan-nodeId
|
* The agent-split question tools (docs/probe-teach-split.md §1):
|
||||||
* validation (tools.ts). The grading contract is question-core's and is
|
*
|
||||||
* UNTOUCHED; the For variant chains one session-relative check on top:
|
* - teachQuizToolFor — the TEACHER's quiz: the shared authoring contract +
|
||||||
* a quiz whose nodeId doesn't match a node of the CURRENT plan tree is
|
* the per-session plan-nodeId validation (position signal; a mismatched id
|
||||||
* rejected at the call boundary (never reaches the learner — the SDK
|
* is rejected at the call boundary and returns to the agent to fix).
|
||||||
* returns the validation error to the teacher as a tool result, and it
|
* - createProberTools — the PROBER's quiz has NO nodeId field at all (the
|
||||||
* retries).
|
* schema is built from quizInputBase; the key is stripped if the model
|
||||||
|
* tries anyway), plus ask + probe_done.
|
||||||
|
*
|
||||||
|
* The grading contract (question-core) is shared and untouched by both.
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { after, describe, it } from "node:test";
|
import { after, describe, it } from "node:test";
|
||||||
import { questionTools, questionToolsFor } from "../src/agent/tools.ts";
|
import { createProberTools, teachQuizToolFor } from "../src/agent/tools.ts";
|
||||||
|
import { probeQuizInputSchema } from "../src/core/question-core.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { PATHS } from "../config.ts";
|
import { PATHS } from "../config.ts";
|
||||||
|
|
||||||
@@ -38,8 +42,8 @@ function newChat(title: string): string {
|
|||||||
const validQuiz = {
|
const validQuiz = {
|
||||||
question: "What is 2 + 2?",
|
question: "What is 2 + 2?",
|
||||||
options: [
|
options: [
|
||||||
{ label: "3" },
|
{ label: "3", value: "3" },
|
||||||
{ label: "4" },
|
{ label: "4", value: "4" },
|
||||||
],
|
],
|
||||||
correctAnswer: "4",
|
correctAnswer: "4",
|
||||||
explanation: "Arithmetic.",
|
explanation: "Arithmetic.",
|
||||||
@@ -48,9 +52,11 @@ const validQuiz = {
|
|||||||
interface QuizToolSpec {
|
interface QuizToolSpec {
|
||||||
inputSchema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ message: string }> } } };
|
inputSchema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ message: string }> } } };
|
||||||
}
|
}
|
||||||
const quizSchemaOf = (tools: Record<string, unknown>) => (tools.quiz as QuizToolSpec).inputSchema;
|
/** teachQuizToolFor returns the single quiz tool; createProberTools a registry. */
|
||||||
|
const schemaOf = (toolLike: unknown) => (toolLike as QuizToolSpec).inputSchema;
|
||||||
|
const quizSchemaOf = (tools: Record<string, unknown>) => schemaOf(tools.quiz);
|
||||||
|
|
||||||
describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
|
describe("teachQuizToolFor: the dynamic plan-nodeId validation (position signal)", () => {
|
||||||
it("accepts a nodeId that matches a node of the session's tree", () => {
|
it("accepts a nodeId that matches a node of the session's tree", () => {
|
||||||
const chatId = newChat("NodeId: valid");
|
const chatId = newChat("NodeId: valid");
|
||||||
chatStore.saveTree(chatId, {
|
chatStore.saveTree(chatId, {
|
||||||
@@ -59,11 +65,9 @@ describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
|
|||||||
{ id: "expressions", title: "Expressions" },
|
{ id: "expressions", title: "Expressions" },
|
||||||
],
|
],
|
||||||
edges: [{ from: "arith", to: "expressions" }],
|
edges: [{ from: "arith", to: "expressions" }],
|
||||||
currentNodeId: null,
|
|
||||||
doneNodeIds: [],
|
|
||||||
version: 1,
|
version: 1,
|
||||||
});
|
});
|
||||||
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
|
const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({
|
||||||
...validQuiz,
|
...validQuiz,
|
||||||
nodeId: "expressions",
|
nodeId: "expressions",
|
||||||
});
|
});
|
||||||
@@ -75,11 +79,9 @@ describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
|
|||||||
chatStore.saveTree(chatId, {
|
chatStore.saveTree(chatId, {
|
||||||
nodes: [{ id: "arith", title: "Arithmetic" }],
|
nodes: [{ id: "arith", title: "Arithmetic" }],
|
||||||
edges: [],
|
edges: [],
|
||||||
currentNodeId: null,
|
|
||||||
doneNodeIds: [],
|
|
||||||
version: 1,
|
version: 1,
|
||||||
});
|
});
|
||||||
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
|
const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({
|
||||||
...validQuiz,
|
...validQuiz,
|
||||||
nodeId: "arithmetic", // the typo — slug is 'arith'
|
nodeId: "arithmetic", // the typo — slug is 'arith'
|
||||||
});
|
});
|
||||||
@@ -89,23 +91,20 @@ describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
|
|||||||
assert.match(msg, /arith/);
|
assert.match(msg, /arith/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no tree yet (probe phase): ANY nodeId is rejected — ad-hoc checks must omit it", () => {
|
it("no tree yet: ANY nodeId is rejected — omit it for ad-hoc checks", () => {
|
||||||
const chatId = newChat("NodeId: probe");
|
const chatId = newChat("NodeId: no tree");
|
||||||
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
|
const schema = schemaOf(teachQuizToolFor({ chatId, chatStore }));
|
||||||
...validQuiz,
|
const res = schema.safeParse({ ...validQuiz, nodeId: "anything" });
|
||||||
nodeId: "anything",
|
|
||||||
});
|
|
||||||
assert.equal(res.success, false);
|
assert.equal(res.success, false);
|
||||||
assert.match(res.error!.issues.at(-1)!.message, /No plan map exists yet/);
|
assert.match(res.error!.issues.at(-1)!.message, /No plan map exists yet/);
|
||||||
// …while an untagged probe check is fine
|
// …while an untagged ad-hoc check is fine
|
||||||
const plain = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse(validQuiz);
|
const plain = schema.safeParse(validQuiz);
|
||||||
assert.equal(plain.success, true);
|
assert.equal(plain.success, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads the LIVE record — a plan_init mutation mid-turn is honored immediately", () => {
|
it("reads the LIVE record — a plan_init mutation mid-turn is honored immediately", () => {
|
||||||
const chatId = newChat("NodeId: live record");
|
const chatId = newChat("NodeId: live record");
|
||||||
const tools = questionToolsFor({ chatId, chatStore });
|
const schema = schemaOf(teachQuizToolFor({ chatId, chatStore }));
|
||||||
const schema = quizSchemaOf(tools);
|
|
||||||
|
|
||||||
const before = schema.safeParse({ ...validQuiz, nodeId: "fresh-node" });
|
const before = schema.safeParse({ ...validQuiz, nodeId: "fresh-node" });
|
||||||
assert.equal(before.success, false, "no tree yet → rejected");
|
assert.equal(before.success, false, "no tree yet → rejected");
|
||||||
@@ -113,8 +112,6 @@ describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
|
|||||||
chatStore.saveTree(chatId, {
|
chatStore.saveTree(chatId, {
|
||||||
nodes: [{ id: "fresh-node", title: "Just initialized" }],
|
nodes: [{ id: "fresh-node", title: "Just initialized" }],
|
||||||
edges: [],
|
edges: [],
|
||||||
currentNodeId: null,
|
|
||||||
doneNodeIds: [],
|
|
||||||
version: 1,
|
version: 1,
|
||||||
});
|
});
|
||||||
const after = schema.safeParse({ ...validQuiz, nodeId: "fresh-node" });
|
const after = schema.safeParse({ ...validQuiz, nodeId: "fresh-node" });
|
||||||
@@ -123,7 +120,7 @@ describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
|
|||||||
|
|
||||||
it("the shared grading rules still chain FIRST (schema-valid ⇔ gradeable)", () => {
|
it("the shared grading rules still chain FIRST (schema-valid ⇔ gradeable)", () => {
|
||||||
const chatId = newChat("NodeId: grading rules intact");
|
const chatId = newChat("NodeId: grading rules intact");
|
||||||
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
|
const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({
|
||||||
...validQuiz,
|
...validQuiz,
|
||||||
correctAnswer: "42", // matches no option — question-core's rule
|
correctAnswer: "42", // matches no option — question-core's rule
|
||||||
nodeId: "ghost", // also bad — BOTH problems surface
|
nodeId: "ghost", // also bad — BOTH problems surface
|
||||||
@@ -133,16 +130,22 @@ describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
|
|||||||
assert.match(msgs, /correctAnswer/);
|
assert.match(msgs, /correctAnswer/);
|
||||||
assert.match(msgs, /matches no node/);
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the prober's quiz: no nodeId field at all", () => {
|
||||||
|
it("the probe schema accepts a plain quiz and STRIPS a smuggled nodeId", () => {
|
||||||
|
const plain = probeQuizInputSchema.safeParse(validQuiz);
|
||||||
|
assert.equal(plain.success, true);
|
||||||
|
const smuggled = probeQuizInputSchema.safeParse({ ...validQuiz, nodeId: "anything" });
|
||||||
|
assert.equal(smuggled.success, true, "the field doesn't exist — zod strips it");
|
||||||
|
assert.equal("nodeId" in smuggled.data, false, "stripped, not tolerated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the prober's tool registry carries the nodeId-less quiz and probe_done only", () => {
|
||||||
|
const chatId = newChat("Prober: registry");
|
||||||
|
const tools = createProberTools({ chatId, chatStore, deliver: () => {} });
|
||||||
|
assert.deepEqual(Object.keys(tools).sort(), ["ask_user_question", "probe_done", "quiz"]);
|
||||||
|
const schema = quizSchemaOf(tools);
|
||||||
|
assert.equal(schema.safeParse({ ...validQuiz, nodeId: "anything" }).success, true, "no session check to fail");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import type { PlanBlockData, PlanTree } from "../src/core/contracts.ts";
|
import type { PlanBlockData, PlanTree } from "../src/core/contracts.ts";
|
||||||
|
|
||||||
// Plan blocks: the pure layered layout, the frozen-snapshot rendering
|
// 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.
|
// with the glowing current), and the popup's LIVE derivation.
|
||||||
|
|
||||||
const CHAIN: PlanTree = {
|
const CHAIN: PlanTree = {
|
||||||
@@ -28,8 +28,6 @@ const CHAIN: PlanTree = {
|
|||||||
{ from: "a", to: "b" },
|
{ from: "a", to: "b" },
|
||||||
{ from: "b", to: "c" },
|
{ from: "b", to: "c" },
|
||||||
],
|
],
|
||||||
currentNodeId: null,
|
|
||||||
doneNodeIds: [],
|
|
||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,7 +73,7 @@ describe("plan block render", () => {
|
|||||||
at: "2026-09-09T12:00:00Z",
|
at: "2026-09-09T12:00:00Z",
|
||||||
current: null,
|
current: null,
|
||||||
nodes: [
|
nodes: [
|
||||||
{ id: "a", title: "Alpha node", status: "done" },
|
{ id: "a", title: "Alpha node", status: "visited" },
|
||||||
{ id: "b", title: "Beta node", status: "upcoming" },
|
{ id: "b", title: "Beta node", status: "upcoming" },
|
||||||
],
|
],
|
||||||
edges: [{ from: "a", to: "b" }],
|
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.
|
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 natural height, width = one node column.
|
||||||
assert.match(html, /viewBox="0 0 160 136"/);
|
assert.match(html, /viewBox="0 0 160 136"/);
|
||||||
// Snapshot statuses render as carried.
|
// 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, /data-testid="plan-node-b" data-status="upcoming"/);
|
||||||
assert.match(html, /Open the full plan/);
|
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)", () => {
|
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={() => {}} />);
|
const html = renderToStaticMarkup(<PlanPopup messages={messages()} onClose={() => {}} />);
|
||||||
assert.match(html, /data-testid="plan-popup"/);
|
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-b" data-status="current"/);
|
||||||
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
|
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
|
||||||
// The current node is the glow target.
|
// The current node is the glow target.
|
||||||
|
|||||||
Reference in New Issue
Block a user