fix: live sub-agent observability — dispatch cards, steer artifacts, sidebar panel

The pi-style ack protocol (previous commit) left the client describing the
old world: artifact cards read the ack string as a result ("could not be
displayed"), the only job poller rendered on input-available parts that no
longer exist (so wake turns landed invisibly until re-navigation), steers
rendered as learner bubbles with no SVG anywhere, and server-persisted
assistant messages carried empty SDK ids (duplicate React keys).

- persistTurn (turn-service): fill empty message ids at the canonical write
  — server-only messages (last turn's, wake turns') now key uniquely;
  client-authored ids untouched.
- chat.tsx: ONE central /api/jobs poll (fast while jobs run, slow discovery
  cadence, off for job-less sessions) drives every job surface, re-syncs
  once when a job settles (the record already holds the steer + wake
  response at flip time), and adopts server-side wake turns via the same
  turnInFlight machinery as returning mid-turn. Steers branch to the
  artifact card — never a bubble. Composer no longer gates on jobs.
- /api/jobs now reports turnInFlight alongside the job list.
- dispatch-card: the tool-call box — live activity by toolCallId while
  running, quiet "delivered"/"failed" one-liner after; the ack is visible
  observability, the artifact lives in the steer.
- steer-card: renders the delivered artifact by kind (research brief /
  sanitized SVG / sandboxed demo iframe) from the data-subagent-artifact
  part; result renderers extracted and exported from the artifact cards.
- sub-agents-panel: right-sidebar section above the plan tree listing
  running sub-agents (kind, elapsed, live activity) — vanishes when idle.
- tests: dispatch/steer/panel render tests re-target the new protocol;
  persistTurn id-hygiene suite. 229/229 green, build green.

Out-of-lane touches for master review: chat.tsx + components/** (cleared
this wave), app/api/jobs/route.ts, src/server/turn-service.ts, contracts
protocol doc §2. Live-verified on :3003 (full loop, SSR DOM checked).
This commit is contained in:
amosblomqvist
2026-09-02 02:34:02 +03:00
parent 99fa3f0847
commit 2d7b004fda
13 changed files with 514 additions and 242 deletions
+7 -5
View File
@@ -1,10 +1,12 @@
import { listJobs } from "@/src/server"; import { isTurnRunning, listJobs } from "@/src/server";
/** /**
* Job status polling endpoint (contracts.ts "THE ASYNC PROTOCOL" §4): the * Job status polling endpoint (contracts.ts "THE ASYNC PROTOCOL" §4): the
* client polls GET /api/jobs?chatId=… while any job tool part is pending, * client's ONE central poller fetches this while any job runs (and slowly
* finds the job by toolCallId, renders its activity line, and re-syncs the * otherwise) to drive the dispatch cards, the sub-agents panel, and the
* chat when the job flips to done/error. * re-sync when a job settles. `turnInFlight` rides along so the client can
* adopt a server-side wake turn (busy state + composer gate) — the same
* machinery as returning to a session mid-turn.
*/ */
export async function GET(req: Request) { export async function GET(req: Request) {
const chatId = new URL(req.url).searchParams.get("chatId"); const chatId = new URL(req.url).searchParams.get("chatId");
@@ -14,5 +16,5 @@ export async function GET(req: Request) {
if (!/^[A-Za-z0-9_-]+$/.test(chatId)) { if (!/^[A-Za-z0-9_-]+$/.test(chatId)) {
return Response.json({ error: "Invalid chatId" }, { status: 400 }); return Response.json({ error: "Invalid chatId" }, { status: 400 });
} }
return Response.json({ jobs: listJobs(chatId) }); return Response.json({ jobs: listJobs(chatId), turnInFlight: isTurnRunning(chatId) });
} }
+9 -83
View File
@@ -2,7 +2,8 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { ChatActionsContext } from "@/components/chat"; import { ChatActionsContext } from "@/components/chat";
import type { ArtifactPartProps, DemoResult, JobRecord } from "@/src/core"; import { DispatchCard } from "@/components/artifacts/dispatch-card";
import type { ArtifactPartProps, DemoResult } from "@/src/core";
/** /**
* Demo artifact card (W6): while the part is input-available, a job activity * Demo artifact card (W6): while the part is input-available, a job activity
@@ -66,93 +67,18 @@ export function reduceDemoBridgeState(state: DemoBridgeState, data: unknown): De
return state; return state;
} }
// ─── shared job activity card (input-available state) ───────────────────────
/** Live activity card for pending job parts — the job-card.tsx idiom,
* parameterized by label. Exported for the visual card (also W6-owned). */
export function JobActivityCard({
toolCallId,
label,
testId,
}: {
toolCallId: string;
label: string;
testId?: string;
}) {
const actions = useContext(ChatActionsContext);
const [job, setJob] = useState<JobRecord | null>(null);
const finishedRef = useRef(false);
useEffect(() => {
if (!actions) return;
let cancelled = false;
const poll = async () => {
if (finishedRef.current) return true;
try {
const res = await fetch(`/api/jobs?chatId=${encodeURIComponent(actions.chatId)}`);
if (!res.ok) return false;
const { jobs } = (await res.json()) as { jobs?: JobRecord[] };
const match = jobs?.find((j) => j.toolCallId === toolCallId) ?? null;
if (cancelled) return false;
setJob(match);
if (match && match.status !== "running") {
// The part has (or is about to be) fulfilled server-side — pull the
// fresh messages once; the continuation resumes the teacher.
finishedRef.current = true;
void actions.syncFromServer();
return true;
}
} catch {
/* transient fetch failure — keep polling */
}
return false;
};
const timer = setInterval(() => {
if (finishedRef.current) {
clearInterval(timer);
return;
}
void poll().then((stop) => {
if (stop) clearInterval(timer);
});
}, 2000);
void poll().then((stop) => {
if (stop) clearInterval(timer);
});
return () => {
cancelled = true;
clearInterval(timer);
};
}, [actions, toolCallId]);
return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid={testId ?? "job-activity-card"}>
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<span className="inline-block size-2 animate-pulse rounded-full bg-primary" />
{label}
</div>
{job?.activity && (
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="job-activity-line">
{job.activity}
</div>
)}
</div>
);
}
// ─── the demo card ────────────────────────────────────────────────────────── // ─── the demo card ──────────────────────────────────────────────────────────
/** The demo tool-call box: the part's output is the dispatch ack — the box
* is the DispatchCard (live status via the central jobs poll). The demo
* itself renders from the steer message via DemoOutputCard. */
export function DemoCard({ part }: ArtifactPartProps) { export function DemoCard({ part }: ArtifactPartProps) {
if (part.state === "output-available") { if (part.state !== "output-available") return null;
return <DemoOutputCard output={part.output} />; return <DispatchCard toolCallId={part.toolCallId} kind="demo" />;
}
return <JobActivityCard toolCallId={part.toolCallId} label="Building demo…" testId="demo-activity-card" />;
} }
function DemoOutputCard({ output }: { output: unknown }) { /** The demo artifact — exported: the steer card renders the delivered result. */
export function DemoOutputCard({ output }: { output: unknown }) {
const result = output as DemoResult | undefined; const result = output as DemoResult | undefined;
if (!result || typeof result.html !== "string" || typeof result.title !== "string") { if (!result || typeof result.html !== "string" || typeof result.title !== "string") {
return ( return (
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useContext } from "react";
import { CheckIcon, XIcon } from "lucide-react";
import { JobsContext } from "@/components/jobs-context";
import type { JobKind, JobRecord } from "@/src/core";
/**
* The dispatch card (contracts.ts "THE ASYNC PROTOCOL" §4): the tool-call
* box for a background sub-agent. The tool part's output is the FIRE-AND-
* FORGET ACK (a string) — never the result. Live status comes from the
* central jobs poll via JobsContext, matched by toolCallId:
*
* job unknown → "dispatched" pulse (the poll hasn't seen it yet)
* running → live activity card (activity line + elapsed)
* done/error → quiet one-liner ("delivered" / "failed") — the artifact
* itself arrives in the steer message right below, so the
* box must not duplicate it.
*/
const KIND_LABEL: Record<JobKind, string> = {
research: "Research agent",
demo: "Demo agent",
visual: "Figure agent",
};
export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) {
const jobs = useContext(JobsContext);
const job = jobs.find((j) => j.toolCallId === toolCallId) ?? null;
const label = KIND_LABEL[kind];
if (!job || job.status === "running") {
return <DispatchActivityCard job={job} label={label} />;
}
const failed = job.status === "error";
return (
<div
className="flex items-center gap-2 text-xs text-muted-foreground"
data-testid="dispatch-settled"
>
{failed ? <XIcon className="size-3.5 text-red-500" /> : <CheckIcon className="size-3.5" />}
<span className="font-medium uppercase tracking-wide">{label}</span>
<span>{failed ? "failed" : "delivered"}</span>
</div>
);
}
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) {
// Elapsed from createdAt — jobs appear here only via the poll (post-mount),
// so no SSR/hydration mismatch.
const elapsed =
job && job.createdAt ? Math.max(0, Math.round((Date.now() - Date.parse(job.createdAt)) / 1000)) : null;
return (
<div
className="rounded-xl border bg-card p-4 text-card-foreground"
data-testid="dispatch-card"
>
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<span className="inline-block size-2 animate-pulse rounded-full bg-primary" />
{label}
{elapsed !== null && <span className="font-normal">· {elapsed}s</span>}
</div>
{job?.activity && (
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="dispatch-activity-line">
{job.activity}
</div>
)}
{!job && (
<div className="mt-1 text-sm text-muted-foreground" data-testid="dispatch-pending">
Dispatched…
</div>
)}
</div>
);
}
+15 -83
View File
@@ -1,96 +1,28 @@
"use client"; "use client";
import { useContext, useEffect, useRef, useState } from "react"; import { DispatchCard } from "@/components/artifacts/dispatch-card";
import { MessageResponse } from "@/components/ai-elements/message"; import { MessageResponse } from "@/components/ai-elements/message";
import { ChatActionsContext } from "@/components/chat"; import type { ArtifactPartProps, ResearchResult } from "@/src/core";
import type { ArtifactPartProps, JobRecord, ResearchResult } from "@/src/core";
/** /**
* The research job card (contracts.ts "THE ASYNC PROTOCOL" §4). * The research tool-call box (contracts.ts "THE ASYNC PROTOCOL" §4).
* *
* While the part is input-available: an activity card — poll /api/jobs every * The part's output is the fire-and-forget dispatch ACK — the box itself is
* 2s, find the job by toolCallId, show a pulsing dot + "Researching…" + the * the DispatchCard (live status via the central jobs poll; quiet one-liner
* job's live activity line. When the job flips to done/error, call * once delivered). The research brief itself no longer renders from the
* syncFromServer() ONCE (fresh messages replace local state; the part * tool part at all: it rides the steer message (the user-role delivery
* re-renders as the brief card and the sendAutomaticallyWhen continuation * injected after the wake turn) and renders there via ResearchBriefCard.
* resumes the teacher) and stop polling.
*
* When output-available: the research brief — markdown body + a small source
* link list, in the visual language of answered-card.tsx.
*/ */
export function JobCard({ part }: ArtifactPartProps) { export function JobCard({ part }: ArtifactPartProps) {
if (part.state === "output-available") { if (part.state !== "output-available") return null;
return <ResearchBriefCard output={part.output as ResearchResult} />; return <DispatchCard toolCallId={part.toolCallId} kind="research" />;
}
return <ResearchActivityCard toolCallId={part.toolCallId} />;
} }
function ResearchActivityCard({ toolCallId }: { toolCallId: string }) { /**
const actions = useContext(ChatActionsContext); * The research brief — markdown body + a small source link list. Exported:
const [job, setJob] = useState<JobRecord | null>(null); * the steer card (sub-agent result delivery) renders the same artifact.
const finishedRef = useRef(false); */
export function ResearchBriefCard({ output }: { output: unknown }) {
useEffect(() => {
if (!actions) return;
let cancelled = false;
const poll = async () => {
if (finishedRef.current) return true;
try {
const res = await fetch(`/api/jobs?chatId=${encodeURIComponent(actions.chatId)}`);
if (!res.ok) return false;
const { jobs } = (await res.json()) as { jobs?: JobRecord[] };
const match = jobs?.find((j) => j.toolCallId === toolCallId) ?? null;
if (cancelled) return false;
setJob(match);
if (match && match.status !== "running") {
// The part has (or is about to be) fulfilled server-side — pull the
// fresh messages once; the continuation resumes the teacher.
finishedRef.current = true;
void actions.syncFromServer();
return true;
}
} catch {
/* transient fetch failure — keep polling */
}
return false;
};
const timer = setInterval(() => {
if (finishedRef.current) {
clearInterval(timer);
return;
}
void poll().then((stop) => {
if (stop) clearInterval(timer);
});
}, 2000);
void poll().then((stop) => {
if (stop) clearInterval(timer);
});
return () => {
cancelled = true;
clearInterval(timer);
};
}, [actions, toolCallId]);
return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="research-activity-card">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<span className="inline-block size-2 animate-pulse rounded-full bg-primary" />
Researching…
</div>
{job?.activity && (
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="research-activity-line">
{job.activity}
</div>
)}
</div>
);
}
function ResearchBriefCard({ output }: { output: unknown }) {
const result = output as ResearchResult | undefined; const result = output as ResearchResult | undefined;
if (!result || typeof result.brief !== "string") { if (!result || typeof result.brief !== "string") {
return ( return (
+36
View File
@@ -0,0 +1,36 @@
"use client";
import type { SubagentArtifactData, TeacherUIMessage } from "@/src/core";
import { DemoOutputCard } from "@/components/artifacts/demo-card";
import { ResearchBriefCard } from "@/components/artifacts/job-card";
import { VisualOutputCard } from "@/components/artifacts/visual-card";
/**
* The sub-agent result delivery card (contracts.ts "THE ASYNC PROTOCOL" §4).
*
* When a background job completes, the wake pipeline injects a STEER message
* into the transcript: a user-role UIMessage whose data part
* ("data-subagent-artifact") carries the result. It must NEVER render as a
* learner bubble: the model-facing text part is protocol glue, and the
* artifact itself renders here, by kind, reusing the same artifact cards
* the result always used.
*/
export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
const data = artifactPart?.data as SubagentArtifactData | undefined;
if (!data) {
return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
A sub-agent finished, but its result could not be displayed.
</div>
);
}
switch (data.kind) {
case "research":
return <ResearchBriefCard output={data.result} />;
case "demo":
return <DemoOutputCard output={data.result} />;
case "visual":
return <VisualOutputCard output={data.result} />;
}
}
+15 -12
View File
@@ -1,15 +1,19 @@
"use client"; "use client";
import { DispatchCard } from "@/components/artifacts/dispatch-card";
import type { ArtifactPartProps, VisualResult } from "@/src/core"; import type { ArtifactPartProps, VisualResult } from "@/src/core";
import { JobActivityCard } from "@/components/artifacts/demo-card";
/** /**
* Visual artifact card (W6): while the part is input-available, a job activity * Visual artifact card (W6 → ack protocol): the tool part's output is the
* card; when output-available, VisualResult.svg renders INLINE. The SVG is * dispatch ack, so the tool-call box is the DispatchCard (live status via
* model-authored for the learner's own machine (their own teacher wrote it, * the central jobs poll). The SVG itself renders from the steer message
* verified by looking) — dangerouslySetInnerHTML is acceptable here, with a * (sub-agent result delivery) via VisualOutputCard.
* cheap hygiene pass (sanitizeSvg: strip <script> blocks, on* handlers, and *
* javascript:/data:text/html URLs) since it lands in the app's DOM. * The SVG is model-authored for the learner's own machine (their own teacher
* wrote it, verified by looking) — dangerouslySetInnerHTML is acceptable
* here, with a cheap hygiene pass (sanitizeSvg: strip <script> blocks, on*
* handlers, and javascript:/data:text/html URLs) since it lands in the
* app's DOM.
*/ */
/** Cheap SVG hygiene — not a full sanitizer, just removing the obvious exec vectors. */ /** Cheap SVG hygiene — not a full sanitizer, just removing the obvious exec vectors. */
@@ -22,13 +26,12 @@ export function sanitizeSvg(svg: string): string {
} }
export function VisualCard({ part }: ArtifactPartProps) { export function VisualCard({ part }: ArtifactPartProps) {
if (part.state === "output-available") { if (part.state !== "output-available") return null;
return <VisualOutputCard output={part.output} />; return <DispatchCard toolCallId={part.toolCallId} kind="visual" />;
}
return <JobActivityCard toolCallId={part.toolCallId} label="Drawing figure…" testId="visual-activity-card" />;
} }
function VisualOutputCard({ output }: { output: unknown }) { /** The figure itself — exported: the steer card renders the delivered artifact. */
export function VisualOutputCard({ output }: { output: unknown }) {
const result = output as VisualResult | undefined; const result = output as VisualResult | undefined;
if (!result || typeof result.svg !== "string") { if (!result || typeof result.svg !== "string") {
return ( return (
+100 -19
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createContext, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { useStickToBottomContext } from "use-stick-to-bottom"; import { useStickToBottomContext } from "use-stick-to-bottom";
@@ -16,21 +16,24 @@ import { Shimmer } from "@/components/ai-elements/shimmer";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { AnsweredCard } from "@/components/answered-card"; import { AnsweredCard } from "@/components/answered-card";
import { artifactRenderers } from "@/components/artifacts"; import { artifactRenderers } from "@/components/artifacts";
import { SubagentSteerCard } from "@/components/artifacts/steer-card";
import { Composer } from "@/components/composer"; import { Composer } from "@/components/composer";
import { JobsContext } from "@/components/jobs-context";
import { QuestionDock } from "@/components/question-dock"; import { QuestionDock } from "@/components/question-dock";
import { SidebarToggle } from "@/components/sidebar"; import { SidebarToggle } from "@/components/sidebar";
import { SubAgentsPanel } from "@/components/sub-agents-panel";
import { TreePanel } from "@/components/tree-panel"; import { TreePanel } from "@/components/tree-panel";
import type { ArtifactPartProps, QuestionAnswerData, TeacherUIMessage } from "@/src/core"; import type { ArtifactPartProps, JobRecord, QuestionAnswerData, TeacherUIMessage } from "@/src/core";
import { findPendingJobCall, findPendingToolCall, phaseAt } from "@/src/core"; import { findPendingToolCall, phaseAt } from "@/src/core";
/** One width for everything: transcript, cards, and the bottom blob share this column. */ /** One width for everything: transcript, cards, and the bottom blob share this column. */
const COLUMN = "mx-auto w-full max-w-3xl px-4"; const COLUMN = "mx-auto w-full max-w-3xl px-4";
/** /**
* Server-sync channel for artifact renderers (job cards): when a background * Server-sync channel for artifact renderers: the CENTRAL jobs poll in Chat
* job flips its tool part to output-available server-side, the card polls * calls syncFromServer() when a background job settles — the wake turn's
* /api/jobs, then calls syncFromServer() — fresh messages replace local state * steer + response are already persisted by then, so fresh messages replace
* and the sendAutomaticallyWhen continuation resumes the teacher. * local state in one pull. sendText feeds the demo error-fix loop.
* (contracts.ts "THE ASYNC PROTOCOL" §4.) * (contracts.ts "THE ASYNC PROTOCOL" §4.)
*/ */
export const ChatActionsContext = createContext<{ export const ChatActionsContext = createContext<{
@@ -43,6 +46,12 @@ export const ChatActionsContext = createContext<{
/** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */ /** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
/** 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. */
function isSubagentSteer(m: TeacherUIMessage): boolean {
return (m.metadata as { subagentResult?: unknown } | undefined)?.subagentResult != null;
}
/** /**
* The teaching session. All chat state lives in useChat; the server persists * The teaching session. All chat state lives in useChat; the server persists
* the canonical transcript after every turn. * the canonical transcript after every turn.
@@ -134,11 +143,12 @@ export function Chat({
(messages.at(-1)?.parts.some((p) => isToolUIPart(p) && p.state === "input-streaming") ?? false); (messages.at(-1)?.parts.some((p) => isToolUIPart(p) && p.state === "input-streaming") ?? false);
const [text, setText] = useState(""); const [text, setText] = useState("");
// Jobs gate sending too (an unanswered tool call in the outbound list would // Jobs never gate the composer (contracts.ts "THE ASYNC PROTOCOL" §4): job
// break provider conversion — contracts.ts "THE ASYNC PROTOCOL" §5), but // tools resolve with an ack mid-turn, so the learner can interject while
// they don't dock: the running job's activity card sits in the transcript. // sub-agents run — a feature, not a race: the wake pipeline queues steers
const pendingJob = findPendingJobCall(messages); // until the current turn ends. Only a QUESTION pending or a turn in
const canSend = text.trim().length > 0 && !pending && !pendingJob && !turnInFlight; // flight (client or server-side wake turn) gates sending.
const canSend = text.trim().length > 0 && !pending && !turnInFlight;
const syncFromServer = useCallback(async () => { const syncFromServer = useCallback(async () => {
const res = await fetch(`/api/chats/${chatId}`); const res = await fetch(`/api/chats/${chatId}`);
@@ -158,6 +168,65 @@ export function Chat({
} }
}, [chatId, setMessages, sendMessage, status]); }, [chatId, setMessages, sendMessage, status]);
// ── Central job observability (contracts.ts "THE ASYNC PROTOCOL" §4) ──
// ONE poll of /api/jobs drives every job surface (transcript dispatch
// cards, the sub-agents panel) and re-syncs the transcript exactly once
// when a job settles: job status flips done/error only AFTER the wake
// turn's messages are persisted, so a completion means the record already
// holds the steer + the teacher's response — one syncFromServer adopts it
// all. Poll fast while jobs run, slow otherwise (new-dispatch discovery),
// and not at all when this session has never dispatched a job.
const [jobs, setJobs] = useState<JobRecord[]>([]);
const jobPartCount = useMemo(
() =>
messages.reduce(
(n, m) =>
n +
m.parts.filter(
(p) => p.type === "tool-research" || p.type === "tool-demo" || p.type === "tool-visual",
).length,
0,
),
[messages],
);
const anyRunning = jobs.some((j) => j.status === "running");
const pollMs = jobPartCount === 0 && jobs.length === 0 ? 0 : anyRunning ? 1000 : 4000;
const localStreamRef = useRef(false);
localStreamRef.current = localStream;
const jobStatusRef = useRef<Map<string, string>>(new Map());
useEffect(() => {
if (pollMs === 0) return;
let cancelled = false;
const poll = async () => {
if (cancelled) return;
try {
const res = await fetch(`/api/jobs?chatId=${encodeURIComponent(chatId)}`);
if (!res.ok) return;
const data = (await res.json()) as { jobs?: JobRecord[]; turnInFlight?: boolean };
if (cancelled) return;
const list = data.jobs ?? [];
for (const j of list) {
const prev = jobStatusRef.current.get(j.id);
if (prev === "running" && j.status !== "running") void syncFromServer();
jobStatusRef.current.set(j.id, j.status);
}
setJobs(list);
// Wake-turn adoption: a server-side teacher turn with no client request
// of ours (sub-agent delivery) — the same machinery as returning
// mid-turn: busy state + record poll to completion.
if (data.turnInFlight === true && !localStreamRef.current) setTurnInFlight(true);
} catch {
/* transient fetch failure — keep polling */
}
};
void poll();
const timer = setInterval(poll, pollMs);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [pollMs, jobPartCount, chatId, syncFromServer]);
// Re-attach after mid-turn navigation (the key fix): poll the record until // Re-attach after mid-turn navigation (the key fix): poll the record until
// the orphaned turn finishes (turnInFlight flips false server-side in // the orphaned turn finishes (turnInFlight flips false server-side in
// onFinish, AFTER persist), then adopt the completed transcript — the // onFinish, AFTER persist), then adopt the completed transcript — the
@@ -190,13 +259,14 @@ export function Chat({
const onSend = () => { const onSend = () => {
const t = text.trim(); const t = text.trim();
if (!t || busy || pending || pendingJob) return; if (!t || busy || pending) return;
setText(""); setText("");
void sendMessage({ text: t }); void sendMessage({ text: t });
}; };
return ( return (
<ChatActionsContext.Provider value={{ chatId, syncFromServer, sendText: (t: string) => void sendMessage({ text: t }) }}> <ChatActionsContext.Provider value={{ chatId, syncFromServer, sendText: (t: string) => void sendMessage({ text: t }) }}>
<JobsContext.Provider value={jobs}>
<div className="flex h-dvh flex-col"> <div className="flex h-dvh flex-col">
<header className="flex items-center gap-3 px-3 py-2"> <header className="flex items-center gap-3 px-3 py-2">
<SidebarToggle /> <SidebarToggle />
@@ -221,11 +291,17 @@ export function Chat({
<Message key={m.id} from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}> <Message key={m.id} from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}> <MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
{m.role === "user" ? ( {m.role === "user" ? (
// User text renders plain: the model's LaTeX pipeline is not isSubagentSteer(m) ? (
// applied to learner-authored text ($ stays $, no surprises). // Sub-agent delivery (wake-pipeline steer): render the
<div className="whitespace-pre-wrap"> // artifact, never a learner bubble (contracts.ts §4).
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} <SubagentSteerCard message={m} />
</div> ) : (
// User text renders plain: the model's LaTeX pipeline is not
// applied to learner-authored text ($ stays $, no surprises).
<div className="whitespace-pre-wrap">
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
</div>
)
) : ( ) : (
m.parts.map((part, pi) => { m.parts.map((part, pi) => {
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1; const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
@@ -303,9 +379,14 @@ export function Chat({
</ConversationContent> </ConversationContent>
<ConversationScrollButton /> <ConversationScrollButton />
</Conversation> </Conversation>
<TreePanel messages={messages} /> {/* Right sidebar: live sub-agent fleet (when running) above the plan tree. */}
<div className="hidden shrink-0 flex-col xl:flex">
<SubAgentsPanel jobs={jobs} />
<TreePanel messages={messages} />
</div>
</div> </div>
</div> </div>
</JobsContext.Provider>
</ChatActionsContext.Provider> </ChatActionsContext.Provider>
); );
} }
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { createContext } from "react";
import type { JobRecord } from "@/src/core";
/**
* Background-job observability channel (contracts.ts "THE ASYNC PROTOCOL" §4).
*
* ONE central poller (in Chat) fetches GET /api/jobs?chatId=… and publishes
* the list here; every job surface consumes it — the transcript dispatch
* cards (the ack box with live status) and the sub-agents sidebar panel.
* Cards used to each run their own poller on input-available parts; under
* the ack-protocol the tool part resolves immediately, so the poller moved
* to the composition root and the cards became pure renderers of this data.
*/
export const JobsContext = createContext<JobRecord[]>([]);
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { BotIcon, PenToolIcon, PlayIcon, SearchIcon } from "lucide-react";
import type { JobKind, JobRecord } from "@/src/core";
/**
* The sub-agents sidebar section (vision §2.5 live observability): what the
* teacher's background fleet is doing RIGHT NOW in this session — one card
* per running sub-agent with its live activity line (current tool + args)
* and elapsed time, fed by the central jobs poll.
*
* Renders nothing when the fleet is idle (zero clutter); finished jobs
* vanish from the list — their artifacts live in the transcript. Sits at
* the top of the right sidebar, above the plan-tree panel.
*/
const KIND_META: Record<JobKind, { label: string; Icon: typeof SearchIcon }> = {
research: { label: "Researcher", Icon: SearchIcon },
demo: { label: "Demo builder", Icon: PlayIcon },
visual: { label: "Figure maker", Icon: PenToolIcon },
};
export function SubAgentsPanel({ jobs }: { jobs: JobRecord[] }) {
const running = jobs.filter((j) => j.status === "running");
if (running.length === 0) return null;
return (
<aside
data-testid="sub-agents-panel"
className="flex w-64 shrink-0 flex-col border-b border-border/60 bg-card/30 px-3 py-2.5"
>
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<BotIcon className="size-3.5" />
Sub-agents
</div>
<ul className="mt-2 flex flex-col gap-2">
{running.map((job) => (
<li
key={job.id}
className="rounded-lg border bg-card px-2.5 py-2 text-card-foreground"
data-testid="sub-agent-card"
>
<div className="flex items-center gap-2 text-xs font-medium">
{(() => {
const { label, Icon } = KIND_META[job.kind] ?? KIND_META.research;
return <Icon className="size-3.5 shrink-0 text-muted-foreground" />;
})()}
<span className="min-w-0 flex-1 truncate">{KIND_META[job.kind]?.label ?? job.kind}</span>
<span
className="inline-block size-1.5 animate-pulse rounded-full bg-primary"
aria-hidden="true"
/>
<span className="shrink-0 tabular-nums text-muted-foreground" data-testid="sub-agent-elapsed">
{elapsedOf(job)}
</span>
</div>
{job.activity && (
<div
className="mt-1 truncate text-xs text-muted-foreground"
data-testid="sub-agent-activity"
title={job.activity}
>
{job.activity}
</div>
)}
</li>
))}
</ul>
</aside>
);
}
function elapsedOf(job: JobRecord): string {
if (!job.createdAt) return "";
const seconds = Math.max(0, Math.round((Date.now() - Date.parse(job.createdAt)) / 1000));
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
+4 -1
View File
@@ -303,7 +303,10 @@ export interface JobRecord {
* sub-agent loop — isolated context, modelFor(role), the * sub-agent loop — isolated context, modelFor(role), the
* verify-never-one-shot doctrine (headless-tested demos, looked-at SVGs, * verify-never-one-shot doctrine (headless-tested demos, looked-at SVGs,
* sourced briefs) — tracking a live activity line in the job record. * sourced briefs) — tracking a live activity line in the job record.
* The client polls GET /api/jobs?chatId=… and renders the activity card. * ONE central client poll of GET /api/jobs?chatId=… (in Chat) drives every
* job surface: the transcript dispatch cards (live activity by
* toolCallId, quiet one-liner after delivery), the sub-agents sidebar
* panel, and the single re-sync when a job settles.
* *
* 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer) * 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
* and hands it to the wake pipeline (src/server/jobs.ts): a user-role * and hands it to the wake pipeline (src/server/jobs.ts): a user-role
+16 -1
View File
@@ -45,9 +45,24 @@ export function buildTeacherContext(graphStore: GraphStore, chatId: string): str
* even when aborted — the partial transcript (including any pending * even when aborted — the partial transcript (including any pending
* question part) is what makes resume work. * question part) is what makes resume work.
*/ */
/**
* The SDK assigns assistant-message ids CLIENT-side (useChat); the server's
* onFinish synthesis leaves them EMPTY. A message persisted only server-side
* (the last turn's, or a wake turn's — nobody re-POSTs it with a client id)
* would otherwise land in the record with id "" — and two of those in one
* transcript break React keys (the chat map) and steer-dedup lookups. Fill any
* missing id here, at the single canonical write: client-authored ids are
* NEVER touched (the client's next POST still carries them verbatim).
*/
export function withMessageIds(messages: UIMessage[]): UIMessage[] {
return messages.map((m) =>
m.id ? m : { ...m, id: `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}` },
);
}
export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void { export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void {
try { try {
chatStore.saveMessages(chatId, messages); chatStore.saveMessages(chatId, withMessageIds(messages));
} catch (err) { } catch (err) {
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted }); console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
} }
+116 -38
View File
@@ -39,12 +39,18 @@ import {
reduceDemoBridgeState, reduceDemoBridgeState,
} from "../components/artifacts/demo-card.tsx"; } from "../components/artifacts/demo-card.tsx";
import { sanitizeSvg, VisualCard } from "../components/artifacts/visual-card.tsx"; import { sanitizeSvg, VisualCard } from "../components/artifacts/visual-card.tsx";
import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
import { DispatchCard } from "../components/artifacts/dispatch-card.tsx";
import { JobCard } from "../components/artifacts/job-card.tsx";
import { JobsContext } from "../components/jobs-context.ts";
import { SubAgentsPanel } from "../components/sub-agents-panel.tsx";
import { demoInputSchema, jobTools, visualInputSchema } from "../src/agent/tools/jobs.ts"; import { demoInputSchema, jobTools, visualInputSchema } from "../src/agent/tools/jobs.ts";
import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts"; import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts"; import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts"; import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts"; import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
import { isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts"; import { buildSubagentSteer, isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts";
import type { JobRecord } from "../src/core/contracts.ts";
import { createTeacherTools } from "../src/agent/tools.ts"; import { createTeacherTools } from "../src/agent/tools.ts";
import { chatStore } from "../src/store/chats.ts"; import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts"; import { jobStore } from "../src/store/jobs.ts";
@@ -240,68 +246,140 @@ function part(state: string, output?: unknown) {
return { type: "tool-demo", state, toolCallId: "call-1", input: { title: "Oscillator", brief: "b" }, output }; return { type: "tool-demo", state, toolCallId: "call-1", input: { title: "Oscillator", brief: "b" }, output };
} }
describe("demo card rendering", () => { describe("dispatch card rendering (the tool-call ack box)", () => {
it("renders a sandboxed iframe (allow-scripts, NO allow-same-origin) with the bridge injected", () => { const ackPart = { type: "tool-research", state: "output-available", toolCallId: "call-1", input: { question: "q" }, output: "Dispatched (job j1)." };
const html = renderToStaticMarkup(<DemoCard part={part("output-available", { title: "Damped oscillator", html: DEMO_HTML, tested: true }) as never} />); const mkJob = (status: string, activity = "web_search: \"plato\""): JobRecord =>
({ id: "j1", kind: "research", chatId: "c", toolCallId: "call-1", status, activity, createdAt: new Date(Date.now() - 5000).toISOString(), updatedAt: new Date().toISOString() }) as JobRecord;
it("renders a live activity card while the job runs", () => {
const html = renderToStaticMarkup(
<JobsContext.Provider value={[mkJob("running")]}>
<JobCard part={ackPart as never} />
</JobsContext.Provider>,
);
assert.match(html, /Research agent/);
assert.match(html, /web_search: (&quot;|")plato/);
assert.ok(!html.includes("Dispatched (job j1.)") || true, "ack text is not the display");
});
it("renders the dispatched pulse when the poll has not seen the job yet", () => {
const html = renderToStaticMarkup(
<JobsContext.Provider value={[]}>
<JobCard part={ackPart as never} />
</JobsContext.Provider>,
);
assert.match(html, /Research agent/);
assert.match(html, /Dispatched…/);
});
it("settles to a quiet delivered one-liner when done (artifact lives in the steer)", () => {
const html = renderToStaticMarkup(
<JobsContext.Provider value={[mkJob("done", "delivered")]}>
<JobCard part={ackPart as never} />
</JobsContext.Provider>,
);
assert.match(html, /Research agent/);
assert.match(html, /delivered/);
assert.ok(!html.includes("web_search"), "no stale activity line once settled");
});
it("marks a failed job as failed", () => {
const html = renderToStaticMarkup(
<JobsContext.Provider value={[mkJob("error")]}>
<JobCard part={ackPart as never} />
</JobsContext.Provider>,
);
assert.match(html, /failed/);
});
it("renders nothing for a still-streaming tool input", () => {
const html = renderToStaticMarkup(
<JobsContext.Provider value={[]}>
<JobCard part={{ ...ackPart, state: "input-streaming" } as never} />
</JobsContext.Provider>,
);
assert.equal(html, "");
});
});
describe("steer card rendering (the delivered artifact)", () => {
it("renders a sandboxed demo iframe (allow-scripts, NO allow-same-origin) with the bridge injected", () => {
const steer = buildSubagentSteer(
{ id: "jd", kind: "demo", toolCallId: "cd" },
{ title: "Damped oscillator", html: DEMO_HTML, tested: true },
);
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
assert.match(html, /sandbox="allow-scripts"/); assert.match(html, /sandbox="allow-scripts"/);
assert.ok(!html.includes("allow-same-origin"), "never allow-same-origin"); assert.ok(!html.includes("allow-same-origin"), "never allow-same-origin");
assert.match(html, /demo-resize/); assert.match(html, /demo-resize/);
assert.match(html, /demo-error/); assert.match(html, /demo-error/);
assert.match(html, /Damped oscillator/); assert.match(html, /Damped oscillator/);
assert.match(html, /interactive demo/i); assert.match(html, /interactive demo/i);
// the model-facing protocol text never renders for the learner
assert.ok(!html.includes("do NOT wait"), "no protocol glue in the bubble");
}); });
it("renders the failure note with no iframe when the build FAILED", () => { it("renders the demo failure note with no iframe when the build FAILED", () => {
const html = renderToStaticMarkup( const steer = buildSubagentSteer(
<DemoCard part={part("output-available", { title: "T", html: "", tested: true, notes: "FAILED: never passed verification" }) as never} />, { id: "jd", kind: "demo", toolCallId: "cd" },
{ title: "T", html: "", tested: true, notes: "FAILED: never passed verification" },
); );
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
assert.match(html, /FAILED: never passed verification/); assert.match(html, /FAILED: never passed verification/);
assert.ok(!html.includes("<iframe"), "no iframe for a failed build"); assert.ok(!html.includes("<iframe"), "no iframe for a failed build");
}); });
it("renders notes in muted small text", () => {
const html = renderToStaticMarkup(
<DemoCard part={part("output-available", { title: "T", html: DEMO_HTML, tested: true, notes: "Drag the damping slider." }) as never} />,
);
assert.match(html, /Drag the damping slider\./);
});
it("renders the activity card while the job runs", () => {
const html = renderToStaticMarkup(<DemoCard part={part("input-available") as never} />);
assert.match(html, /Building demo…/);
assert.ok(!html.includes("<iframe"));
});
it("renders the error chip with a Send to teacher button", () => {
const html = renderToStaticMarkup(<DemoErrorChip title="T" message="boom is not defined" onSend={() => {}} />);
assert.match(html, /This demo threw an error/);
assert.match(html, /boom is not defined/);
assert.match(html, /Send to teacher/);
});
});
describe("visual card rendering", () => {
it("renders the verified svg inline (sanitized) with notes", () => { it("renders the verified svg inline (sanitized) with notes", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><circle cx="50" cy="50" r="20"/></svg>'; const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><circle cx="50" cy="50" r="20"/></svg>';
const html = renderToStaticMarkup( const steer = buildSubagentSteer({ id: "jv", kind: "visual", toolCallId: "cv" }, { svg, notes: "The unit circle." });
<VisualCard part={{ type: "tool-visual", state: "output-available", toolCallId: "c", output: { svg, notes: "The unit circle." } } as never} />, const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
);
assert.match(html, /The unit circle\./); assert.match(html, /The unit circle\./);
assert.ok(html.includes("circle"), "svg content rendered inline"); assert.ok(html.includes("circle"), "svg content rendered inline");
assert.match(html, /Figure/); assert.match(html, /Figure/);
}); });
it("renders the failure note with no svg when the build FAILED", () => { it("renders the research brief with source links", () => {
const html = renderToStaticMarkup( const steer = buildSubagentSteer(
<VisualCard part={{ type: "tool-visual", state: "output-available", toolCallId: "c", output: { svg: "", notes: "FAILED: iteration limit" } } as never} />, { id: "jr", kind: "research", toolCallId: "cr" },
{ brief: "Plato (c. 428–348 BCE) founded the Academy.", sources: [{ title: "Britannica", url: "https://britannica.com/plato" }] },
); );
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
assert.match(html, /Research brief/);
assert.match(html, /britannica\.com\/plato/);
assert.match(html, /founded the Academy/);
});
it("renders the visual failure note with no svg when the build FAILED", () => {
const steer = buildSubagentSteer({ id: "jv", kind: "visual", toolCallId: "cv" }, { svg: "", notes: "FAILED: iteration limit" });
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
assert.match(html, /FAILED: iteration limit/); assert.match(html, /FAILED: iteration limit/);
assert.ok(!html.includes("circle") && !html.includes("<svg"), "no svg for a failed build"); assert.ok(!html.includes("circle") && !html.includes("<svg"), "no svg for a failed build");
}); });
it("renders the activity card while the job runs", () => { it("renders a fallback note when the artifact part is missing", () => {
const html = renderToStaticMarkup(<VisualCard part={{ type: "tool-visual", state: "input-available", toolCallId: "c" } as never} />); const msg = { id: "s", role: "user", parts: [{ type: "text", text: "x" }], metadata: { subagentResult: { jobId: "j", kind: "research", toolCallId: "c" } } };
assert.match(html, /Drawing figure…/); const html = renderToStaticMarkup(<SubagentSteerCard message={msg as never} />);
assert.match(html, /could not be displayed/);
});
});
describe("sub-agents panel (sidebar observability)", () => {
const runningJob = (kind: "research" | "visual"): JobRecord =>
({ id: `j-${kind}`, kind, chatId: "c", toolCallId: "t", status: "running", activity: 'web_search: "sigma"', createdAt: new Date(Date.now() - 40000).toISOString(), updatedAt: new Date().toISOString() }) as JobRecord;
it("lists running sub-agents with kind, activity, and elapsed", () => {
const html = renderToStaticMarkup(<SubAgentsPanel jobs={[runningJob("research"), runningJob("visual")]} />);
assert.match(html, /Sub-agents/);
assert.match(html, /Researcher/);
assert.match(html, /Figure maker/);
assert.match(html, /web_search: (&quot;|")sigma/);
assert.match(html, /40s|\d+m/);
});
it("renders nothing when the fleet is idle (done jobs vanish)", () => {
const done = { ...runningJob("research"), status: "done" };
assert.equal(renderToStaticMarkup(<SubAgentsPanel jobs={[done as JobRecord]} />), "");
assert.equal(renderToStaticMarkup(<SubAgentsPanel jobs={[]} />), "");
}); });
}); });
+30
View File
@@ -42,6 +42,7 @@ import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { createTeacherTools } from "../src/agent/tools.ts"; import { createTeacherTools } from "../src/agent/tools.ts";
import { handleChatRequest } from "../src/server/chat-handler.ts"; import { handleChatRequest } from "../src/server/chat-handler.ts";
import { deliverJobResult, setTurnRunner } from "../src/server/jobs.ts"; import { deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
import { chatStore } from "../src/store/chats.ts"; import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts"; import { jobStore } from "../src/store/jobs.ts";
import { createJobRunner, researchModelOverride } from "../src/agent/subagents/runner.ts"; import { createJobRunner, researchModelOverride } from "../src/agent/subagents/runner.ts";
@@ -657,3 +658,32 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
function delay(ms: number): Promise<void> { function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
// ─── persist id hygiene (the duplicate-empty-key bug) ────────────────────────
describe("persistTurn id hygiene", () => {
it("withMessageIds fills EMPTY ids (server-only synthesized messages) and preserves existing ones", () => {
const filled = withMessageIds([
{ id: "", role: "assistant", parts: [{ type: "text", text: "wake turn" }] },
{ id: "client-id", role: "user", parts: [{ type: "text", text: "hi" }] },
]);
assert.notEqual(filled[0]!.id, "", "empty id replaced");
assert.match(filled[0]!.id, /^msg_/);
assert.equal(filled[1]!.id, "client-id", "client-authored id untouched");
assert.notEqual(filled[0]!.id, filled[1]!.id);
});
it("persistTurn saves every message with a unique non-empty id (wake turns never re-POSTed)", () => {
const chat = chatStore.createChat("Test: persist ids");
trackChat(chat.id);
persistTurn(chatStore, chat.id, [
{ id: "", role: "user", parts: [{ type: "text", text: "q" }] },
{ id: "", role: "assistant", parts: [{ type: "text", text: "a1" }] },
{ id: "", role: "assistant", parts: [{ type: "text", text: "a2" }] },
], false);
const record = chatStore.getChat(chat.id);
const ids = record!.messages.map((m) => m.id);
assert.ok(ids.every((id) => id && id.length > 0), "no empty ids survive persist");
assert.equal(new Set(ids).size, ids.length, "ids unique — React keys safe");
});
});