diff --git a/src/agent/prompts/shared/25-confidence.md b/src/agent/prompts/shared/25-confidence.md index ad1d57b..b503c73 100644 --- a/src/agent/prompts/shared/25-confidence.md +++ b/src/agent/prompts/shared/25-confidence.md @@ -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. diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 5b8542a..5d56f20 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -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"], diff --git a/src/core/question-core.ts b/src/core/question-core.ts index 4059d0d..dc314a9 100644 --- a/src/core/question-core.ts +++ b/src/core/question-core.ts @@ -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(); - 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 { + if (ad.dismissed) return { kind: ad.kind, dismissed: true }; + const out: Record = { 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; } diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index c636e9b..c238309 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -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. diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index ce23988..c3af4ce 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -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", }), ]), diff --git a/tests/graph.test.ts b/tests/graph.test.ts index fffb569..0e3cc6f 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -143,7 +143,6 @@ function askPart( function answer(over: Partial): 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()); diff --git a/tests/question-core.test.ts b/tests/question-core.test.ts index 9a77b02..64ac2c2 100644 --- a/tests/question-core.test.ts +++ b/tests/question-core.test.ts @@ -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)); }); }); diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts index 74d24c6..6ed0b10 100644 --- a/tests/session-core.test.ts +++ b/tests/session-core.test.ts @@ -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?"); + }); +});