diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index cba0669..46b8237 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -110,6 +110,15 @@ export function ActivityProvider({ children }: { children: React.ReactNode }) { return {children}; } +/** The activity feed seam for the rest of the shell: the dock's data + + * its refresh nudge. The chat page nudges on turn start (owner ground + * rules, 2026-09-15): a background process that starts at send — the + * knowledge-context injection — must be visible in the dock the moment + * it begins, without the dock polling faster while idle. */ +export function useActivity(): { feed: ActivityFeed; refresh: () => void } { + return useContext(ActivityContext); +} + // ─── Pure presentation helpers (exported for tests) ────────────────────────── /** One dock blob: the unified view of a job or an extraction run. */ diff --git a/components/chat.tsx b/components/chat.tsx index ec52e83..5a5ea65 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -21,8 +21,9 @@ 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 { graphContextOfMessage, type ChatSettings } from "@/src/core"; -import { formatTokens } from "@/components/usage-format"; +import { GraphContextMarker } from "@/components/graph-context-marker"; +import { useActivity } from "@/components/activity-dock"; +import { mergeAdopted, type ChatSettings } from "@/src/core"; import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanMarginalia } from "@/components/plan-window"; import { WaypointsIcon } from "lucide-react"; @@ -82,16 +83,6 @@ 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). */ @@ -235,6 +226,73 @@ export function Chat({ } }, [chatId, setMessages, sendMessage, status]); + // ── Append-only record adoption (owner ground rules, 2026-09-15) ── + // Content delivered into the record server-side — the knowledge-context + // block, sub-agent artifacts, abort notes — must become visible the + // moment it persists, mid-turn included: it sits between messages, and + // nothing about a running turn should hide it. The full snapshot sync + // mid-stream is forbidden (it breaks the SDK's in-place continuation + // merge — the stale-snapshot lesson), so adoption here is APPEND-ONLY + // (core mergeAdopted): insert the record's new ids in their canonical + // order, never rewrite a local one, keep the in-flight tail last. The + // full syncFromServer stays the net at idle (reorder, plan blocks, turns). + const messagesRef = useRef(messages); + messagesRef.current = messages; + const adoptAppendOnly = useCallback(async () => { + try { + const res = await fetch(`/api/chats/${chatId}`); + if (!res.ok) return; + const record = (await res.json()) as { messages?: TeacherUIMessage[] }; + if (!Array.isArray(record.messages)) return; + const merged = mergeAdopted(messagesRef.current, record.messages); + if (merged) setMessages(merged); + } catch { + /* transient — the idle full sync is the net */ + } + }, [chatId, setMessages]); + + // Turn-start window: the server inserts claimed steers right after our + // outgoing message, BEFORE the stream produces anything — so a bounded, + // self-terminating window of three record fetches (400ms/1s/2s) catches + // the insert and shows the marker during the turn, then stops. No + // standing poll — the window exists only for these seconds. The same + // instant nudges the activity feed: the knowledge-context job starts at + // send (server-side), and the dock must say so immediately. + const { refresh: refreshActivity } = useActivity(); + const turnStartStatusRef = useRef(status); + // The window must survive the submitted→streaming flip (the stream starts + // in well under 400ms), so the timers are guarded by this ref — dropped at + // unmount, left alone otherwise: the window self-terminates after 2s. + const adoptionWindowOpenRef = useRef(false); + const turnStartTimerRefs = useRef[]>([]); + useEffect(() => { + const was = turnStartStatusRef.current; + turnStartStatusRef.current = status; + if (was === "submitted" || status !== "submitted") return; + // A turn just started (status flipped ready → submitted): open the + // bounded adoption window and nudge the dock's feed. + refreshActivity(); + adoptionWindowOpenRef.current = true; + const timers = [400, 1000, 2000].map((ms) => + setTimeout(() => { + if (adoptionWindowOpenRef.current) void adoptAppendOnly(); + }, ms), + ); + // Deliberately no cleanup here: this effect re-runs on every status + // flip, which would cancel the window at submitted→streaming. + timers.forEach((t) => { + turnStartTimerRefs.current.push(t); + }); + }, [status, adoptAppendOnly, refreshActivity]); + useEffect(() => { + const timers = turnStartTimerRefs; + return () => { + adoptionWindowOpenRef.current = false; + timers.current.forEach(clearTimeout); + timers.current = []; + }; + }, []); + // The live plan popup (wave 4): opened by any transcript plan block, the // top-bar plan chip, and the dock band's marginalia. const [planOpen, setPlanOpen] = useState(false); @@ -422,14 +480,18 @@ export function Chat({ const prev = jobStatusRef.current.get(j.id); if (prev === "running" && j.status !== "running") { // The record is only guaranteed complete when no turn is in - // flight — locally or server-side. A coalesced settle fires - // mid-turn by design; adopt the record on the first idle tick. + // flight — locally or server-side (a coalesced settle fires + // mid-turn by design), so the FULL sync waits for idle. + // Ground rule (2026-09-15): delivered content shows + // immediately anyway — adopt append-only NOW; the idle + // sync still settles the record properly. if (localStreamRef.current || data.turnInFlight === true) { pendingSyncRef.current = true; + void adoptAppendOnly(); } else { void syncFromServer(); + } } - } jobStatusRef.current.set(j.id, j.status); } if ( @@ -455,7 +517,7 @@ export function Chat({ cancelled = true; clearInterval(timer); }; - }, [pollMs, jobPartCount, chatId, syncFromServer]); + }, [pollMs, jobPartCount, chatId, syncFromServer, adoptAppendOnly]); // Re-attach after mid-turn navigation (the key fix): poll the record until // the orphaned turn finishes (turnInFlight flips false server-side in @@ -470,10 +532,24 @@ export function Chat({ if (cancelled) return; try { const res = await fetch(`/api/chats/${chatId}`); - if (res.ok && !((await res.json()) as { turnInFlight?: boolean }).turnInFlight) { - setTurnInFlight(false); - void syncFromServer(); - return; + if (res.ok) { + const record = (await res.json()) as { + turnInFlight?: boolean; + messages?: TeacherUIMessage[]; + }; + // Ground rule (2026-09-15): steer content persisted by the + // wake shows NOW, at the wake turn's start — not at its + // finish. Append-only is safe here: no local stream runs + // under a server-side wake turn. + if (Array.isArray(record.messages)) { + const merged = mergeAdopted(messagesRef.current, record.messages); + if (merged) setMessages(merged); + } + if (!record.turnInFlight) { + setTurnInFlight(false); + void syncFromServer(); + return; + } } } catch { /* transient network hiccup — keep polling */ @@ -553,11 +629,11 @@ export function Chat({ ) : 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)} - + // a quiet, CLICKABLE line (owner observability ask, 2026-09-15) — + // the popover shows which propositions were delivered, in delivery + // order; the block itself was for the model (adapter flattening), + // the part data is the audit trail this reads from. + ) : 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, diff --git a/components/graph-context-marker.tsx b/components/graph-context-marker.tsx new file mode 100644 index 0000000..7d3b833 --- /dev/null +++ b/components/graph-context-marker.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { BookOpenIcon } from "lucide-react"; +import { graphContextOfMessage, type GraphContextData } from "@/src/core"; +import { formatTokens } from "@/components/usage-format"; + +/** + * The knowledge-context delivery marker (vision-context-injection.md): the + * quiet transcript line for the injected prior-knowledge block — CLICKABLE + * (owner observability ask, 2026-09-15): the popover shows exactly which + * propositions were delivered, IN DELIVERY ORDER (most similar first — + * the order the selection filled the budget), each with its folded current + * understanding level. The record part is the audit trail; this makes it + * readable at the seam where it landed. + * + * Same quiet-line discipline as the SystemLine family (centered, muted, + * small); same popover discipline as the stats-detail (close on outside + * pointerdown + Escape, anchored below the marker). + */ +export function GraphContextMarker({ + message, + testId = "graph-context", +}: { + /** The steer message (marker-only user message). */ + message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }; + testId?: string; +}) { + const [open, setOpen] = useState(false); + const data = graphContextOfMessage(message); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (e: PointerEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + const summary = data + ? `Knowledge context — ~${formatTokens(data.tokens)} · ${data.propositions.length} propositions` + : "Knowledge context"; + + return ( +
+ + {open && data && } +
+ ); +} + +/** The popover body — exported for render tests. Delivery order preserved. */ +export function GraphContextList({ data }: { data: GraphContextData }): ReactNode { + return ( +
+
+ + Prior knowledge delivered + + + ~{formatTokens(data.tokens)} tok of {data.budget} budget + +
+

+ Most similar first — the order the budget filled. The tags are your current + understanding level for each proposition. +

+
    + {data.propositions.map((p, i) => ( +
  1. + {i + 1}. + {p.level && ( + + {p.level} + + )} + {p.statement} +
  2. + ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/core/ui-message.ts b/src/core/ui-message.ts index f2b4258..4c062bf 100644 --- a/src/core/ui-message.ts +++ b/src/core/ui-message.ts @@ -56,3 +56,49 @@ export type TeacherUIDataParts = { }; export type TeacherUIMessage = UIMessage; + +// ─── Append-only record adoption (background-process ground rules) ────────── + +/** + * Merge server-added messages into the client's live list WITHOUT the full + * snapshot swap (owner ground rules, 2026-09-15: content delivered into the + * record — context blocks, sub-agent artifacts, abort notes — must become + * visible the moment it persists, mid-turn included; it sits between messages + * and nothing about a running turn should hide it). + * + * Safe against the stale-snapshot lesson (mid-turn full syncs break the SDK's + * in-place continuation merge): this NEVER rewrites or removes an existing + * message — it returns the RECORD's message order when that order is exactly + * the client's persisted core plus new server-added ids (the server appends + * only: steers are inserted right after the last persisted user message), + * with the client's in-flight streaming assistant message — its last entry, + * not yet persisted — kept at the tail. Any other shape (a missing local id + * = the record was rewritten/reordered) returns null: the caller defers to + * the full sync at idle, the pre-existing safety net. + */ +export function mergeAdopted( + local: ReadonlyArray, + record: ReadonlyArray, +): TeacherUIMessage[] | null { + const recordIds = new Set(record.map((m) => m.id)); + // The in-flight tail: the client's last message may legitimately be absent + // from the record (the SDK's streaming assistant message persists only at + // onFinish). Exactly one such tail is allowed. + let core = local; + let tail: TeacherUIMessage | undefined; + const last = local.at(-1); + if (last && !recordIds.has(last.id)) { + tail = last; + core = local.slice(0, -1); + } + // No rewrite happened if every core message is still in the record — + // otherwise this is not a pure append; bail to the full-sync-at-idle. + if (core.some((m) => !recordIds.has(m.id))) return null; + // Nothing new → nothing to do. + const localIds = new Set(local.map((m) => m.id)); + if (!record.some((m) => !localIds.has(m.id))) return null; + // The record's order is canonical for everything persisted (the server + // appends only; plan-block part attachment changes parts, not order) — + // merged = record + the in-flight tail. + return tail ? [...record, tail] : [...record]; +} diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index b1969c9..ffb545a 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -146,6 +146,20 @@ export async function handleChatRequest( console.error(`[chat ${chatId}] write-ahead persist failed:`, err); } + // THE GRAPH READ CHANNEL (vision-context-injection.md): one shot per + // chat, at session start — fired HERE, at turn start, not in onFinish: + // the injection's only input is the first typed message (the topic), + // which the write-ahead persist above just made durable, so it runs + // CONCURRENT with the prober's turn (embedding + cosine scan, no model + // call) and the dock shows the job the moment the session starts. Its + // steer then waits in the wake queue through the turn (waitForIdle) — + // exactly the same hold as a sub-agent result finishing mid-turn. All + // gates (exactly-one-text-message topic, budget, one-shot) live in the + // runner module; for every session past its start this is a no-op. + // Extraction stays in onFinish by contrast: it depends on quiz + // answers accumulated DURING the turn. + triggerGraphContext(graphContext, chatId); + // MONEY CHANNEL (wave 4): one collector per request, fed by the agent's // message_end events. Every model call's usage lands in the chat record's // append-only turns log at onFinish — idempotent per turn id, so a @@ -237,9 +251,6 @@ 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/tests/merge-adopted.test.ts b/tests/merge-adopted.test.ts new file mode 100644 index 0000000..2488a56 --- /dev/null +++ b/tests/merge-adopted.test.ts @@ -0,0 +1,71 @@ +/** + * mergeAdopted (core/ui-message.ts) — the append-only record-adoption seam + * (owner ground rules, 2026-09-15): server-added steer messages must become + * visible mid-turn without the full snapshot swap that breaks the SDK's + * continuation merge. Pure, no React. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { mergeAdopted, type TeacherUIMessage } from "../src/core/index.ts"; + +function userText(id: string, text: string): TeacherUIMessage { + return { id, role: "user", parts: [{ type: "text", text }] }; +} +function steer(id: string): TeacherUIMessage { + return { id, role: "user", parts: [{ type: "data-graph-context", data: { chatId: "c", budget: 1024, tokens: 1, propositions: [] } }] }; +} +function assistant(id: string): TeacherUIMessage { + return { id, role: "assistant", parts: [{ type: "text", text: "reply" }] }; +} + +describe("mergeAdopted — append-only adoption", () => { + it("inserts a server-added steer between the persisted core and the in-flight tail", () => { + const local = [userText("u1", "topic"), userText("u2", "answer"), assistant("a_inflight")]; + const record = [userText("u1", "topic"), userText("u2", "answer"), steer("steer_ctx_c")]; + const merged = mergeAdopted(local, record); + assert.ok(merged); + assert.deepEqual( + merged.map((m) => m.id), + ["u1", "u2", "steer_ctx_c", "a_inflight"], + "marker between the answer and the streaming bubble", + ); + }); + + it("adopts into an idle client (no in-flight tail)", () => { + const local = [userText("u1", "topic")]; + const record = [userText("u1", "topic"), steer("s1"), assistant("a1")]; + const merged = mergeAdopted(local, record); + assert.deepEqual( + merged?.map((m) => m.id), + ["u1", "s1", "a1"], + ); + }); + + it("nothing new → null (no needless re-render)", () => { + const local = [userText("u1", "topic")]; + assert.equal(mergeAdopted(local, [userText("u1", "topic")]), null); + // The tail alone is not 'new' — nothing to adopt. + assert.equal(mergeAdopted([assistant("a_x")], []), null); + }); + + it("a missing core id (record rewrite) → null: defer to the full sync at idle", () => { + // A local message vanished from the record — mid-turn this means a + // rewrite, and the append-only shortcut must not paper over it. + const local = [userText("u1", "a"), userText("u2", "b"), assistant("tail")]; + const record = [userText("u2", "b"), steer("s1")]; + assert.equal(mergeAdopted(local, record), null); + }); + + it("only the LAST local message may be the absent in-flight tail", () => { + // Two absent local messages: not a simple append — bail. + const local = [userText("u1", "a"), userText("u2", "gone"), assistant("tail")]; + const record = [userText("u1", "a"), steer("s1")]; + assert.equal(mergeAdopted(local, record), null); + }); + + it("an empty local list adopts the whole record", () => { + const record = [steer("s1"), assistant("a1")]; + assert.deepEqual(mergeAdopted([], record)?.map((m) => m.id), ["s1", "a1"]); + }); +}); \ No newline at end of file diff --git a/tests/render-graph-context-marker.test.tsx b/tests/render-graph-context-marker.test.tsx new file mode 100644 index 0000000..4125828 --- /dev/null +++ b/tests/render-graph-context-marker.test.tsx @@ -0,0 +1,59 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { GraphContextList, GraphContextMarker } from "../components/graph-context-marker.tsx"; +import type { GraphContextData, TeacherUIMessage } from "../src/core/index.ts"; + +// The knowledge-context delivery marker (owner observability ask, 2026-09-15): +// a quiet line that opens the delivered propositions, in delivery order, +// each with its folded understanding level. Static markup can't click — the +// closed line and the popover body are pinned separately. + +const DATA: GraphContextData = { + chatId: "c1", + budget: 1024, + tokens: 537, + propositions: [ + { id: "n1", statement: "Water boils at 100°C at standard pressure.", level: "solid" }, + { id: "n2", statement: "Modal realism takes possible worlds as concrete.", level: "shaky" }, + { id: "n3", statement: "An untagged proposition has no folded level.", level: null }, + ], +}; + +function markerMessage(): TeacherUIMessage { + return { id: "steer_ctx_c1", role: "user", parts: [{ type: "data-graph-context", data: DATA }] }; +} + +describe("GraphContextMarker", () => { + it("renders the quiet summary line with token + proposition counts", () => { + const html = renderToStaticMarkup(); + assert.ok(html.includes("Knowledge context — ~537")); + assert.ok(html.includes("3 propositions")); + assert.ok(html.includes('data-testid="graph-context"')); + assert.ok(html.includes(" { + const html = renderToStaticMarkup( + , + ); + assert.ok(html.includes("Knowledge context { + it("lists propositions 1..n with their level tags; null level gets no tag", () => { + const html = renderToStaticMarkup(); + const first = html.indexOf("Water boils"); + const second = html.indexOf("Modal realism"); + const third = html.indexOf("untagged"); + assert.ok(first !== -1 && second !== -1 && third !== -1, "all three statements render"); + assert.ok(first < second && second < third, "delivery order preserved"); + assert.ok(html.includes("solid") && html.includes("shaky"), "level tags render"); + // The null-level row: a bare of text, no level chip before it. + const nullRow = html.slice(html.indexOf("untagged") - 120, html.indexOf("untagged")); + assert.ok(!nullRow.includes(">resisted<") && !nullRow.includes(">accepted<")); + assert.ok(html.includes("537 tok of 1024 budget"), "budget + delivered tokens visible"); + }); +}); \ No newline at end of file