diff --git a/components/practice-cards.tsx b/components/practice-cards.tsx index c895100..4574f92 100644 --- a/components/practice-cards.tsx +++ b/components/practice-cards.tsx @@ -1,10 +1,7 @@ "use client"; -import { useState } from "react"; -import { ChevronRightIcon } from "lucide-react"; import { MessageResponse } from "@/components/ai-elements/message"; import type { ArtifactPartProps } from "@/src/core"; -import { cn } from "@/lib/utils"; /** * The practice transcript cards (practice sessions, owner design @@ -32,25 +29,18 @@ export interface PracticeThinkingData { } export function ProblemCard({ part }: ArtifactPartProps) { - const [open, setOpen] = useState(false); + // Always open, no chevron (owner round, 2026-10-05): the record IS the + // statement — same container + eyebrow discipline as the answered card + // and the dock (which mirrors this in Slice D). The commitment never + // renders — only the call's statement. const input = (part.input ?? {}) as { statement?: string }; const statement = typeof input.statement === "string" ? input.statement : ""; return ( -
- - {open && ( -
+
+
Problem
+
{statement}
- )}
); } @@ -64,15 +54,17 @@ const OUTCOME_LABEL: Record = { }; export function ConcludeCard({ part }: ArtifactPartProps) { - const [open, setOpen] = useState(false); - const input = (part.input ?? {}) as { - outcome?: string; - verdict?: string; - solutionReveal?: string; - }; + // The conclusion redesigned (owner round, 2026-10-05): the SOLUTION is + // the hero — the modelSolution committed at pose time (part.reveal, the + // view-seam join), shown verbatim, never re-authored. The verdict sits + // below in the quiz-explanation register (small, muted, low-key). + // Nothing collapsible. Legacy inputs (pre-slim records) that carry + // their own solutionReveal fall back to it. + const input = (part.input ?? {}) as { outcome?: string; verdict?: string; solutionReveal?: string }; const outcome = typeof input.outcome === "string" ? input.outcome : ""; const verdict = typeof input.verdict === "string" ? input.verdict : ""; - const reveal = typeof input.solutionReveal === "string" ? input.solutionReveal : ""; + const reveal = + typeof part.reveal === "string" ? part.reveal : typeof input.solutionReveal === "string" ? input.solutionReveal : ""; // The dismissal synthesis: no verdict, no reveal — one muted line (the // learner clicked; the record needs only the fact). @@ -85,35 +77,21 @@ export function ConcludeCard({ part }: ArtifactPartProps) { } return ( -
- - {open && ( -
- {verdict.length > 0 && ( -
- {verdict} -
- )} - {reveal.length > 0 && ( -
-
- The solution -
-
- {reveal} -
-
- )} +
+
+ {OUTCOME_LABEL[outcome] ?? "Concluded"}
- )} + {reveal.length > 0 && ( +
+ {reveal} +
+ )} + {verdict.length > 0 && ( +
+ Verdict: + {verdict} +
+ )}
); } diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index 4ca0b83..9700bc9 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -270,5 +270,9 @@ export interface ArtifactPartProps { toolCallId: string; input?: unknown; output?: unknown; + /** The view-seam join for conclude cards (owner round, 2026-10-05): + * the modelSolution committed at POSE time, folded onto the part by + * projectView — the reveal is never re-authored. */ + reveal?: unknown; }; } diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 4895093..a32fb86 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -328,6 +328,40 @@ function cleanToolCallIds(messages: readonly LhMessage[]): Set { return ids; } +/** The conclusion's reveal (owner round, 2026-10-05): the hero of a + * conclude card is the modelSolution committed at POSE time — the newest + * cleanly-posed problem BEFORE this conclude call. The reveal is never + * re-authored (the slim schema dropped solutionReveal); legacy conclude + * inputs that still carry their own solutionReveal fall back to it at the + * card. Undefined when the pose carries no string solution. */ +export function modelSolutionForConclude( + messages: readonly LhMessage[], + concludeCallId: string, +): string | undefined { + let mi = messages.findIndex( + (m) => + (m as { role?: string }).role === "assistant" && + (m as AssistantMessage).content.some( + (b) => b.type === "toolCall" && b.id === concludeCallId, + ), + ); + if (mi < 0) return undefined; + const clean = cleanToolCallIds(messages); + for (; mi >= 0; mi--) { + const m = messages[mi]!; + if ((m as { role?: string }).role !== "assistant") continue; + const assistant = m as AssistantMessage; + for (let bi = assistant.content.length - 1; bi >= 0; bi--) { + const block = assistant.content[bi]!; + if (block.type !== "toolCall" || block.name !== PRACTICE_PROBLEM_TOOL) continue; + if (!clean.has(block.id)) continue; // a refused pose revealed nothing + const solution = (block.arguments as { modelSolution?: unknown } | undefined)?.modelSolution; + return typeof solution === "string" ? solution : undefined; + } + } + return undefined; +} + export function activeProblemOf( messages: readonly LhMessage[], ): { toolCallId: string; input: unknown } | null { diff --git a/src/core/view.ts b/src/core/view.ts index 4284abf..24e2afa 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -34,6 +34,7 @@ import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"; import type { LhMarkerMessage, LhMessage } from "./native-messages.ts"; +import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL } from "./native-messages.ts"; import { QUESTION_TOOLS } from "./pending.ts"; import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts"; import type { PlanBlockData } from "./contracts/chat.ts"; @@ -194,7 +195,14 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState } else if (block.type === "thinking") { reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" }); } else if (block.type === "toolCall") { - rest.push(toolPartOf(block, live, streamingMessage)); + const part = toolPartOf(block, live, streamingMessage); + // The conclusion's reveal join (owner round, 2026-10-05): the + // hero of a conclude card is the modelSolution committed at POSE + // time — a record fold, enriched here so the card renders pure. + if (block.name === PRACTICE_CONCLUDE_TOOL) { + part.reveal = modelSolutionForConclude(messages, block.id); + } + rest.push(part); } } const parts: LhViewPart[] = [...reasoning, ...rest]; diff --git a/tests/practice-record.test.ts b/tests/practice-record.test.ts index 269d55a..3713a8e 100644 --- a/tests/practice-record.test.ts +++ b/tests/practice-record.test.ts @@ -27,6 +27,8 @@ import { } from "../src/core/native-messages.ts"; import { CONCLUDE_ACK_TEXT } from "../src/core/contracts/chat.ts"; import { dismissAppends, inputAppends, parseChatInput, parseDismissInput } from "../src/core/input-events.ts"; +import { modelSolutionForConclude } from "../src/core/native-messages.ts"; +import { projectView } from "../src/core/index.ts"; // ── builders (native-shaped test fixtures) ─────────────────────────────────── @@ -50,7 +52,7 @@ function problemPose(callId = "call_p1", statement = "Show that…"): LhMessage[ id: `msg_a_${callId}`, role: "assistant", content: [ - { type: "toolCall", id: callId, name: PRACTICE_PROBLEM_TOOL, arguments: { statement, mode: "recall" } }, + { type: "toolCall", id: callId, name: PRACTICE_PROBLEM_TOOL, arguments: { statement, mode: "recall", modelSolution: statement } }, ], timestamp: 1, ...STAMPS, @@ -110,7 +112,7 @@ describe("activeProblemOf (the conclude-before-next-problem gate's fold)", () => const messages = problemPose(); const active = activeProblemOf(messages); assert.equal(active?.toolCallId, "call_p1"); - assert.deepEqual(active?.input, { statement: "Show that…", mode: "recall" }); + assert.deepEqual(active?.input, { statement: "Show that…", mode: "recall", modelSolution: "Show that…" }); }); it("thinking submissions keep it active (nudges never conclude)", () => { @@ -345,3 +347,39 @@ describe("needsContinuation (a persisted pose ack owes nothing)", () => { assert.equal(needsContinuation(messages), true); }); }); + + +// ── the reveal join (owner round, 2026-10-05) ─────────────────────────────── + +describe("modelSolutionForConclude (the conclusion's hero is the pose's commitment)", () => { + it("returns the cleanly-posed problem's modelSolution before the conclude", () => { + const messages = [...problemPose(), ...concludeCall("solved")]; + assert.equal(modelSolutionForConclude(messages, "call_conclude_solved"), "Show that…"); + }); + + it("skips refused poses — a rejected pose revealed nothing", () => { + const rejected = problemPose("call_bad", "A different student…"); + (rejected[1]! as unknown as { isError: boolean }).isError = true; + const messages = [...rejected, ...concludeCall("solved")]; + assert.equal(modelSolutionForConclude(messages, "call_conclude_solved"), undefined); + }); + + it("the newest pose before ITS conclude wins (multi-problem records)", () => { + const messages = [ + ...problemPose("call_p1", "first"), + ...concludeCall("solved"), + ...problemPose("call_p2", "second"), + ...concludeCall("movedOn"), + ]; + assert.equal(modelSolutionForConclude(messages, "call_conclude_movedOn"), "second"); + assert.equal(modelSolutionForConclude(messages, "call_conclude_solved"), "first"); + }); + + it("projectView enriches the conclude part with the reveal", () => { + const messages = [...problemPose(), ...concludeCall("solved")]; + const view = projectView(messages); + const conclude = view.flatMap((m) => m.parts).find((p) => p.type === "tool-conclude"); + assert.ok(conclude, "the conclude part projects"); + assert.equal(conclude!.reveal, "Show that…", "the view-seam join carries the committed solution"); + }); +}); diff --git a/tests/render-practice-surface.test.tsx b/tests/render-practice-surface.test.tsx index a0a7911..052c84a 100644 --- a/tests/render-practice-surface.test.tsx +++ b/tests/render-practice-surface.test.tsx @@ -76,7 +76,7 @@ describe("PracticeDock", () => { // ── the transcript cards ───────────────────────────────────────────────────── describe("the practice transcript cards", () => { - it("ProblemCard: the statement renders — the commitment NEVER does", () => { + it("ProblemCard: ALWAYS OPEN, the 'Problem' eyebrow + the statement — the commitment NEVER renders", () => { const html = renderToStaticMarkup( { statement: "Prove the parity claim.", mode: "recall", modelSolution: "Pair them.", - decisionPoints: ["pairing"], - expectedWrongMoves: [{ move: "induction", resolution: "pairing is direct" }], - strategy: "near-twin", - rationale: "weakest target", }, })} />, ); assert.match(html, /data-testid="problem-card"/); + assert.match(html, /Problem/); assert.match(html, /Prove the parity claim\./); - for (const hidden of ["Pair them.", "induction", "near-twin", "weakest target", "pairing"]) { - assert.doesNotMatch(html, new RegExp(hidden.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `commitment leaked: ${hidden}`); - } + assert.doesNotMatch(html, /Pair them\./, "the commitment stays hidden"); + // No chevron, no collapsed bar (owner round, 2026-10-05). + assert.doesNotMatch(html, /lucide-chevron|