2026-09-28 16:54:44 +03:00
|
|
|
/**
|
|
|
|
|
* The shared session-markdown engine (core/session-markdown.ts, owner ruling
|
|
|
|
|
* 2026-09-27): ONE renderer for the extractor's source view and the future
|
|
|
|
|
* user-facing session export — id-anchored speaker blocks, readable
|
|
|
|
|
* quiz/recall/question blocks, one-line notes for other tools. Pinned here:
|
|
|
|
|
* block shapes, anchor handling (on/off), title header, empty-message
|
|
|
|
|
* skipping, and content parity with the coverage line builder.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
import { describe, it } from "node:test";
|
|
|
|
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
|
|
|
|
import { messageMarkdown, sessionMarkdown } from "../src/core/session-markdown.ts";
|
|
|
|
|
import { messageViewLines } from "../src/core/coverage.ts";
|
|
|
|
|
import { coverageSlots } from "../src/core/coverage.ts";
|
|
|
|
|
|
|
|
|
|
function user(id: string, text: string): LhMessage {
|
|
|
|
|
return { id, role: "user", content: text, timestamp: Date.now() } as never;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function teacher(id: string, text: string): LhMessage {
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: [{ type: "text", text }],
|
|
|
|
|
api: "openai-completions",
|
|
|
|
|
provider: "openrouter",
|
|
|
|
|
model: "test",
|
|
|
|
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
|
|
|
stopReason: "stop",
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
} as never;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function result(
|
|
|
|
|
id: string,
|
|
|
|
|
toolCallId: string,
|
|
|
|
|
toolName: string,
|
|
|
|
|
details: unknown,
|
|
|
|
|
isError = false,
|
|
|
|
|
): LhMessage {
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
role: "toolResult",
|
|
|
|
|
toolCallId,
|
|
|
|
|
toolName,
|
|
|
|
|
content: [{ type: "text", text: "ok" }],
|
|
|
|
|
details,
|
|
|
|
|
isError,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
} as never;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 10:23:16 +03:00
|
|
|
/** An assistant message carrying one toolCall part (the practice join's
|
|
|
|
|
* call side — args live HERE, the results are bare acks). */
|
|
|
|
|
function callMsg(id: string, callId: string, name: string, args: unknown): LhMessage {
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: [{ type: "toolCall", id: callId, name, arguments: args }],
|
|
|
|
|
api: "openai-completions",
|
|
|
|
|
provider: "openrouter",
|
|
|
|
|
model: "test",
|
|
|
|
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
|
|
|
stopReason: "stop",
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
} as never;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 16:54:44 +03:00
|
|
|
describe("messageMarkdown", () => {
|
|
|
|
|
it("renders speaker blocks with the id anchor", () => {
|
|
|
|
|
const md = messageMarkdown(user("u1", "teach me the chain rule"), "u1");
|
|
|
|
|
assert.equal(md, "### Learner · u1\n\nteach me the chain rule");
|
|
|
|
|
const md2 = messageMarkdown(teacher("a1", "The derivative measures change."), "a1");
|
|
|
|
|
assert.equal(md2, "### Teacher · a1\n\nThe derivative measures change.");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("anchors off — the same document without the ids (the user export)", () => {
|
|
|
|
|
const md = messageMarkdown(user("u1", "hello"), "u1", false);
|
|
|
|
|
assert.equal(md, "### Learner\n\nhello");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("quiz blocks: question + outcome + the learner's note", () => {
|
|
|
|
|
const md = messageMarkdown(
|
|
|
|
|
result("r1", "q1", "quiz", { questionText: "What is a derivative?", correct: false, dontKnow: false, note: "mixed up signs" }),
|
|
|
|
|
"r1",
|
|
|
|
|
)!;
|
|
|
|
|
assert.match(md, /^### Quiz · r1$/m);
|
|
|
|
|
assert.match(md, /\*\*Q:\*\* What is a derivative\?/);
|
|
|
|
|
assert.match(md, /The learner answered incorrectly\./);
|
|
|
|
|
assert.match(md, /\*\(his thinking: mixed up signs\)\*/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("explain results: the learner's own words are shown", () => {
|
|
|
|
|
const md = messageMarkdown(
|
|
|
|
|
result("r1", "e1", "explain", { questionText: "Why does Mercury stay near the Sun?", text: "it never strays far" }),
|
|
|
|
|
"r1",
|
|
|
|
|
)!;
|
|
|
|
|
assert.match(md, /^### Recall check · r1$/m);
|
|
|
|
|
assert.match(md, /The learner answered in his own words: it never strays far/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("ask blocks: question + chosen options + free text", () => {
|
|
|
|
|
const chosen = messageMarkdown(
|
|
|
|
|
result("r1", "a1", "ask_user_question", { questionText: "Which way?", answers: ["second"], other: "also the third" }),
|
|
|
|
|
"r1",
|
|
|
|
|
)!;
|
|
|
|
|
assert.match(chosen, /^### Question · r1$/m);
|
|
|
|
|
assert.match(chosen, /The learner chose: second, also the third/);
|
|
|
|
|
const free = messageMarkdown(
|
|
|
|
|
result("r2", "a2", "ask_user_question", { questionText: "Why?", text: "because" }),
|
|
|
|
|
"r2",
|
|
|
|
|
)!;
|
|
|
|
|
assert.match(free, /The learner answered: because/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("other clean tool results: one line; failed calls and markers: nothing", () => {
|
|
|
|
|
assert.equal(messageMarkdown(result("r1", "j1", "research", { brief: "long" }), "r1"), "### Tool · r1\n\n*research result delivered*");
|
|
|
|
|
assert.equal(messageMarkdown(result("r2", "j2", "research", {}, true), "r2"), null, "a failed call is retry context");
|
|
|
|
|
assert.equal(messageMarkdown(user("u3", " "), "u3"), null, "empty text renders nothing");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-06 10:23:16 +03:00
|
|
|
describe("sessionMarkdown — the practice join (owner-approved, 2026-10-06)", () => {
|
|
|
|
|
it("a problem result renders the POSE's statement, joined off the call id", () => {
|
|
|
|
|
const messages = [
|
|
|
|
|
callMsg("a1", "call-p1", "problem", {
|
|
|
|
|
statement: "Push the prior through the learning transition.",
|
|
|
|
|
mode: "recall",
|
|
|
|
|
target: [],
|
|
|
|
|
modelSolution: "Two phases, in forced order.",
|
|
|
|
|
}),
|
|
|
|
|
result("r1", "call-p1", "problem", null),
|
|
|
|
|
];
|
|
|
|
|
const md = sessionMarkdown(messages);
|
|
|
|
|
assert.ok(md.includes("### Problem"), "the Problem heading renders");
|
|
|
|
|
assert.ok(md.includes("Push the prior through the learning transition."), "the statement renders");
|
|
|
|
|
assert.ok(!md.includes("Two phases"), "the solution does NOT leak at pose time");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("a conclude result renders outcome + verdict + the pose's model solution as the reveal", () => {
|
|
|
|
|
const messages = [
|
|
|
|
|
callMsg("a1", "call-p1", "problem", {
|
|
|
|
|
statement: "Compute P(knows | correct).",
|
|
|
|
|
modelSolution: "Bayes, twice over.",
|
|
|
|
|
}),
|
|
|
|
|
result("r1", "call-p1", "problem", null),
|
|
|
|
|
callMsg("a2", "call-c1", "conclude", { outcome: "solvedWithHints", verdict: "Right answer, one nudge." }),
|
|
|
|
|
result("r2", "call-c1", "conclude", null),
|
|
|
|
|
];
|
|
|
|
|
const md = sessionMarkdown(messages);
|
|
|
|
|
assert.ok(md.includes("### Concluded"), "the Concluded heading renders");
|
|
|
|
|
assert.ok(md.includes("**Solved with hints.** Right answer, one nudge."), "outcome reads as prose, then the verdict");
|
|
|
|
|
assert.ok(md.includes("> **Model solution**"), "the reveal block quotes the committed solution");
|
|
|
|
|
assert.ok(md.includes("> Bayes, twice over."), "the solution text carries the blockquote prefix");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("a dismissed problem concludes without leaking the model solution", () => {
|
|
|
|
|
const messages = [
|
|
|
|
|
callMsg("a1", "call-p1", "problem", { statement: "Derive the posterior.", modelSolution: "The answer." }),
|
|
|
|
|
result("r1", "call-p1", "problem", null),
|
|
|
|
|
callMsg("a2", "call-c1", "conclude", { outcome: "dismissed" }),
|
|
|
|
|
result("r2", "call-c1", "conclude", null),
|
|
|
|
|
];
|
|
|
|
|
const md = sessionMarkdown(messages);
|
|
|
|
|
assert.ok(md.includes("The learner skipped this problem."), "the dismissal reads as a skip");
|
|
|
|
|
assert.ok(!md.includes("The answer."), "no reveal on a skipped problem — the card's refusal law");
|
|
|
|
|
assert.ok(md.includes("### Problem"), "the statement still renders (it was on screen)");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("an errored pose renders nothing — the failed-tool law holds for practice", () => {
|
|
|
|
|
const messages = [
|
|
|
|
|
callMsg("a1", "call-p1", "problem", { statement: "Never acked." }),
|
|
|
|
|
result("r1", "call-p1", "problem", null, true),
|
|
|
|
|
];
|
|
|
|
|
const md = sessionMarkdown(messages);
|
|
|
|
|
assert.ok(!md.includes("### Problem"), "no problem block");
|
|
|
|
|
assert.ok(!md.includes("Never acked."), "no statement either — the pose never happened");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-28 16:54:44 +03:00
|
|
|
describe("sessionMarkdown", () => {
|
|
|
|
|
it("assembles blocks in order under the title header, skipping nothings", () => {
|
|
|
|
|
const md = sessionMarkdown([user("u1", "go"), teacher("a1", "First."), user("u2", " ")], { title: "My session" });
|
|
|
|
|
assert.equal(md, "# My session\n\n### Learner · u1\n\ngo\n\n### Teacher · a1\n\nFirst.");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("no title → no header; anchors default on", () => {
|
|
|
|
|
const md = sessionMarkdown([user("u1", "go")]);
|
|
|
|
|
assert.equal(md, "### Learner · u1\n\ngo");
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-28 22:37:18 +03:00
|
|
|
it("the seam split (owner, 2026-09-28): teacher prose renders in BOTH; the rest renders ONLY here", () => {
|
|
|
|
|
// The extraction view became TEACHER-PROSE-ONLY: its line builder
|
|
|
|
|
// yields lines for teacher text alone. This engine keeps the FULL
|
|
|
|
|
// session format for the future user-facing export — so parity now
|
|
|
|
|
// holds exactly for teacher prose, and everything else (learner,
|
|
|
|
|
// quiz, tool results) is markdown-only.
|
2026-09-28 16:54:44 +03:00
|
|
|
const messages: LhMessage[] = [
|
|
|
|
|
user("u1", "teach me"),
|
|
|
|
|
teacher("a1", "The chain rule multiplies local rates."),
|
|
|
|
|
result("r1", "q1", "quiz", { questionText: "Q?", correct: true, dontKnow: false }),
|
|
|
|
|
result("r2", "j1", "research", { brief: "b" }),
|
|
|
|
|
result("r3", "j2", "research", {}, true),
|
|
|
|
|
];
|
|
|
|
|
for (const message of messages) {
|
2026-09-28 22:37:18 +03:00
|
|
|
const isTeacher = (message as { role?: string }).role === "assistant";
|
2026-09-28 16:54:44 +03:00
|
|
|
const hasLines = messageViewLines(message, message.id).length > 0;
|
|
|
|
|
const hasMarkdown = messageMarkdown(message, message.id) !== null;
|
2026-09-28 22:37:18 +03:00
|
|
|
assert.equal(hasLines, isTeacher && hasMarkdown, `view lines follow teacher prose for ${message.id}`);
|
2026-09-28 16:54:44 +03:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|