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:
amosblomqvist
2026-09-15 16:34:25 +03:00
parent 4022e3f0d0
commit 31978f3fc3
7 changed files with 404 additions and 28 deletions
+101 -25
View File
@@ -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<ReturnType<typeof setTimeout>[]>([]);
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({
<SubagentSteerCard key={m.id || `m-${mi}`} message={m} />
) : 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.
<SystemLine key={m.id || `m-${mi}`} testId="graph-context">
{graphContextLine(m)}
</SystemLine>
// 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.
<GraphContextMarker key={m.id || `m-${mi}`} message={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,