Files
learnharness/tests/practice-agents.test.ts
T
amosblomqvist 09480979c4 practice: slim the tool schemas — the modelSolution IS the reveal
Owner round (2026-10-05): the problem pose only really needs statement,
mode, target, modelSolution; conclude needs no reveal field.

- problemInputObject drops decisionPoints, expectedWrongMoves, strategy,
  rationale. Halves the pose payload (the GLM incident's failing args
  were 3.3KB — a direct weak-model hardening).
- concludeInputObject drops solutionReveal: the reveal is the
  modelSolution committed at pose time, shown verbatim at conclude.
- Doctrine pass (00-base mechanics, 10-doctrine V.4/V.5): the commitment
  is the solution alone; the reveal instruction points at it.

Fixtures slimmed to the new shapes (flow, agents, heal). ConcludeCard's
render of legacy solutionReveal input stays until the card redesign
lands (Slice C).
2026-10-05 02:22:04 +03:00

198 lines
8.3 KiB
TypeScript

/**
* THE PRACTICE AGENT (practice sessions, owner design 2026-10-04): the
* tool gating law (an agent cannot pose while a problem is active, cannot
* conclude with nothing active), the registry shape (problem + conclude
* ONLY — no question, plan, or job tools), and the prompt composition
* (practice composes its own directory alone — no teaching base, no
* shared fragments).
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
import {
activeProblemOf,
PRACTICE_CONCLUDE_TOOL,
PRACTICE_PROBLEM_TOOL,
type LhMessage,
} from "../src/core/native-messages.ts";
import { concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
import { composeInstructions, createPracticeAgent } from "../src/agent/teacher-agent.ts";
import { allSegmentPaths } from "../src/agent/prompt-segments.ts";
// ── fixtures ─────────────────────────────────────────────────────────────────
const STAMPS = {
api: "faux",
provider: "test",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
} as const;
/** A record whose latest problem call is ACTIVE (pose + ack, nothing after). */
function activeProblemRecord(): LhMessage[] {
const pose: LhMessage & AssistantMessage = {
id: "msg_a1",
role: "assistant",
content: [
{ type: "toolCall", id: "call_p1", name: PRACTICE_PROBLEM_TOOL, arguments: { statement: "Show…" } },
],
timestamp: 1,
...STAMPS,
};
const ack: LhMessage & ToolResultMessage = {
id: "msg_r1",
role: "toolResult",
toolCallId: "call_p1",
toolName: PRACTICE_PROBLEM_TOOL,
content: [{ type: "text", text: "Problem posed — the turn ends here…" }],
details: null,
isError: false,
timestamp: 2,
};
return [pose, ack];
}
function recordStore(messages: LhMessage[]) {
return {
store: {
getChat: (_id: string) => ({ messages, kind: "practice" }),
},
};
}
const ctxOf = (messages: LhMessage[]) => ({ chatId: "c1", chatStore: recordStore(messages).store });
const PROBLEM_INPUT = {
statement: "Prove the sum is even.",
mode: "recall",
target: ["parity-sum"],
modelSolution: "Pair them; each pair is even.",
} as const;
const CONCLUDE_INPUT = {
outcome: "solved",
verdict: "Clean — the pairing came unaided.",
rung: null,
missClass: null,
retest: false,
} as const;
// ── the gating law ───────────────────────────────────────────────────────────
describe("the practice tool gating law", () => {
it("problem acks on a quiet record and ends the turn (terminate)", async () => {
const result = await problemTool(createPracticeGate(ctxOf([]))).execute("call_x", PROBLEM_INPUT as never);
assert.equal(result.isError, undefined);
assert.equal(result.terminate, true);
assert.match((result.content[0] as { text: string }).text, /Problem posed/);
});
it("problem is REJECTED while a problem is active (conclude first)", async () => {
const result = await problemTool(createPracticeGate(ctxOf(activeProblemRecord()))).execute("call_x", PROBLEM_INPUT as never);
assert.equal(result.isError, true);
assert.deepEqual((result.details as { problems: string[] }).problems, [
"a problem is already active — call conclude on it before posing the next one",
]);
});
it("conclude is REJECTED with nothing active", async () => {
const result = await concludeTool(createPracticeGate(ctxOf([]))).execute("call_x", CONCLUDE_INPUT as never);
assert.equal(result.isError, true);
assert.deepEqual((result.details as { problems: string[] }).problems, [
"no active problem to conclude — pose one first",
]);
});
it("conclude acks on the active problem and the turn CONTINUES (no terminate)", async () => {
const result = await concludeTool(createPracticeGate(ctxOf(activeProblemRecord()))).execute("call_x", CONCLUDE_INPUT as never);
assert.equal(result.isError, undefined);
assert.equal(result.terminate, undefined);
});
it("THE BKT INCIDENT — a mid-turn conclude → pose pair works (the gate is turn-scoped, not record-frozen)", async () => {
// One turn, one toolset: the record froze at the write-ahead with
// the old problem active. The gates used to re-read that frozen
// record per execute, so the pose after a successful conclude
// always died on "a problem is already active" until the model
// gave up ("the problem slot is stuck").
const gate = createPracticeGate(ctxOf(activeProblemRecord()));
const tools = { problem: problemTool(gate), conclude: concludeTool(gate) };
const conclude = await tools.conclude.execute("call_c1", CONCLUDE_INPUT as never);
assert.equal(conclude.isError, undefined, "the conclude acks against the seeded record");
const pose = await tools.problem.execute("call_p2", PROBLEM_INPUT as never);
assert.equal(pose.isError, undefined, "the freed gate lets the next problem through — SAME turn");
const greedy = await tools.problem.execute("call_p3", PROBLEM_INPUT as never);
assert.equal(greedy.isError, true, "the gate re-arms on the successful pose");
});
});
// ── the registry + the agent ────────────────────────────────────────────────
describe("the practice agent", () => {
it("registry is problem + conclude ONLY — no question, plan, or job tools", () => {
const tools = practiceTools(ctxOf([]));
assert.deepEqual(Object.keys(tools).sort(), ["conclude", "problem"]);
});
it("constructs with exactly the two practice tools", () => {
// baseAgentOptions needs an engine (the BYOK thrower is the only
// fallback) — inject a never-streamed faux engine for the smoke.
const agent = createPracticeAgent(ctxOf([]), {
engine: {
model: { api: "faux", provider: "faux", id: "faux-model", contextWindow: 8192 } as never,
streamFn: (async () => {
throw new Error("not streamed in this test");
}) as never,
},
});
// The agent built with the live turn API (the registry itself is
// pinned above; pi's Agent exposes no state getter).
assert.equal(typeof agent.prompt, "function");
assert.equal(typeof agent.steer, "function");
assert.equal(typeof agent.waitForIdle, "function");
});
});
// ── the prompt composition ──────────────────────────────────────────────────
describe("practice prompt composition (own directory + the formatting appendix)", () => {
it("composes base + doctrine — never the teaching base or question/confidence mechanics", () => {
const prompt = composeInstructions("practice");
// The base's voice section and the doctrine's spine are present…
assert.match(prompt, /Everything in this section deletes a default/);
assert.match(prompt, /You run practice sessions\./);
assert.match(prompt, /## Standing prohibitions/);
// …and nothing from the teaching system prompt or shared fragments.
assert.doesNotMatch(prompt, /install understanding/);
// The shared 20-question-tools doctrine (predict/check) must not
// compose — practice has its own demand theory.
assert.doesNotMatch(prompt, /predict/i);
});
it("the SHARED formatting guide composes (owner, 2026-10-04 late): LaTeX delimiters in every setting", () => {
const prompt = composeInstructions("practice");
// The delimiter law — practice must write \(...\)/\[...\] too.
assert.match(prompt, /NEVER `\$` or `\$\$`/);
assert.ok(prompt.indexOf("# The practice session") < prompt.indexOf("## Formatting"), "formatting rides as the appendix");
});
it("base precedes doctrine (00- sorts before 10-)", () => {
const prompt = composeInstructions("practice");
assert.ok(prompt.indexOf("The agent") < prompt.indexOf("# The practice session"));
});
it("the segment registry knows practice (override paths resolve)", () => {
assert.ok(allSegmentPaths().includes("practice/00-base.md"));
assert.ok(allSegmentPaths().includes("practice/10-doctrine.md"));
});
});