tool-result diet: question answers return only learner-added truth (compact JSON for the model, full record in details); dead-code sweep
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
## Confidence — reading the learner's self-assessment
|
||||
|
||||
Every quiz answer arrives with a `Confidence: N%` line: the learner's stated probability that his selected answer is correct, set on a snapped slider (0, 30, 50, 70, 90, 100). This is calibration data, not decoration. Read it against the verdict:
|
||||
Every quiz answer arrives as a compact JSON record; its `confidence` field is the learner's stated probability that his selected answer is correct, set on a snapped slider (0, 30, 50, 70, 90, 100). This is calibration data, not decoration. Read it against the verdict:
|
||||
|
||||
- 50 is a genuine coin flip. 90–100 claims certainty; 0–30 claims real doubt.
|
||||
- For an n-option question, chance is 1/n. Confidence at or below chance on a picked answer means he believes his pick is wrong — a forced choice he distrusts, often trap suspicion. Treat it as honesty, not error.
|
||||
@@ -8,4 +8,4 @@ Every quiz answer arrives with a `Confidence: N%` line: the learner's stated pro
|
||||
- Correct + low confidence: fragile knowledge or a lucky guess — worth one reinforcing touch, not celebration.
|
||||
- Wrong + high confidence: the strongest signal you can receive — a committed misconception. Name the crack, probe it gently, and re-teach around it.
|
||||
- Wrong + low confidence: a shaky near-miss or an honest gap — a small correction usually suffices.
|
||||
- "I don't know" carries no confidence line: he declined to guess. A genuine gap, never a wrong answer.
|
||||
- "I don't know" (`dontKnow: true`) carries no confidence: he declined to guess. A genuine gap, never a wrong answer.
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||
import { QUESTION_TOOLS } from "./pending.ts";
|
||||
import { HANDOFF_TEXT } from "./contracts/chat.ts";
|
||||
import { compactAnswerForModel, type QuestionAnswerData } from "./question-core.ts";
|
||||
|
||||
/** The hard-invariant wording for an aborted turn's unanswered calls —
|
||||
* carried over verbatim from the pre-native era so the model's view
|
||||
@@ -103,9 +104,11 @@ export function newUserMessage(text: string): LhMessage & UserMessage {
|
||||
}
|
||||
|
||||
/** The learner's answer — the question call's tool result (fork 1).
|
||||
* `answer` is the structured QuestionAnswerData the client graded; the
|
||||
* MODEL sees its JSON encoding (the pre-native wire shape),
|
||||
* the UI sees the structured payload via `details`. */
|
||||
* TWO AUDIENCES (owner, tool-result diet 2026-10-02): the MODEL sees the
|
||||
* compact learner-only record (compactAnswerForModel — the call's own
|
||||
* arguments already carry the question/options/explanation/commitment;
|
||||
* restating them doubled every answer's tokens), while `details` keeps
|
||||
* the FULL graded QuestionAnswerData for the record and the UI. */
|
||||
export function newAnswerResult(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
@@ -116,7 +119,7 @@ export function newAnswerResult(
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName,
|
||||
content: [{ type: "text", text: JSON.stringify(answer) }],
|
||||
content: [{ type: "text", text: JSON.stringify(compactAnswerForModel(answer as QuestionAnswerData)) }],
|
||||
// 0.87.1 narrowed ToolResultMessage.details to JsonRepresentation —
|
||||
// our payloads are JSON-safe answer structures, so the cast is honest.
|
||||
details: answer as ToolResultMessage["details"],
|
||||
|
||||
+27
-86
@@ -339,9 +339,10 @@ export interface QuestionData {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The learner's answer — full structured record, stored in `lh_question_answered`. */
|
||||
/** The learner's answer — full structured record, stored in `lh_question_answered`.
|
||||
* NO questionId (owner, tool-result diet 2026-10-02): it was the
|
||||
* toolCallId restated — the toolResult message already carries it. */
|
||||
export interface QuestionAnswerData {
|
||||
questionId: string;
|
||||
kind: QuestionKind;
|
||||
questionText: string;
|
||||
options?: { index: number; label: string }[]; // snapshot for rendering
|
||||
@@ -425,9 +426,6 @@ export function toggleOther(mode: SelectMode, sel: DockSelection): DockSelection
|
||||
return { selected: new Set(), otherSelected: true, dontKnow: false };
|
||||
}
|
||||
|
||||
export const QUESTION_ENTRY = "lh_question";
|
||||
export const QUESTION_ANSWERED_ENTRY = "lh_question_answered";
|
||||
|
||||
// ─── options ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function normalizeOptions(
|
||||
@@ -546,10 +544,6 @@ export function isCorrect(selectedIndices: number[], correctIndices: number[]):
|
||||
|
||||
// ─── arming + answering ─────────────────────────────────────────────────────
|
||||
|
||||
export function makeId(): string {
|
||||
return `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function toClientQuestion(qd: QuestionData): ClientQuestion {
|
||||
return {
|
||||
id: qd.id,
|
||||
@@ -572,7 +566,6 @@ function formatOptionRef(options: QuizOption[], index: number): string {
|
||||
*/
|
||||
export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswerData {
|
||||
const base: QuestionAnswerData = {
|
||||
questionId: qd.id,
|
||||
kind: qd.kind,
|
||||
questionText: qd.question,
|
||||
options: qd.options.map((o, i) => ({ index: i + 1, label: o.label })),
|
||||
@@ -613,82 +606,30 @@ export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswe
|
||||
return base;
|
||||
}
|
||||
|
||||
/** The user message that carries the answer into the agent's context. */
|
||||
export function formatAnswerMessage(ad: QuestionAnswerData): string {
|
||||
const label = ad.kind === "quiz" ? "Quiz answer" : ad.kind === "explain" ? "Explain answer" : "Answer to your question";
|
||||
const head = `[${label}]`;
|
||||
const lines: string[] = [head];
|
||||
|
||||
if (ad.dismissed) {
|
||||
lines.push("The learner dismissed this question without answering.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
if (ad.kind === "quiz") {
|
||||
const correctStr = (ad.correctIndices ?? []).map((i) => formatOptionRefSnapshot(ad, i)).join(", ");
|
||||
if (ad.dontKnow) {
|
||||
lines.push(
|
||||
`The learner selected "I don't know" — they did NOT attempt an answer (a genuine knowledge gap, not a wrong guess).`,
|
||||
);
|
||||
} else {
|
||||
const verdict = ad.correct ? "CORRECT" : "INCORRECT";
|
||||
const selectedStr = ad.answers.map((a) => `${a.index}. ${a.label}`).join(", ");
|
||||
lines.push(`Selected: ${selectedStr || "(none)"}`);
|
||||
lines.push(`Result: ${verdict}`);
|
||||
if (ad.confidence !== undefined) {
|
||||
lines.push(`Confidence: ${ad.confidence}% — the learner's stated probability that his selected answer is correct.`);
|
||||
}
|
||||
}
|
||||
lines.push(`Correct: ${correctStr}`);
|
||||
} else if (ad.kind === "explain") {
|
||||
// No opt-out row on explain (owner ruling 2026-09-27): if he doesn't
|
||||
// know, he says so in his own words — his text is all there is.
|
||||
lines.push(`The learner answered: ${ad.text ?? "(no answer)"}`);
|
||||
if (ad.committed) {
|
||||
lines.push(`Your committed correct answer (immutable — grade against it, never revise it): ${ad.committed}`);
|
||||
}
|
||||
lines.push("Grade his answer strictly against your committed answer in your reply: name what he got and what he missed.");
|
||||
} else {
|
||||
if (ad.text) {
|
||||
lines.push(`The learner answered: ${ad.text}`);
|
||||
} else if (ad.other) {
|
||||
lines.push(`The learner answered (Other): ${ad.other}`);
|
||||
} else if (ad.answers.length > 0) {
|
||||
const selectedStr = ad.answers.map((a) => `${a.index}. ${a.label}`).join(", ");
|
||||
lines.push(`Selected: ${selectedStr}`);
|
||||
} else {
|
||||
lines.push("(no answer)");
|
||||
}
|
||||
}
|
||||
|
||||
if (ad.note) lines.push(`Note: ${ad.note}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatOptionRefSnapshot(ad: QuestionAnswerData, index: number): string {
|
||||
const opt = (ad.options ?? []).find((o) => o.index === index);
|
||||
return `${index}. ${opt ? opt.label : "(unknown)"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the pending (oldest unanswered) question from a session's entries.
|
||||
* Used on resume/backfill — pending state lives in the session file itself.
|
||||
* The MODEL-VISIBLE answer record (owner, tool-result diet 2026-10-02):
|
||||
* the tool call already carries everything the model itself authored —
|
||||
* the question, every option, the correct answer, the explanation, the
|
||||
* explain commitment — so the result returns ONLY what the learner added:
|
||||
* his selection or words, the graded verdict, his stated confidence, his
|
||||
* note. The full graded QuestionAnswerData rides `details` for the record
|
||||
* and the UI; this is the JSON the agent actually reads.
|
||||
*/
|
||||
export function findPendingQuestion(
|
||||
entries: Array<{ type: string; customType?: string; data?: unknown }>,
|
||||
): ClientQuestion | null {
|
||||
const answered = new Set<string>();
|
||||
for (const e of entries) {
|
||||
if (e.type === "custom" && e.customType === QUESTION_ANSWERED_ENTRY) {
|
||||
const d = e.data as QuestionAnswerData | undefined;
|
||||
if (d?.questionId) answered.add(d.questionId);
|
||||
}
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (e.type === "custom" && e.customType === QUESTION_ENTRY) {
|
||||
const d = e.data as QuestionData | undefined;
|
||||
if (d && !answered.has(d.id)) return toClientQuestion(d);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
export function compactAnswerForModel(ad: QuestionAnswerData): Record<string, unknown> {
|
||||
if (ad.dismissed) return { kind: ad.kind, dismissed: true };
|
||||
const out: Record<string, unknown> = { kind: ad.kind };
|
||||
if (ad.dontKnow) out.dontKnow = true;
|
||||
// Array.isArray: the seam is called with `unknown` answers (test
|
||||
// fixtures, foreign records) — degrade to omission, never crash.
|
||||
if (Array.isArray(ad.answers) && ad.answers.length > 0)
|
||||
out.answers = ad.answers.map((a) => ({ index: a.index, label: a.label }));
|
||||
if (ad.text) out.text = ad.text;
|
||||
if (ad.other) out.other = ad.other;
|
||||
// The verdict + stated confidence are the learner's PERFORMANCE — new
|
||||
// information the model needs for its next move. (dontKnow implies
|
||||
// incorrect and carries no confidence: both stay implicit.)
|
||||
if (ad.correct !== undefined && !ad.dontKnow) out.correct = ad.correct;
|
||||
if (ad.confidence !== undefined) out.confidence = ad.confidence;
|
||||
if (ad.note) out.note = ad.note;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -282,8 +282,8 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
// The graded answer rode the record as the call's tool result and
|
||||
// round-trips through JSON (undefined keys drop) — compare its
|
||||
// SUBSTANCE, not key-for-key against the in-memory build.
|
||||
const out = (answeredQuiz as { output?: { questionId?: string; correct?: boolean } }).output!;
|
||||
assert.equal(out.questionId, "call-1");
|
||||
const out = (answeredQuiz as { output?: { kind?: string; correct?: boolean } }).output!;
|
||||
assert.equal(out.kind, "quiz");
|
||||
assert.equal(out.correct, true);
|
||||
|
||||
// The model actually received the graded answer as a tool result.
|
||||
|
||||
@@ -196,7 +196,7 @@ function fixtureChat() {
|
||||
...assistant("a1", [
|
||||
{ type: "text", text: "The chain rule composes rates of change along a path." },
|
||||
quizPart("probe-1", { question: "What is a derivative, roughly?" }, {
|
||||
questionId: "probe-1", kind: "quiz", questionText: "What is a derivative, roughly?",
|
||||
kind: "quiz", questionText: "What is a derivative, roughly?",
|
||||
correct: false, dontKnow: false, answeredAt: "2026-08-22T01:00:00.000Z",
|
||||
}),
|
||||
]),
|
||||
@@ -211,7 +211,7 @@ function fixtureChat() {
|
||||
user("u3", "check me"),
|
||||
...assistant("a3", [
|
||||
quizPart("check-1", { question: "Check" }, {
|
||||
questionId: "check-1", kind: "quiz", questionText: "Check", correct: true, dontKnow: false,
|
||||
kind: "quiz", questionText: "Check", correct: true, dontKnow: false,
|
||||
answeredAt: "2026-08-22T02:00:00.000Z",
|
||||
}),
|
||||
]),
|
||||
|
||||
+10
-11
@@ -143,7 +143,6 @@ function askPart(
|
||||
|
||||
function answer(over: Partial<QuestionAnswerData>): QuestionAnswerData {
|
||||
return {
|
||||
questionId: "q",
|
||||
kind: "quiz",
|
||||
questionText: "question text",
|
||||
dontKnow: false,
|
||||
@@ -170,14 +169,14 @@ function fixtureMessages(): LhMessage[] {
|
||||
quizPart(
|
||||
"call-1",
|
||||
{ question: "Q1", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||
answer({ questionId: "call-1", questionText: "Q1 text", correct: false, answeredAt: "2026-08-22T01:00:00.000Z" }),
|
||||
answer({ questionText: "Q1 text", correct: false, answeredAt: "2026-08-22T01:00:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
assistant("a2", [
|
||||
quizPart(
|
||||
"call-2",
|
||||
{ question: "Q2", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||
answer({ questionId: "call-2", questionText: "Q2 text", dontKnow: true, correct: false, answeredAt: "2026-08-22T01:01:00.000Z" }),
|
||||
answer({ questionText: "Q2 text", dontKnow: true, correct: false, answeredAt: "2026-08-22T01:01:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
assistant("a3", [
|
||||
@@ -186,21 +185,21 @@ function fixtureMessages(): LhMessage[] {
|
||||
quizPart(
|
||||
"call-3",
|
||||
{ question: "Q3", nodeId: "alpha", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||
answer({ questionId: "call-3", questionText: "Q3 text", correct: true, note: "recalled the diagram", answeredAt: "2026-08-22T02:00:00.000Z" }),
|
||||
answer({ questionText: "Q3 text", correct: true, note: "recalled the diagram", answeredAt: "2026-08-22T02:00:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
assistant("a4", [
|
||||
quizPart(
|
||||
"call-4",
|
||||
{ question: "Q4", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||
answer({ questionId: "call-4", questionText: "Q4 text", correct: false, answeredAt: "2026-08-22T02:01:00.000Z" }),
|
||||
answer({ questionText: "Q4 text", correct: false, answeredAt: "2026-08-22T02:01:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
assistant("a5", [
|
||||
quizPart(
|
||||
"call-5",
|
||||
{ question: "Q5", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||
answer({ questionId: "call-5", questionText: "Q5 text", dontKnow: true, correct: false, answeredAt: "2026-08-22T02:02:00.000Z" }),
|
||||
answer({ questionText: "Q5 text", dontKnow: true, correct: false, answeredAt: "2026-08-22T02:02:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
assistant("a6", [
|
||||
@@ -208,12 +207,12 @@ function fixtureMessages(): LhMessage[] {
|
||||
quizPart(
|
||||
"call-6",
|
||||
{ question: "Q6", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||
answer({ questionId: "call-6", questionText: "Q6 text", dismissed: true }),
|
||||
answer({ questionText: "Q6 text", dismissed: true }),
|
||||
),
|
||||
askPart(
|
||||
"call-7",
|
||||
{ question: "free text?" },
|
||||
answer({ questionId: "call-7", kind: "ask", questionText: "free text?", text: "because" }),
|
||||
answer({ kind: "ask", questionText: "free text?", text: "because" }),
|
||||
),
|
||||
]),
|
||||
].flat();
|
||||
@@ -354,7 +353,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
quizPart(
|
||||
"call-9",
|
||||
{ question: "Q9", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||
answer({ questionId: "call-9", questionText: "Q9 text", correct: true, answeredAt: "2026-08-22T03:00:00.000Z" }),
|
||||
answer({ questionText: "Q9 text", correct: true, answeredAt: "2026-08-22T03:00:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
].flat());
|
||||
@@ -624,7 +623,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
quizPart(
|
||||
"d-call-1",
|
||||
{ question: "DQ1", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||
answer({ questionId: "d-call-1", questionText: "DQ1 text", correct: true, answeredAt: "2026-09-23T00:00:00.000Z" }),
|
||||
answer({ questionText: "DQ1 text", correct: true, answeredAt: "2026-09-23T00:00:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
].flat());
|
||||
@@ -649,7 +648,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
quizPart(
|
||||
"e-call-1",
|
||||
{ question: "EQ1", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||
answer({ questionId: "e-call-1", questionText: "EQ1 text", correct: false, answeredAt: "2026-09-23T00:01:00.000Z" }),
|
||||
answer({ questionText: "EQ1 text", correct: false, answeredAt: "2026-09-23T00:01:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
].flat());
|
||||
|
||||
+48
-105
@@ -11,16 +11,12 @@ import {
|
||||
quizInputBase,
|
||||
askOptionProblems,
|
||||
buildAnswer,
|
||||
compactAnswerForModel,
|
||||
coerceCorrectAnswer,
|
||||
CONFIDENCE_STOPS,
|
||||
DEFAULT_CONFIDENCE_INDEX,
|
||||
findPendingQuestion,
|
||||
formatAnswerMessage,
|
||||
isCorrect,
|
||||
makeId,
|
||||
normalizeOptions,
|
||||
QUESTION_ANSWERED_ENTRY,
|
||||
QUESTION_ENTRY,
|
||||
quizInputProblems,
|
||||
explainInputBase,
|
||||
explainInputObject,
|
||||
@@ -415,47 +411,50 @@ describe("buildAnswer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAnswerMessage", () => {
|
||||
it("formats a correct quiz answer for the agent", () => {
|
||||
const ad = buildAnswer(quizData(), { values: ["mercury"] });
|
||||
const text = formatAnswerMessage(ad);
|
||||
assert.ok(text.startsWith("[Quiz answer]"));
|
||||
assert.match(text, /Selected: 1\. Mercury/);
|
||||
assert.match(text, /Result: CORRECT/);
|
||||
|
||||
// ─── compactAnswerForModel (the model-visible diet, owner 2026-10-02) ──────
|
||||
|
||||
describe("compactAnswerForModel", () => {
|
||||
it("quiz: the learner's selection, verdict, confidence, note — NOTHING the model authored", () => {
|
||||
const ad = buildAnswer(quizData(), { values: ["mercury"], confidence: 70, note: "guessed" });
|
||||
const compact = compactAnswerForModel(ad);
|
||||
assert.deepEqual(compact, {
|
||||
kind: "quiz",
|
||||
answers: [{ index: 1, label: "Mercury" }],
|
||||
correct: true,
|
||||
confidence: 70,
|
||||
note: "guessed",
|
||||
});
|
||||
const raw = JSON.stringify(compact);
|
||||
// every one of these lives in the tool CALL's arguments already
|
||||
assert.ok(!raw.includes("Which planet"), "no questionText");
|
||||
assert.ok(!raw.includes("Venus"), "no option-list restatement");
|
||||
assert.ok(!raw.includes("Mercury orbits closest"), "no explanation");
|
||||
assert.ok(!("correctIndices" in compact), "correct is the verdict; indices are the call's");
|
||||
assert.ok(!("questionId" in compact), "questionId was the toolCallId restated");
|
||||
});
|
||||
|
||||
it("formats dontKnow as a gap, not a guess", () => {
|
||||
const ad = buildAnswer(quizData(), { dontKnow: true });
|
||||
const text = formatAnswerMessage(ad);
|
||||
assert.match(text, /did NOT attempt/);
|
||||
assert.match(text, /knowledge gap/);
|
||||
assert.ok(!text.includes("Result:"));
|
||||
it("dontKnow: the gap flag stands alone — no verdict, no confidence", () => {
|
||||
const compact = compactAnswerForModel(buildAnswer(quizData(), { dontKnow: true }));
|
||||
assert.deepEqual(compact, { kind: "quiz", dontKnow: true });
|
||||
});
|
||||
|
||||
it("includes the note when present", () => {
|
||||
const ad = buildAnswer(quizData(), { values: ["venus"], note: "guessed" });
|
||||
assert.match(formatAnswerMessage(ad), /Note: guessed/);
|
||||
it("ask: selection or text; never graded", () => {
|
||||
const askQD: QuestionData = {
|
||||
...quizData(),
|
||||
kind: "ask",
|
||||
mode: "text",
|
||||
correctIndices: undefined,
|
||||
explanation: undefined,
|
||||
};
|
||||
const compact = compactAnswerForModel(buildAnswer(askQD, { text: "lateral thinking" }));
|
||||
assert.deepEqual(compact, { kind: "ask", text: "lateral thinking" });
|
||||
});
|
||||
|
||||
it("states the confidence line on answered quizzes, absent on dontKnow", () => {
|
||||
const ad = buildAnswer(quizData(), { values: ["mercury"], confidence: 70 });
|
||||
assert.match(formatAnswerMessage(ad), /Confidence: 70% — the learner's stated probability that his selected answer is correct\./);
|
||||
const idk = buildAnswer(quizData(), { dontKnow: true, confidence: 70 });
|
||||
assert.ok(!formatAnswerMessage(idk).includes("Confidence:"));
|
||||
});
|
||||
|
||||
it("formats ask answers", () => {
|
||||
const ask = quizData({ kind: "ask", correctIndices: undefined, explanation: undefined });
|
||||
const ad = buildAnswer(ask, { other: "whatever" });
|
||||
const text = formatAnswerMessage(ad);
|
||||
assert.ok(text.startsWith("[Answer to your question]"));
|
||||
assert.match(text, /\(Other\): whatever/);
|
||||
});
|
||||
|
||||
it("formats a dismissed question", () => {
|
||||
const ad = buildAnswer(quizData(), {});
|
||||
ad.dismissed = true;
|
||||
assert.match(formatAnswerMessage(ad), /dismissed/);
|
||||
it("dismissed: a one-field record", () => {
|
||||
const ad = { ...buildAnswer(quizData(), { dontKnow: true }), dismissed: true };
|
||||
const compact = compactAnswerForModel(ad);
|
||||
assert.deepEqual(compact, { kind: "quiz", dismissed: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,64 +472,6 @@ describe("toClientQuestion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── findPendingQuestion (resume re-arm) ────────────────────────────────────
|
||||
|
||||
describe("findPendingQuestion", () => {
|
||||
it("finds an unanswered question", () => {
|
||||
const entries = [
|
||||
{ type: "custom", customType: QUESTION_ENTRY, data: quizData() },
|
||||
];
|
||||
const pq = findPendingQuestion(entries);
|
||||
assert.ok(pq);
|
||||
assert.equal(pq.id, "q_test");
|
||||
});
|
||||
|
||||
it("returns null once answered", () => {
|
||||
const entries = [
|
||||
{ type: "custom", customType: QUESTION_ENTRY, data: quizData() },
|
||||
{
|
||||
type: "custom",
|
||||
customType: QUESTION_ANSWERED_ENTRY,
|
||||
data: { questionId: "q_test", dontKnow: true, answers: [] },
|
||||
},
|
||||
];
|
||||
assert.equal(findPendingQuestion(entries), null);
|
||||
});
|
||||
|
||||
it("returns the OLDEST unanswered question", () => {
|
||||
const entries = [
|
||||
{ type: "custom", customType: QUESTION_ENTRY, data: quizData({ id: "q1" }) },
|
||||
{ type: "custom", customType: QUESTION_ENTRY, data: quizData({ id: "q2" }) },
|
||||
];
|
||||
assert.equal(findPendingQuestion(entries)?.id, "q1");
|
||||
});
|
||||
|
||||
it("skips a dismissed question", () => {
|
||||
const entries = [
|
||||
{ type: "custom", customType: QUESTION_ENTRY, data: quizData() },
|
||||
{
|
||||
type: "custom",
|
||||
customType: QUESTION_ANSWERED_ENTRY,
|
||||
data: { questionId: "q_test", dismissed: true, answers: [] },
|
||||
},
|
||||
];
|
||||
assert.equal(findPendingQuestion(entries), null);
|
||||
});
|
||||
|
||||
it("ignores other custom entries", () => {
|
||||
const entries = [{ type: "custom", customType: "md-log", data: { file: null } }];
|
||||
assert.equal(findPendingQuestion(entries), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── makeId ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("makeId", () => {
|
||||
it("generates unique ids", () => {
|
||||
const ids = new Set(Array.from({ length: 200 }, () => makeId()));
|
||||
assert.equal(ids.size, 200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the dock selection state machine (multi-select Other coexistence) ──
|
||||
// The live bug: in a multi-select ask, picking the custom "Other…" row
|
||||
@@ -630,13 +571,15 @@ describe("buildAnswer / formatAnswerMessage: explain", () => {
|
||||
assert.equal(ad.text, undefined);
|
||||
});
|
||||
|
||||
it("formats the model's view: his answer, the immutable commitment, and the grading instruction", () => {
|
||||
const text = formatAnswerMessage(buildAnswer(explainQD(), { text: "No idea about aether." }));
|
||||
assert.ok(text.startsWith("[Explain answer]"));
|
||||
assert.match(text, /The learner answered: No idea about aether\./);
|
||||
assert.match(text, /Your committed correct answer \(immutable/);
|
||||
assert.match(text, /luminiferous aether/);
|
||||
assert.match(text, /Grade his answer strictly/);
|
||||
it("compactAnswerForModel (explain): his words ONLY — never the model's own commitment", () => {
|
||||
const compact = compactAnswerForModel(buildAnswer(explainQD(), { text: "No idea about aether." }));
|
||||
const raw = JSON.stringify(compact);
|
||||
assert.equal(compact.text, "No idea about aether.");
|
||||
assert.equal(compact.kind, "explain");
|
||||
// the commitment is the model's own call argument — NEVER restated
|
||||
assert.ok(!raw.includes("luminiferous"), "the commitment must not ride the result");
|
||||
assert.ok(!("committed" in compact));
|
||||
assert.ok(!("questionText" in compact));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { absorbed, pendingCallTool, SessionModel } from "../components/session-c
|
||||
import type { ChatInput } from "../src/core/input-events.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { newUserMessage, newAnswerResult, needsContinuation } from "../src/core/native-messages.ts";
|
||||
import { buildAnswer, type QuestionData } from "../src/core/question-core.ts";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
@@ -825,3 +826,35 @@ describe("pendingCallTool", () => {
|
||||
assert.equal(pendingCallTool(messages, "call-zz"), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── newAnswerResult: the two-audience split (owner, 2026-10-02) ────────────
|
||||
|
||||
describe("newAnswerResult two audiences", () => {
|
||||
it("MODEL sees the compact learner-only record; details keeps the FULL graded answer", () => {
|
||||
const qd: QuestionData = {
|
||||
id: "call-1",
|
||||
kind: "quiz",
|
||||
question: "Which planet is closest to the Sun?",
|
||||
mode: "single-select",
|
||||
options: [
|
||||
{ label: "Mercury", value: "mercury" },
|
||||
{ label: "Venus", value: "venus" },
|
||||
],
|
||||
correctIndices: [1],
|
||||
explanation: "Mercury orbits closest.",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const full = buildAnswer(qd, { values: ["mercury"], confidence: 70, note: "guessed" });
|
||||
const msg = newAnswerResult("call-1", "quiz", full);
|
||||
const modelSees = JSON.parse((msg.content as { type: "text"; text: string }[])[0]!.text);
|
||||
assert.deepEqual(modelSees, {
|
||||
kind: "quiz",
|
||||
answers: [{ index: 1, label: "Mercury" }],
|
||||
correct: true,
|
||||
confidence: 70,
|
||||
note: "guessed",
|
||||
});
|
||||
assert.equal(msg.details, full, "details is the full QuestionAnswerData verbatim");
|
||||
assert.equal((msg.details as { questionText?: string }).questionText, "Which planet is closest to the Sun?");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user