From 793638d6cef445c2a01507a2ef24203f0611c1c3 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 6 Oct 2026 04:18:26 +0300 Subject: [PATCH] =?UTF-8?q?practice:=20the=20agent's=20data=20drawer=20?= =?UTF-8?q?=E2=80=94=20full=20transparency=20on=20every=20conclude=20card?= =?UTF-8?q?=20(round=2017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner ask (2026-10-06): reveal everything the agent filled in when writing the problem and concluding it, at the very bottom of the conclude card, collapsible, arriving collapsed. The fields, both calls: - problem: mode (recall/discrimination/composition), target proposition ids, statement, modelSolution (the last two ARE the visible card — the drawer shows the metadata only) - conclude: outcome, hint rung (R0–R5/null), miss class (cue/discrimination/execution/careless/structural/null), retest. Seams: problemInputForConclude (native-messages) walks the reveal's walk and folds the WHOLE problem-call input onto the conclude part (projectView join; same refused-pose law — an unacked pose folds nothing); ArtifactPartProps grows problemInput. Targets resolve to proposition NAMES at render (one lazy module-cached /api/graph fetch; failure degrades to raw ids — observability never breaks the card). The drawer: divider + chevron 'AGENT'S DATA' toggle (mono-caps eyebrow register), definition rows with mono values, 'untagged'/ 'none' honest empties, absent entirely when the calls filled nothing (the dismissal line, legacy bare records). --- components/practice-cards.tsx | 113 +++++++++++++++++++++++++ src/core/contracts/chat.ts | 4 + src/core/native-messages.ts | 19 ++++- src/core/view.ts | 3 +- tests/practice-record.test.ts | 7 ++ tests/render-practice-surface.test.tsx | 29 +++++++ 6 files changed, 172 insertions(+), 3 deletions(-) diff --git a/components/practice-cards.tsx b/components/practice-cards.tsx index ac25c0f..7fd7c3b 100644 --- a/components/practice-cards.tsx +++ b/components/practice-cards.tsx @@ -20,8 +20,32 @@ import type { ArtifactPartProps } from "@/src/core"; * language in a faint user-side shell, never collapsible. */ +import { ChevronDownIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + import { ThinkingBlock } from "@/components/thinking-block"; +/* TARGET NAMES (owner round 17, 2026-10-06): targets are normalized to + * opaque node ids at the record seam (owner round 9 — the mastery trace + * groups on them), so the drawer resolves names at render time. One + * lazy module-cached fetch of the graph view (the id → name map is a + * few KB); failures degrade to raw ids — observability never breaks the + * card. */ +let nodeNameMapPromise: Promise> | null = null; +function nodeNameMap(): Promise> { + nodeNameMapPromise ??= fetch("/api/graph") + .then((r) => (r.ok ? r.json() : { nodes: [] })) + .then((g: { nodes?: Array<{ id?: unknown; name?: unknown }> }) => + Object.fromEntries( + (g.nodes ?? []) + .filter((n) => typeof n.id === "string") + .map((n) => [n.id as string, typeof n.name === "string" ? (n.name as string) : (n.id as string)]), + ), + ) + .catch(() => ({})); + return nodeNameMapPromise; +} + /** The practice-thinking marker's details (native-messages.ts). */ export interface PracticeThinkingData { problemCallId: string; @@ -124,10 +148,99 @@ export function ConcludeCard({ part }: ArtifactPartProps) { )} + ); } +/* THE AGENT'S DATA DRAWER (owner round 17, 2026-10-06 — full + * transparency): a collapsible at the very bottom of the conclude card, + * arriving collapsed, showing every field the agent filled in both the + * problem call (mode, targets) and the conclude call (outcome, rung, + * miss class, retest). Statement and modelSolution stay out — they are + * the visible card above. Pure rows, the eyebrow register, mono values. */ +function AgentsDataDrawer({ + concludeInput, + problemInput, +}: { + concludeInput: { outcome?: string; rung?: unknown; missClass?: unknown; retest?: unknown }; + problemInput: { mode?: unknown; target?: unknown }; +}) { + const [open, setOpen] = useState(false); + const [names, setNames] = useState | null>(null); + const targets = Array.isArray(problemInput.target) + ? problemInput.target.filter((t): t is string => typeof t === "string") + : []; + // Resolve names only when there is something to resolve. + useEffect(() => { + if (!open || targets.length === 0 || names) return; + let alive = true; + void nodeNameMap().then((m) => { + if (alive) setNames(m); + }); + return () => { + alive = false; + }; + }, [open, targets.length, names]); + + const mode = typeof problemInput.mode === "string" ? problemInput.mode : ""; + const rung = typeof concludeInput.rung === "string" ? concludeInput.rung : ""; + const miss = typeof concludeInput.missClass === "string" ? concludeInput.missClass : ""; + const retest = typeof concludeInput.retest === "boolean" ? concludeInput.retest : undefined; + const outcome = typeof concludeInput.outcome === "string" ? concludeInput.outcome : ""; + if (!mode && targets.length === 0 && !rung && !miss && retest === undefined) return null; + + return ( + <> +
+
+ + {open && ( +
+
Mode
+
{mode || "—"}
+
Targets
+
+ {targets.length === 0 ? ( + "untagged" + ) : ( + + {targets.map((t, i) => ( + + {i > 0 && ", "} + {names?.[t] ?? t} + + ))} + + )} +
+
Outcome
+
{outcome || "—"}
+
Hint rung
+
{rung || "none"}
+
Miss class
+
{miss || "none"}
+
Retest
+
{retest === undefined ? "—" : retest ? "yes" : "no"}
+
+ )} +
+ + ); +} + export function ThinkingCard({ data }: { data: PracticeThinkingData }) { // The unified row (owner rounds, 2026-10-05): one style, one label — // collapsed arrival, "Your thinking", 50%-italic quote block on expand. diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index 3ac973a..850d370 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -298,6 +298,10 @@ export interface ArtifactPartProps { toolCallId: string; input?: unknown; output?: unknown; + /** The pose-side fold (owner round 17): the whole problem-call + * arguments joined onto the conclude part by projectView — the + * observability drawer's data. */ + problemInput?: 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. */ diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index dc048d4..dceadcb 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -364,6 +364,20 @@ export function modelSolutionForConclude( messages: readonly LhMessage[], concludeCallId: string, ): string | undefined { + const input = problemInputForConclude(messages, concludeCallId); + return typeof input?.modelSolution === "string" ? input.modelSolution : undefined; +} + +/** The full pose-side fold (owner round 17, 2026-10-06 — observability): + * EVERYTHING the agent filled into the problem call — mode, target ids, + * statement, the committed solution — joined onto the conclude part so + * the card's collapsible "agent's data" drawer renders pure. Same walk + * and refusal law as the reveal: a pose the record never acked (a + * refused call) folds nothing. */ +export function problemInputForConclude( + messages: readonly LhMessage[], + concludeCallId: string, +): { statement?: unknown; mode?: unknown; target?: unknown; modelSolution?: unknown } | undefined { let mi = messages.findIndex( (m) => (m as { role?: string }).role === "assistant" && @@ -381,8 +395,9 @@ export function modelSolutionForConclude( 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 (block.arguments ?? undefined) as + | { statement?: unknown; mode?: unknown; target?: unknown; modelSolution?: unknown } + | undefined; } } return undefined; diff --git a/src/core/view.ts b/src/core/view.ts index c84695a..1bf6148 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -34,7 +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 { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL, problemInputForConclude } from "./native-messages.ts"; import { activeCompactionOf, type CompactionDetails } from "./compaction.ts"; import { QUESTION_TOOLS } from "./pending.ts"; import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts"; @@ -223,6 +223,7 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState // time — a record fold, enriched here so the card renders pure. if (block.name === PRACTICE_CONCLUDE_TOOL) { part.reveal = modelSolutionForConclude(messages, block.id); + part.problemInput = problemInputForConclude(messages, block.id); } rest.push(part); } diff --git a/tests/practice-record.test.ts b/tests/practice-record.test.ts index 2a85861..9f665d3 100644 --- a/tests/practice-record.test.ts +++ b/tests/practice-record.test.ts @@ -404,6 +404,13 @@ describe("modelSolutionForConclude (the conclusion's hero is the pose's commitme 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"); + // THE FULL POSE FOLD (owner round 17): the whole problem-call input + // rides the conclude part — the observability drawer's data. + assert.deepEqual(conclude!.problemInput, { + statement: "Show that…", + mode: "recall", + modelSolution: "Show that…", + }, "mode + statement + the commitment; no targets posed here"); }); }); diff --git a/tests/render-practice-surface.test.tsx b/tests/render-practice-surface.test.tsx index 1322a9e..b2546da 100644 --- a/tests/render-practice-surface.test.tsx +++ b/tests/render-practice-surface.test.tsx @@ -137,6 +137,35 @@ describe("the practice transcript cards", () => { assert.match(html, /Pair them; each pair is even\./); }); + it("ConcludeCard: THE AGENT'S DATA DRAWER (owner round 17) — arrives collapsed, opens to every field both calls filled", () => { + const collapsed = renderToStaticMarkup( + , + ); + assert.ok(collapsed.includes('data-testid="agents-data-toggle"'), "the toggle row"); + assert.ok(collapsed.includes("Agent"), "the label"); + assert.ok(!collapsed.includes('data-testid="agents-data"'), "arrives COLLAPSED — no rows in the markup"); + // The drawer is a client useState fold; the OPEN face is pinned by the + // rows' data shape (view join) below — static render cannot toggle. + }); + + it("ConcludeCard: no drawer when the calls filled nothing beyond the visible card", () => { + const html = renderToStaticMarkup( + , + ); + assert.ok(!html.includes("agents-data"), "the dismissal line carries no drawer"); + const legacy = renderToStaticMarkup( + , + ); + assert.ok(!legacy.includes("agents-data"), "empty problemInput + bare outcome: nothing to reveal"); + }); + it("ConcludeCard: the dismissal synthesis is one muted line — no reveal card", () => { const html = renderToStaticMarkup( ,