Files
learnharness/tests/graph-context.test.ts
T
amosblomqvist 53112d2861 practice round: dead session starts, prompt laws, preamble, label unify
THE START BUG (owner report, reproduced live both kinds): new sessions
(?prompt flow — practice and teach alike) sat dead until a second
message, which then persisted WITHOUT the first. Root cause, proven
both directions: the submit POST rode the view's consumeAbort
controller; React StrictMode's simulated remount fires Diet A's
unmount cleanup (model.stop()) ~8ms in and killed the POST mid-flight;
the page's sentInitial guard had already flipped, so it never retried.
Teach was equally broken — only unnoticed, because every teach chat in
use predates the diet and practice starts are always fresh. Fix: the
submit law — POSTs are not abortable by view teardown (they are ~100ms
acks since Diet B and cannot starve the pool); after the ack, a
consumeAbort identity check guards the stream attach. Pinned with the
stop-mid-submit regression test. Live: StrictMode on, practice draft
POST 200/67ms, stream attached, message persisted, problem posed.

PRACTICE PROMPT (00-base): the opening rule — material arrives two
ways, both first-class (picked propositions primary; free-form —
topics, course material, files/PDFs — fully supported, never refused);
as soon as material exists the reply poses, at most one short line
before the problem call, no warm-up interview. Plus the hard law: no
problem is ever conversation — every problem through the tool (no
thinking field/no reveal/no verdict attaches to prose).

GRAPH-CONTEXT PREAMBLE: custom markers flatten to user-role on the
wire, so the old "Contextualization — the learner picked…" read like a
system broadcast in the learner's voice channel. Now the bracket
convention: "[Session context] — picked from the learner's knowledge
map…".

SIDEBAR: busy rows unify to "Working…" for both kinds (the spinner's
default label included); "Practicing…"/"Teacher is working…" retired.

Gates: 1490/1490, typecheck, lint. doctrine's stale doc pointer fixed.
2026-10-10 12:19:31 +03:00

155 lines
7.2 KiB
TypeScript

/**
* THE GRAPH-CONTEXT MARKER (owner, 2026-10-04 late): a session started
* with map picks gets its context as a SEPARATE custom message BEFORE
* the learner's first user message — the picked propositions plus the
* reasoning chains among them (formatChainLine lines), no more seed
* text glued into the opening prompt. The model reads the marker's
* content (toLlmMessages flattens it to a user message like every
* marker); `details` feeds the transcript's collapsible
* GraphContextCard (render-practice pins live in the render suite).
*
* The route pin drives POST /api/chats under an isolated LH_DATA_DIR
* (the birth-stamp discipline): picks ride the CREATION, the marker
* lands as message[0], and the chains derive from the live graph
* snapshot.
*/
import assert from "node:assert/strict";
import fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, describe, it } from "node:test";
// ── the seam, BEFORE any config-loading import ──────────────────────────────
const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-graph-context-"));
process.env.LH_DATA_DIR = TMP_DATA;
const { newGraphContextMessage } = await import("../src/core/native-messages.ts");
const { toLlmMessages } = await import("../src/core/native-messages.ts");
import type { LhMessage } from "../src/core/native-messages.ts";
const { projectView } = await import("../src/core/view.ts");
const { POST } = await import("../app/api/chats/route.ts");
const { initialPromptOwed } = await import("../components/chat.tsx");
const { chatStore } = await import("../src/store/chats.ts");
const { graphStore } = await import("../src/store/graph.ts");
after(() => {
fs.rmSync(TMP_DATA, { recursive: true, force: true });
});
const PICKS = [
{ id: "n_a", name: "Modus ponens", statement: "If P implies Q and P holds, Q holds." },
{ id: "n_b", name: "Soundness", statement: "A valid argument from true premises has a true conclusion." },
];
// ── the pure marker ─────────────────────────────────────────────────────────
describe("newGraphContextMessage", () => {
it("frames the context for the model: propositions listed, chains appended", () => {
const m = newGraphContextMessage(PICKS, ["Modus ponens → Soundness"]);
assert.equal(m.customType, "graph-context");
assert.match(m.content, /\[Session context\] — picked from the learner's knowledge map/);
// IDS FIRST (owner round 9): every pick carries its id — the problem
// tool's target field references these; names alone no longer.
assert.ok(m.content.includes(`- [${PICKS[0]!.id}] Modus ponens: If P implies Q and P holds, Q holds.`));
assert.ok(m.content.includes("Reasoning chains among them"));
assert.ok(m.content.includes("- Modus ponens → Soundness"));
// the UI payload is the structured pick, not the flattened text
assert.deepEqual(m.details, { propositions: PICKS, chains: ["Modus ponens → Soundness"] });
});
it("no chains: the chains section stays out entirely", () => {
const m = newGraphContextMessage(PICKS, []);
assert.ok(!m.content.includes("Reasoning chains"));
assert.deepEqual(m.details, { propositions: PICKS, chains: [] });
});
it("toLlmMessages flattens it to a user message (the model's face)", () => {
const m = newGraphContextMessage(PICKS, ["Modus ponens → Soundness"]);
const wire = toLlmMessages([m as unknown as LhMessage] as never);
assert.equal(wire.length, 1);
assert.equal(wire[0]!.role, "user");
assert.ok(String((wire[0]!.content as { text?: string }[])[0]!.text).includes("[Session context]"));
});
it("projectView: a user-side data part — never a text bubble", () => {
const m = newGraphContextMessage(PICKS, []);
const view = projectView([m as unknown as LhMessage] as never);
assert.equal(view.length, 1);
assert.equal(view[0]!.role, "user");
const part = view[0]!.parts[0] as { type: string; data: unknown };
assert.equal(part.type, "data-graph-context");
assert.deepEqual(part.data, m.details);
});
});
// ── the first-send gate (the dead first turn, owner report 2026-10-04) ──────
describe("initialPromptOwed", () => {
it("a marker-only record still owes the prompt — scaffolding is not conversation", () => {
const marker = newGraphContextMessage(PICKS, []);
assert.equal(initialPromptOwed([marker as never], "drill me on logic"), true);
});
it("once a user or assistant message exists, the prompt is spent", () => {
const marker = newGraphContextMessage(PICKS, []);
const user = { role: "user" } as never;
assert.equal(initialPromptOwed([marker as never, user], "drill me"), false);
assert.equal(initialPromptOwed([{ role: "assistant" } as never], "drill me"), false);
});
it("no prompt → nothing owed (the empty-draft case)", () => {
assert.equal(initialPromptOwed([], undefined), false);
});
});
// ── the route (picks ride the creation) ─────────────────────────────────────
describe("POST /api/chats — graphContext becomes message[0]", () => {
it("writes the marker before any user message; chains derive from the live graph", async () => {
// Seed the map: two picked nodes joined by one chain.
const node = (id: string, name: string, statement: string) => ({
id, name, statement, aliases: [] as string[], createdAt: "2026-10-01T00:00:00.000Z", provenance: [],
});
graphStore.appendNode(node("n_a", "Modus ponens", "If P implies Q and P holds, Q holds."));
graphStore.appendNode(node("n_b", "Soundness", "A valid argument from true premises has a true conclusion."));
graphStore.appendChain({
id: "arg_1", premises: ["n_a"], conclusion: "n_b",
provenance: [], createdAt: "2026-10-01T00:00:00.000Z",
});
const res = await POST(new Request("http://test/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "drill me on logic",
kind: "practice",
graphContext: { propositions: PICKS },
}),
}));
assert.equal(res.status, 200);
const { id } = (await res.json()) as { id: string };
const record = chatStore.getChat(id)!;
assert.equal(record.messages.length, 1, "the marker is message[0], alone until the first turn");
const m = record.messages[0] as { role: string; customType: string; content: string };
assert.equal(m.role, "custom");
assert.equal(m.customType, "graph-context");
assert.ok(m.content.includes("- [n_a] Modus ponens: If P implies Q and P holds, Q holds."));
// the chain AMONG the picks rides the marker (formatChainLine shape — names)
assert.ok(m.content.includes("- Modus ponens → Soundness"));
});
it("no picks (or malformed) → no marker, an empty record as before", async () => {
for (const body of [{ title: "t" }, { title: "t", graphContext: { propositions: "junk" } }]) {
const res = await POST(new Request("http://test/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
const { id } = (await res.json()) as { id: string };
assert.equal(chatStore.getChat(id)!.messages.length, 0);
}
});
});