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.
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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(<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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user