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:
@@ -113,7 +113,7 @@ after(() => {
|
||||
describe("teacher chat flow (scripted model)", () => {
|
||||
it("quiz tool call persists as a pending part; the answer continues the lesson", async () => {
|
||||
const calls: string[][] = [];
|
||||
const agent = createTeacherAgent(scriptedModel(calls));
|
||||
const agent = createTeacherAgent(undefined, scriptedModel(calls));
|
||||
const chat = chatStore.createChat("Test: solar system");
|
||||
createdChats.push(chat.id);
|
||||
|
||||
@@ -200,7 +200,7 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
// 'input-streaming', which the stream processor sets from the
|
||||
// tool-input-start chunk. If a transport change ever stopped emitting
|
||||
// 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");
|
||||
createdChats.push(chat.id);
|
||||
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 () => {
|
||||
const calls: string[][] = [];
|
||||
const agent = createTeacherAgent(scriptedModel(calls));
|
||||
const agent = createTeacherAgent(undefined, scriptedModel(calls));
|
||||
const chat = chatStore.createChat("Test: abort");
|
||||
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");
|
||||
createdChats.push(chat.id);
|
||||
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)", () => {
|
||||
it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => {
|
||||
const calls: string[][] = [];
|
||||
const agent = createTeacherAgent(scriptedModel(calls));
|
||||
const agent = createTeacherAgent(undefined, scriptedModel(calls));
|
||||
const chat = chatStore.createChat("Test: turn guard");
|
||||
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 () => {
|
||||
const calls: string[][] = [];
|
||||
const agent = createTeacherAgent(scriptedModel(calls));
|
||||
const agent = createTeacherAgent(undefined, scriptedModel(calls));
|
||||
const chat = chatStore.createChat("Test: write-ahead");
|
||||
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
|
||||
// exists — the path where no onFinish will ever run.
|
||||
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(isTurnRunning(chat.id), false, "registry cleared when the stream never started");
|
||||
|
||||
// And the chat is NOT locked: the next (valid) turn is accepted.
|
||||
const ok = await handleChatRequest(
|
||||
post(chat.id, [{ id: "m1", role: "user", parts: [{ type: "text", text: "teach me" }] }]),
|
||||
createTeacherAgent(scriptedModel([])),
|
||||
createTeacherAgent(undefined, scriptedModel([])),
|
||||
);
|
||||
assert.equal(ok.status, 200);
|
||||
await drain(ok);
|
||||
|
||||
@@ -732,7 +732,9 @@ describe("demo/visual job runners", () => {
|
||||
}),
|
||||
}),
|
||||
createTeacherAgent(
|
||||
undefined,
|
||||
teacher,
|
||||
undefined,
|
||||
createTeacherTools({
|
||||
chatId: chat.id,
|
||||
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 SubagentSteerMessage,
|
||||
type VisualResult,
|
||||
buildProbeHandoffSteer,
|
||||
type ProbeHandoffSteerMessage,
|
||||
} from "../src/core/contracts.ts";
|
||||
import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||
@@ -237,11 +239,17 @@ describe("steer helpers (contracts.ts)", () => {
|
||||
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);
|
||||
assert.equal(steerDelivered([], "job_y"), false);
|
||||
assert.equal(steerDelivered([steer], "job_y"), true);
|
||||
assert.equal(steerDelivered([steer], "job_z"), false);
|
||||
assert.equal(steer.id, "steer_job_y");
|
||||
assert.equal(steerDelivered([], steer.id), 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)", () => {
|
||||
@@ -325,7 +333,9 @@ describe("dispatch (async protocol §2)", () => {
|
||||
const delivery = fakeDeliver();
|
||||
const testRunner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
|
||||
const agent = createTeacherAgent(
|
||||
undefined,
|
||||
researchCallingTeacher(),
|
||||
undefined,
|
||||
createTeacherTools({
|
||||
chatId: chat.id,
|
||||
chatStore,
|
||||
@@ -603,6 +613,50 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
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 () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
let seen: { chatId: string; lastMessage: unknown } | null = null;
|
||||
@@ -867,7 +921,7 @@ describe("persistTurn id hygiene", () => {
|
||||
|
||||
describe("prompt composition", () => {
|
||||
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("Rules for adding a fragment"), "README content must not leak (rules)");
|
||||
// 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();
|
||||
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");
|
||||
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.parts.length, 1, "text only — no artifact part");
|
||||
// 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
|
||||
await waitFor(() => fired.length > 0, 8000, "wake fired");
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
+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
|
||||
* first message containing a tool-plan_init part flips everything from it
|
||||
* onward to "teach". The boundary cases here are the ones the UI leans on:
|
||||
* teach checks collapse behind the reveal button, probe questions auto-open.
|
||||
* probe ends at the first CONCLUDED probe_done (output-available); planning
|
||||
* ends at the first successful plan_init (a tree with nodes). No stored-tree
|
||||
* seam: the phase is a pure fold over the transcript, so it can never stick
|
||||
* (the old currentNodeId-based phaseOfTree read a field nothing ever set).
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
phaseAt,
|
||||
phaseAt3,
|
||||
phaseOfTree,
|
||||
phaseTimeline,
|
||||
type Phase3Message,
|
||||
type PhaseMessage,
|
||||
} from "../src/core/phases.ts";
|
||||
import assert from "node:assert";
|
||||
import test from "node:test";
|
||||
import type { UIMessage } from "ai";
|
||||
import { phaseAt3, phaseTimeline, type Phase3 } from "../src/core/phases.ts";
|
||||
|
||||
/** A minimal message: just typed parts, exactly the structural slice phaseAt reads. */
|
||||
function msg(...partTypes: string[]): PhaseMessage {
|
||||
return { parts: partTypes.map((type) => ({ type })) };
|
||||
/** Text-only user message. */
|
||||
function user(text: string): UIMessage {
|
||||
return { id: `u_${text}`, role: "user", parts: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
/** Realistic shape: full tool part objects with state fields (extra fields ignored). */
|
||||
function toolMsg(...parts: { type: string; state: string; toolCallId: string }[]): PhaseMessage {
|
||||
return { parts };
|
||||
}
|
||||
|
||||
describe("phaseAt", () => {
|
||||
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 {
|
||||
/** An assistant message carrying a tool part in the given state. */
|
||||
function assistantTool(
|
||||
name: string,
|
||||
state: "input-available" | "output-available" | "output-error",
|
||||
output?: unknown,
|
||||
): UIMessage {
|
||||
return {
|
||||
parts: ops.map((op, i) => ({
|
||||
type: `tool-plan_${op.tool}`,
|
||||
state: "output-available",
|
||||
toolCallId: `p${i}`,
|
||||
output: { ok: op.ok, tree: op.tree },
|
||||
})),
|
||||
id: `a_${name}_${state}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: "assistant",
|
||||
parts: [{ type: `tool-${name}`, state, ...(output !== undefined ? { output } : {}) } as never],
|
||||
};
|
||||
}
|
||||
|
||||
describe("phaseOfTree", () => {
|
||||
it("no tree / empty tree → probe", () => {
|
||||
assert.equal(phaseOfTree(undefined), "probe");
|
||||
assert.equal(phaseOfTree(null), "probe");
|
||||
assert.equal(phaseOfTree(tree([], null)), "probe", "emptyTree() (a failed init) reads probe");
|
||||
});
|
||||
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
|
||||
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } };
|
||||
|
||||
it("tree with no current node → plan", () => {
|
||||
assert.equal(phaseOfTree(tree(["a", "b"], null)), "plan");
|
||||
});
|
||||
test("phaseAt3: probe until a concluded probe_done; plan until a successful plan_init; teach after", () => {
|
||||
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", () => {
|
||||
assert.equal(phaseOfTree(tree(["a", "b"], "a")), "teach");
|
||||
});
|
||||
const concluded = phaseAt3([
|
||||
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", () => {
|
||||
it("no plan parts anywhere → probe at every index", () => {
|
||||
const messages: Phase3Message[] = [msg("text"), msg("tool-quiz"), msg("text")];
|
||||
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");
|
||||
});
|
||||
test("phaseAt3: a mere probe_done CALL (input-available, not yet concluded) keeps the probe open", () => {
|
||||
const still = phaseAt3([user("teach me"), assistantTool("probe_done", "input-available")]);
|
||||
assert.equal(still, "probe");
|
||||
});
|
||||
|
||||
describe("phaseTimeline", () => {
|
||||
it("equals phaseAt3 at every index (the linear fold is the same derivation)", () => {
|
||||
const messages: Phase3Message[] = [
|
||||
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("phaseAt3: legacy transcripts without probe_done read as probe (no backwards compat — data wiped)", () => {
|
||||
const legacy = phaseAt3([user("teach me lacan"), assistantTool("plan_init", "output-available", PLAN_OK)]);
|
||||
assert.equal(legacy, "probe", "plan_init alone never flips a phase post-split");
|
||||
});
|
||||
|
||||
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 { 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";
|
||||
|
||||
/** The 5-node DAG the neighborhood tests slice:
|
||||
* A → B → C, B → D → E (B's children C,D; A's only child is B). */
|
||||
function tree(): PlanTree {
|
||||
// 5-node fixture: a → b → c, b → d, d → e.
|
||||
const TREE: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "a", title: "A" },
|
||||
{ id: "b", title: "B" },
|
||||
{ id: "c", title: "C" },
|
||||
{ id: "d", title: "D" },
|
||||
{ id: "e", title: "E" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "c" },
|
||||
{ from: "b", to: "d" },
|
||||
{ from: "d", to: "e" },
|
||||
],
|
||||
version: 1,
|
||||
};
|
||||
|
||||
/** An assistant message carrying a successful plan_init with the fixture tree. */
|
||||
function planInitMessage(id: string, tree: PlanTree = TREE): UIMessage {
|
||||
return {
|
||||
nodes: [
|
||||
{ id: "a", title: "Foundations" },
|
||||
{ id: "b", title: "Core" },
|
||||
{ id: "c", title: "Applied" },
|
||||
{ id: "d", title: "Deep dive" },
|
||||
{ id: "e", title: "Frontier" },
|
||||
id,
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "Here is the plan." },
|
||||
{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree } },
|
||||
],
|
||||
edges: [
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "c" },
|
||||
{ from: "b", to: "d" },
|
||||
{ from: "d", to: "e" },
|
||||
],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
} as unknown as UIMessage;
|
||||
}
|
||||
|
||||
function msg(id: string, role: "user" | "assistant", parts: UIMessage["parts"] = []): UIMessage {
|
||||
return { id, role, parts };
|
||||
function blocksOf(messages: UIMessage[]): PlanBlockPart[] {
|
||||
return messages.flatMap((m) => m.parts.filter((p) => p.type === "data-plan-block") as PlanBlockPart[]);
|
||||
}
|
||||
|
||||
function blocksOf(m: UIMessage): PlanBlockPart[] {
|
||||
return m.parts.filter((p): p is PlanBlockPart => p.type === "data-plan-block") as PlanBlockPart[];
|
||||
}
|
||||
|
||||
function allBlocks(messages: UIMessage[]): PlanBlockPart[] {
|
||||
return messages.flatMap((m) => blocksOf(m));
|
||||
}
|
||||
|
||||
/** tool-plan_init part in terminal state (the authoring call succeeded). */
|
||||
function planInitPart(tree: PlanTree, ok = true) {
|
||||
return { type: "tool-plan_init", state: "output-available", input: { mermaid: "graph TD" }, output: { ok, tree } } as unknown as UIMessage["parts"][number];
|
||||
}
|
||||
|
||||
/** 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"]);
|
||||
});
|
||||
test("withPlanBlocks: a successful plan_init message gains ONE full block (all nodes, statuses from the transcript)", () => {
|
||||
const out = withPlanBlocks([planInitMessage("m1")], TREE);
|
||||
const blocks = blocksOf(out);
|
||||
assert.equal(blocks.length, 1);
|
||||
const data = blocks[0]!.data;
|
||||
assert.equal(data.kind, "full");
|
||||
assert.equal(data.current, null, "at authoring nothing is current");
|
||||
assert.equal(data.nodes.length, 5);
|
||||
assert.ok(data.nodes.every((n) => n.status === "upcoming"));
|
||||
assert.equal(data.edges.length, 4);
|
||||
// attached to the plan_init message itself
|
||||
assert.equal(blocksOf([out[0]!]).length, 1);
|
||||
});
|
||||
|
||||
describe("withPlanBlocks", () => {
|
||||
it("attaches a FULL block to the assistant message with a successful plan_init", () => {
|
||||
const t = tree();
|
||||
const out = withPlanBlocks(
|
||||
[msg("m1", "user", [{ type: "text", text: "teach me" } as never]), msg("m2", "assistant", [planInitPart(t)])],
|
||||
t,
|
||||
);
|
||||
const blocks = allBlocks(out);
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0]!.data.kind, "full");
|
||||
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: a FAILED plan_init gains nothing", () => {
|
||||
const failed: UIMessage = {
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "oops" },
|
||||
{ type: "tool-plan_init", state: "output-error", output: { ok: false, error: "bad" } },
|
||||
],
|
||||
} as unknown as UIMessage;
|
||||
assert.equal(blocksOf(withPlanBlocks([failed], TREE)).length, 0);
|
||||
});
|
||||
|
||||
test("withPlanBlocks: idempotent — a message that already carries a block is never given another", () => {
|
||||
const once = withPlanBlocks([planInitMessage("m1")], TREE);
|
||||
const twice = withPlanBlocks(once, TREE);
|
||||
assert.equal(blocksOf(twice).length, 1, "no duplicate block on persist replay");
|
||||
});
|
||||
|
||||
test("withPlanBlocks: a plan REWRITE (second successful plan_init) gets its own full block; the old stays frozen", () => {
|
||||
const rewrittenTree: PlanTree = { nodes: TREE.nodes.slice(0, 3), edges: [{ from: "a", to: "b" }], version: 2 };
|
||||
const out = withPlanBlocks([planInitMessage("m1"), planInitMessage("m2", rewrittenTree)], rewrittenTree);
|
||||
const blocks = blocksOf(out);
|
||||
assert.equal(blocks.length, 2, "one full block per authoring event");
|
||||
assert.equal(blocks[1]!.data.nodes.length, 3);
|
||||
});
|
||||
|
||||
test("withPlanBlocks: quizzes alone emit nothing (no neighborhood blocks anymore)", () => {
|
||||
const quizTurn: UIMessage = {
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "check" },
|
||||
{ type: "tool-quiz", state: "output-available", input: { nodeId: "b" }, output: { correct: true } },
|
||||
],
|
||||
} as unknown as UIMessage;
|
||||
assert.equal(blocksOf(withPlanBlocks([quizTurn], TREE)).length, 0);
|
||||
});
|
||||
|
||||
test("withPlanBlocks: no tree / empty tree / no messages are no-ops", () => {
|
||||
assert.equal(withPlanBlocks([planInitMessage("m1")], null).length, 1, "passthrough");
|
||||
const emptyTree: PlanTree = { nodes: [], edges: [], version: 0 };
|
||||
assert.equal(blocksOf(withPlanBlocks([planInitMessage("m1")], emptyTree)).length, 0);
|
||||
assert.equal(withPlanBlocks([], TREE).length, 0);
|
||||
});
|
||||
|
||||
test("withPlanBlocks: mid-lesson rewrite snapshot derives statuses from the whole transcript", () => {
|
||||
// A tagged quiz call on node b happened before the rewrite: b is current.
|
||||
const quizBefore: UIMessage = {
|
||||
id: "m0",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "about b" },
|
||||
{ type: "tool-quiz", state: "output-available", input: { nodeId: "b" }, output: { correct: true } },
|
||||
],
|
||||
} as unknown as UIMessage;
|
||||
const out = withPlanBlocks([quizBefore, planInitMessage("m1")], TREE);
|
||||
const data = blocksOf(out)[0]!.data;
|
||||
assert.equal(data.current, "b");
|
||||
const b = data.nodes.find((n) => n.id === "b")!;
|
||||
assert.equal(b.status, "current");
|
||||
});
|
||||
+65
-169
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Plan-tree tests (W1) — two layers:
|
||||
*
|
||||
* 1. Pure ops (src/core/plan-tree.ts): every validation error path, cascade
|
||||
* remove, version semantics, setCurrent/markDone behavior.
|
||||
* 1. Pure ops (src/core/plan-tree.ts): every validation error path and
|
||||
* version semantics (structure only — progress derives from the
|
||||
* transcript, docs/probe-teach-split.md §3).
|
||||
* 2. Tool execute (src/agent/tools/plan.ts): against the REAL chat store —
|
||||
* plan_init happy path + refusal to re-init, a mutation round-trip that
|
||||
* persists to the record, error results on invalid ops.
|
||||
* plan_init happy path, the re-callable rewrite, error results.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
@@ -13,18 +13,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { after, describe, it } from "node:test";
|
||||
import { derivePlanStatuses, derivedNodeStatus, type PlanTree, type PlanToolResult } from "../src/core/contracts.ts";
|
||||
import {
|
||||
addEdge,
|
||||
addNode,
|
||||
createTree,
|
||||
emptyTree,
|
||||
markDone,
|
||||
PlanTreeError,
|
||||
removeEdge,
|
||||
removeNode,
|
||||
setCurrent,
|
||||
updateNode,
|
||||
} from "../src/core/plan-tree.ts";
|
||||
import { createTree, emptyTree, PlanTreeError } from "../src/core/plan-tree.ts";
|
||||
import { planTools } from "../src/agent/tools/plan.ts";
|
||||
import { parseMermaidPlan } from "../src/agent/tools/mermaid-plan.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
@@ -60,8 +49,8 @@ describe("plan-tree pure ops", () => {
|
||||
const edges = [E("a", "b")];
|
||||
const t = createTree(nodes, edges);
|
||||
assert.equal(t.version, 1);
|
||||
assert.equal(t.currentNodeId, null);
|
||||
assert.deepEqual(t.doneNodeIds, []);
|
||||
assert.ok(!("currentNodeId" in t), "no stored progress — structure only");
|
||||
assert.ok(!("doneNodeIds" in t), "no done ledger of any kind");
|
||||
assert.deepEqual(t.nodes.map((n) => n.id), ["a", "b"]);
|
||||
assert.deepEqual(t.edges, [E("a", "b")]);
|
||||
// Input arrays untouched (deep-copied into the tree).
|
||||
@@ -93,119 +82,6 @@ describe("plan-tree pure ops", () => {
|
||||
assertReject(() => createTree([{ id: "a", title: "" }], []), /non-empty `title`/);
|
||||
});
|
||||
|
||||
it("addNode appends and increments version; parents become requires edges", () => {
|
||||
const t = addNode(tree(["a", "b"], [["a", "b"]]), N("c", "C", "the goal"), ["a", "b"]);
|
||||
assert.equal(t.version, 2); // createTree=1, plus this op
|
||||
assert.deepEqual(t.nodes.map((n) => n.id), ["a", "b", "c"]);
|
||||
assert.deepEqual(t.edges, [E("a", "b"), E("a", "c"), E("b", "c")]);
|
||||
});
|
||||
|
||||
it("addNode rejects duplicate ids, unknown parents, self-parent, and cycle-creating parents", () => {
|
||||
const base = tree(["a", "b"], [["a", "b"]]);
|
||||
assertReject(() => addNode(base, N("a")), /already exists/);
|
||||
assertReject(() => addNode(base, N("c"), ["ghost"]), /unknown node id "ghost"/);
|
||||
assertReject(() => addNode(base, N("c"), ["c"]), /itself as a parent/);
|
||||
// a→b exists; adding node x with parent b gives b→x — fine. But a NEW
|
||||
// node whose parents would create a cycle can't happen (new nodes have no
|
||||
// incoming edges yet) — the cycle case is covered via addEdge below.
|
||||
});
|
||||
|
||||
it("updateNode patches title/summary and requires an existing node", () => {
|
||||
let t = tree(["a"]);
|
||||
t = updateNode(t, "a", { title: "A2" });
|
||||
assert.equal(t.nodes[0]!.title, "A2");
|
||||
assert.equal(t.version, 2);
|
||||
t = updateNode(t, "a", { summary: "what it covers" });
|
||||
assert.equal(t.nodes[0]!.summary, "what it covers");
|
||||
assert.equal(t.nodes[0]!.title, "A2");
|
||||
assert.equal(t.version, 3);
|
||||
assertReject(() => updateNode(t, "ghost", { title: "x" }), /Cannot update node "ghost"/);
|
||||
});
|
||||
|
||||
it("removeNode cascades: edges both directions, currentNodeId, doneNodeIds", () => {
|
||||
// a→b→c, b current, a+b done.
|
||||
let t = tree(["a", "b", "c"], [["a", "b"], ["b", "c"]]);
|
||||
t = setCurrent(t, "b");
|
||||
t = markDone(t, "a");
|
||||
t = markDone(t, "b");
|
||||
t = removeNode(t, "b");
|
||||
assert.deepEqual(t.nodes.map((n) => n.id), ["a", "c"]);
|
||||
assert.deepEqual(t.edges, []); // both edges touched b
|
||||
assert.equal(t.currentNodeId, null); // b was current
|
||||
assert.deepEqual(t.doneNodeIds, ["a"]); // b's done entry gone
|
||||
assert.equal(t.version, 5);
|
||||
assertReject(() => removeNode(t, "b"), /Cannot remove node "b"/);
|
||||
});
|
||||
|
||||
it("addEdge validates: unknown endpoints, self-loop, duplicate, cycle", () => {
|
||||
const t = tree(["a", "b", "c"], [["a", "b"]]);
|
||||
assertReject(() => addEdge(t, "a", "ghost"), /unknown node id "ghost"/);
|
||||
assertReject(() => addEdge(t, "ghost", "a"), /unknown node id "ghost"/);
|
||||
assertReject(() => addEdge(t, "a", "a"), /self-loop/);
|
||||
assertReject(() => addEdge(t, "a", "b"), /already exists/);
|
||||
// a→b exists; b→a closes the loop.
|
||||
assertReject(() => addEdge(t, "b", "a"), /cycle/);
|
||||
});
|
||||
|
||||
it("addEdge rejects an indirect cycle (A→B→C then C→A)", () => {
|
||||
const t = tree(["a", "b", "c"], [["a", "b"], ["b", "c"]]);
|
||||
assertReject(() => addEdge(t, "c", "a"), /cycle/);
|
||||
// The reverse direction is fine (c already depends on a transitively).
|
||||
const ok = addEdge(t, "a", "c");
|
||||
assert.deepEqual(ok.edges, [E("a", "b"), E("b", "c"), E("a", "c")]);
|
||||
assert.equal(ok.version, 2);
|
||||
});
|
||||
|
||||
it("removeEdge requires the edge to exist", () => {
|
||||
const t = tree(["a", "b"], [["a", "b"]]);
|
||||
const next = removeEdge(t, "a", "b");
|
||||
assert.deepEqual(next.edges, []);
|
||||
assert.equal(next.version, 2);
|
||||
assertReject(() => removeEdge(next, "a", "b"), /no such edge/);
|
||||
});
|
||||
|
||||
it("setCurrent requires an existing node and never touches doneNodeIds", () => {
|
||||
let t = tree(["a", "b"]);
|
||||
t = markDone(t, "a");
|
||||
t = setCurrent(t, "b");
|
||||
assert.equal(t.currentNodeId, "b");
|
||||
assert.deepEqual(t.doneNodeIds, ["a"]); // untouched
|
||||
assert.equal(t.version, 3);
|
||||
assertReject(() => setCurrent(t, "ghost"), /Cannot set current node "ghost"/);
|
||||
});
|
||||
|
||||
it("setCurrent on the already-current node is a true no-op (no version bump)", () => {
|
||||
let t = tree(["a"]);
|
||||
t = setCurrent(t, "a");
|
||||
const version = t.version;
|
||||
const again = setCurrent(t, "a");
|
||||
assert.equal(again.version, version);
|
||||
assert.equal(again, t); // same object: nothing changed
|
||||
});
|
||||
|
||||
it("markDone requires an existing node and is idempotent", () => {
|
||||
let t = tree(["a"]);
|
||||
t = markDone(t, "a");
|
||||
assert.deepEqual(t.doneNodeIds, ["a"]);
|
||||
const version = t.version;
|
||||
const again = markDone(t, "a");
|
||||
assert.equal(again.version, version);
|
||||
assert.equal(again, t); // same object: nothing changed
|
||||
assertReject(() => markDone(t, "ghost"), /Cannot mark node "ghost" done/);
|
||||
});
|
||||
|
||||
it("every op returns a new tree object (no mutation of the input)", () => {
|
||||
const t = tree(["a", "b", "c"], [["a", "b"]]);
|
||||
const snapshot = JSON.stringify(t);
|
||||
addNode(t, N("d"), ["b"]);
|
||||
updateNode(t, "a", { title: "X" });
|
||||
removeNode(t, "a");
|
||||
addEdge(t, "b", "c");
|
||||
removeEdge(t, "a", "b");
|
||||
setCurrent(t, "b");
|
||||
markDone(t, "b");
|
||||
assert.equal(JSON.stringify(t), snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. tool execute against the real store ─────────────────────────────────
|
||||
@@ -379,9 +255,9 @@ describe("parseMermaidPlan (the constrained mermaid subset)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── derived statuses (the tracking the teacher no longer does) ───────────────
|
||||
// ─── derived statuses: position from CALLS, never results (§3) ─────────────────
|
||||
|
||||
describe("derivePlanStatuses (statuses derive from checks, never declarations)", () => {
|
||||
describe("derivePlanStatuses (the map tracks position, not completion)", () => {
|
||||
const tree: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "a", title: "A" },
|
||||
@@ -392,73 +268,93 @@ describe("derivePlanStatuses (statuses derive from checks, never declarations)",
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "c" },
|
||||
],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
const quizPart = (state: string, nodeId: string | undefined, correct?: boolean) => ({
|
||||
type: "tool-quiz",
|
||||
state,
|
||||
input: nodeId === undefined ? {} : { nodeId },
|
||||
output: correct === undefined ? undefined : { correct },
|
||||
...(correct === undefined ? {} : { output: { correct } }),
|
||||
});
|
||||
type QuizPart = { type: string; state: string; input: unknown; output?: unknown };
|
||||
const msg = (...parts: QuizPart[]) => ({ parts });
|
||||
|
||||
it("empty transcript → current = first node (authoring order, no prereqs), nothing done", () => {
|
||||
it("empty transcript → no current, nothing visited (no fallback current)", () => {
|
||||
const st = derivePlanStatuses(tree, []);
|
||||
assert.equal(st.current, "a");
|
||||
assert.equal(st.done.size, 0);
|
||||
assert.equal(st.current, null);
|
||||
assert.equal(st.visited.size, 0);
|
||||
});
|
||||
|
||||
it("a passed check on a tags it done; current advances to the next topologically-ready node", () => {
|
||||
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", true))]);
|
||||
assert.deepEqual([...st.done], ["a"]);
|
||||
assert.equal(st.current, "b");
|
||||
it("current = the node of the most recent tagged CALL — regardless of state or outcome", () => {
|
||||
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "b", false))]);
|
||||
assert.equal(st.current, "b", "even a FAILED check positions the map: the tag is about, not completion");
|
||||
assert.equal(st.visited.size, 0);
|
||||
const open = derivePlanStatuses(tree, [msg(quizPart("input-available", "c"))]);
|
||||
assert.equal(open.current, "c", "a merely ASKED question is already the position");
|
||||
});
|
||||
|
||||
it("a pending check OWNS current, wherever the topo frontier is", () => {
|
||||
it("visited = earlier tagged nodes, sticky; results feed nothing", () => {
|
||||
const st = derivePlanStatuses(tree, [
|
||||
msg(quizPart("output-available", "a", true)),
|
||||
msg(quizPart("input-available", "c")), // a jump check on the sink
|
||||
msg(quizPart("output-available", "b", true)),
|
||||
]);
|
||||
assert.equal(st.current, "c", "the open check is where we are");
|
||||
assert.deepEqual([...st.done], ["a"]);
|
||||
assert.equal(st.current, "b");
|
||||
assert.deepEqual([...st.visited], ["a"]);
|
||||
// a passed check never marks anything finished — only moving on does
|
||||
const again = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", true))]);
|
||||
assert.equal(again.current, "a");
|
||||
assert.equal(again.visited.size, 0, "one correct answer on a does not finish it");
|
||||
});
|
||||
|
||||
it("a FAILED check marks nothing done (explaining is yours, done is theirs)", () => {
|
||||
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", false))]);
|
||||
assert.equal(st.done.size, 0);
|
||||
assert.equal(st.current, "a", "frontier unmoved — resume teaching");
|
||||
it("revisits keep both: A→B→A gives current A, visited {A,B}", () => {
|
||||
const st = derivePlanStatuses(tree, [
|
||||
msg(quizPart("output-available", "a", true)),
|
||||
msg(quizPart("output-available", "b", true)),
|
||||
msg(quizPart("input-available", "a")),
|
||||
]);
|
||||
assert.equal(st.current, "a");
|
||||
assert.deepEqual([...st.visited].sort(), ["a", "b"]);
|
||||
});
|
||||
|
||||
it("untagged checks and unknown nodeIds are lost signal — ignored", () => {
|
||||
it("several checks on one node hold the map still", () => {
|
||||
const st = derivePlanStatuses(tree, [
|
||||
msg(quizPart("output-available", "a", true)),
|
||||
msg(quizPart("output-available", "a", false)),
|
||||
msg(quizPart("input-available", "a")),
|
||||
]);
|
||||
assert.equal(st.current, "a");
|
||||
assert.equal(st.visited.size, 0);
|
||||
});
|
||||
|
||||
it("untagged checks and unknown nodeIds are ad-hoc — they move nothing", () => {
|
||||
const st = derivePlanStatuses(tree, [
|
||||
msg(quizPart("output-available", undefined, true)),
|
||||
msg(quizPart("output-available", "ghost", true)),
|
||||
]);
|
||||
assert.equal(st.done.size, 0);
|
||||
assert.equal(st.current, "a");
|
||||
assert.equal(st.current, null);
|
||||
assert.equal(st.visited.size, 0);
|
||||
});
|
||||
|
||||
it("prereqs gate the frontier: c isn't next until b is demonstrated", () => {
|
||||
it("a rewritten map derives against the CURRENT tree — dropped slugs stop counting", () => {
|
||||
const rewritten: PlanTree = { ...tree, nodes: [tree.nodes[0]!, tree.nodes[1]!] };
|
||||
const st = derivePlanStatuses(rewritten, [
|
||||
msg(quizPart("output-available", "a", true)),
|
||||
msg(quizPart("output-available", "c", true)), // c removed by the rewrite
|
||||
]);
|
||||
assert.equal(st.current, "a", "the orphaned tag is ignored");
|
||||
assert.equal(st.visited.size, 0);
|
||||
});
|
||||
|
||||
it("derivedNodeStatus: current wins over visited; upcoming otherwise", () => {
|
||||
const st = derivePlanStatuses(tree, [msg(quizPart("output-available", "a", true))]);
|
||||
assert.equal(st.current, "b", "c requires b");
|
||||
const st2 = derivePlanStatuses(tree, [
|
||||
assert.equal(derivedNodeStatus(st, "a"), "current");
|
||||
assert.equal(derivedNodeStatus(st, "b"), "upcoming");
|
||||
const later = derivePlanStatuses(tree, [
|
||||
msg(quizPart("output-available", "a", true)),
|
||||
msg(quizPart("output-available", "b", true)),
|
||||
]);
|
||||
assert.equal(st2.current, "c");
|
||||
assert.equal(derivedNodeStatus(st2, "a"), "done");
|
||||
assert.equal(derivedNodeStatus(st2, "c"), "current");
|
||||
assert.equal(derivedNodeStatus(st2, "b"), "done");
|
||||
});
|
||||
|
||||
it("a rewritten map keeps honest progress for reused slugs (derivation matches the CURRENT tree)", () => {
|
||||
const rewritten: PlanTree = { ...tree, nodes: [tree.nodes[0]!, tree.nodes[1]!] };
|
||||
const st = derivePlanStatuses(rewritten, [msg(quizPart("output-available", "a", true))]);
|
||||
assert.equal(st.current, "b");
|
||||
const orphaned = derivePlanStatuses(rewritten, [msg(quizPart("output-available", "c", true))]);
|
||||
assert.equal(orphaned.done.size, 0, "a slug removed from the rewrite stops counting");
|
||||
assert.equal(derivedNodeStatus(later, "a"), "visited");
|
||||
assert.equal(derivedNodeStatus(later, "b"), "current");
|
||||
assert.equal(derivedNodeStatus(later, "c"), "upcoming");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
* validation (tools.ts). The grading contract is question-core's and is
|
||||
* UNTOUCHED; the For variant chains one session-relative check on top:
|
||||
* a quiz whose nodeId doesn't match a node of the CURRENT plan tree is
|
||||
* rejected at the call boundary (never reaches the learner — the SDK
|
||||
* returns the validation error to the teacher as a tool result, and it
|
||||
* retries).
|
||||
* The agent-split question tools (docs/probe-teach-split.md §1):
|
||||
*
|
||||
* - teachQuizToolFor — the TEACHER's quiz: the shared authoring contract +
|
||||
* the per-session plan-nodeId validation (position signal; a mismatched id
|
||||
* is rejected at the call boundary and returns to the agent to fix).
|
||||
* - createProberTools — the PROBER's quiz has NO nodeId field at all (the
|
||||
* 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 * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
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 { PATHS } from "../config.ts";
|
||||
|
||||
@@ -38,8 +42,8 @@ function newChat(title: string): string {
|
||||
const validQuiz = {
|
||||
question: "What is 2 + 2?",
|
||||
options: [
|
||||
{ label: "3" },
|
||||
{ label: "4" },
|
||||
{ label: "3", value: "3" },
|
||||
{ label: "4", value: "4" },
|
||||
],
|
||||
correctAnswer: "4",
|
||||
explanation: "Arithmetic.",
|
||||
@@ -48,9 +52,11 @@ const validQuiz = {
|
||||
interface QuizToolSpec {
|
||||
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", () => {
|
||||
const chatId = newChat("NodeId: valid");
|
||||
chatStore.saveTree(chatId, {
|
||||
@@ -59,11 +65,9 @@ describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
|
||||
{ id: "expressions", title: "Expressions" },
|
||||
],
|
||||
edges: [{ from: "arith", to: "expressions" }],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
});
|
||||
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
|
||||
const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({
|
||||
...validQuiz,
|
||||
nodeId: "expressions",
|
||||
});
|
||||
@@ -75,11 +79,9 @@ describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
|
||||
chatStore.saveTree(chatId, {
|
||||
nodes: [{ id: "arith", title: "Arithmetic" }],
|
||||
edges: [],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
});
|
||||
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
|
||||
const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({
|
||||
...validQuiz,
|
||||
nodeId: "arithmetic", // the typo — slug is 'arith'
|
||||
});
|
||||
@@ -89,23 +91,20 @@ describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
|
||||
assert.match(msg, /arith/);
|
||||
});
|
||||
|
||||
it("no tree yet (probe phase): ANY nodeId is rejected — ad-hoc checks must omit it", () => {
|
||||
const chatId = newChat("NodeId: probe");
|
||||
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
|
||||
...validQuiz,
|
||||
nodeId: "anything",
|
||||
});
|
||||
it("no tree yet: ANY nodeId is rejected — omit it for ad-hoc checks", () => {
|
||||
const chatId = newChat("NodeId: no tree");
|
||||
const schema = schemaOf(teachQuizToolFor({ chatId, chatStore }));
|
||||
const res = schema.safeParse({ ...validQuiz, nodeId: "anything" });
|
||||
assert.equal(res.success, false);
|
||||
assert.match(res.error!.issues.at(-1)!.message, /No plan map exists yet/);
|
||||
// …while an untagged probe check is fine
|
||||
const plain = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse(validQuiz);
|
||||
// …while an untagged ad-hoc check is fine
|
||||
const plain = schema.safeParse(validQuiz);
|
||||
assert.equal(plain.success, true);
|
||||
});
|
||||
|
||||
it("reads the LIVE record — a plan_init mutation mid-turn is honored immediately", () => {
|
||||
const chatId = newChat("NodeId: live record");
|
||||
const tools = questionToolsFor({ chatId, chatStore });
|
||||
const schema = quizSchemaOf(tools);
|
||||
const schema = schemaOf(teachQuizToolFor({ chatId, chatStore }));
|
||||
|
||||
const before = schema.safeParse({ ...validQuiz, nodeId: "fresh-node" });
|
||||
assert.equal(before.success, false, "no tree yet → rejected");
|
||||
@@ -113,8 +112,6 @@ describe("questionToolsFor: the dynamic plan-nodeId validation", () => {
|
||||
chatStore.saveTree(chatId, {
|
||||
nodes: [{ id: "fresh-node", title: "Just initialized" }],
|
||||
edges: [],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
});
|
||||
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)", () => {
|
||||
const chatId = newChat("NodeId: grading rules intact");
|
||||
const res = quizSchemaOf(questionToolsFor({ chatId, chatStore })).safeParse({
|
||||
const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({
|
||||
...validQuiz,
|
||||
correctAnswer: "42", // matches no option — question-core's rule
|
||||
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, /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";
|
||||
|
||||
// Plan blocks: the pure layered layout, the frozen-snapshot rendering
|
||||
// (full = borderless + column-filling, neighborhood = labeled fragment
|
||||
// (full = borderless + column-filling
|
||||
// with the glowing current), and the popup's LIVE derivation.
|
||||
|
||||
const CHAIN: PlanTree = {
|
||||
@@ -28,8 +28,6 @@ const CHAIN: PlanTree = {
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "c" },
|
||||
],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
|
||||
@@ -75,7 +73,7 @@ describe("plan block render", () => {
|
||||
at: "2026-09-09T12:00:00Z",
|
||||
current: null,
|
||||
nodes: [
|
||||
{ id: "a", title: "Alpha node", status: "done" },
|
||||
{ id: "a", title: "Alpha node", status: "visited" },
|
||||
{ id: "b", title: "Beta node", status: "upcoming" },
|
||||
],
|
||||
edges: [{ from: "a", to: "b" }],
|
||||
@@ -90,33 +88,11 @@ describe("plan block render", () => {
|
||||
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 natural height, width = one node column.
|
||||
assert.match(html, /viewBox="0 0 160 136"/);
|
||||
// Snapshot statuses render as carried.
|
||||
assert.match(html, /data-testid="plan-node-a" data-status="done"/);
|
||||
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
|
||||
assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/);
|
||||
assert.match(html, /Open the full plan/);
|
||||
});
|
||||
|
||||
it("neighborhood block: own small container, labeled fragment, glowing current", () => {
|
||||
const data: PlanBlockData = {
|
||||
kind: "neighborhood",
|
||||
at: "2026-09-09T12:00:00Z",
|
||||
current: "mid",
|
||||
nodes: [
|
||||
{ id: "up", title: "Upstream", status: "done" },
|
||||
{ id: "mid", title: "Current node", status: "current" },
|
||||
{ id: "down", title: "Downstream", status: "upcoming" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "up", to: "mid" },
|
||||
],
|
||||
};
|
||||
const html = renderToStaticMarkup(<PlanBlock data={data} onOpen={() => {}} />);
|
||||
assert.match(html, /data-testid="plan-block-neighborhood"/);
|
||||
assert.match(html, /class="[^"]*border/, "the fragment has its own container");
|
||||
assert.match(html, /Plan fragment/);
|
||||
// The glow: a pulsing primary halo around the current node.
|
||||
assert.match(html, /stroke-primary\/70 animate-pulse/);
|
||||
assert.match(html, /data-testid="plan-node-mid" data-status="current"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan popup (live derivation)", () => {
|
||||
@@ -153,10 +129,10 @@ describe("plan popup (live derivation)", () => {
|
||||
];
|
||||
}
|
||||
|
||||
it("derives statuses at click time: passed → done, live check → current (glowing)", () => {
|
||||
it("derives statuses at click time: latest tagged call → current, earlier → visited (glowing)", () => {
|
||||
const html = renderToStaticMarkup(<PlanPopup messages={messages()} onClose={() => {}} />);
|
||||
assert.match(html, /data-testid="plan-popup"/);
|
||||
assert.match(html, /data-testid="plan-node-a" data-status="done"/);
|
||||
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
|
||||
assert.match(html, /data-testid="plan-node-b" data-status="current"/);
|
||||
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
|
||||
// The current node is the glow target.
|
||||
|
||||
Reference in New Issue
Block a user