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).
216 lines
9.4 KiB
TypeScript
216 lines
9.4 KiB
TypeScript
/**
|
|
* Practice-flow integration test (practice sessions, owner design
|
|
* 2026-10-04) — the full practice loop with zero tokens, mirroring
|
|
* chat-flow.test.ts's harness (handleChatRequest + the faux engine):
|
|
*
|
|
* learner opens → practice agent poses (problem, terminate) → thinking
|
|
* arrives as a labeled message → agent concludes + reveals + wraps
|
|
* → Esc dismissal synthesizes the conclude pair on the next input.
|
|
*
|
|
* The record law under pin: the pose ack PERSISTS (unlike a question's
|
|
* placeholder) and a record ending on it owes no continuation.
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { after, describe, it } from "node:test";
|
|
import {
|
|
createFauxCore,
|
|
fauxAssistantMessage,
|
|
fauxText,
|
|
fauxToolCall,
|
|
type AssistantMessage,
|
|
type FauxResponseFactory,
|
|
type FauxResponseStep,
|
|
type TranscriptContext,
|
|
} from "@earendil-works/pi-ai";
|
|
import type { PiEngine } from "../src/agent/models.ts";
|
|
import { createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
|
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
|
import { chatStore } from "../src/store/chats.ts";
|
|
import { activeProblemOf, needsContinuation } from "../src/core/index.ts";
|
|
import { PATHS } from "../config.ts";
|
|
import { CONCLUDE_ACK_TEXT } from "../src/core/contracts/chat.ts";
|
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
|
|
// The BYOK key-presence gate reads env-first (chat-flow.test.ts's idiom).
|
|
process.env.OPENROUTER_API_KEY ??= "test-key-presence-gate";
|
|
|
|
/** A valid problem pose — the slim schema: statement, mode, target,
|
|
* modelSolution (the commitment, which is also the reveal). */
|
|
const POSE = {
|
|
statement: "Prove that every finite group of prime order is cyclic.",
|
|
mode: "recall",
|
|
target: ["group-order"],
|
|
modelSolution: "Let |G| = p. Any x ≠ e generates a subgroup whose order divides p…",
|
|
} as const;
|
|
|
|
/** A valid conclude — no reveal field; the pose's modelSolution is the reveal. */
|
|
const CONCLUDE = {
|
|
outcome: "solved",
|
|
verdict: "The subgroup-order argument is credited in full.",
|
|
rung: null,
|
|
missClass: null,
|
|
retest: false,
|
|
} as const;
|
|
|
|
const createdChats: string[] = [];
|
|
after(() => {
|
|
for (const id of createdChats) {
|
|
try {
|
|
fs.unlinkSync(path.join(PATHS.chats, `${id}.json`));
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
}
|
|
});
|
|
|
|
function createPracticeChat(title: string) {
|
|
const chat = chatStore.createChat(title, "practice");
|
|
chatStore.saveSettings(chat.id, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } });
|
|
createdChats.push(chat.id);
|
|
return chat;
|
|
}
|
|
|
|
function post(chatId: string, input: unknown): Request {
|
|
return new Request("http://test/api/chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ chatId, input }),
|
|
});
|
|
}
|
|
|
|
async function drain(res: Response): Promise<string> {
|
|
if (res.status !== 200) {
|
|
assert.fail(`expected 200, got ${res.status}: ${await res.text()}`);
|
|
}
|
|
return res.text();
|
|
}
|
|
|
|
/** The practice agent factory (the handler's kind branch, test-shaped). */
|
|
function practiceFactory(chatId: string, engine: PiEngine): Parameters<typeof handleChatRequest>[1] {
|
|
return (opts) => createPracticeAgent({ chatId, chatStore }, { engine, messages: opts.messages });
|
|
}
|
|
|
|
/** Each step records the prompt it received — the transcript the model
|
|
* saw (the chat-flow capture idiom). */
|
|
function scripted(steps: FauxResponseStep[]): { engine: PiEngine; calls: string[] } {
|
|
const calls: string[] = [];
|
|
const faux = createFauxCore({});
|
|
const wrap = (step: FauxResponseStep) =>
|
|
async (context: TranscriptContext): Promise<AssistantMessage> => {
|
|
calls.push(
|
|
context.messages.map((m) => JSON.stringify((m as { content?: unknown }).content)).join("\n"),
|
|
);
|
|
return typeof step === "function"
|
|
? await (step as FauxResponseFactory)(context, undefined, undefined as never, undefined as never)
|
|
: step;
|
|
};
|
|
faux.setResponses(steps.map(wrap));
|
|
return { engine: { model: faux.getModel(), streamFn: faux.streamSimple }, calls };
|
|
}
|
|
|
|
const lastSaw = (calls: string[], needle: string) => calls[calls.length - 1]!.includes(needle);
|
|
|
|
describe("practice flow (scripted model)", () => {
|
|
it("pose → thinking commit → conclude + reveal; the pose ack persists and owes nothing", async () => {
|
|
const chat = createPracticeChat("Test: practice groups");
|
|
const { engine, calls } = scripted([
|
|
// Turn 1: the agent poses immediately (terminate:true ends the run).
|
|
() => fauxAssistantMessage([fauxToolCall("problem", POSE, { id: "call-p1" })], { stopReason: "toolUse" }),
|
|
// Turn 2 (after the commit): conclude (non-terminating) …
|
|
() => fauxAssistantMessage([fauxToolCall("conclude", CONCLUDE, { id: "call-c1" })], { stopReason: "toolUse" }),
|
|
// … the loop continues: reveal text ends the turn.
|
|
() => fauxAssistantMessage([fauxText("Clean proof. Ready for the next one?")]),
|
|
]);
|
|
const factory = practiceFactory(chat.id, engine);
|
|
|
|
// ── Turn 1: the opening text; the agent poses ──
|
|
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "practice group theory" }), factory));
|
|
const afterPose = chatStore.getChat(chat.id)!.messages;
|
|
const ack = afterPose[afterPose.length - 1] as LhMessage & ToolResultMessage;
|
|
assert.equal(ack.role, "toolResult");
|
|
assert.equal(ack.toolName, "problem");
|
|
assert.equal(ack.isError, false);
|
|
assert.equal(needsContinuation(afterPose), false);
|
|
assert.equal(activeProblemOf(afterPose)?.toolCallId, "call-p1");
|
|
|
|
// ── Turn 2: the commit arrives as a labeled thinking message ──
|
|
await drain(
|
|
await handleChatRequest(
|
|
post(chat.id, { type: "thinking", problemCallId: "call-p1", commit: true, text: "|G|=p; ⟨x⟩ has order dividing p" }),
|
|
factory,
|
|
),
|
|
);
|
|
// The model saw the COMMIT label, not the hint label.
|
|
assert.ok(lastSaw(calls, "Committed answer:"), "the conclude turn's prompt carries the committed answer");
|
|
const afterConclude = chatStore.getChat(chat.id)!.messages;
|
|
const concludeAck = afterConclude.find(
|
|
(m) => (m as ToolResultMessage).role === "toolResult" && (m as ToolResultMessage).toolName === "conclude",
|
|
) as LhMessage & ToolResultMessage;
|
|
assert.ok(concludeAck, "the conclude ack persisted");
|
|
assert.ok((concludeAck.content[0] as { text: string }).text.includes("composer is free"));
|
|
// The turn continued past conclude to the reveal text.
|
|
assert.ok(afterConclude.some((m) => (m as { role?: string }).role === "assistant" && JSON.stringify((m as { content?: unknown }).content).includes("Clean proof.")));
|
|
assert.equal(activeProblemOf(afterConclude), null);
|
|
});
|
|
|
|
it("the nudge (commit:false) carries the hint label", async () => {
|
|
const chat = createPracticeChat("Test: practice nudge");
|
|
const { engine, calls } = scripted([
|
|
() => fauxAssistantMessage([fauxToolCall("problem", POSE, { id: "call-p2" })], { stopReason: "toolUse" }),
|
|
() => fauxAssistantMessage([fauxText("R0: reread the subgroup constraint.")]),
|
|
]);
|
|
const factory = practiceFactory(chat.id, engine);
|
|
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "go" }), factory));
|
|
await drain(
|
|
await handleChatRequest(
|
|
post(chat.id, { type: "thinking", problemCallId: "call-p2", commit: false, text: "stuck at the second step" }),
|
|
factory,
|
|
),
|
|
);
|
|
assert.ok(lastSaw(calls, "Thinking trace (working, not committed):"), "the nudge turn's prompt carries the hint label");
|
|
// The problem is still active — a nudge never concludes.
|
|
assert.equal(activeProblemOf(chatStore.getChat(chat.id)!.messages)?.toolCallId, "call-p2");
|
|
});
|
|
|
|
it("Esc dismissal marks silently; the next input rides the synthesized conclude pair", async () => {
|
|
const chat = createPracticeChat("Test: practice dismiss");
|
|
const { engine, calls } = scripted([
|
|
() => fauxAssistantMessage([fauxToolCall("problem", POSE, { id: "call-p3" })], { stopReason: "toolUse" }),
|
|
() => fauxAssistantMessage([fauxText("Dropped. Something else, then.")]),
|
|
]);
|
|
const factory = practiceFactory(chat.id, engine);
|
|
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "go" }), factory));
|
|
|
|
// The silent dismissal route (the same POST the client's Esc takes).
|
|
const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts");
|
|
const dismissRes = await dismissRoute.POST(
|
|
new Request(`http://test/api/chats/${chat.id}/dismiss`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ input: { type: "dismiss-problem", problemCallId: "call-p3" } }),
|
|
}),
|
|
{ params: Promise.resolve({ id: chat.id }) },
|
|
);
|
|
assert.equal(dismissRes.status, 200);
|
|
const marked = chatStore.getChat(chat.id)!.messages;
|
|
assert.equal(activeProblemOf(marked), null, "the marker deactivates at Esc time");
|
|
|
|
// The next plain input synthesizes the conclude pair ahead of itself.
|
|
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "something easier" }), factory));
|
|
const msgs = chatStore.getChat(chat.id)!.messages;
|
|
const synth = msgs.find(
|
|
(m) => (m as ToolResultMessage).role === "toolResult" && (m as ToolResultMessage).toolName === "conclude",
|
|
) as (LhMessage & ToolResultMessage) | undefined;
|
|
assert.ok(synth, "the synthesized conclude result persisted");
|
|
assert.ok(
|
|
(synth.content[0] as { text: string }).text.includes(CONCLUDE_ACK_TEXT),
|
|
"the pair carries the shared conclude ack text",
|
|
);
|
|
assert.ok(lastSaw(calls, "dismissed"), "the model saw the dismissed outcome");
|
|
});
|
|
});
|