graph context: a separate marker message before the first user turn
The map picks no longer glue into the opening prompt as seed text
(owner, 2026-10-04 late). POST /api/chats accepts graphContext
{propositions}; the route resolves the picks against the live graph,
derives the reasoning chains among them (chainsForSelection →
formatChainLine), and writes a graph-context custom marker as
message[0] — before the learner's first user message, in either mode.
- Core: newGraphContextMessage (model-facing content = the
contextualization framing + proposition lines + chain lines;
details = the structured pick); toLlmMessages flattens it to a user
message like every marker, so the model reads it for free.
- View: projects to a user-side data-graph-context part.
- UI: GraphContextCard — minimal, muted, dashed, collapsed by default
(counts only); expand reveals name — statement rows and the chain
lines. Same compact-expandable pattern as the practice cards.
- Home: sends graphContext with the creation whenever picks exist;
the prompt param carries the learner's own words only.
This commit is contained in:
+30
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import { chainsForSelection, formatChainLine } from "@/src/core";
|
||||||
|
import { newGraphContextMessage, type GraphContextPick } from "@/src/core";
|
||||||
import { createChat, isTurnRunning, listChats, readSettingsRecord, stores, titleFromPrompt } from "@/src/server";
|
import { createChat, isTurnRunning, listChats, readSettingsRecord, stores, titleFromPrompt } from "@/src/server";
|
||||||
import type { ChatListItem } from "@/src/core";
|
import type { ChatListItem } from "@/src/core";
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const body = (await req.json().catch(() => ({}))) as { title?: unknown; kind?: unknown };
|
const body = (await req.json().catch(() => ({}))) as { title?: unknown; kind?: unknown; graphContext?: unknown };
|
||||||
const title = typeof body.title === "string" ? body.title : "";
|
const title = typeof body.title === "string" ? body.title : "";
|
||||||
// Practice sessions (owner, 2026-10-04): kind "practice" marks the
|
// Practice sessions (owner, 2026-10-04): kind "practice" marks the
|
||||||
// record — the practice agent + its tools from turn 1, no extraction.
|
// record — the practice agent + its tools from turn 1, no extraction.
|
||||||
@@ -37,5 +39,32 @@ export async function POST(req: Request) {
|
|||||||
thinkingLevel: strong.thinkingLevel,
|
thinkingLevel: strong.thinkingLevel,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The session's map context (owner, 2026-10-04): the picked
|
||||||
|
// propositions ride the CREATION — message[0] is a graph-context
|
||||||
|
// marker (a separate custom message BEFORE the learner's first user
|
||||||
|
// message), carrying the picks plus the reasoning chains among them.
|
||||||
|
// The picks carry their own name/statement (what the learner saw at
|
||||||
|
// pick time); the chains derive from the CURRENT graph snapshot.
|
||||||
|
const rawPicks = (body.graphContext as { propositions?: unknown } | undefined)?.propositions;
|
||||||
|
const picks: GraphContextPick[] = Array.isArray(rawPicks)
|
||||||
|
? rawPicks
|
||||||
|
.filter(
|
||||||
|
(p): p is GraphContextPick =>
|
||||||
|
typeof p === "object" &&
|
||||||
|
p !== null &&
|
||||||
|
typeof (p as GraphContextPick).id === "string" &&
|
||||||
|
typeof (p as GraphContextPick).name === "string" &&
|
||||||
|
typeof (p as GraphContextPick).statement === "string",
|
||||||
|
)
|
||||||
|
.slice(0, 64)
|
||||||
|
: [];
|
||||||
|
if (picks.length > 0) {
|
||||||
|
const byId = new Map(stores.graphStore.readNodes().map((n) => [n.id, n]));
|
||||||
|
const pickedNodes = picks.map((p) => byId.get(p.id)).filter((n) => n !== undefined);
|
||||||
|
const chainLines = chainsForSelection(pickedNodes, stores.graphStore.readChains()).map((c) =>
|
||||||
|
formatChainLine(c, Object.fromEntries(byId)),
|
||||||
|
);
|
||||||
|
stores.chatStore.saveMessages(chat.id, [newGraphContextMessage(picks, chainLines)]);
|
||||||
|
}
|
||||||
return Response.json({ id: chat.id, title: chat.title });
|
return Response.json({ id: chat.id, title: chat.title });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type { PlanBlockData } from "@/src/core";
|
|||||||
import { QuestionDock } from "@/components/question-dock";
|
import { QuestionDock } from "@/components/question-dock";
|
||||||
import { PracticeDock } from "@/components/practice-dock";
|
import { PracticeDock } from "@/components/practice-dock";
|
||||||
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
||||||
|
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
||||||
import { TopBar } from "@/components/top-bar";
|
import { TopBar } from "@/components/top-bar";
|
||||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
|
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
|
||||||
import type { LhViewMessage } from "@/src/core";
|
import type { LhViewMessage } from "@/src/core";
|
||||||
@@ -1258,6 +1259,13 @@ export function Chat({
|
|||||||
);
|
);
|
||||||
case "step-start":
|
case "step-start":
|
||||||
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
|
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
|
||||||
|
case "data-graph-context":
|
||||||
|
// The session's map pick (2026-10-04): a minimal
|
||||||
|
// collapsible card — scaffolding above the first
|
||||||
|
// user message, never a bubble.
|
||||||
|
return (
|
||||||
|
<GraphContextCard key={pi} data={part.data as GraphContextData} />
|
||||||
|
);
|
||||||
case "data-practice-thinking":
|
case "data-practice-thinking":
|
||||||
// The learner's practice submission (2026-10-04):
|
// The learner's practice submission (2026-10-04):
|
||||||
// user-side — the register (thinking trace /
|
// user-side — the register (thinking trace /
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronRightIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The graph-context card (owner, 2026-10-04): the session's map pick —
|
||||||
|
* message[0] of a session started with propositions picked from the
|
||||||
|
* graph. Scaffolding, not a turn: minimal, muted, collapsed by default;
|
||||||
|
* expand reveals the picked propositions (name — statement) and the
|
||||||
|
* reasoning chains among them (premises → conclusion lines). The model
|
||||||
|
* reads the same content through the marker's own text (toLlmMessages);
|
||||||
|
* this card is the learner's face of it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The graph-context marker's details (native-messages.ts). */
|
||||||
|
export interface GraphContextData {
|
||||||
|
propositions: { id: string; name: string; statement: string }[];
|
||||||
|
chains: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphContextCard({ data }: { data: GraphContextData }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { propositions = [], chains = [] } = data ?? {};
|
||||||
|
const noun = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||||
|
return (
|
||||||
|
<div data-testid="graph-context-card" className="my-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="graph-context-toggle"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border border-dashed border-border/70 px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon aria-hidden="true" className={cn("size-3.5 shrink-0 transition-transform", open && "rotate-90")} />
|
||||||
|
<span className="font-medium">Graph context</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{noun(propositions.length, "proposition", "propositions")}
|
||||||
|
{chains.length > 0 ? ` · ${noun(chains.length, "chain", "chains")}` : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div data-testid="graph-context-body" className="mt-1 space-y-1 rounded-lg border border-dashed border-border/70 px-4 py-3 text-xs">
|
||||||
|
{propositions.map((p) => (
|
||||||
|
<div key={p.id} data-testid={`graph-context-prop-${p.id}`} className="leading-relaxed">
|
||||||
|
<span className="font-medium text-foreground/80">{p.name}</span>
|
||||||
|
<span className="text-muted-foreground"> — {p.statement}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{chains.length > 0 && (
|
||||||
|
<div className="pt-1">
|
||||||
|
{chains.map((c, i) => (
|
||||||
|
<div key={i} className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
{c}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-12
@@ -112,26 +112,25 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
|
|||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
// The practice seed: the picks ride the OPENING PROMPT (never
|
// The map context (owner, 2026-10-04): picks in EITHER mode ride
|
||||||
// the title — the title stays the learner's own words).
|
// the CREATION as graphContext — the server writes a
|
||||||
const seed =
|
// graph-context marker as message[0] (a separate custom message
|
||||||
mode === "practice" && practiceProps.length > 0
|
// before the learner's first user message, propositions + the
|
||||||
? `\n\nPropositions from the map:\n${practiceProps
|
// chains among them); the prompt stays the learner's own words.
|
||||||
.map((p) => `- ${p.name}: ${p.statement}`)
|
|
||||||
.join("\n")}`
|
|
||||||
: "";
|
|
||||||
const res = await fetch("/api/chats", {
|
const res = await fetch("/api/chats", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(
|
body: JSON.stringify({
|
||||||
mode === "practice" ? { title: t, kind: "practice" } : { title: t },
|
title: t,
|
||||||
),
|
...(mode === "practice" ? { kind: "practice" } : {}),
|
||||||
|
...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const { id } = (await res.json()) as { id: string };
|
const { id } = (await res.json()) as { id: string };
|
||||||
// No settings PUT on create (per-session models, owner
|
// No settings PUT on create (per-session models, owner
|
||||||
// 2026-09-28): the SERVER stamps the newborn from the strong
|
// 2026-09-28): the SERVER stamps the newborn from the strong
|
||||||
// slot — the same default this surface's model control edits.
|
// slot — the same default this surface's model control edits.
|
||||||
router.push(`/chat/${id}?prompt=${encodeURIComponent(t + seed)}`);
|
router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`);
|
||||||
} catch {
|
} catch {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface LhMarkerMessage {
|
|||||||
id: string;
|
id: string;
|
||||||
role: "custom";
|
role: "custom";
|
||||||
customType:
|
customType:
|
||||||
|
| "graph-context"
|
||||||
| "probe-handoff"
|
| "probe-handoff"
|
||||||
| "subagent-steer"
|
| "subagent-steer"
|
||||||
| "subagent-abort"
|
| "subagent-abort"
|
||||||
@@ -187,6 +188,42 @@ export function newPlanBlockMessage(
|
|||||||
export const PRACTICE_PROBLEM_TOOL = "problem";
|
export const PRACTICE_PROBLEM_TOOL = "problem";
|
||||||
export const PRACTICE_CONCLUDE_TOOL = "conclude";
|
export const PRACTICE_CONCLUDE_TOOL = "conclude";
|
||||||
|
|
||||||
|
/** One picked proposition of the graph-context marker (the learner's map
|
||||||
|
* pick — name + statement as the learner SAW them at pick time). */
|
||||||
|
export interface GraphContextPick {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
statement: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session's map context (owner, 2026-10-04): a SEPARATE custom
|
||||||
|
* message BEFORE the learner's first user message — the picked
|
||||||
|
* propositions plus the reasoning chains among them (pre-formatted
|
||||||
|
* lines, formatChainLine shape). The model reads `content` (flattened
|
||||||
|
* to a user message by toLlmMessages like every marker); `details`
|
||||||
|
* feeds the transcript's collapsible GraphContextCard. No more seed
|
||||||
|
* text glued into the first prompt. */
|
||||||
|
export function newGraphContextMessage(picks: readonly GraphContextPick[], chains: readonly string[]): LhMarkerMessage {
|
||||||
|
const lines = [
|
||||||
|
"Contextualization — the learner picked these propositions from their knowledge map when starting this session:",
|
||||||
|
"",
|
||||||
|
"Propositions:",
|
||||||
|
...picks.map((p) => `- ${p.name}: ${p.statement}`),
|
||||||
|
];
|
||||||
|
if (chains.length > 0) {
|
||||||
|
lines.push("", "Reasoning chains among them (premises → conclusion):", ...chains.map((c) => `- ${c}`));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: newMessageId(),
|
||||||
|
role: "custom",
|
||||||
|
customType: "graph-context",
|
||||||
|
content: lines.join("\n"),
|
||||||
|
display: true,
|
||||||
|
details: { propositions: picks, chains },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The learner's thinking submission (the practice dock's two buttons):
|
/** The learner's thinking submission (the practice dock's two buttons):
|
||||||
* a user-register message the model reads WITH its label (hint vs
|
* a user-register message the model reads WITH its label (hint vs
|
||||||
* commit — the register is the whole difference), while `details` keeps
|
* commit — the register is the whole difference), while `details` keeps
|
||||||
|
|||||||
@@ -290,6 +290,17 @@ function applyResultTo(part: LhViewPart | undefined, result: ToolResultMessage):
|
|||||||
/** A marker's projected message (handoff/steers) or splice (plan block). */
|
/** A marker's projected message (handoff/steers) or splice (plan block). */
|
||||||
function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
|
function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
|
||||||
switch (marker.customType) {
|
switch (marker.customType) {
|
||||||
|
case "graph-context": {
|
||||||
|
// The session's map context (owner, 2026-10-04): user-side —
|
||||||
|
// a minimal collapsible card ABOVE the first user message;
|
||||||
|
// never a text bubble (scaffolding, like the thinking card).
|
||||||
|
out.push({
|
||||||
|
id: marker.id,
|
||||||
|
role: "user",
|
||||||
|
parts: [{ type: "data-graph-context", id: `gc_${marker.id}`, data: marker.details }],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "probe-handoff": {
|
case "probe-handoff": {
|
||||||
out.push({
|
out.push({
|
||||||
id: marker.id,
|
id: marker.id,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* THE GRAPH-CONTEXT MARKER (owner, 2026-10-04 late): a session started
|
||||||
|
* with map picks gets its context as a SEPARATE custom message BEFORE
|
||||||
|
* the learner's first user message — the picked propositions plus the
|
||||||
|
* reasoning chains among them (formatChainLine lines), no more seed
|
||||||
|
* text glued into the opening prompt. The model reads the marker's
|
||||||
|
* content (toLlmMessages flattens it to a user message like every
|
||||||
|
* marker); `details` feeds the transcript's collapsible
|
||||||
|
* GraphContextCard (render-practice pins live in the render suite).
|
||||||
|
*
|
||||||
|
* The route pin drives POST /api/chats under an isolated LH_DATA_DIR
|
||||||
|
* (the birth-stamp discipline): picks ride the CREATION, the marker
|
||||||
|
* lands as message[0], and the chains derive from the live graph
|
||||||
|
* snapshot.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { after, describe, it } from "node:test";
|
||||||
|
|
||||||
|
// ── the seam, BEFORE any config-loading import ──────────────────────────────
|
||||||
|
const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-graph-context-"));
|
||||||
|
process.env.LH_DATA_DIR = TMP_DATA;
|
||||||
|
|
||||||
|
const { newGraphContextMessage } = await import("../src/core/native-messages.ts");
|
||||||
|
const { toLlmMessages } = await import("../src/core/native-messages.ts");
|
||||||
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
|
const { projectView } = await import("../src/core/view.ts");
|
||||||
|
const { POST } = await import("../app/api/chats/route.ts");
|
||||||
|
const { chatStore } = await import("../src/store/chats.ts");
|
||||||
|
const { graphStore } = await import("../src/store/graph.ts");
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
fs.rmSync(TMP_DATA, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const PICKS = [
|
||||||
|
{ id: "n_a", name: "Modus ponens", statement: "If P implies Q and P holds, Q holds." },
|
||||||
|
{ id: "n_b", name: "Soundness", statement: "A valid argument from true premises has a true conclusion." },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── the pure marker ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("newGraphContextMessage", () => {
|
||||||
|
it("frames the context for the model: propositions listed, chains appended", () => {
|
||||||
|
const m = newGraphContextMessage(PICKS, ["Modus ponens → Soundness"]);
|
||||||
|
assert.equal(m.customType, "graph-context");
|
||||||
|
assert.match(m.content, /Contextualization — the learner picked these propositions/);
|
||||||
|
assert.ok(m.content.includes("- Modus ponens: If P implies Q and P holds, Q holds."));
|
||||||
|
assert.ok(m.content.includes("Reasoning chains among them"));
|
||||||
|
assert.ok(m.content.includes("- Modus ponens → Soundness"));
|
||||||
|
// the UI payload is the structured pick, not the flattened text
|
||||||
|
assert.deepEqual(m.details, { propositions: PICKS, chains: ["Modus ponens → Soundness"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no chains: the chains section stays out entirely", () => {
|
||||||
|
const m = newGraphContextMessage(PICKS, []);
|
||||||
|
assert.ok(!m.content.includes("Reasoning chains"));
|
||||||
|
assert.deepEqual(m.details, { propositions: PICKS, chains: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toLlmMessages flattens it to a user message (the model's face)", () => {
|
||||||
|
const m = newGraphContextMessage(PICKS, ["Modus ponens → Soundness"]);
|
||||||
|
const wire = toLlmMessages([m as unknown as LhMessage] as never);
|
||||||
|
assert.equal(wire.length, 1);
|
||||||
|
assert.equal(wire[0]!.role, "user");
|
||||||
|
assert.ok(String((wire[0]!.content as { text?: string }[])[0]!.text).includes("Contextualization"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projectView: a user-side data part — never a text bubble", () => {
|
||||||
|
const m = newGraphContextMessage(PICKS, []);
|
||||||
|
const view = projectView([m as unknown as LhMessage] as never);
|
||||||
|
assert.equal(view.length, 1);
|
||||||
|
assert.equal(view[0]!.role, "user");
|
||||||
|
const part = view[0]!.parts[0] as { type: string; data: unknown };
|
||||||
|
assert.equal(part.type, "data-graph-context");
|
||||||
|
assert.deepEqual(part.data, m.details);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── the route (picks ride the creation) ─────────────────────────────────────
|
||||||
|
|
||||||
|
describe("POST /api/chats — graphContext becomes message[0]", () => {
|
||||||
|
it("writes the marker before any user message; chains derive from the live graph", async () => {
|
||||||
|
// Seed the map: two picked nodes joined by one chain.
|
||||||
|
const node = (id: string, name: string, statement: string) => ({
|
||||||
|
id, name, statement, aliases: [] as string[], createdAt: "2026-10-01T00:00:00.000Z", provenance: [],
|
||||||
|
});
|
||||||
|
graphStore.appendNode(node("n_a", "Modus ponens", "If P implies Q and P holds, Q holds."));
|
||||||
|
graphStore.appendNode(node("n_b", "Soundness", "A valid argument from true premises has a true conclusion."));
|
||||||
|
graphStore.appendChain({
|
||||||
|
id: "arg_1", premises: ["n_a"], conclusion: "n_b",
|
||||||
|
provenance: [], createdAt: "2026-10-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST(new Request("http://test/api/chats", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "drill me on logic",
|
||||||
|
kind: "practice",
|
||||||
|
graphContext: { propositions: PICKS },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const { id } = (await res.json()) as { id: string };
|
||||||
|
|
||||||
|
const record = chatStore.getChat(id)!;
|
||||||
|
assert.equal(record.messages.length, 1, "the marker is message[0], alone until the first turn");
|
||||||
|
const m = record.messages[0] as { role: string; customType: string; content: string };
|
||||||
|
assert.equal(m.role, "custom");
|
||||||
|
assert.equal(m.customType, "graph-context");
|
||||||
|
assert.ok(m.content.includes("- Modus ponens: If P implies Q and P holds, Q holds."));
|
||||||
|
// the chain AMONG the picks rides the marker (formatChainLine shape)
|
||||||
|
assert.ok(m.content.includes("- Modus ponens → Soundness"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no picks (or malformed) → no marker, an empty record as before", async () => {
|
||||||
|
for (const body of [{ title: "t" }, { title: "t", graphContext: { propositions: "junk" } }]) {
|
||||||
|
const res = await POST(new Request("http://test/api/chats", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
const { id } = (await res.json()) as { id: string };
|
||||||
|
assert.equal(chatStore.getChat(id)!.messages.length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,8 @@ import { describe, it } from "node:test";
|
|||||||
|
|
||||||
import { PracticeDock } from "../components/practice-dock.tsx";
|
import { PracticeDock } from "../components/practice-dock.tsx";
|
||||||
import { ConcludeCard, ProblemCard, ThinkingCard } from "../components/practice-cards.tsx";
|
import { ConcludeCard, ProblemCard, ThinkingCard } from "../components/practice-cards.tsx";
|
||||||
|
import { GraphContextCard } from "../components/graph-context-card.tsx";
|
||||||
|
import { newGraphContextMessage } from "../src/core/native-messages.ts";
|
||||||
import { projectView } from "../src/core/view.ts";
|
import { projectView } from "../src/core/view.ts";
|
||||||
import type { ArtifactPartProps } from "../src/core";
|
import type { ArtifactPartProps } from "../src/core";
|
||||||
import {
|
import {
|
||||||
@@ -164,3 +166,37 @@ describe("the practice view projection", () => {
|
|||||||
assert.equal(toolPart.state, "input-available");
|
assert.equal(toolPart.state, "input-available");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── the graph-context card (a session's map pick; collapsed by default) ─────
|
||||||
|
|
||||||
|
describe("GraphContextCard", () => {
|
||||||
|
it("collapsed: counts only — the statements stay hidden until expanded", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<GraphContextCard data={{ propositions: [
|
||||||
|
{ id: "n1", name: "Modus ponens", statement: "If P implies Q and P holds, Q holds." },
|
||||||
|
{ id: "n2", name: "Soundness", statement: "A valid argument from true premises has a true conclusion." },
|
||||||
|
], chains: ["Modus ponens → Soundness"] }} />,
|
||||||
|
);
|
||||||
|
assert.ok(html.includes('data-testid="graph-context-card"'));
|
||||||
|
assert.ok(html.includes("2 propositions"));
|
||||||
|
assert.ok(html.includes("1 chain"));
|
||||||
|
// minimal: the collapsed bar carries NO statement text
|
||||||
|
assert.ok(!html.includes("If P implies Q"));
|
||||||
|
assert.ok(!html.includes("graph-context-body"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zero chains: the chain count stays out of the summary", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<GraphContextCard data={{ propositions: [{ id: "n1", name: "A", statement: "S." }], chains: [] }} />,
|
||||||
|
);
|
||||||
|
assert.ok(html.includes("1 proposition</span>"));
|
||||||
|
assert.ok(!html.includes("chain"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the marker projects to the card's part (projectView fold)", () => {
|
||||||
|
const view = projectView([newGraphContextMessage(
|
||||||
|
[{ id: "n1", name: "A", statement: "S." }], [],
|
||||||
|
) as unknown as LhMessage]);
|
||||||
|
assert.equal((view[0]!.parts[0] as { type: string }).type, "data-graph-context");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user