2026-08-22 10:26:51 +03:00
|
|
|
/**
|
2026-09-11 06:44:20 +03:00
|
|
|
* phases — the event-driven phase chain (docs/probe-teach-split.md §4).
|
2026-08-22 10:26:51 +03:00
|
|
|
*
|
2026-09-27 00:20:07 +03:00
|
|
|
* probe ends at the first CONCLUDED probe_done (output-available); teaching
|
|
|
|
|
* begins at the FIRST nodeId-tagged quiz CALL (the inferred seam, owner
|
|
|
|
|
* ruling 2026-09-26 — the verdict death: presenting the plan ends the
|
|
|
|
|
* teacher's turn, the learner's reply is natural text, and nothing flips
|
|
|
|
|
* the phase until a question ABOUT a plan node is actually posed). No
|
2026-09-11 21:26:29 +03:00
|
|
|
* 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).
|
2026-08-22 10:26:51 +03:00
|
|
|
*/
|
2026-09-11 06:44:20 +03:00
|
|
|
import assert from "node:assert";
|
|
|
|
|
import test from "node:test";
|
2026-09-26 01:52:47 +03:00
|
|
|
import type { LhViewMessage } from "../src/core/view.ts";
|
2026-09-11 06:44:20 +03:00
|
|
|
import { phaseAt3, phaseTimeline, type Phase3 } from "../src/core/phases.ts";
|
2026-08-22 10:26:51 +03:00
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
/** Text-only user message. */
|
2026-09-26 01:52:47 +03:00
|
|
|
function user(text: string): LhViewMessage {
|
2026-09-11 06:44:20 +03:00
|
|
|
return { id: `u_${text}`, role: "user", parts: [{ type: "text", text }] };
|
2026-08-22 10:26:51 +03:00
|
|
|
}
|
|
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
/** An assistant message carrying a tool part in the given state. */
|
|
|
|
|
function assistantTool(
|
|
|
|
|
name: string,
|
|
|
|
|
state: "input-available" | "output-available" | "output-error",
|
|
|
|
|
output?: unknown,
|
2026-09-26 01:52:47 +03:00
|
|
|
): LhViewMessage {
|
2026-09-01 18:24:13 +03:00
|
|
|
return {
|
2026-09-11 06:44:20 +03:00
|
|
|
id: `a_${name}_${state}_${Math.random().toString(36).slice(2, 6)}`,
|
|
|
|
|
role: "assistant",
|
|
|
|
|
parts: [{ type: `tool-${name}`, state, ...(output !== undefined ? { output } : {}) } as never],
|
2026-09-01 18:24:13 +03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 00:20:07 +03:00
|
|
|
/** A quiz call with its parsed input — `nodeId` present iff tagged. */
|
|
|
|
|
function assistantQuiz(input: Record<string, unknown> | undefined): LhViewMessage {
|
2026-09-11 21:26:29 +03:00
|
|
|
return {
|
2026-09-27 00:20:07 +03:00
|
|
|
id: `a_quiz_${Math.random().toString(36).slice(2, 6)}`,
|
|
|
|
|
role: "assistant",
|
|
|
|
|
parts: [{ type: "tool-quiz", ...(input !== undefined ? { input } : {}) } as never],
|
2026-09-11 21:26:29 +03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 00:20:07 +03:00
|
|
|
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
|
|
|
|
|
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } };
|
2026-09-12 05:14:40 +03:00
|
|
|
|
2026-09-27 00:20:07 +03:00
|
|
|
test("phaseAt3: probe until a concluded probe_done; plan until the first nodeId-tagged quiz; teach after", () => {
|
|
|
|
|
const probe = phaseAt3([user("teach me calculus"), assistantQuiz({ question: "q", correctAnswer: "a" })]);
|
|
|
|
|
assert.equal(probe, "probe", "an UNTAGGED quiz alone never ends the probe (probe quizzes carry no nodeId)");
|
2026-09-01 18:24:13 +03:00
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
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)");
|
|
|
|
|
|
2026-09-11 21:26:29 +03:00
|
|
|
const presented = phaseAt3([
|
2026-09-11 06:44:20 +03:00
|
|
|
user("teach me"),
|
|
|
|
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
|
|
|
|
assistantTool("plan_init", "output-available", PLAN_OK),
|
2026-09-27 00:20:07 +03:00
|
|
|
user("looks good, go"),
|
2026-09-11 06:44:20 +03:00
|
|
|
]);
|
2026-09-27 00:20:07 +03:00
|
|
|
assert.equal(presented, "plan", "plan_init + the learner's go-ahead TEXT present and sanction nothing");
|
2026-09-11 21:26:29 +03:00
|
|
|
|
2026-09-27 00:20:07 +03:00
|
|
|
const teaching = phaseAt3([
|
2026-09-11 21:26:29 +03:00
|
|
|
user("teach me"),
|
|
|
|
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
|
|
|
|
assistantTool("plan_init", "output-available", PLAN_OK),
|
2026-09-27 00:20:07 +03:00
|
|
|
user("looks good, go"),
|
|
|
|
|
assistantQuiz({ question: "q", nodeId: "gravity-basics", correctAnswer: "a" }),
|
2026-09-11 21:26:29 +03:00
|
|
|
]);
|
2026-09-27 00:20:07 +03:00
|
|
|
assert.equal(teaching, "teach", "the first nodeId-tagged quiz CALL is where teaching begins");
|
2026-09-11 06:44:20 +03:00
|
|
|
|
|
|
|
|
const failedPlan = phaseAt3([
|
|
|
|
|
user("teach me"),
|
|
|
|
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
|
|
|
|
assistantTool("plan_init", "output-error", { ok: false, error: "bad mermaid" }),
|
2026-09-27 00:20:07 +03:00
|
|
|
assistantQuiz({ question: "q", nodeId: "n1", correctAnswer: "a" }),
|
2026-09-11 06:44:20 +03:00
|
|
|
]);
|
2026-09-27 00:20:07 +03:00
|
|
|
assert.equal(failedPlan, "teach", "a tagged quiz begins teaching even after a failed init attempt");
|
2026-09-01 18:24:13 +03:00
|
|
|
});
|
|
|
|
|
|
2026-09-27 00:20:07 +03:00
|
|
|
test("phaseAt3: the tag is the seam — natural text and untagged questions never start teaching", () => {
|
2026-09-12 05:14:40 +03:00
|
|
|
const plainText = phaseAt3([
|
|
|
|
|
user("teach me"),
|
|
|
|
|
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
|
|
|
|
assistantTool("plan_init", "output-available", PLAN_OK),
|
2026-09-27 00:20:07 +03:00
|
|
|
user("The plan is accepted — begin teaching."), // words alone
|
|
|
|
|
assistantQuiz({ question: "ad-hoc check", correctAnswer: "a" }), // untagged
|
2026-09-12 05:14:40 +03:00
|
|
|
]);
|
2026-09-27 00:20:07 +03:00
|
|
|
assert.equal(plainText, "plan", "only a nodeId-tagged quiz flips the seam — text and ad-hoc checks never do");
|
2026-09-12 05:14:40 +03:00
|
|
|
|
2026-09-27 00:20:07 +03:00
|
|
|
const taggedOutOfOrder = phaseAt3([
|
2026-09-12 05:14:40 +03:00
|
|
|
user("teach me"),
|
2026-09-27 00:20:07 +03:00
|
|
|
assistantQuiz({ question: "q", nodeId: "n1", correctAnswer: "a" }), // before probe_done
|
2026-09-12 05:14:40 +03:00
|
|
|
]);
|
2026-09-27 00:20:07 +03:00
|
|
|
assert.equal(taggedOutOfOrder, "probe", "the chain never skips");
|
2026-09-12 05:14:40 +03:00
|
|
|
});
|
|
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
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");
|
2026-09-01 18:24:13 +03:00
|
|
|
});
|
2026-09-01 18:31:01 +03:00
|
|
|
|
2026-10-27 20:49:04 +02:00
|
|
|
test("phaseAt3 and phaseTimeline: explicit probe refusals never advance the UI phase", () => {
|
|
|
|
|
const refused = assistantTool("probe_done", "output-available", { ok: false, error: "question open" });
|
|
|
|
|
const errored = assistantTool("probe_done", "output-error", { ok: true });
|
|
|
|
|
const tagged = assistantQuiz({ nodeId: "n1" });
|
|
|
|
|
const clean = assistantTool("probe_done", "output-available", { ok: true });
|
|
|
|
|
const messages = [refused, errored, tagged, clean, tagged];
|
|
|
|
|
assert.equal(phaseAt3(messages, 2), "probe");
|
|
|
|
|
assert.deepEqual(phaseTimeline(messages), ["probe", "probe", "probe", "plan", "teach"]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
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");
|
2026-09-01 18:31:01 +03:00
|
|
|
});
|
2026-09-11 06:44:20 +03:00
|
|
|
|
|
|
|
|
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),
|
2026-09-11 21:26:29 +03:00
|
|
|
user("what does node 1 mean?"), // side question — still planning
|
2026-09-11 06:44:20 +03:00
|
|
|
user("go on"),
|
2026-09-27 00:20:07 +03:00
|
|
|
assistantQuiz({ question: "q", nodeId: "n1", correctAnswer: "a" }),
|
|
|
|
|
assistantQuiz({ question: "q2", nodeId: "n2", correctAnswer: "a" }),
|
2026-09-11 06:44:20 +03:00
|
|
|
]);
|
2026-09-27 00:20:07 +03:00
|
|
|
assert.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "plan", "plan", "plan", "teach", "teach"]);
|
|
|
|
|
assert.equal(timeline.length, 8);
|
2026-09-11 06:44:20 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("phaseAt3: empty transcript is probe", () => {
|
|
|
|
|
assert.equal(phaseAt3([]), "probe");
|
2026-09-27 00:20:07 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── the native fold (phaseAt3Lh over LhMessage[]) ──────────────────────────
|
|
|
|
|
|
|
|
|
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
|
|
|
|
import { phaseAt3Lh } from "../src/core/phases.ts";
|
|
|
|
|
|
|
|
|
|
/** A native toolResult (the probe_done conclusion). */
|
|
|
|
|
function toolResult(toolName: string, isError = false): LhMessage {
|
|
|
|
|
return {
|
|
|
|
|
id: `tr_${toolName}_${Math.random().toString(36).slice(2, 6)}`,
|
|
|
|
|
role: "toolResult",
|
|
|
|
|
toolCallId: `call_${toolName}`,
|
|
|
|
|
toolName,
|
|
|
|
|
content: [{ type: "text", text: "ok" }],
|
|
|
|
|
isError,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
} as LhMessage;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** A native assistant message carrying a quiz toolCall — arguments carry
|
|
|
|
|
* `nodeId` iff tagged (exactly what the wire would persist). */
|
|
|
|
|
function assistantQuizCall(arguments_: Record<string, unknown>): LhMessage {
|
|
|
|
|
return {
|
|
|
|
|
id: `a_${Math.random().toString(36).slice(2, 6)}`,
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: [{ type: "toolCall", id: "call_quiz", name: "quiz", arguments: arguments_ }],
|
|
|
|
|
stopReason: "toolUse",
|
|
|
|
|
usage: undefined,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
} as unknown as LhMessage;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 20:49:04 +02:00
|
|
|
function assistantProbeCall(): LhMessage {
|
|
|
|
|
return {
|
|
|
|
|
...assistantQuizCall({}),
|
|
|
|
|
content: [{ type: "toolCall", id: "call_probe_done", name: "probe_done", arguments: {} }],
|
|
|
|
|
} as LhMessage;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 00:20:07 +03:00
|
|
|
test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; plan→teach at the first tagged quiz CALL", () => {
|
|
|
|
|
const done = toolResult("probe_done");
|
2026-10-27 20:49:04 +02:00
|
|
|
const call = assistantProbeCall();
|
2026-09-27 00:20:07 +03:00
|
|
|
const tagged = assistantQuizCall({ question: "q", nodeId: "gravity-basics", correctAnswer: "a" });
|
|
|
|
|
const untagged = assistantQuizCall({ question: "q", correctAnswer: "a" });
|
|
|
|
|
|
2026-10-27 20:49:04 +02:00
|
|
|
assert.equal(phaseAt3Lh([call, done, untagged]), "plan", "an untagged quiz call never starts teaching");
|
|
|
|
|
assert.equal(phaseAt3Lh([call, done, tagged]), "teach", "the tagged quiz CALL is the seam");
|
2026-09-27 00:20:07 +03:00
|
|
|
assert.equal(phaseAt3Lh([tagged]), "probe", "the chain never skips (tag before probe_done)");
|
|
|
|
|
assert.equal(
|
2026-10-27 20:49:04 +02:00
|
|
|
phaseAt3Lh([call, toolResult("probe_done", true), tagged]),
|
2026-09-27 00:20:07 +03:00
|
|
|
"probe",
|
|
|
|
|
"an ERRORED probe_done concludes nothing",
|
|
|
|
|
);
|
|
|
|
|
// The two folds agree on the same settled transcript (the view projection
|
|
|
|
|
// of the tagged call carries its arguments as the part input).
|
|
|
|
|
assert.equal(
|
2026-10-27 20:49:04 +02:00
|
|
|
phaseAt3Lh([call, done, tagged]),
|
2026-09-27 00:20:07 +03:00
|
|
|
phaseAt3([
|
|
|
|
|
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
|
|
|
|
|
{ parts: [{ type: "tool-quiz", input: { nodeId: "gravity-basics" } } as never] },
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-09-27 08:55:13 +03:00
|
|
|
|
|
|
|
|
// ─── the explain mechanic at the seam (owner 2026-09-27) ───────────────────
|
|
|
|
|
// Both quizzing tools (quiz OR explain, whichever the settings armed) open
|
|
|
|
|
// teaching — the seam is structural, not name-bound to the MC tool.
|
|
|
|
|
|
|
|
|
|
test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too", () => {
|
|
|
|
|
const done = toolResult("probe_done");
|
2026-10-27 20:49:04 +02:00
|
|
|
const call = assistantProbeCall();
|
2026-09-27 08:55:13 +03:00
|
|
|
const explainCall = (args: Record<string, unknown>): LhMessage =>
|
|
|
|
|
({
|
|
|
|
|
id: "a_explain",
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: [{ type: "toolCall", id: "call_explain", name: "explain", arguments: args }],
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
}) as unknown as LhMessage;
|
|
|
|
|
|
|
|
|
|
const tagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e", nodeId: "gravity-basics" });
|
|
|
|
|
const untagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e" });
|
|
|
|
|
|
2026-10-27 20:49:04 +02:00
|
|
|
assert.equal(phaseAt3Lh([call, done, untagged]), "plan", "an untagged explain never starts teaching");
|
|
|
|
|
assert.equal(phaseAt3Lh([call, done, tagged]), "teach", "the tagged explain CALL is the seam");
|
2026-09-27 08:55:13 +03:00
|
|
|
assert.equal(
|
2026-10-27 20:49:04 +02:00
|
|
|
phaseAt3Lh([call, done, tagged]),
|
2026-09-27 08:55:13 +03:00
|
|
|
phaseAt3([
|
|
|
|
|
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
|
|
|
|
|
{ parts: [{ type: "tool-explain", input: { nodeId: "gravity-basics" } } as never] },
|
|
|
|
|
]),
|
|
|
|
|
"the two folds agree on the explain mechanic",
|
|
|
|
|
);
|
|
|
|
|
});
|