- {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|