Files
learnharness/tests/render-graph-context-marker.test.tsx
T
amosblomqvist 31978f3fc3 fix: background-delivery ground rules — the dock is the source of truth, delivered content shows the moment it persists
The owner's three-part test of the context-injection flow surfaced
timing/visibility bugs, fixed as standing ground rules for ALL background
processes (owner rules, 2026-09-15):

1. THE JOB STARTS AT SEND. triggerGraphContext moves from onFinish to
   right after the write-ahead persist in handleChatRequest: its only
   input is the first typed message (the topic), just made durable — so
   embedding + cosine scan run CONCURRENT with the prober's turn and
   the dock blob appears the moment the session starts, not after turn
   one finishes. Extraction stays at onFinish (it needs answers
   accumulated DURING the turn).

2. DELIVERED CONTENT SHOWS MID-TURN. Steers inserted server-side
   (claimed at turn start, persisted write-ahead) used to wait for the
   next turn's completion to appear — the full record sync mid-stream
   is forbidden (it breaks the SDK's in-place continuation merge), so
   adoption was deferred to idle. New append-only seam: core
   mergeAdopted() inserts the record's new message ids in canonical
   order (in-flight streaming tail kept last) and never rewrites a
   local message; any other shape bails to the full-sync-at-idle net.
   Wired at three points, no new standing polls:
   - turn-start window: three bounded record fetches (400ms/1s/2s)
     after a send catch the claimed steer while the turn streams;
     self-terminating, survives the submitted->streaming flip.
   - jobs-poll settle: mid-turn settles adopt append-only NOW, the
     owed full sync still runs at idle.
   - wake-adoption poll: each tick adopts persisted wake content, so
     steers show at the wake turn's START, not its finish.
   The dock stays honest with zero gap: jobs hold "running" while
   queued behind a pending question ("delivering context"), then flip
   done at claim/delivery — visible from start to delivery, and the
   turn-start nudge (useActivity().refresh) clears it within ~150ms.

3. THE MARKER IS CLICKABLE (observability). The knowledge-context
   SystemLine becomes GraphContextMarker: quiet line, popover with the
   delivered propositions IN DELIVERY ORDER (most similar first — the
   order the budget filled), each with its folded understanding level;
   Esc / outside pointerdown close. The part data was already the audit
   trail — now it's readable at the seam where it landed.

Tests: mergeAdopted unit pins (6), marker render pins (3) — 468/468,
tsc, eslint, build all green; live-smoked end-to-end: blob at send,
marker at t+500ms mid-turn, popover order + levels, instant dock clear.
2026-09-15 16:34:25 +03:00

59 lines
2.8 KiB
TypeScript

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(<GraphContextMarker message={markerMessage()} />);
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("<button"), "the line is clickable");
});
it("a malformed part still renders the generic line (parse-don't-cast)", () => {
const html = renderToStaticMarkup(
<GraphContextMarker message={{ parts: [{ type: "data-graph-context", data: { nope: true } }] }} />,
);
assert.ok(html.includes("Knowledge context</"));
assert.ok(!html.includes("Knowledge context —"), "no counts on a malformed part");
});
});
describe("GraphContextList — delivery order is the render order", () => {
it("lists propositions 1..n with their level tags; null level gets no tag", () => {
const html = renderToStaticMarkup(<GraphContextList data={DATA} />);
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 <span> 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");
});
});