diff --git a/app/api/chats/[id]/settings/route.ts b/app/api/chats/[id]/settings/route.ts index 2d8fc9f..869d078 100644 --- a/app/api/chats/[id]/settings/route.ts +++ b/app/api/chats/[id]/settings/route.ts @@ -1,6 +1,6 @@ import { getChat, validateChatSettings, stores } from "@/src/server"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { ChatSettings } from "@/src/core"; +import { GRAPH_CONTEXT_TOKENS_MAX, type ChatSettings } from "@/src/core"; /** * The per-chat model control (model-control wave): replace-whole settings — @@ -16,7 +16,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri if (!getChat(id)) return Response.json({ error: "Not found" }, { status: 404 }); const body = (await req.json().catch(() => null)) as - | { model?: { provider?: unknown; id?: unknown }; thinkingLevel?: unknown } + | { model?: { provider?: unknown; id?: unknown }; thinkingLevel?: unknown; graphContextTokens?: unknown } | null; if (!body || typeof body !== "object") { return Response.json({ error: "Invalid JSON body" }, { status: 400 }); @@ -39,6 +39,23 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri } settings.thinkingLevel = body.thinkingLevel as ThinkingLevel; } + // Graph context injection budget (vision-context-injection.md): integer + // tokens, 0 (off) to GRAPH_CONTEXT_TOKENS_MAX, no silent clamping — the + // slider is the only writer, and it only ever sends multiples of the step. + if (body.graphContextTokens !== undefined) { + if ( + typeof body.graphContextTokens !== "number" || + !Number.isInteger(body.graphContextTokens) || + body.graphContextTokens < 0 || + body.graphContextTokens > GRAPH_CONTEXT_TOKENS_MAX + ) { + return Response.json( + { error: `graphContextTokens must be an integer between 0 and ${GRAPH_CONTEXT_TOKENS_MAX}` }, + { status: 400 }, + ); + } + settings.graphContextTokens = body.graphContextTokens; + } const verdict = validateChatSettings(settings); if (!verdict.ok) { diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index f965409..cba0669 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -1,7 +1,7 @@ "use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react"; +import { ActivityIcon, BookOpenIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { CallUsage, JobKind, JobRecord } from "@/src/core"; import { CONTEXT_LIMITS, jobCost } from "@/src/core"; @@ -204,6 +204,7 @@ const KIND_ICON: Record = { research: SearchIcon, demo: PlayIcon, visual: PenToolIcon, + "graph-context": BookOpenIcon, }; /** Abort states per blob id: pending (fired) / failed (backend absent). */ @@ -318,7 +319,9 @@ function ActivityBlobRow({ ? "Demo builder" : blob.jobKind === "visual" ? "Figure maker" - : "Sub-agent"; + : blob.jobKind === "graph-context" + ? "Knowledge context" + : "Sub-agent"; return (
= { research: "Research agent", demo: "Demo agent", visual: "Figure agent", + // The injection is server-initiated — it has no tool part, so never a + // dispatch card. The entry exists only for the exhaustive record. + "graph-context": "Knowledge context", }; export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) { diff --git a/components/chat.tsx b/components/chat.tsx index 8606b35..ec52e83 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -21,7 +21,8 @@ import { SubagentSteerCard } from "@/components/artifacts/steer-card"; import { Composer } from "@/components/composer"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; -import type { ChatSettings } from "@/src/core"; +import { graphContextOfMessage, type ChatSettings } from "@/src/core"; +import { formatTokens } from "@/components/usage-format"; import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanMarginalia } from "@/components/plan-window"; import { WaypointsIcon } from "lucide-react"; @@ -81,6 +82,16 @@ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayout /** A wake-pipeline steer message: user-role delivery whose metadata marks it * as a sub-agent result — rendered as an artifact card, never a bubble. */ +/** The knowledge-context card's one-liner: the numbers the observability + * doctrine wants visible at the seam — tokens and proposition count + * (parse-don't-cast; a malformed part still renders a quiet generic line). */ +function graphContextLine(message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }): string { + const data = graphContextOfMessage(message); + return data + ? `Knowledge context — ~${formatTokens(data.tokens)} · ${data.propositions.length} propositions` + : "Knowledge context"; +} + /** The quiet system line (custom-message scheme rendering): centered, muted, * small — the transcript's voice for events that are not speech (wake * scaffolding, the learner's plan verdict). */ @@ -540,7 +551,14 @@ export function Chat({ // PROTOCOL" §4). Key fallback: legacy transcripts (pre id-hygiene) // can still hold empty ids — the index keeps keys unique for them. - ) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? ( + ) : m.role === "user" && m.parts.some((p) => p.type === "data-graph-context") ? ( + // The prior-knowledge injection's delivery (vision-context-injection.md): + // quiet system line, the numbers only — the block itself was for the + // model (adapter flattening); the part data stays the audit trail. + + {graphContextLine(m)} + + ) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? ( // The probe→teach handoff wake (custom-message scheme): quiet // system line, never a user bubble — the wake is scaffolding, // the "Planning" divider marks the seam, and the model-facing diff --git a/components/graph-context-control.tsx b/components/graph-context-control.tsx new file mode 100644 index 0000000..2f24a39 --- /dev/null +++ b/components/graph-context-control.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { BookOpenIcon } from "lucide-react"; +import { + GRAPH_CONTEXT_TOKENS_DEFAULT, + GRAPH_CONTEXT_TOKENS_MAX, + GRAPH_CONTEXT_TOKENS_STEP, +} from "@/src/core"; +import { formatTokens } from "@/components/usage-format"; + +/** + * The knowledge-context budget control (vision-context-injection.md) — the + * "session data" section's one input, shown where sessions START: the home + * (graph) view's new-session dock and the /chat/new draft. Input before + * output: the panel that will later show what the session contributed to + * the graph asks first what the graph will contribute to the session. + * + * One quiet slider row: the token budget for the prior-knowledge block the + * injection steers in at session start. 0 = off; absent settings show the + * DEFAULT (1024 — on). The value rides ChatSettings (the same lifted + * settings the model picker uses), so it persists pre-chat via the same + * deferred PUT. Sessions never show this control — the injection is + * one-shot at session start, so mid-session there is nothing to decide. + */ +export function GraphContextControl({ + value, + onChange, + testId = "graph-context-control", +}: { + value: number; + onChange: (tokens: number) => void; + testId?: string; +}) { + return ( +
+ + Knowledge context + onChange(Number(e.target.value))} + aria-label="Prior-knowledge token budget" + className="h-1 flex-1 accent-primary" + data-testid="graph-context-slider" + /> + + {value === 0 ? "off" : `${formatTokens(value)} tokens`} + +
+ ); +} + +/** The control's effective value for a chat's settings — absent = the + * default (on), never 0: the decided default is a modest slice of prior + * knowledge for every fresh session. */ +export function effectiveContextTokens(settings: { graphContextTokens?: number } | undefined): number { + return settings?.graphContextTokens ?? GRAPH_CONTEXT_TOKENS_DEFAULT; +} \ No newline at end of file diff --git a/components/home.tsx b/components/home.tsx index a86300e..082dac5 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { CornerDownLeftIcon } from "lucide-react"; import { GraphView } from "@/components/graph-view"; +import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control"; import { SidebarToggle } from "@/components/sidebar"; import { StatsBar } from "@/components/stats-bar"; import type { ChatSettings, TurnUsage } from "@/src/core"; @@ -71,6 +72,17 @@ export function Home() { px-4) so nothing shifts when a session starts (owner). */}
+ {/* The session-data strip (vision-context-injection.md): what the + graph will contribute to the session, decided before the session + starts. Rides the same lifted settings as the model picker. */} +
+ + setSettings((s) => ({ ...s, graphContextTokens: tokens })) + } + /> +
{ diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 9dbba2c..3665b00 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -245,7 +245,9 @@ export function ModelControl({ effectiveLevel !== undefined && m.supportedLevels.includes(effectiveLevel) ? effectiveLevel : undefined; - void save({ model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel }); + // Replace-whole discipline: spread the current settings — a model pick + // must not silently clear a sibling override (graphContextTokens). + void save({ ...(settings ?? {}), model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel }); }} className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${ m.id === effectiveId ? "bg-muted/60" : "" diff --git a/components/new-session.tsx b/components/new-session.tsx index f5faf00..496b505 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -4,6 +4,7 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Composer } from "@/components/composer"; +import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control"; import { PhaseChip } from "@/components/phase-divider"; import { SidebarToggle } from "@/components/sidebar"; import { StatsBar } from "@/components/stats-bar"; @@ -98,6 +99,16 @@ export function DraftShell({
+ {/* The session-data strip (vision-context-injection.md) — same + control as the home view, riding the same lifted settings. */} +
+ + onSettingsSaved({ ...settings, graphContextTokens: tokens }) + } + /> +
Promise; +} + +export interface GraphContextDeps { + chatStore: Pick; + jobStore: Pick; + graphStore: Pick; + embedder: GraphContextEmbedder; + /** The wake seam — jobs.ts deliverJobResult (resolves on delivery). */ + deliver: (steer: SteerMessage) => Promise; +} + +/** The injection job's synthetic toolCallId — no tool part exists; the id + * is deterministic per chat so findJobByToolCallId doubles as the one-shot + * guard, mirroring the spawn-dedup the tool jobs get for free. */ +export function contextToolCallId(chatId: string): string { + return `ctx_${chatId}`; +} + +/** The session's effective budget: settings override, else the default. + * Absent ≠ 0 — the decided default is ON (1024 tokens). */ +export function effectiveContextBudget(settings: ChatSettings | undefined): number { + return settings?.graphContextTokens ?? GRAPH_CONTEXT_TOKENS_DEFAULT; +} + +/** The session's topic: its ONLY text-bearing user message, if exactly one + * exists. Steers (verdicts, handoffs, answers) carry no text part, so + * quiz rounds during probing never break the gate. */ +export function topicOf(messages: UIMessage[]): string | null { + let topic: string | null = null; + let count = 0; + for (const m of messages) { + if (m.role !== "user") continue; + const text = m.parts + .filter((p) => p.type === "text") + .map((p) => (p as { text?: unknown }).text) + .filter((t): t is string => typeof t === "string" && t.trim().length > 0) + .join("\n"); + if (text.length === 0) continue; + count++; + topic = text; + } + return count === 1 ? topic : null; +} + +/** + * The one-shot trigger (fire-and-forget, called from turn-service at every + * turn's onFinish): starts the injection job if and only if the session is + * at its start (exactly one typed message — the topic), the budget is on, + * and nothing was injected before (delivered steer OR any prior context + * job — success or failure: one shot means one shot). Returns the created + * job for tests; null when nothing was started. + */ +export function startGraphContextInjection(deps: GraphContextDeps, chatId: string): JobRecord | null { + const { chatStore, jobStore } = deps; + const chat = chatStore.getChat(chatId); + if (!chat) return null; + if (effectiveContextBudget(chat.settings) <= 0) return null; + if (topicOf(chat.messages) === null) return null; + // One per chat, ever: a delivered steer OR a prior job (any status). + if (steerDelivered(chat.messages, `steer_ctx_${chatId}`)) return null; + if (jobStore.findJobByToolCallId(chatId, contextToolCallId(chatId))) return null; + + const job = jobStore.createJob({ + kind: "graph-context", + chatId, + toolCallId: contextToolCallId(chatId), + activity: "searching knowledge graph", + }); + void runGraphContextJob(deps, job.id).catch((err: unknown) => { + console.error(`[chat ${chatId}] graph-context injection crashed:`, err); + }); + return job; +} + +/** Run one injection job to completion (background; never throws). Also the + * crash-recovery respawn seam: a job record orphaned "running" by a + * process crash re-runs from here (the record is the only input needed). */ +export async function runGraphContextJob(deps: GraphContextDeps, jobId: string): Promise { + const { chatStore, jobStore, graphStore, embedder, deliver } = deps; + const job = jobStore.getJob(jobId); + if (!job || job.status !== "running") return; + + const fail = (reason: string) => { + jobStore.updateJob(jobId, { status: "error", error: reason, activity: "failed" }); + }; + const chat = chatStore.getChat(job.chatId); + if (!chat) return fail(`chat not found: ${job.chatId}`); + const topic = topicOf(chat.messages); + if (topic === null) return fail("no injectable topic (session past its start)"); + const budget = effectiveContextBudget(chat.settings); + + // Real-kill wiring: the dock's END fires this controller; the runner + // checks it before every phase transition and unwinds silently. + const controller = new AbortController(); + registerJobAbort(jobId, controller); + try { + jobStore.updateJob(jobId, { activity: "embedding topic" }); + const ledger = graphStore.readEmbeddings(); + if (ledger.model !== embedder.model || ledger.dim !== embedder.dim) { + return fail("embeddings ledger is stale (model/dim mismatch) — injection skipped"); + } + if (Object.keys(ledger.vectors).length === 0) { + return fail("the knowledge graph has no embeddings yet — nothing to inject"); + } + const query = await embedder.embedQuery(topic); + if (controller.signal.aborted) return; + + jobStore.updateJob(jobId, { activity: "selecting propositions" }); + const data = selectGraphContext({ + chatId: job.chatId, + query, + vectors: ledger.vectors, + nodes: graphStore.readNodes(), + events: graphStore.readEvents(), + budget, + }); + if (!hasContext(data)) return fail("nothing relevant fit the budget"); + if (controller.signal.aborted) return; + + // The result rides the record (crash recovery re-delivers it); the + // wake pipeline flips the job done when the lesson has resumed. + jobStore.updateJob(jobId, { result: data, activity: "delivering context" }); + try { + await deliver(buildGraphContextSteer({ chatId: job.chatId, jobId }, data)); + } catch (err) { + console.error(`[chat ${job.chatId}] graph-context steer delivery failed:`, err); + fail("context delivery failed"); + } + } catch (err) { + if (controller.signal.aborted) return; // the abort path owns the record + console.error(`[chat ${job.chatId}] graph-context injection failed:`, err); + fail(err instanceof Error ? err.message : String(err)); + } finally { + unregisterJobAbort(jobId); + } +} \ No newline at end of file diff --git a/src/agent/subagents/runner.ts b/src/agent/subagents/runner.ts index 9247097..82d1b88 100644 --- a/src/agent/subagents/runner.ts +++ b/src/agent/subagents/runner.ts @@ -89,11 +89,14 @@ interface LoopContext { * table mirrors MODELS in config.ts (research→researcher, demo→demoBuilder, * visual→visualMaker); the id is taken from config so a price-table sync * re-prices jobs for free. Model overrides (tests) still bill under the - * configured model — usage is what is being captured, not which instance. */ + * configured model — usage is what is being captured, not which instance. + * Context-injection jobs make NO model call ("" — onUsage never fires for + * them; the kind is deliberately absent from the runners below). */ const KIND_MODEL: Record = { research: MODELS.researcher.model, demo: MODELS.demoBuilder.model, visual: MODELS.visualMaker.model, + "graph-context": "", }; export function createJobRunner(deps: JobRunnerDeps): JobRunner { @@ -128,11 +131,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { return jobStore.getJob(jobId)?.status === "running"; } - /** The kind→runner map: one sub-agent loop per job kind. */ + /** The kind→runner map: one sub-agent loop per SUB-AGENT kind. + * graph-context is deliberately absent: injection jobs are not + * sub-agent loops (no model, graph deps instead) — their spawn and + * crash-respawn seam is subagents/graph-context.ts, and runJob refuses + * them below rather than guessing wrong. */ // Each kind's runner narrows the union input itself (dispatch-time inputs // are kind-correct by construction; transcript fallbacks are cast after). const KIND_RUNNERS: Record< - JobKind, + Exclude, (jobId: string, input: JobToolInput | undefined, loop: LoopContext) => Promise > = { research: (jobId, input, loop) => runResearchJob(jobId, researchModelOverride.current, input, loop), @@ -147,6 +154,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { return; } if (job.status !== "running") return; // already finished / re-run after crash recovery + if (job.kind === "graph-context") { + // Not a sub-agent: the injection's own respawn seam + // (setContextRespawn → runGraphContextJob) owns these records. + console.error(`[job ${jobId}] graph-context job reached the sub-agent runner — wrong seam`); + return; + } const run = KIND_RUNNERS[job.kind]; if (!run) { console.error(`[job ${jobId}] unknown kind: ${job.kind}`); diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index 683bd19..b53895d 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -10,7 +10,7 @@ import type { UIMessage } from "ai"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Phase3 } from "../phases.ts"; import type { PlanNodeStatus, PlanTree } from "./plan.ts"; -import type { EmissionStats } from "./graph.ts"; +import type { EmissionStats, UnderstandingLevel } from "./graph.ts"; // ─── Chat records (the session store's on-disk format) ─────────────────────── @@ -58,8 +58,23 @@ export interface ChatSettings { /** pi thinking level — must be in the chosen model's supported list * (getSupportedThinkingLevels; enforced by the settings route). */ thinkingLevel?: ThinkingLevel; + /** Graph context injection (vision-context-injection.md): the session's + * token budget for the prior-knowledge block — the top semantically + * closest propositions from the knowledge graph, injected once, at + * session start, as a steer. Absent = the default (on); 0 = off. The + * settings route enforces the range; the trigger (turn-service → + * subagents/graph-context.ts) is one-shot, so a mid-session change + * affects only sessions that haven't injected yet. */ + graphContextTokens?: number; } +/** The injection budget's decided knobs (2026-09-14): default 1024 (a + * meaningful, modest slice of prior knowledge), hard max 4096, slider + * step 128, 0 = off. */ +export const GRAPH_CONTEXT_TOKENS_DEFAULT = 1024; +export const GRAPH_CONTEXT_TOKENS_MAX = 4096; +export const GRAPH_CONTEXT_TOKENS_STEP = 128; + // ─── Background-extraction usage (the money channel, extraction side) ──────── /** One pipeline stage's model usage — the stage's model + every step it @@ -176,6 +191,54 @@ export const HANDOFF_TEXT = "The probe is concluded — the learner assessment is recorded. " + "Author the session's plan map now (call plan_init) and present it, then begin teaching."; +// ─── Graph context injection (vision-context-injection.md, 2026-09-14) ──────── + +/** One selected prior-knowledge proposition: similarity-ranked, annotated + * with the learner's CURRENT understanding level — the fold is MOST + * RECENT extraction instance (levels are per-instance, tied to moments in + * time; the latest evidence is the best estimate of current understanding). + * null = the node has no observation event at all. */ +export interface GraphContextProposition { + id: string; + statement: string; + level: UnderstandingLevel | null; +} + +/** The data-graph-context part payload: what the UI renders (the counts) + * AND the audit trail (the full block content — the transcript shows + * exactly what was injected, and when). The model-facing text is derived + * from this by graphContextText below — the adapter flattens the marker + * part to it (the custom-message scheme: no authored human speech). */ +export interface GraphContextData { + chatId: string; + /** The session's budget at injection time (for the card's readout). */ + budget: number; + /** Estimated tokens of the injected block — chars/4 heuristic. */ + tokens: number; + /** The selected propositions, similarity-descending. */ + propositions: GraphContextProposition[]; +} + +/** The model-facing block text — single-sourced (the adapter, the tests, + * and reality can't drift). The level word travels with each line: the + * closing gloss tells the agent what to do with each. */ +export function graphContextText(data: GraphContextData): string { + const n = data.propositions.length; + if (n === 0) return ""; + const lines = data.propositions.map( + (p) => `- ${p.level ? `[${p.level}] ` : ""}${p.statement}`, + ); + return [ + `Prior-knowledge context — what this learner already knows (from their knowledge graph, ` + + `${n} proposition${n === 1 ? "" : "s"}, ~${data.tokens} tokens):`, + "", + ...lines, + "", + "Treat this as reliable prior knowledge: build on what is solid, firm up what is shaky, " + + "and teach around anything resisted. Do not re-teach solid ground.", + ].join("\n"); +} + export interface PlanBlockData { /** Only "full" survives (the 2026-09-11 pivot): the plan renders IN * FLOW once, at authoring. The neighborhood fragments are retired — diff --git a/src/core/contracts/jobs.ts b/src/core/contracts/jobs.ts index f617ebf..25d1db1 100644 --- a/src/core/contracts/jobs.ts +++ b/src/core/contracts/jobs.ts @@ -7,11 +7,11 @@ * of "ai" (safe everywhere: server, client, tests). See docs/vision.md. */ import type { UIMessage } from "ai"; -import type { CallUsage } from "./chat.ts"; +import type { CallUsage, GraphContextData } from "./chat.ts"; // ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ──────────── -export type JobKind = "research" | "demo" | "visual"; +export type JobKind = "research" | "demo" | "visual" | "graph-context"; export type JobStatus = "running" | "done" | "error"; export interface JobRecord { @@ -263,7 +263,11 @@ export interface AbortSteerMessage extends UIMessage { } /** Anything the wake pipeline may queue and deliver. */ -export type SteerMessage = SubagentSteerMessage | AbortSteerMessage | ProbeHandoffSteerMessage; +export type SteerMessage = + | SubagentSteerMessage + | AbortSteerMessage + | ProbeHandoffSteerMessage + | GraphContextSteerMessage; /** Whether a result is the failure shape (html/svg empty, failure brief). */ export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean { @@ -380,6 +384,34 @@ export function buildProbeHandoffSteer(chatId: string): ProbeHandoffSteerMessage }; } +// ─── the graph-context steer (vision-context-injection.md) ─────────────────── +// +// The injection's delivery: same wake pipeline, same gates — it queues +// behind a pending quiz and rides the turn after the learner's answer, or +// fires in the pump's idle window. Marker-only (the custom-message scheme): +// no text part; the adapter flattens data-graph-context to graphContextText +// (core/contracts/chat.ts owns the wording). One per chat, ever — the id is +// deterministic per chat, so re-delivery is idempotent. + +export interface GraphContextSteerMessage extends UIMessage { + id: `steer_ctx_${string}`; + role: "user"; + metadata: { graphContext: { chatId: string; jobId: string } }; + parts: [{ type: "data-graph-context"; data: GraphContextData }]; +} + +export function buildGraphContextSteer( + meta: { chatId: string; jobId: string }, + data: GraphContextData, +): GraphContextSteerMessage { + return { + id: `steer_ctx_${meta.chatId}`, + role: "user", + metadata: { graphContext: meta }, + parts: [{ type: "data-graph-context", data }], + }; +} + /** True when the transcript already carries this steer — idempotent * delivery for ANY steer kind: every steer id is deterministic per its * event (steer_ for jobs, steer_probe_ for the handoff). */ diff --git a/src/core/graph-context.ts b/src/core/graph-context.ts new file mode 100644 index 0000000..cd79e99 --- /dev/null +++ b/src/core/graph-context.ts @@ -0,0 +1,184 @@ +/** + * Graph context injection — the pure selection core + * (docs/vision-context-injection.md, settled 2026-09-14). + * + * The heavy lifting is deliberately tiny: embedding the topic is the only + * slow step (the embedder singleton), and SEARCH IS BRUTE-FORCE COSINE — + * ~1M multiply-adds at 2k propositions, exact top-K from the in-memory + * ledger, no ANN index until ~10⁶ scale (far away). + * + * Selection doctrine (owner-decided): the token budget is the ONLY control + * on amount — there is NO similarity threshold. Similarity decides ORDER + * (which propositions fill the budget first); irrelevant-but-cheap content + * rides along harmlessly. Levels fold MOST RECENT: UnderstandingLevel lives + * per extraction instance (tied to moments in time), and the latest evidence + * is the best estimate of current understanding — an optimistic fold risks + * stale claims, a pessimistic fold wastes covered ground. + */ + + +import { + GRAPH_CONTEXT_TOKENS_MAX, + type GraphContextData, + type GraphContextProposition, + graphContextText, +} from "./contracts/chat.ts"; +import type { UnderstandingLevel } from "./contracts/graph.ts"; +import type { GraphNode, LedgerEvent } from "./contracts/graph.ts"; + +/** Belt-and-braces count cap — the budget is the primary control; a huge + * budget still can't pour hundreds of lines into the block. */ +export const GRAPH_CONTEXT_MAX_PROPOSITIONS = 64; + +/** The block's framing (header + gloss) in estimated tokens — reserved so + * the composed block, not just the proposition lines, stays under budget. */ +const FRAMING_TOKENS = 55; + +const LEVELS: ReadonlySet = new Set([ + "solid", + "shaky", + "accepted", + "resisted", + "not yet engaged", +]); + +export function isUnderstandingLevel(word: unknown): word is UnderstandingLevel { + return typeof word === "string" && LEVELS.has(word); +} + +/** + * Fold each node's CURRENT understanding level: the level word of its most + * recent observation event (latest `at`; ledger order breaks ties — the + * same rule the snapshot's nodeObservations uses, but across ALL sessions, + * not per chat). Malformed notes (not on the scale) never count. + */ +export function currentUnderstanding(events: LedgerEvent[]): Map { + const latest = new Map(); + events.forEach((event, index) => { + if (event.kind !== "observation" || event.nodeId === null) return; + if (!isUnderstandingLevel(event.note)) return; + const current = latest.get(event.nodeId); + if (!current || event.at > current.at || (event.at === current.at && index > current.index)) { + latest.set(event.nodeId, { at: event.at, level: event.note, index }); + } + }); + const levels = new Map(); + for (const [nodeId, { level }] of latest) levels.set(nodeId, level); + return levels; +} + +/** Estimated tokens for one proposition line: chars/4 (the agreed + * heuristic — no server-side tokenizer) + one for the level tag. */ +function lineTokens(p: GraphContextProposition): number { + return Math.ceil(p.statement.length / 4) + (p.level ? 1 : 0); +} + +export interface SelectGraphContextInput { + chatId: string; + /** The topic's query embedding (same model + dim as the ledger — the + * caller asserts parity; dimension-mismatched vectors are skipped). */ + query: number[]; + /** The embeddings ledger's vectors: nodeId → truncated, L2-normalized + * (cosine = dot product). */ + vectors: Record; + /** Raw node lines (append order; first line per id defines — the + * ledger merge rule, mirrored from core/graph.ts). */ + nodes: GraphNode[]; + /** The event ledger — for the most-recent level fold. */ + events: LedgerEvent[]; + /** The session's token budget (estimated tokens, chars/4). */ + budget: number; +} + +/** + * Select the prior-knowledge block: similarity-descending greedy fill under + * the token budget, capped by count, each line annotated with its folded + * current level. Pure — server composes it with the embedder; tests drive + * it with hand-made vectors. + */ +export function selectGraphContext(input: SelectGraphContextInput): GraphContextData { + const { chatId, query, vectors, nodes, events, budget } = input; + const levels = currentUnderstanding(events); + + // Score every embedded node: dot product (vectors are normalized). A + // dimension mismatch (a stale ledger, a foreign query) never scores. + const scored: Array<{ id: string; statement: string; score: number }> = []; + const seen = new Set(); + for (const node of nodes) { + if (seen.has(node.id)) continue; // first line defines (merge rule) + seen.add(node.id); + const vec = vectors[node.id]; + if (!vec || vec.length !== query.length) continue; + let dot = 0; + for (let i = 0; i < query.length; i++) dot += query[i]! * vec[i]!; + scored.push({ id: node.id, statement: node.statement, score: dot }); + } + scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)); + + // Greedy fill: similarity order decides WHICH propositions enter; the + // budget decides WHEN to stop. No similarity threshold, by design. + const propositions: GraphContextProposition[] = []; + let used = FRAMING_TOKENS; + for (const { id, statement } of scored) { + if (propositions.length >= GRAPH_CONTEXT_MAX_PROPOSITIONS) break; + const p: GraphContextProposition = { + id, + statement, + level: levels.get(id) ?? null, + }; + const cost = lineTokens(p); + if (used + cost > budget) continue; // too big for the budget — a later, smaller one may still fit + used += cost; + propositions.push(p); + } + return { chatId, budget, tokens: used, propositions }; +} + +/** True when the selection would inject anything (guards the runner: an + * empty block is never delivered — no steer, the job errors instead). */ +export function hasContext(data: GraphContextData): boolean { + return data.propositions.length > 0 && graphContextText(data).length > 0; +} + +// ─── The marker-part parser (parse-don't-cast — tool-parts doctrine) ────────── + +function parseProposition(raw: unknown): GraphContextProposition | null { + if (typeof raw !== "object" || raw === null) return null; + const { id, statement, level } = raw as Record; + if (typeof id !== "string" || typeof statement !== "string") return null; + if (level !== null && level !== undefined && !isUnderstandingLevel(level)) return null; + return { id, statement, level: (level as UnderstandingLevel | null | undefined) ?? null }; +} + +function parseGraphContextData(raw: unknown): GraphContextData | null { + if (typeof raw !== "object" || raw === null) return null; + const { chatId, budget, tokens, propositions } = raw as Record; + if (typeof chatId !== "string") return null; + if (typeof budget !== "number" || budget < 0 || budget > GRAPH_CONTEXT_TOKENS_MAX) return null; + if (typeof tokens !== "number" || tokens < 0) return null; + if (!Array.isArray(propositions)) return null; + const parsed: GraphContextProposition[] = []; + for (const item of propositions) { + const p = parseProposition(item); + if (!p) return null; // malformed content: the whole part is untrusted + parsed.push(p); + } + return { chatId, budget, tokens, propositions: parsed }; +} + +/** + * The data-graph-context part of a message, validated — or null when the + * part is absent or malformed. The seam the adapter and the chat view read + * the marker through (never a raw cast: the part's data crossed a + * serialization boundary). + */ +export function graphContextOfMessage( + message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }, +): GraphContextData | null { + for (const part of message.parts) { + if (part.type === "data-graph-context") { + return parseGraphContextData((part as { data?: unknown }).data); + } + } + return null; +} \ No newline at end of file diff --git a/src/core/index.ts b/src/core/index.ts index 5e1a4bf..753794b 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -34,3 +34,4 @@ export * from "./ui-message.ts"; export * from "./tool-parts.ts"; export * from "./prices.ts"; export * from "./fuzzy.ts"; +export * from "./graph-context.ts"; diff --git a/src/core/ui-message.ts b/src/core/ui-message.ts index 807d103..f2b4258 100644 --- a/src/core/ui-message.ts +++ b/src/core/ui-message.ts @@ -1,6 +1,7 @@ import type { UIMessage } from "ai"; import type { DemoToolInput, + GraphContextData, PlanVerdictData, PlanBlockData, ProbeDoneInput, @@ -46,6 +47,10 @@ export type TeacherUIDataParts = { /** The probe→teach handoff wake: the wake pipeline's steer message, * carrying ONLY this part (the model-facing text lives in the adapter). */ "probe-handoff": { chatId: string }; + /** The prior-knowledge injection's delivery (vision-context-injection.md): + * the marker-only steer — the counts for the UI, the full block content + * for the audit trail; the adapter flattens it to graphContextText. */ + "graph-context": GraphContextData; /** The abort steer's message-type marker (the abort badge renders off it). */ "subagent-aborted": SubagentResultMeta & { aborted: true }; }; diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index a160b9f..b1969c9 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -4,14 +4,15 @@ import { createProberAgent, createTeacherAgent, type TeachingAgent } from "../ag import { piEngineFor } from "../agent/models.ts"; import { createJobRunner } from "../agent/subagents/runner.ts"; import { createExtractionPipeline } from "../agent/subagents/extraction.ts"; -import { embedNewNodes } from "./embeddings.ts"; +import { runGraphContextJob, type GraphContextDeps } from "../agent/subagents/graph-context.ts"; +import { EMBEDDING_DIM, EMBEDDING_MODEL, embedNewNodes, embedQuery } from "./embeddings.ts"; import type { ModelRole } from "../../config.ts"; -import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts"; +import { claimSteers, deliverJobResult, setContextRespawn, setJobRespawn, setTurnRunner } from "./jobs.ts"; import { recordToPiMessages } from "./pi/record-adapter.ts"; import { createPiTurnResponse } from "./pi/ui-stream.ts"; import { stores } from "./stores.ts"; import { beginTurn, endTurn } from "./turn-liveness.ts"; -import { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts"; +import { persistTurn, triggerExtraction, triggerGraphContext, withMessageIds } from "./turn-service.ts"; import type { CallUsage } from "../core/contracts.ts"; /** @@ -48,6 +49,18 @@ export const extraction = createExtractionPipeline({ embedder: embedNewNodes, }); +/** Graph context injection (vision-context-injection.md): the runner's + * deps — same storage ports, the REAL embedder as the query seam (same + * warm singleton as extraction, so vector-space parity holds by + * construction), and the wake pipeline as the delivery seam. */ +export const graphContext: GraphContextDeps = { + chatStore: stores.chatStore, + jobStore: stores.jobStore, + graphStore: stores.graphStore, + embedder: { model: EMBEDDING_MODEL, dim: EMBEDDING_DIM, embedQuery }, + deliver: deliverJobResult, +}; + // Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline // fires its server-side teacher turns through THIS composition root, and // crash recovery re-spawns orphaned sub-agent loops through THIS runner. @@ -65,6 +78,7 @@ setTurnRunner(async (chatId, messages) => { return res.status; }); setJobRespawn((jobId) => void jobRunner.runJob(jobId)); +setContextRespawn((jobId) => void runGraphContextJob(graphContext, jobId)); /** The message-shape gate the SDK's UIMessageValidator owned: a malformed * body must fail BEFORE the stream exists — the path where no onFinish will @@ -223,6 +237,9 @@ export async function handleChatRequest( }); } triggerExtraction(stores, extraction, chatId); + // The graph READ channel: one-shot prior-knowledge injection at + // session start — a no-op for every session past its start. + triggerGraphContext(graphContext, chatId); // LAST: a poller that sees not-in-flight must read an already-persisted // record — endTurn only after persistTurn has done its job. endTurn(chatId); diff --git a/src/server/embeddings.ts b/src/server/embeddings.ts index 0d9194f..1bb6091 100644 --- a/src/server/embeddings.ts +++ b/src/server/embeddings.ts @@ -42,6 +42,24 @@ function embedInput(statement: string): string { return `title: none | text: ${statement}`; } +/** EmbeddingGemma's retrieval-QUERY format (model card) — the topic side of + * graph context injection (vision-context-injection.md). Role-asymmetric + * BY DESIGN: queries and documents use different trained templates; the + * requirement is same model + same truncation (one vector space), each + * side in the format the model was trained for. */ +function queryInput(text: string): string { + return `task: search result | query: ${text}`; +} + +/** Embed one retrieval query (the session's topic) — same warm singleton, + * same 512-dim MRL truncation as the ledger. The one embedding call the + * injection job makes; tens–~200ms warm. */ +export async function embedQuery(text: string): Promise { + const extractor = await getPipeline(); + const output = await extractor([queryInput(text)], { pooling: "mean", normalize: true }); + return truncateNormalize((output.tolist() as number[][])[0]!); +} + /** Truncate an L2-normalized vector to EMBEDDING_DIM (MRL: leading dims * carry most of the signal) and re-normalize so cosine = dot product. */ function truncateNormalize(vec: readonly number[]): number[] { diff --git a/src/server/jobs.ts b/src/server/jobs.ts index 3adf096..aab8518 100644 --- a/src/server/jobs.ts +++ b/src/server/jobs.ts @@ -34,6 +34,7 @@ import type { UIMessage } from "ai"; import { buildAbortSteer, + buildGraphContextSteer, buildSubagentSteer, isSubagentFailure, steerDelivered, @@ -60,6 +61,9 @@ interface QueuedSteer { interface WakeState { turnRunner?: TurnRunner; respawn?: JobRespawn; + /** Crash recovery for context-injection jobs (they are not sub-agent + * loops — their runner takes the graph deps; chat-handler wires it). */ + contextRespawn?: (jobId: string) => void; /** chatId → steers awaiting delivery, in arrival order. */ queues: Map; /** Chats whose pump loop is currently active (serialization per chat). */ @@ -87,6 +91,13 @@ export function setJobRespawn(respawn: JobRespawn): void { state().respawn = respawn; } +/** chat-handler wires this at module init: the graph-context runner + * (subagents/graph-context.ts runGraphContextJob — re-runnable from the + * job record alone, which is all crash recovery has). */ +export function setContextRespawn(respawn: (jobId: string) => void): void { + state().contextRespawn = respawn; +} + const POLL_MS = 400; /** After the gates open, wait a beat before firing — a client that just * answered a question is about to send its continuation; don't 409 it. @@ -110,11 +121,14 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -/** The job a steer belongs to, if it is a job steer (result/abort); +/** The job a steer belongs to, if it is a job steer (result/abort/context); * probe-handoff steers have no job — null. */ function jobIdOf(steer: SteerMessage): string | null { - const meta = steer.metadata as { subagentResult?: { jobId: string } }; - return meta.subagentResult?.jobId ?? null; + const meta = steer.metadata as { + subagentResult?: { jobId: string }; + graphContext?: { jobId: string }; + }; + return meta.subagentResult?.jobId ?? meta.graphContext?.jobId ?? null; } /** Finalize a delivered steer's job bookkeeping — job steers only. */ @@ -220,6 +234,12 @@ export function abortJob(jobId: string): AbortOutcome { if (!job) return { ok: false, reason: "not_found" }; if (job.status !== "running") return { ok: false, reason: "not_running" }; + // Context-injection jobs abort SILENTLY: nothing was ever promised in the + // lesson (server-initiated scaffolding, unlike a teacher-dispatched + // sub-agent), so no abort steer — flipping the record and killing the + // run is the whole story. Steps 1–3 below are shared; step 4 is skipped. + const isContext = job.kind === "graph-context"; + fireJobAbort(jobId); // no-op when the loop already returned (delivering) // Drop any queued steers for this job (their deliver() resolves unfired). @@ -234,16 +254,22 @@ export function abortJob(jobId: string): AbortOutcome { } stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" }); - void deliverJobResult(buildAbortSteer(job)).catch(() => { - /* deliverJobResult marks the job error on unrecoverable failure */ - }); + if (!isContext) { + void deliverJobResult(buildAbortSteer(job)).catch(() => { + /* deliverJobResult marks the job error on unrecoverable failure */ + }); + } return { ok: true }; } function chatIdOf(steer: SteerMessage): string { - // The probe handoff carries its chat directly (no job record exists). + // The probe handoff and the context injection carry their chat directly + // (the context steer's job record would say it too, but the metadata is + // the cheap path — same rule as the handoff). const handoff = (steer.metadata as { probeHandoff?: { chatId: string } }).probeHandoff; if (handoff) return handoff.chatId; + const injected = (steer.metadata as { graphContext?: { chatId: string } }).graphContext; + if (injected) return injected.chatId; // The chat is resolvable from the job record; the steer's metadata // carries jobId, which indexes the store. const jobId = jobIdOf(steer); @@ -352,6 +378,12 @@ async function pump(chatId: string): Promise { function finalizeJob(jobId: string): void { const job = stores.jobStore.getJob(jobId); if (!job || job.status !== "running") return; + // Context-injection jobs have no sub-agent result shape — a delivered + // block is a success, full stop (isSubagentFailure would misread it). + if (job.kind === "graph-context") { + stores.jobStore.updateJob(jobId, { status: "done", activity: "delivered" }); + return; + } const result = job.result; const failed = result ? isSubagentFailure(job.kind, result as never) : true; stores.jobStore.updateJob(jobId, { @@ -418,6 +450,23 @@ function recoverStaleJobs(): void { if (now - Date.parse(job.updatedAt) < STALE_JOB_MS) continue; if (!stores.chatStore.getChat(job.chatId)) continue; if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now + if (job.kind === "graph-context") { + // Context-injection orphans: a stored block re-delivers in its own + // steer shape; a job that died before selecting re-runs from the + // record (the graph runner — not the sub-agent respawn seam). + if (job.result) { + stores.jobStore.updateJob(job.id, { activity: "recovering: re-delivering" }); + void deliverJobResult( + buildGraphContextSteer({ chatId: job.chatId, jobId: job.id }, job.result as never), + ).catch(() => { + /* deliverJobResult marks the job error on failure */ + }); + } else if (s.contextRespawn) { + stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" }); + s.contextRespawn(job.id); + } + continue; + } if (job.result) { // The wake never fired (or crashed mid-flight): re-deliver. const steer = buildSubagentSteer(job, job.result as never); diff --git a/src/server/pi/record-adapter.ts b/src/server/pi/record-adapter.ts index 029324d..f2b193e 100644 --- a/src/server/pi/record-adapter.ts +++ b/src/server/pi/record-adapter.ts @@ -1,7 +1,8 @@ import type { UIMessage } from "ai"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai"; -import { HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts"; +import { graphContextOfMessage } from "../../core/graph-context.ts"; +import { graphContextText, HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts"; /** * The record → pi-ai transcript adapter (docs/pi-engine-swap.md §4). The @@ -55,12 +56,19 @@ function zeroUsage() { /** The model-facing text of a marker-only user message, or null when the * message has no model face at all (UI-only data parts). */ -function systemTextOf(parts: ReadonlyArray<{ type: string; data?: unknown }>): string | null { - for (const part of parts) { +function systemTextOf(message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }): string | null { + for (const part of message.parts) { if (part.type === "data-verdict") { return verdictText((part.data as { accepted?: unknown } | undefined)?.accepted === true); } if (part.type === "data-probe-handoff") return HANDOFF_TEXT; + // Graph context injection: the block text derives from the part's + // validated data (parse-don't-cast — a malformed part has no model + // face, and the message then never reaches the provider). + if (part.type === "data-graph-context") { + const data = graphContextOfMessage({ parts: message.parts }); + return data ? graphContextText(data) : null; + } } return null; } @@ -79,7 +87,7 @@ export function recordToPiMessages(messages: UIMessage[]): Message[] { // wording lives in the adapter's reach (core contracts), never // authored as fake human speech in the transcript. if (texts.length === 0) { - const flat = systemTextOf(message.parts); + const flat = systemTextOf(message); if (flat === null) continue; // pure UI data part, no model face texts.push(flat); } diff --git a/src/server/turn-service.ts b/src/server/turn-service.ts index 0733e33..ff120cb 100644 --- a/src/server/turn-service.ts +++ b/src/server/turn-service.ts @@ -21,6 +21,7 @@ import { pendingQuizAnswers } from "../core/graph.ts"; import type { ChatStore, GraphStore } from "../core/ports.ts"; import { EXTRACTION } from "../../config.ts"; import type { ExtractionPipeline } from "../agent/subagents/extraction.ts"; +import { startGraphContextInjection, type GraphContextDeps } from "../agent/subagents/graph-context.ts"; /** * The canonical state write: the client owns the message list between turns @@ -98,3 +99,22 @@ export function triggerExtraction( console.error(`[chat ${chatId}] extraction trigger failed:`, err); } } + +/** + * The graph READ channel (vision-context-injection.md): one shot per chat, + * at session start — the first typed message is the session's topic; the + * semantically closest propositions are selected under the session's token + * budget and steered in through the wake pipeline. Fire-and-forget like + * triggerExtraction; the one-shot gates (topic, budget, prior injection) + * live in the runner module, not here — this seam only decides WHEN to ask. + * Skipped under tests, same doctrine: the runner is tested directly with a + * scripted embedder. + */ +export function triggerGraphContext(deps: GraphContextDeps, chatId: string): void { + if (process.env.NODE_ENV === "test") return; + try { + startGraphContextInjection(deps, chatId); + } catch (err) { + console.error(`[chat ${chatId}] graph-context trigger failed:`, err); + } +} diff --git a/tests/graph-context-core.test.ts b/tests/graph-context-core.test.ts new file mode 100644 index 0000000..cdfb5a3 --- /dev/null +++ b/tests/graph-context-core.test.ts @@ -0,0 +1,291 @@ +/** + * Graph context injection — the pure core (docs/vision-context-injection.md): + * selection doctrine, level fold, block text, the marker-part parser, and + * the runner's pure gates (topic/budget/one-shot). Deterministic, zero + * network, zero stores — hand-made vectors. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { UIMessage } from "ai"; +import { + buildGraphContextSteer, + steerDelivered, + GRAPH_CONTEXT_TOKENS_DEFAULT, + GRAPH_CONTEXT_TOKENS_MAX, + GRAPH_CONTEXT_TOKENS_STEP, + graphContextText, + type GraphContextData, +} from "../src/core/contracts.ts"; +import { + currentUnderstanding, + GRAPH_CONTEXT_MAX_PROPOSITIONS, + graphContextOfMessage, + hasContext, + isUnderstandingLevel, + selectGraphContext, +} from "../src/core/graph-context.ts"; +import { + contextToolCallId, + effectiveContextBudget, + topicOf, +} from "../src/agent/subagents/graph-context.ts"; +import type { GraphNode, LedgerEvent } from "../src/core/contracts/graph.ts"; + +function unit(i: number): number[] { + const v = new Array(8).fill(0); + v[i] = 1; + return v; +} + +function node(id: string, statement: string): GraphNode { + return { + id, + name: id, + statement, + aliases: [], + provenance: [], + createdAt: "2026-09-14T00:00:00.000Z", + }; +} + +function obs(nodeId: string, level: string, at: string, chatId = "chat_x"): LedgerEvent { + return { nodeId, kind: "observation", chatId, at, note: level }; +} + +/** A text-bearing user message (the learner's typed speech). */ +function userText(text: string): UIMessage { + return { id: `m_${text.length}`, role: "user", parts: [{ type: "text", text }] }; +} + +/** A marker-only user message (steer scaffolding — no text). */ +function marker(type: string, data: unknown): UIMessage { + return { id: `m_${type}`, role: "user", parts: [{ type, data } as never] }; +} + +describe("currentUnderstanding — the most-recent fold", () => { + it("latest observation wins, across chats, ledger order breaks ties", () => { + const events = [ + obs("n1", "shaky", "2026-09-01T00:00:00Z", "chat_a"), + obs("n1", "solid", "2026-09-10T00:00:00Z", "chat_b"), + obs("n2", "resisted", "2026-09-05T00:00:00Z"), + obs("n2", "accepted", "2026-09-05T00:00:00Z"), // same at → later line wins + ]; + const levels = currentUnderstanding(events); + assert.equal(levels.get("n1"), "solid"); + assert.equal(levels.get("n2"), "accepted"); + assert.equal(levels.size, 2); + }); + + it("off-scale notes never count (the closed list is the only vocabulary)", () => { + assert.equal(isUnderstandingLevel("solid"), true); + assert.equal(isUnderstandingLevel("pretty good"), false); + const levels = currentUnderstanding([obs("n1", "confident", "2026-09-01T00:00:00Z")]); + assert.equal(levels.size, 0); + }); + + it("quiz outcome events and null nodes are not observations", () => { + const events: LedgerEvent[] = [ + { nodeId: "n1", kind: "correct", chatId: "c", at: "2026-09-01T00:00:00Z" }, + { nodeId: null, kind: "observation", chatId: "c", at: "2026-09-01T00:00:00Z", note: "solid" }, + ]; + assert.equal(currentUnderstanding(events).size, 0); + }); +}); + +describe("selectGraphContext — similarity orders, the budget is the only amount control", () => { + const base = { + chatId: "chat_1", + query: unit(0), + events: [] as LedgerEvent[], + }; + + it("fills similarity-descending and stops before exceeding the budget", () => { + const vectors = { a: unit(0), b: unit(0), c: unit(7) }; + const nodes = [ + node("a", "Water boils at 100°C at sea level."), + node("b", "Evaporation cools the surrounding air."), + node("c", "The Schrödinger equation is linear."), + ]; + // budget 55 (framing) + ~9 tokens for statement a ≈ fits exactly one line. + const data = selectGraphContext({ ...base, vectors, nodes, budget: 66 }); + assert.deepEqual( + data.propositions.map((p) => p.id), + ["a"], + ); + assert.equal(data.tokens <= 66, true); + assert.equal(data.budget, 66); + }); + + it("an oversized line is skipped, a later smaller one may still fit (no threshold, pure budget)", () => { + const vectors = { big: unit(0), small: unit(0), off: unit(7) }; + const big = "X".repeat(400); // ~100 tokens — bigger than the whole budget + const nodes = [ + node("big", big), + node("small", "Ice melts."), + node("off", "Irrelevant."), + ]; + // budget 60: 'big' (~100 tokens) is skipped outright; 'small' (4 tokens + // incl. the level slot + framing 55 = 59) fits; 'off' (63) doesn't. + const data = selectGraphContext({ ...base, vectors, nodes, budget: 60 }); + assert.deepEqual( + data.propositions.map((p) => p.id), + ["small"], + ); + }); + + it("annotates each line with the folded current level (null when unobserved)", () => { + const vectors = { a: unit(0), b: unit(0) }; + const nodes = [node("a", "A."), node("b", "B.")]; + const events = [obs("b", "shaky", "2026-09-10T00:00:00Z")]; + const data = selectGraphContext({ ...base, vectors, nodes, events, budget: 4096 }); + const byId = Object.fromEntries(data.propositions.map((p) => [p.id, p])); + assert.equal(byId.a!.level, null); + assert.equal(byId.b!.level, "shaky"); + }); + + it("the count cap bounds even a huge budget", () => { + const vectors: Record = {}; + const nodes: GraphNode[] = []; + for (let i = 0; i < GRAPH_CONTEXT_MAX_PROPOSITIONS + 10; i++) { + const id = `n${i}`; + vectors[id] = unit(0); + nodes.push(node(id, `Statement ${i}.`)); + } + const data = selectGraphContext({ ...base, vectors, nodes, budget: GRAPH_CONTEXT_TOKENS_MAX }); + assert.equal(data.propositions.length, GRAPH_CONTEXT_MAX_PROPOSITIONS); + }); + + it("dimension-mismatched vectors and duplicate node lines are handled", () => { + const vectors = { ok: unit(0), stale: [1, 2, 3] }; + const nodes = [ + node("ok", "Fine."), + node("dup", "First line."), + node("dup", "Second line never redefines."), + ]; + // "dup" has no vector anyway; the pin is that the scan neither throws + // nor scores the stale-dim vector. + const data = selectGraphContext({ ...base, vectors, nodes, budget: 4096 }); + assert.deepEqual( + data.propositions.map((p) => p.id), + ["ok"], + ); + }); +}); + +describe("graphContextText + hasContext — the canonical block", () => { + it("carries the counts in the header, the level tag per line, the gloss at the end", () => { + const data: GraphContextData = { + chatId: "c1", + budget: 1024, + tokens: 42, + propositions: [ + { id: "n1", statement: "Water boils at 100°C.", level: "solid" }, + { id: "n2", statement: "Humidity slows evaporation.", level: null }, + ], + }; + const text = graphContextText(data); + assert.match(text, /2 propositions, ~42 tokens/); + assert.match(text, /- \[solid\] Water boils at 100°C\./); + assert.match(text, /- Humidity slows evaporation\./); + assert.match(text, /Do not re-teach solid ground\./); + }); + + it("an empty selection has no text and no context", () => { + const empty: GraphContextData = { chatId: "c1", budget: 1024, tokens: 55, propositions: [] }; + assert.equal(graphContextText(empty), ""); + assert.equal(hasContext(empty), false); + }); +}); + +describe("graphContextOfMessage — parse-don't-cast at the marker seam", () => { + it("round-trips a well-formed part", () => { + const msg = marker("data-graph-context", { + chatId: "c1", + budget: 512, + tokens: 60, + propositions: [{ id: "n1", statement: "S.", level: "shaky" }], + }); + const data = graphContextOfMessage(msg); + assert.equal(data?.chatId, "c1"); + assert.equal(data?.propositions[0]?.level, "shaky"); + }); + + it("rejects malformed shapes wholesale (bad level, bad budget, non-array)", () => { + assert.equal(graphContextOfMessage(marker("data-graph-context", { chatId: "c" })), null); + assert.equal( + graphContextOfMessage( + marker("data-graph-context", { + chatId: "c", + budget: 512, + tokens: 1, + propositions: [{ id: "n", statement: "s", level: "kinda" }], + }), + ), + null, + ); + assert.equal( + graphContextOfMessage( + marker("data-graph-context", { chatId: "c", budget: 999999, tokens: 1, propositions: [] }), + ), + null, + ); + }); + + it("absent part → null", () => { + assert.equal(graphContextOfMessage(userText("hello")), null); + }); +}); + +describe("buildGraphContextSteer — the marker-only delivery", () => { + it("id is deterministic per chat; metadata carries chat + job; one part", () => { + const data: GraphContextData = { + chatId: "chat_9", + budget: 1024, + tokens: 10, + propositions: [{ id: "n1", statement: "S.", level: null }], + }; + const steer = buildGraphContextSteer({ chatId: "chat_9", jobId: "job_1" }, data); + assert.equal(steer.id, "steer_ctx_chat_9"); + assert.equal(steer.role, "user"); + assert.deepEqual(steer.metadata, { graphContext: { chatId: "chat_9", jobId: "job_1" } }); + assert.equal(steer.parts.length, 1); + assert.equal(steer.parts[0]!.type, "data-graph-context"); + // steerDelivered keys on the id — the one-shot guard. + assert.equal(steerDelivered([steer], "steer_ctx_chat_9"), true); + }); +}); + +describe("the runner's pure gates", () => { + it("topicOf: exactly one typed message is the topic; steers and answers never count", () => { + assert.equal(topicOf([userText("the water cycle")]), "the water cycle"); + assert.equal(topicOf([]), null); + assert.equal(topicOf([userText("a"), userText("b")]), null); + // A quiz answer: tool-output part, no text — probing never breaks the gate. + const answer: UIMessage = { + id: "m_ans", + role: "user", + parts: [{ type: "tool-quiz", toolCallId: "t1", state: "output-available", input: {}, output: {} } as never], + }; + assert.equal(topicOf([userText("topic"), answer, marker("data-probe-handoff", { chatId: "c" })]), "topic"); + // Multi-part text joins into the topic. + assert.equal(topicOf([userText("a"), userText("")]), "a"); + }); + + it("effectiveContextBudget: absent = the default (on), 0 = off, set = set", () => { + assert.equal(effectiveContextBudget(undefined), GRAPH_CONTEXT_TOKENS_DEFAULT); + assert.equal(effectiveContextBudget({}), GRAPH_CONTEXT_TOKENS_DEFAULT); + assert.equal(effectiveContextBudget({ graphContextTokens: 0 }), 0); + assert.equal(effectiveContextBudget({ graphContextTokens: 2048 }), 2048); + }); + + it("the decided knobs: default 1024, max 4096, step 128", () => { + assert.equal(GRAPH_CONTEXT_TOKENS_DEFAULT, 1024); + assert.equal(GRAPH_CONTEXT_TOKENS_MAX, 4096); + assert.equal(GRAPH_CONTEXT_TOKENS_STEP, 128); + }); + + it("contextToolCallId is deterministic per chat (the one-shot dedup key)", () => { + assert.equal(contextToolCallId("chat_abc"), "ctx_chat_abc"); + }); +}); \ No newline at end of file diff --git a/tests/graph-context-job.test.ts b/tests/graph-context-job.test.ts new file mode 100644 index 0000000..8cad3ec --- /dev/null +++ b/tests/graph-context-job.test.ts @@ -0,0 +1,316 @@ +/** + * Graph context injection — the runner + the wake integration + * (docs/vision-context-injection.md). The trigger gates, the failure + * modes, the one happy delivery (real wake pipeline, fake turn runner, + * scripted embedder), and the SILENT abort (the decided contrast with + * teacher-dispatched sub-agents, which abort with a steer). + * + * Cleanup follows tests/jobs.test.ts: files removed in finally/after. + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; +import { deliverJobResult, setTurnRunner } from "../src/server/jobs.ts"; +import { abortJob } from "../src/server/jobs.ts"; +import { persistTurn } from "../src/server/turn-service.ts"; +import { startGraphContextInjection, runGraphContextJob, type GraphContextDeps } from "../src/agent/subagents/graph-context.ts"; +import { chatStore } from "../src/store/chats.ts"; +import { jobStore } from "../src/store/jobs.ts"; +import { PATHS } from "../config.ts"; +import type { EmbeddingsLedger, GraphNode, LedgerEvent } from "../src/core/contracts/graph.ts"; +import type { JobRecord, SteerMessage } from "../src/core/contracts.ts"; + +const createdChats: string[] = []; +const createdJobs: string[] = []; + +function trackChat(id: string) { + createdChats.push(id); +} +function trackJob(id: string) { + createdJobs.push(id); +} + +after(() => { + for (const id of createdChats) { + try { + fs.unlinkSync(path.join(PATHS.chats, `${id}.json`)); + } catch { + /* already gone */ + } + } + for (const id of createdJobs) { + try { + fs.unlinkSync(path.join(PATHS.data, "jobs", `${id}.json`)); + } catch { + /* already gone */ + } + } +}); + +function waitFor(cond: () => boolean, timeoutMs = 8000, what = "condition"): Promise { + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + if (cond()) return resolve(); + if (Date.now() - started > timeoutMs) return reject(new Error(`timeout waiting for ${what}`)); + setTimeout(tick, 25); + }; + tick(); + }); +} + +function unit(i: number): number[] { + const v = new Array(8).fill(0); + v[i] = 1; + return v; +} + +function node(id: string, statement: string): GraphNode { + return { id, name: id, statement, aliases: [], provenance: [], createdAt: "2026-09-14T00:00:00.000Z" }; +} + +/** A minimal in-memory graph store — the runner only reads. */ +function fakeGraphStore(opts: { + vectors?: Record; + nodes?: GraphNode[]; + events?: LedgerEvent[]; + model?: string; + dim?: number; +}): Pick { + const ledger: EmbeddingsLedger = { + model: opts.model ?? "test-embed", + dim: opts.dim ?? 8, + vectors: opts.vectors ?? {}, + clusters: [], + updatedAt: "2026-09-14T00:00:00.000Z", + }; + return { + readNodes: () => opts.nodes ?? [], + readEvents: () => opts.events ?? [], + readEmbeddings: () => ledger, + }; +} + +/** A chat at session start: exactly one typed message (the topic). */ +function newTopicChat(label: string, settings?: Record): { id: string } { + const chat = chatStore.createChat(`Test: ${label}`); + trackChat(chat.id); + chatStore.saveMessages(chat.id, [ + { id: "u1", role: "user", parts: [{ type: "text", text: "the water cycle" }] }, + ] as never); + if (settings) chatStore.saveSettings(chat.id, settings as never); + return { id: chat.id }; +} + +/** The scripted embedder: deterministic query vectors, recorded topics. */ +function fakeEmbedder(vec: number[] = unit(0)) { + const topics: string[] = []; + return { + topics, + embedder: { + model: "test-embed", + dim: 8, + embedQuery: async (text: string) => { + topics.push(text); + return vec; + }, + }, + }; +} + +function deps( + graph: Pick, + embedder: GraphContextDeps["embedder"], + deliver: (steer: SteerMessage) => Promise, +): GraphContextDeps { + return { chatStore, jobStore, graphStore: graph, embedder, deliver }; +} + +describe("startGraphContextInjection — the one-shot gates", () => { + it("starts at session start: job kind graph-context, deterministic toolCallId, runner runs to delivery", async () => { + const { id: chatId } = newTopicChat("injection happy gate"); + const graph = fakeGraphStore({ + vectors: { n1: unit(0) }, + nodes: [node("n1", "Water evaporates from oceans.")], + }); + const { embedder, topics } = fakeEmbedder(); + const delivered: SteerMessage[] = []; + const d = deps(graph, embedder, async (steer) => { + delivered.push(steer); + }); + + const job = startGraphContextInjection(d, chatId); + assert.ok(job, "a chat at session start with budget on starts the injection"); + trackJob(job.id); + assert.equal(job.kind, "graph-context"); + assert.equal(job.toolCallId, `ctx_${chatId}`); + + await waitFor(() => delivered.length > 0, 8000, "delivery"); + assert.deepEqual(topics, ["the water cycle"], "the topic is the session's only typed message"); + const steer = delivered[0]!; + assert.equal(steer.id, `steer_ctx_${chatId}`); + assert.equal(steer.parts.length, 1); + assert.equal(steer.parts[0]!.type, "data-graph-context"); + + // One shot: a second trigger does nothing (job exists). + assert.equal(startGraphContextInjection(d, chatId), null); + }); + + it("budget 0 (off) never starts; a session past its start never injects", async () => { + const { id: off } = newTopicChat("injection off", { graphContextTokens: 0 }); + const { id: rolling } = (() => { + const chat = chatStore.createChat("Test: two typed messages"); + trackChat(chat.id); + chatStore.saveMessages(chat.id, [ + { id: "u1", role: "user", parts: [{ type: "text", text: "topic" }] }, + { id: "u2", role: "user", parts: [{ type: "text", text: "a second message" }] }, + ] as never); + return { id: chat.id }; + })(); + const graph = fakeGraphStore({ vectors: { n1: unit(0) }, nodes: [node("n1", "S.")] }); + const d = deps(graph, fakeEmbedder().embedder, async () => {}); + + assert.equal(startGraphContextInjection(d, off), null, "0 budget = off"); + assert.equal(startGraphContextInjection(d, rolling), null, "no retroactive injection"); + assert.equal(jobStore.listJobs(off).length, 0); + assert.equal(jobStore.listJobs(rolling).length, 0); + assert.equal(startGraphContextInjection(d, "chat_missing"), null, "unknown chat"); + }); + + it("an ERRORED context job still blocks re-injection (one shot means one shot)", async () => { + const { id: chatId } = newTopicChat("injection error one-shot"); + const graph = fakeGraphStore({ vectors: {} }); // empty ledger → the run errors + const d = deps(graph, fakeEmbedder().embedder, async () => {}); + const job = startGraphContextInjection(d, chatId); + assert.ok(job); + trackJob(job.id); + await waitFor(() => jobStore.getJob(job.id)!.status === "error", 8000, "job errors"); + assert.match(jobStore.getJob(job.id)!.error ?? "", /no embeddings yet/); + // Re-trigger with a now-populated ledger: still nothing. + const populated = deps( + fakeGraphStore({ vectors: { n1: unit(0) }, nodes: [node("n1", "S.")] }), + fakeEmbedder().embedder, + async () => {}, + ); + assert.equal(startGraphContextInjection(populated, chatId), null); + }); +}); + +describe("runGraphContextJob — failure modes are honest errors, never phantom deliveries", () => { + it("a stale ledger (model/dim mismatch) errors with the parity reason", async () => { + const { id: chatId } = newTopicChat("injection stale ledger"); + const chat = chatStore.createChat(`Test: ${chatId}`); // placeholder to satisfy lint-free compile + trackChat(chat.id); + const job = jobStore.createJob({ kind: "graph-context", chatId, toolCallId: `ctx_${chatId}` }); + trackJob(job.id); + // Ledger stamped with a DIFFERENT model than the embedder. + const d = deps(fakeGraphStore({ vectors: { n1: unit(0) }, model: "other-model" }), fakeEmbedder().embedder, async () => {}); + await runGraphContextJob(d, job.id); + const got = jobStore.getJob(job.id)!; + assert.equal(got.status, "error"); + assert.match(got.error ?? "", /stale/); + }); + + it("nothing fitting the budget errors rather than delivering an empty block", async () => { + // Budget below the framing overhead: nothing can fit, ever. + const { id: chatId } = newTopicChat("injection empty selection", { graphContextTokens: 10 }); + const job = jobStore.createJob({ kind: "graph-context", chatId, toolCallId: `ctx_${chatId}` }); + trackJob(job.id); + // Query orthogonal to every vector; budget can hold nothing. + const graph = fakeGraphStore({ vectors: { n1: unit(7) }, nodes: [node("n1", "A very long statement indeed.")] }); + const d = deps(graph, fakeEmbedder(unit(0)).embedder, async () => {}); + await runGraphContextJob(d, job.id); + const got = jobStore.getJob(job.id)!; + assert.equal(got.status, "error"); + assert.match(got.error ?? "", /nothing relevant fit the budget/); + }); +}); + +describe("the injection + the wake pipeline (end to end, fake turn runner)", () => { + it("delivers through the wake: the record gains the marker steer, the job flips done, the dock kind rides along", async () => { + const { id: chatId } = newTopicChat("injection wake delivery"); + const graph = fakeGraphStore({ + vectors: { n1: unit(0), n2: unit(0) }, + nodes: [node("n1", "Water evaporates from oceans."), node("n2", "Condensation forms clouds.")], + events: [ + { nodeId: "n1", kind: "observation", chatId: "chat_old", at: "2026-09-10T00:00:00Z", note: "solid" }, + ], + }); + const fired: { chatId: string; last: SteerMessage }[] = []; + setTurnRunner(async (cid, messages) => { + // Mimic the real wake turn: write-ahead persistence of the incoming + // messages (the handler does this), then 200. + await persistTurn(chatStore, cid, messages as never, false); + fired.push({ chatId: cid, last: messages[messages.length - 1] as SteerMessage }); + return 200; + }); + + const d = deps(graph, fakeEmbedder().embedder, deliverJobResult); + const job = startGraphContextInjection(d, chatId); + assert.ok(job); + trackJob(job.id); + + await waitFor(() => (jobStore.getJob(job.id)?.status ?? "running") === "done", 8000, "wake flipped the job"); + const got = jobStore.getJob(job.id)!; + assert.equal(got.status, "done"); + assert.equal(got.activity, "delivered"); + + // The transcript carries the marker steer (audit trail + dedup key). + const record = chatStore.getChat(chatId)!; + const steerMsg = record.messages.find((m) => m.id === `steer_ctx_${chatId}`); + assert.ok(steerMsg, "the steer persisted to the record"); + const part = steerMsg.parts[0] as { type: string; data: { propositions?: unknown[] } }; + assert.equal(part.type, "data-graph-context"); + assert.ok(part.data.propositions!.length >= 1); + + // The server-side turn fired once, with the steer as its last input. + assert.equal(fired.length, 1); + assert.equal(fired[0]!.chatId, chatId); + assert.equal(fired[0]!.last.id, `steer_ctx_${chatId}`); + }); + + it("aborts SILENTLY: the record flips, the teacher is never told (nothing was promised)", async () => { + const { id: chatId } = newTopicChat("injection abort"); + // A hanging embedder — killed mid-call, like the research abort test. + let release: (() => void) | null = null; + const hanging = new Promise((resolve) => { + release = resolve; + }); + const embedder = { + model: "test-embed", + dim: 8, + embedQuery: () => hanging.then(() => unit(0)), + }; + const fired: number[] = []; + setTurnRunner(async () => { + fired.push(1); + return 200; + }); + const graph = fakeGraphStore({ vectors: { n1: unit(0) }, nodes: [node("n1", "S.")] }); + const d = deps(graph, embedder, deliverJobResult); + const job = startGraphContextInjection(d, chatId); + assert.ok(job); + trackJob(job.id); + + await waitFor(() => jobStore.getJob(job.id)!.activity === "embedding topic", 8000, "run started"); + const outcome = abortJob(job.id); + assert.ok(outcome.ok); + const got = jobStore.getJob(job.id)!; + assert.equal(got.status, "error"); + assert.equal(got.error, "Aborted by learner"); + + release!(); // unwind the hanging call; the abort check exits silently + await waitFor(() => fired.length >= 0, 50, "settle"); + await new Promise((r) => setTimeout(r, 150)); + assert.equal(fired.length, 0, "NO abort steer — the decided contrast with sub-agent jobs"); + assert.equal(jobStore.getJob(job.id)!.status, "error", "terminal"); + // And the chat record never gained a context steer. + const record = chatStore.getChat(chatId)!; + assert.equal( + record.messages.some((m) => m.id === `steer_ctx_${chatId}`), + false, + ); + }); +}); \ No newline at end of file diff --git a/tests/record-adapter.test.ts b/tests/record-adapter.test.ts index 3eed091..e892071 100644 --- a/tests/record-adapter.test.ts +++ b/tests/record-adapter.test.ts @@ -85,4 +85,36 @@ describe("record-adapter: system message flattening", () => { ]); assert.equal(out.length, 0, "no model face at all — skipped"); }); + + it("the graph-context steer flattens to the canonical prior-knowledge block", () => { + const out = recordToPiMessages([ + userParts([ + { + type: "data-graph-context", + id: "ctx1", + data: { + chatId: "chat_1", + budget: 1024, + tokens: 64, + propositions: [ + { id: "n1", statement: "Water boils at 100°C.", level: "solid" }, + { id: "n2", statement: "Evaporation cools things.", level: null }, + ], + }, + }, + ]), + ]); + assert.equal(out.length, 1, "one user message — the marker part is the model face"); + const got = texts(out); + assert.equal(got.length, 1); + assert.match(got[0]!, /Prior-knowledge context/); + assert.match(got[0]!, /\[solid\] Water boils at 100°C\./); + }); + + it("a malformed graph-context part has no model face — the message is skipped wholesale", () => { + const out = recordToPiMessages([ + userParts([{ type: "data-graph-context", id: "ctx2", data: { chatId: "chat_1" } }]), + ]); + assert.equal(out.length, 0, "parse-don't-cast: no validated data, no face"); + }); }); \ No newline at end of file diff --git a/tests/render-activity-dock.test.tsx b/tests/render-activity-dock.test.tsx index e311a1e..2ea77b9 100644 --- a/tests/render-activity-dock.test.tsx +++ b/tests/render-activity-dock.test.tsx @@ -152,6 +152,18 @@ describe("activity section render", () => { assert.match(html, /activity-total-cost[^>]*>— { + const html = renderToStaticMarkup( + , + ); + assert.match(html, /data-testid="activity-blob-job"/); + assert.match(html, /Knowledge context/); + assert.match(html, /selecting propositions/); + assert.match(html, /data-testid="activity-abort"/, "END kills the injection like any job"); + }); + it("shows total running cost when jobs are priced", () => { const usage = { model: "z-ai/glm-5.3-flash", diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx index a8675cf..736f962 100644 --- a/tests/render-new-session.test.tsx +++ b/tests/render-new-session.test.tsx @@ -84,6 +84,41 @@ describe("DraftShell — the sessionless surface", () => { assert.ok(/rounded-2xl[^>]*pointer-events-auto|pointer-events-auto[^>]*rounded-2xl/.test(html)); }); + it("renders the knowledge-context slider at the decided default (absent settings = on, 1024)", () => { + const html = renderToStaticMarkup( + {}} + onSend={() => {}} + settings={undefined} + onSettingsSaved={() => {}} + />, + ); + assert.ok(html.includes('data-testid="graph-context-control"')); + // The slider's default position: 1024 of 4096, step 128 (decided). + assert.ok(html.includes('min="0"') && html.includes('max="4096"') && html.includes('step="128"')); + assert.ok(html.includes("1k tokens"), "the readout shows the default budget, not 0 — default is ON"); + assert.ok(html.includes("Knowledge context"), "the control is labeled"); + // Absent settings render the default — never 'off'. + assert.ok(!html.includes(">off<")); + }); + + it("an explicit 0 renders as off — the learner turned the graph off for this session", () => { + const html = renderToStaticMarkup( + {}} + onSend={() => {}} + settings={{ graphContextTokens: 0 }} + onSettingsSaved={() => {}} + />, + ); + assert.ok(html.includes('data-testid="graph-context-value"')); + assert.ok(html.includes(">off<")); + }); + it("no chat id anywhere — nothing in the markup references a session", () => { const html = renderToStaticMarkup(