practice: the learner's creation note — optional free-form input carried to partitioner + generators (owner, 2026-09-29)
- dialog: optional 'Anything else?' textarea (typing-only, 2000-char mirror) - API: body gains note? — trimmed, whitespace-only omitted, hard 400 over 2000 - pipeline: note persisted on the set and threaded to both agent stages - agents: THE LEARNER'S NOTE prompt block (present only when set) + the weighed-never-owed stance pinned in both doctrines - fix en route: doubled THE PARAMETERS heading in both prompts since slice 3 (renderParams owns the heading; the literal blocks removed)
This commit is contained in:
@@ -72,6 +72,25 @@ function silentModel(): PiEngine {
|
||||
return fauxEngine([fauxAssistantMessage("I refuse to use tools"), fauxAssistantMessage("…")]);
|
||||
}
|
||||
|
||||
/** A one-shot submitter that first records the LAST USER MESSAGE it saw —
|
||||
* for pinning what actually rode the prompt. */
|
||||
function capturingSubmitModel(name: string, input: unknown, seen: string[]): PiEngine {
|
||||
return fauxEngine([
|
||||
(context) => {
|
||||
const messages = (context as { messages: Array<{ role: string; content: unknown }> }).messages;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (m.role !== "user") continue;
|
||||
seen.push(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
||||
break;
|
||||
}
|
||||
return fauxAssistantMessage([fauxToolCall(name, input as ToolCall["arguments"], { id: `${name}-call` })], {
|
||||
stopReason: "toolUse",
|
||||
});
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── rendering ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("renderParams — named levels, never a number", () => {
|
||||
@@ -164,6 +183,30 @@ describe("runPartitioner", () => {
|
||||
/the partitioner never submitted a partition/,
|
||||
);
|
||||
});
|
||||
|
||||
it("the learner's note: rides the prompt as its own block when present, absent when unset", async () => {
|
||||
const seen: string[] = [];
|
||||
const agents = createPracticeAgents({
|
||||
partitioner: capturingSubmitModel("submit_partition", { partitions: [{ chains: ["n3", "n2"] }] }, seen),
|
||||
});
|
||||
await agents.runPartitioner({
|
||||
chains,
|
||||
propositions,
|
||||
params,
|
||||
previousProblems: [],
|
||||
note: "one big problem, please — and lean on pricing",
|
||||
});
|
||||
assert.equal(seen.length, 1);
|
||||
assert.ok(seen[0]!.includes("THE LEARNER'S NOTE"), "the block title");
|
||||
assert.ok(seen[0]!.includes("one big problem, please — and lean on pricing"), "the note text");
|
||||
|
||||
const seenBare: string[] = [];
|
||||
const bare = createPracticeAgents({
|
||||
partitioner: capturingSubmitModel("submit_partition", { partitions: [{ chains: ["n3", "n2"] }] }, seenBare),
|
||||
});
|
||||
await bare.runPartitioner({ chains, propositions, params, previousProblems: [] });
|
||||
assert.ok(!seenBare[0]!.includes("THE LEARNER'S NOTE"), "no block when no note");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the problem generator ───────────────────────────────────────────────────
|
||||
@@ -218,6 +261,31 @@ describe("runProblemGenerator", () => {
|
||||
const agents = createPracticeAgents({ problemGenerator: silentModel() });
|
||||
await assert.rejects(agents.runProblemGenerator(generatorInput), /the problem generator never submitted a problem/);
|
||||
});
|
||||
|
||||
it("the learner's note: rides the prompt when present, absent when unset", async () => {
|
||||
const seen: string[] = [];
|
||||
const agents = createPracticeAgents({
|
||||
problemGenerator: capturingSubmitModel(
|
||||
"submit_problem",
|
||||
{ chains: ["n3"], statement: "…", solution: "…", rubric: "…" },
|
||||
seen,
|
||||
),
|
||||
});
|
||||
await agents.runProblemGenerator({ ...generatorInput, note: "situations from central banking, if you can" });
|
||||
assert.ok(seen[0]!.includes("THE LEARNER'S NOTE"));
|
||||
assert.ok(seen[0]!.includes("situations from central banking, if you can"));
|
||||
|
||||
const seenBare: string[] = [];
|
||||
const bare = createPracticeAgents({
|
||||
problemGenerator: capturingSubmitModel(
|
||||
"submit_problem",
|
||||
{ chains: ["n3"], statement: "…", solution: "…", rubric: "…" },
|
||||
seenBare,
|
||||
),
|
||||
});
|
||||
await bare.runProblemGenerator(generatorInput);
|
||||
assert.ok(!seenBare[0]!.includes("THE LEARNER'S NOTE"), "no block when no note");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the grader ──────────────────────────────────────────────────────────────
|
||||
@@ -291,6 +359,13 @@ describe("refusal gates (BYOK honest refusal, extraction's pattern)", () => {
|
||||
// ─── formatting doctrine (owner-approved 2026-09-29) ─────────────────────────
|
||||
|
||||
describe("formatting doctrine pins", () => {
|
||||
it("GENERATOR_DOCTRINE pins the learner-note stance (weighed, never owed)", () => {
|
||||
assert.ok(
|
||||
GENERATOR_DOCTRINE.includes("- THE LEARNER'S NOTE (optional): free-form text the learner attached when creating the set"),
|
||||
);
|
||||
assert.ok(GENERATOR_DOCTRINE.includes("never let it move a problem off its rung or out of its band"));
|
||||
});
|
||||
|
||||
it("GENERATOR_DOCTRINE carries the FORMATTING bullet verbatim (Markdown + LaTeX + fenced code + tables/lists)", () => {
|
||||
assert.ok(
|
||||
GENERATOR_DOCTRINE.includes(
|
||||
|
||||
@@ -183,6 +183,45 @@ function partitionModel(partitions: Array<{ chains: string[] }>): PiEngine {
|
||||
]);
|
||||
}
|
||||
|
||||
/** A partitioner that RECORDS the prompt it saw before submitting — the
|
||||
* learner-note thread pin. */
|
||||
function capturingPartitioner(partitions: Array<{ chains: string[] }>, seen: string[]): PiEngine {
|
||||
return fauxEngine([
|
||||
(context) => {
|
||||
seen.push(lastUserText(context as never));
|
||||
return fauxAssistantMessage(
|
||||
[fauxToolCall("submit_partition", { partitions } as ToolCall["arguments"], { id: "p-call" })],
|
||||
{ stopReason: "toolUse" },
|
||||
);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** An echo generator that RECORDS each prompt before submitting. */
|
||||
function capturingEchoGenerator(seen: string[], count: number): PiEngine {
|
||||
const factory: FauxResponseStep = (context) => {
|
||||
const prompt = lastUserText(context as never);
|
||||
seen.push(prompt);
|
||||
const target = targetOf(prompt);
|
||||
return fauxAssistantMessage(
|
||||
[
|
||||
fauxToolCall(
|
||||
"submit_problem",
|
||||
{
|
||||
chains: target,
|
||||
statement: `Problem on ${target.join(", ")}`,
|
||||
solution: "The model solution.",
|
||||
rubric: "- names it",
|
||||
} as ToolCall["arguments"],
|
||||
{ id: "g-call" },
|
||||
),
|
||||
],
|
||||
{ stopReason: "toolUse" },
|
||||
);
|
||||
};
|
||||
return fauxEngine(Array.from({ length: count }, () => factory));
|
||||
}
|
||||
|
||||
/** Hangs until the call's signal aborts (then resolves no submit call) —
|
||||
* the extraction.test.ts cancellation-mimicking idiom. Queue one step
|
||||
* per concurrent call (faux queues are finite). */
|
||||
@@ -360,6 +399,43 @@ describe("createProblemSet (generation)", () => {
|
||||
assert.equal(practiceStatuses().length, 0);
|
||||
});
|
||||
|
||||
it("the learner's note threads to the partitioner + every generator and persists on the set (owner, 2026-09-29)", async () => {
|
||||
const partSeen: string[] = [];
|
||||
const genSeen: string[] = [];
|
||||
const f = fixture({
|
||||
partitioner: capturingPartitioner([{ chains: ["b"] }, { chains: ["e"] }], partSeen),
|
||||
problemGenerator: capturingEchoGenerator(genSeen, 2),
|
||||
grader: echoGrader(1),
|
||||
});
|
||||
const pipeline = f.make();
|
||||
const { set, done } = await pipeline.createProblemSet(["a", "b", "e"], PARAMS, undefined, "keep it geometric");
|
||||
await done;
|
||||
|
||||
assert.equal(set.note, "keep it geometric", "persisted at creation");
|
||||
assert.equal(f.practice.store.getSet(set.id)!.note, "keep it geometric", "survives the store round-trip");
|
||||
assert.ok(partSeen[0]!.includes("THE LEARNER'S NOTE"), "rode the partitioner prompt");
|
||||
assert.ok(partSeen[0]!.includes("keep it geometric"));
|
||||
assert.equal(genSeen.length, 2);
|
||||
for (const prompt of genSeen) {
|
||||
assert.ok(prompt.includes("THE LEARNER'S NOTE"), "rode every generator prompt");
|
||||
assert.ok(prompt.includes("keep it geometric"));
|
||||
}
|
||||
|
||||
// No note → no block, anywhere (optional in every sense).
|
||||
const partSeenBare: string[] = [];
|
||||
const genSeenBare: string[] = [];
|
||||
const f2 = fixture({
|
||||
partitioner: capturingPartitioner([{ chains: ["b", "e"] }], partSeenBare),
|
||||
problemGenerator: capturingEchoGenerator(genSeenBare, 1),
|
||||
grader: echoGrader(1),
|
||||
});
|
||||
const s2 = await f2.make().createProblemSet(["a", "b", "e"], PARAMS);
|
||||
await s2.done;
|
||||
assert.equal(s2.set.note, undefined);
|
||||
assert.ok(!partSeenBare[0]!.includes("THE LEARNER'S NOTE"));
|
||||
assert.ok(!genSeenBare[0]!.includes("THE LEARNER'S NOTE"));
|
||||
});
|
||||
|
||||
it("per-problem failure isolation: one generator fails, the set still completes", async () => {
|
||||
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["e"] }]), problemGenerator: echoGenerator(8, ["e"]), grader: echoGrader(1) });
|
||||
const pipeline = f.make();
|
||||
|
||||
@@ -41,3 +41,29 @@ describe("createPracticeSetResponse — strict named-level params", () => {
|
||||
assert.equal(await statusOf("high"), 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPracticeSetResponse — the learner's note (owner, 2026-09-29)", () => {
|
||||
const PARAMS = { ladderRung: 2, cueDistance: 1, presentation: "theory" } as const;
|
||||
|
||||
it("non-string note → 400", async () => {
|
||||
const res = await createPracticeSetResponse({ ...NODE_IDS, params: PARAMS, note: 42 });
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it("over 2000 characters → 400 (the API is the law; the textarea's maxLength mirrors it)", async () => {
|
||||
const res = await createPracticeSetResponse({ ...NODE_IDS, params: PARAMS, note: "x".repeat(2001) });
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
assert.match(body.error, /over 2000 characters/);
|
||||
});
|
||||
|
||||
it("exactly 2000 passes the note check (boundary — the next check, not the note, refuses)", async () => {
|
||||
// Malformed nodeIds AFTER a max-length note: reaching the nodeIds
|
||||
// error proves the note check passed (all checks are rejecting-path;
|
||||
// the pipeline is never reached — this suite's doctrine).
|
||||
const res = await createPracticeSetResponse({ nodeIds: 5, params: PARAMS, note: "x".repeat(2000) });
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
assert.match(body.error, /Body must be/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,13 @@ describe("isProblemSet", () => {
|
||||
assert.ok(!isProblemSet({ ...init(), status: "cooked" }));
|
||||
});
|
||||
|
||||
it("the learner's note: optional string, round-trips; non-string refused (owner, 2026-09-29)", () => {
|
||||
assert.ok(isProblemSet({ ...init({ note: "keep it geometric" }), id: "ps_noted" }));
|
||||
assert.ok(!isProblemSet({ ...init({ note: 42 as unknown as string }), id: "ps_badnote" }));
|
||||
const created = practiceStore.createSet(init({ note: "lean toward pricing" }));
|
||||
assert.equal(practiceStore.getSet(created.id)!.note, "lean toward pricing");
|
||||
});
|
||||
|
||||
it("tolerates optional fields — a minimal stub (no error/answer/grade) reads", () => {
|
||||
const minimal = { ...init({ problems: [{ id: "pp_1", chains: [], statement: "", solution: "", rubric: "", status: "generating" }] }), id: "ps_valid" };
|
||||
assert.ok(isProblemSet(minimal));
|
||||
|
||||
@@ -87,6 +87,8 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
|
||||
const base = {
|
||||
params: { ...DEFAULT_PRACTICE_PARAMS },
|
||||
onParam: () => {},
|
||||
note: "",
|
||||
onNote: () => {},
|
||||
clusters: clusters(),
|
||||
selectedKey: null,
|
||||
onSelect: () => {},
|
||||
@@ -126,6 +128,17 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
|
||||
assert.match(moved, /param-presentation-application[^]*border-primary/, "application segment active");
|
||||
});
|
||||
|
||||
it("the learner's note: optional textarea under the params, carries text, capped client-side", () => {
|
||||
const html = renderToStaticMarkup(<PracticeDialogBody {...base} note="lean toward pricing applications" />);
|
||||
assert.ok(html.includes('data-testid="practice-note"'));
|
||||
assert.ok(html.includes("Anything else? (optional)"), "the label");
|
||||
assert.ok(html.includes("lean toward pricing applications"), "controlled value renders");
|
||||
assert.match(html, /maxlength="2000"/i, "client-side cap mirrors the API law");
|
||||
// Empty default: present but empty — optional in every sense.
|
||||
const bare = renderToStaticMarkup(<PracticeDialogBody {...base} />);
|
||||
assert.match(bare, /data-testid="practice-note"[^>]*><\/textarea>/, "no value when unset");
|
||||
});
|
||||
|
||||
it("cluster picker: label + proposition-count rows, notes kind marked, Create gated until picked", () => {
|
||||
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
|
||||
assert.ok(html.includes('data-testid="practice-dialog-clusters"'));
|
||||
|
||||
Reference in New Issue
Block a user