Merge feat/sub-agents: pi-style ack-and-steer sub-agent protocol

Server-executed job tools resolve mid-turn with an immediate ack; the
runner steers the teacher later via a server-side wake turn (per-chat
serialized queue on globalThis, turn-free + quiz-aware firing, 409
retry, idempotent per jobId, stale-job sweep). Live observability client:
central /api/jobs poll, dispatch cards, steer artifacts (research/visual/
demo renderers), sub-agents sidebar panel, wake-turn adoption via the
existing turnInFlight machinery. persistTurn fills empty message ids
(withMessageIds). prompt composition loads numeric-prefix fragments only
(README.md had been leaking into the system prompt).
This commit is contained in:
amosblomqvist
2026-09-02 16:34:32 +03:00
26 changed files with 1968 additions and 677 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) });
} }
+11 -86
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 (
@@ -173,10 +99,10 @@ function DemoOutputCard({ output }: { output: unknown }) {
</div> </div>
); );
} }
return <DemoFrame title={result.title} html={result.html} notes={result.notes} />; return <DemoFrame title={result.title} html={result.html} />;
} }
function DemoFrame({ title, html, notes }: { title: string; html: string; notes?: string }) { function DemoFrame({ title, html }: { title: string; html: string }) {
const actions = useContext(ChatActionsContext); const actions = useContext(ChatActionsContext);
const iframeRef = useRef<HTMLIFrameElement | null>(null); const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [state, setState] = useState<DemoBridgeState>(INITIAL_DEMO_BRIDGE_STATE); const [state, setState] = useState<DemoBridgeState>(INITIAL_DEMO_BRIDGE_STATE);
@@ -219,7 +145,6 @@ function DemoFrame({ title, html, notes }: { title: string; html: string; notes?
style={{ height: state.height ?? 360 }} style={{ height: state.height ?? 360 }}
data-testid="demo-iframe" data-testid="demo-iframe"
/> />
{notes && <p className="mt-2 text-sm text-muted-foreground">{notes}</p>}
</div> </div>
); );
} }
+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 (
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { CheckIcon } from "lucide-react";
import type { ResearchResult, SubagentArtifactData, TeacherUIMessage } from "@/src/core";
import { isSubagentFailure } 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 renders as an artifact —
* NEVER a learner bubble, never wrapped in a message container:
*
* research — a quiet "Research complete" badge. The full brief is TOO LONG
* to inline and the teacher's wake turn weaves it into the lesson anyway;
* it stays persisted in the transcript (the artifact part), so a
* "view full research" affordance can be added later. Failures keep the
* failure card (the brief explains what went wrong).
* demo / visual — the artifact itself (sandboxed iframe / sanitized SVG).
* The sub-agent's verification notes are model-facing glue and do NOT
* render; only a failure note does.
*/
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 isSubagentFailure("research", data.result as ResearchResult) ? (
<ResearchBriefCard output={data.result} />
) : (
<ResearchCompleteBadge result={data.result as ResearchResult} />
);
case "demo":
return <DemoOutputCard output={data.result} />;
case "visual":
return <VisualOutputCard output={data.result} />;
}
}
/** Quiet one-liner, the dispatch-settled idiom: the brief lives in the record,
* the lesson lives in the teacher's wake turn — the badge just marks delivery. */
function ResearchCompleteBadge({ result }: { result: ResearchResult }) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground" data-testid="research-complete-badge">
<CheckIcon className="size-3.5" />
<span className="font-medium uppercase tracking-wide">Research</span>
<span>complete</span>
{result.sources.length > 0 && <span>· {result.sources.length} sources</span>}
</div>
);
}
+20 -15
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 (
@@ -52,13 +55,15 @@ function VisualOutputCard({ output }: { output: unknown }) {
return ( return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="visual-card"> <div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="visual-card">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div> <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div>
{/* max-w-full + h-auto keeps any oversized model-authored svg inside the column */} {/* Scale to the display area: the svg always fills the card's width
(w-full), h-auto keeps the aspect ratio — no white strips beside
narrower figures, oversized ones shrink to fit. bg-white stays as a
neutral backing for svgs without their own background. */}
<div <div
className="mt-2 max-w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:max-w-full" className="mt-2 overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:w-full"
dangerouslySetInnerHTML={{ __html: sanitizeSvg(result.svg) }} dangerouslySetInnerHTML={{ __html: sanitizeSvg(result.svg) }}
data-testid="visual-svg" data-testid="visual-svg"
/> />
{result.notes && <p className="mt-2 text-sm text-muted-foreground">{result.notes}</p>}
</div> </div>
); );
} }
+99 -16
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 />
@@ -217,8 +287,16 @@ export function Chat({
/> />
)} )}
{messages.map((m, mi) => ( {messages.map((m, mi) =>
<Message key={m.id} from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}> m.role === "user" && isSubagentSteer(m) ? (
// Sub-agent delivery (wake-pipeline steer): NO message bubble — the
// artifact card sits in the transcript column directly, full width
// and centered like the teacher's artifacts (contracts.ts "THE ASYNC
// PROTOCOL" §4). Key fallback: legacy transcripts (pre id-hygiene)
// can still hold empty ids — the index keeps keys unique for them.
<SubagentSteerCard key={m.id || `m-${mi}`} message={m} />
) : (
<Message key={m.id || `m-${mi}`} 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 // User text renders plain: the model's LaTeX pipeline is not
@@ -303,9 +381,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`;
}
+12 -10
View File
@@ -9,19 +9,21 @@ outsource what you cannot reliably know yourself:
- **Never use it for:** anything you know cold, internal reasoning, or math. Do not offload - **Never use it for:** anything you know cold, internal reasoning, or math. Do not offload
thinking — only facts you genuinely need verified. thinking — only facts you genuinely need verified.
**The call is asynchronous and your turn ends at it.** The discipline: **The call is fire-and-forget: it resolves immediately with an ack and your turn
continues.** The discipline:
1. **Say your piece first.** Explain the concept, set up what you're doing and why, and tell 1. **Announce it, then keep teaching.** Say what you're checking and why ("let me verify the
the learner roughly what you're checking — the research brief arrives later as the tool's current version while we continue"), make the call, and carry the lesson forward — do NOT
output, and the lesson resumes from there. A bare tool call with no surrounding text is a wait, do NOT poll, do NOT say "hold on until it's back". Never fabricate the result.
failed turn.
2. **Call with a single, specific question.** The researcher sees nothing else of the lesson — 2. **Call with a single, specific question.** The researcher sees nothing else of the lesson —
give it the question plus one or two sentences of context about what you need it for. give it the question plus one or two sentences of context about what you need it for.
3. **Resume from the brief.** When the result returns, weave it into the lesson. If it 3. **The brief arrives as a new message** when the research is done, and the lesson resumes
contradicted what you said, correct yourself openly — that's a teaching moment. from it. Weave it into the lesson then. If it contradicted what you said, correct yourself
openly — that's a teaching moment.
4. **Cite the brief's sources** when you use its findings (a short "source: …" or inline link). 4. **Cite the brief's sources** when you use its findings (a short "source: …" or inline link).
Never present researched facts as your own certainty. Never present researched facts as your own certainty.
At most one research call per turn — don't fan out; the researcher searches multiple angles on Dispatch as many research jobs as you genuinely need — in one turn or across turns, in
its own. If research fails or is unconfigured, you'll get a failure brief: say so plainly and parallel when the questions are independent — but don't fan out on one question; the
continue with what you can establish, flagging the uncertainty. researcher searches multiple angles on its own. If research fails, you'll get a failure
message: say so plainly and continue with what you can establish, flagging the uncertainty.
+16 -12
View File
@@ -3,8 +3,8 @@
If the concept is DYNAMIC — a parameter to sweep, a process to animate, a distribution to sample, a If the concept is DYNAMIC — a parameter to sweep, a process to animate, a distribution to sample, a
system to simulate — build the learner the instrument rather than describing its behavior in prose. system to simulate — build the learner the instrument rather than describing its behavior in prose.
A paragraph about how frequency response changes with damping teaches less than ten seconds of A paragraph about how frequency response changes with damping teaches less than ten seconds of
dragging a slider. You have two artifact tools for this, both asynchronous (same discipline as dragging a slider. You have two artifact tools for this, both fire-and-forget (same async
research): discipline as research).
### `demo` — interactive teaching instruments ### `demo` — interactive teaching instruments
@@ -31,15 +31,19 @@ for geometry mermaid's auto-layout cannot express.
### The async discipline ### The async discipline
The call is asynchronous and **your turn ends at it**, exactly like research: Both calls resolve immediately with an ack and your turn continues:
1. **Say your piece first.** Set up the concept, explain what the learner is about to see and what to 1. **Announce it, then keep teaching.** Set up the concept and tell the learner what's coming
watch for — the artifact arrives later as the tool's output and the lesson resumes from there. A ("I'm having an interactive demo built for this — meanwhile, the idea itself…"), make the call,
bare tool call with no surrounding text is a failed turn. and carry on — do NOT wait, do NOT poll, do NOT say "hold on until it's back". Never fabricate
2. **At most one demo or visual per turn.** the result. A bare tool call with no surrounding text is a failed turn.
3. **When the artifact arrives, TEACH WITH IT.** Reference its controls and elements by name: "drag 2. **Dispatch freely.** Parallel jobs are encouraged when they're genuinely independent — a demo
the damping slider all the way left and watch the overshoot", "notice the vector at t=2s". An and a figure for the same concept, several figures for a multi-step construction. Keep each
brief self-contained.
3. **The artifact arrives as a new message** when it's built, verified, and rendered in the
lesson — **TEACH WITH IT then.** Reference its controls and elements by name: "drag the
damping slider all the way left and watch the overshoot", "notice the vector at t=2s". An
artifact you never point at is decoration. artifact you never point at is decoration.
4. **Failures arrive as notes** ("FAILED: …") with no artifact — say so plainly, fall back to 4. **Failures arrive as messages** with no artifact — say so plainly, fall back to explaining
explaining in prose, and if the brief was the problem, re-call with a tighter one. Re-calling a in prose, and if the brief was the problem, re-call with a tighter one. Re-calling a tool
tool produces a new version of the artifact; use that to fix a demo that misbehaves in the field. produces a new version of the artifact; use that to fix a demo that misbehaves in the field.
+1 -1
View File
@@ -3,7 +3,7 @@
The teacher's system prompt is composed at agent creation: The teacher's system prompt is composed at agent creation:
1. `src/agent/system-prompt.md` — the base (owned by the orchestrator; do not edit from a worker). 1. `src/agent/system-prompt.md` — the base (owned by the orchestrator; do not edit from a worker).
2. Every `*.md` file in this directory, in **sorted filename order**, joined with `---` separators. 2. Every numeric-prefix `*.md` fragment in this directory, in **sorted filename order**, joined with `---` separators. The loader enforces the numeric prefix (`\d*-*.md`) — other files (like this README) never enter the model's prompt.
Rules for adding a fragment: Rules for adding a fragment:
+2 -1
View File
@@ -51,7 +51,8 @@ Numbered findings with inline source citations:
1. **Finding** — explanation. [Source](url) 1. **Finding** — explanation. [Source](url)
## Sources ## Sources
- Source Title (url) — why it's relevant - Kept: Source Title (url) — why relevant
- Dropped: Source Title — why excluded
## Gaps ## Gaps
What couldn't be answered. Suggested next steps.`; What couldn't be answered. Suggested next steps.`;
+163 -163
View File
@@ -1,107 +1,168 @@
import { isToolUIPart, type LanguageModel, type UIMessage } from "ai"; import { isToolUIPart, type LanguageModel, type UIMessage } from "ai";
import type { DemoResult, DemoToolInput, JobKind, ResearchResult, ResearchToolInput, VisualResult, VisualToolInput } from "../../core/contracts.ts"; import {
import { JOB_TOOLS } from "../../core/pending.ts"; buildSubagentSteer,
type DemoResult,
type DemoToolInput,
type JobKind,
type JobRecord,
type ResearchResult,
type ResearchToolInput,
type SubagentSteerMessage,
type VisualResult,
type VisualToolInput,
} from "../../core/contracts.ts";
import type { ChatStore, JobStore } from "../../core/ports.ts"; import type { ChatStore, JobStore } from "../../core/ports.ts";
import { runDemoBuilder } from "./demo-builder.ts"; import { runDemoBuilder } from "./demo-builder.ts";
import { createResearcherAgent } from "./researcher.ts"; import { createResearcherAgent } from "./researcher.ts";
import { runVisualMaker } from "./visual-maker.ts"; import { runVisualMaker } from "./visual-maker.ts";
/** /**
* The background job runner (contracts.ts "THE ASYNC PROTOCOL"), created via * The background job runner (contracts.ts "THE ASYNC PROTOCOL" §2–3), created
* createJobRunner({ chatStore, jobStore }) — the composition root (chat-handler) * via createJobRunner({ chatStore, jobStore, deliver }) — the composition root
* injects the storage ports; this module never imports an adapter. * (chat-handler) injects the storage ports and the WAKE SEAM (the server-side
* steer delivery, src/server/jobs.ts); this module never imports an adapter.
* *
* spawnPendingJobs — called from the turn service AFTER persist: finds * runJob(jobId) — runs the kind's sub-agent loop in the background
* input-available JOB_TOOLS parts in the last assistant message with no * (the job tools' execute fires this via the spawn seam, fire-and-forget),
* existing job and fires the kind's runner WITHOUT awaiting (the HTTP * updating the job's activity line on every iteration. On completion the
* response is long gone). Kind dispatch (research/demo/visual) is a * runner builds the STEER MESSAGE (buildSubagentSteer) and hands it to
* self-contained map here, so no caller changes for new job kinds — * `deliver`; the wake pipeline fires the server-side teacher turn and
* anything listed in JOB_TOOLS routes automatically. * flips the job to done/error when the lesson has actually resumed.
* *
* runResearchJob / runDemoJob / runVisualJob — run the sub-agent loop in * On failure the result is the failure shape (empty html/svg + "FAILED: …"
* the background, updating the job's activity line on every iteration, * notes; research: failure brief) — and it STILL steers, because the
* then rewrite the tool part in the LATEST chat record to * teacher must learn the outcome. If delivery itself fails, the job is
* output-available (read-modify-write; no awaits between the read and * marked error (the learner's activity card shows it).
* the write). The part is fulfilled BEFORE the job status flips, so a
* client that polls the job and then re-syncs always sees the finished
* part.
*
* On failure the part is still fulfilled (DemoResult/VisualResult failure
* shape: empty html/svg + "FAILED: …" notes; research: failure brief) — the
* teacher must learn the outcome — and the job is marked error.
*/ */
/** /**
* Test seam: the spawn hook creates runners with no injection point, so * Test seam: the spawn wiring has no injection point at the tool layer, so
* tests swap in a MockLanguageModelV4 here to keep job tests off the * tests swap in a MockLanguageModelV4 here to keep job tests off the network.
* network. Undefined in production. * Undefined in production. (The jobs spawn through the module-level KIND
* runners, which read these at call time.)
*/ */
export const researchModelOverride: { current?: LanguageModel } = {}; export const researchModelOverride: { current?: LanguageModel } = {};
export const demoModelOverride: { current?: LanguageModel } = {}; export const demoModelOverride: { current?: LanguageModel } = {};
export const visualModelOverride: { current?: LanguageModel } = {}; export const visualModelOverride: { current?: LanguageModel } = {};
/** The dispatch-time tool input (from src/agent/tools/jobs.ts schemas). */
type JobToolInput = ResearchToolInput | DemoToolInput | VisualToolInput;
export interface JobRunnerDeps { export interface JobRunnerDeps {
chatStore: ChatStore; chatStore: ChatStore;
jobStore: JobStore; jobStore: JobStore;
} /**
* Hand a finished job's steer to the wake pipeline — resolves when the
export interface RunResearchJobOptions { * steer is delivered (the wake turn completed and the job status
model?: LanguageModel; * flipped); rejects when delivery fails unrecoverably.
} */
deliver: (steer: SubagentSteerMessage) => Promise<void>;
export interface RunDemoJobOptions {
model?: LanguageModel;
}
export interface RunVisualJobOptions {
model?: LanguageModel;
} }
export interface JobRunner { export interface JobRunner {
spawnPendingJobs(chatId: string, messages: UIMessage[]): void; /** Run one job's sub-agent loop to completion (background; never throws).
runResearchJob(jobId: string, opts?: RunResearchJobOptions): Promise<void>; * The tool input rides along from dispatch (the spawn fires mid-stream,
runDemoJob(jobId: string, opts?: RunDemoJobOptions): Promise<void>; * before the tool part is persisted — only crash-recovery re-spawns
runVisualJob(jobId: string, opts?: RunVisualJobOptions): Promise<void>; * resolve the input from the transcript). */
runJob(jobId: string, input?: ResearchToolInput | DemoToolInput | VisualToolInput): Promise<void>;
} }
export function createJobRunner(deps: JobRunnerDeps): JobRunner { export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const { chatStore, jobStore } = deps; const { chatStore, jobStore, deliver } = deps;
/** The kind→runner map: everything in JOB_TOOLS routes through here. */ /** The kind→runner map: one sub-agent loop per job kind. */
const KIND_RUNNERS: Record<JobKind, (jobId: string) => Promise<void>> = { // Each kind's runner narrows the union input itself (dispatch-time inputs
research: (jobId) => runResearchJob(jobId, { model: researchModelOverride.current }), // are kind-correct by construction; transcript fallbacks are cast after).
demo: (jobId) => runDemoJob(jobId, { model: demoModelOverride.current }), const KIND_RUNNERS: Record<JobKind, (jobId: string, input?: JobToolInput) => Promise<void>> = {
visual: (jobId) => runVisualJob(jobId, { model: visualModelOverride.current }), research: (jobId, input) => runResearchJob(jobId, researchModelOverride.current, input),
demo: (jobId, input) => runDemoJob(jobId, demoModelOverride.current, input),
visual: (jobId, input) => runVisualJob(jobId, visualModelOverride.current, input),
}; };
function spawnPendingJobs(chatId: string, messages: UIMessage[]): void { async function runJob(jobId: string, input?: JobToolInput): Promise<void> {
const last = [...messages].reverse().find((m) => m.role === "assistant"); const job = jobStore.getJob(jobId);
if (!last) return; if (!job) {
for (const part of last.parts) { console.error(`[job ${jobId}] not found — nothing to run`);
if (!isToolUIPart(part) || part.state !== "input-available") continue; return;
const tool = part.type.slice("tool-".length); }
if (!(JOB_TOOLS as readonly string[]).includes(tool)) continue; if (job.status !== "running") return; // already finished / re-run after crash recovery
const runner = KIND_RUNNERS[tool as JobKind]; const run = KIND_RUNNERS[job.kind];
if (!runner) continue; if (!run) {
if (jobStore.findJobByToolCallId(chatId, part.toolCallId)) continue; // already has a job console.error(`[job ${jobId}] unknown kind: ${job.kind}`);
const job = jobStore.createJob({ kind: tool as JobKind, chatId, toolCallId: part.toolCallId }); jobStore.updateJob(jobId, { status: "error", error: `unknown kind ${job.kind}`, activity: "failed" });
void runner(job.id).catch((err) => { return;
console.error(`[job ${job.id}] ${tool} runner crashed:`, err); }
jobStore.updateJob(job.id, { status: "error", error: String(err), activity: "failed" }); await run(jobId, input);
}); }
/** Resolve the job's tool input: dispatch passes it directly; only a
* crash-recovery re-spawn (no input) falls back to scanning the
* transcript (by then onFinish has long persisted the part). */
function resolveInput(
job: JobRecord,
input: JobToolInput | undefined,
): ResearchToolInput | DemoToolInput | VisualToolInput | undefined {
if (input) return input;
const chat = chatStore.getChat(job.chatId);
if (!chat) return undefined;
return (findToolPart(chat.messages, job.toolCallId)?.input ?? undefined) as
| ResearchToolInput
| DemoToolInput
| VisualToolInput
| undefined;
}
// ─── shared plumbing ────────────────────────────────────────────────────
function findToolPart(messages: UIMessage[], toolCallId: string) {
for (const m of messages) {
for (const part of m.parts) {
if (isToolUIPart(part) && part.toolCallId === toolCallId) return part;
}
}
return null;
}
/**
* The tail of every job: store the result, build the steer, hand it to
* the wake pipeline. The pipeline flips the status when the lesson has
* actually resumed — until then the job reads "running" so the client's
* activity card keeps polling and re-syncs once, at the end.
*/
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult): Promise<void> {
const job = jobStore.getJob(jobId);
if (!job) return;
jobStore.updateJob(jobId, { result, activity: "delivering result" });
const steer = buildSubagentSteer(job, result);
try {
await deliver(steer);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.error(`[job ${jobId}] steer delivery failed:`, err);
jobStore.updateJob(jobId, { status: "error", error: reason, activity: "delivery failed" });
} }
} }
async function runResearchJob(jobId: string, opts: RunResearchJobOptions = {}): Promise<void> { /** Chat-record + input resolution shared by all three runners. */
function jobContext(jobId: string): { job: JobRecord; chatId: string } | null {
const job = jobStore.getJob(jobId); const job = jobStore.getJob(jobId);
if (!job || job.status !== "running") return; if (!job || job.status !== "running") return null;
const chat = chatStore.getChat(job.chatId); const chat = chatStore.getChat(job.chatId);
if (!chat) { if (!chat) {
jobStore.updateJob(jobId, { status: "error", error: `Chat not found: ${job.chatId}`, activity: "failed" }); jobStore.updateJob(jobId, { status: "error", error: `Chat not found: ${job.chatId}`, activity: "failed" });
return; return null;
} }
const input = findToolPart(chat.messages, job.toolCallId)?.input as ResearchToolInput | undefined; return { job, chatId: job.chatId };
}
// ─── research ───────────────────────────────────────────────────────────
async function runResearchJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> {
const ctx = jobContext(jobId);
if (!ctx) return;
const { job } = ctx;
const input = (passedInput ?? resolveInput(job, passedInput)) as ResearchToolInput | undefined;
const question = typeof input?.question === "string" && input.question.trim() ? input.question : "(no question)"; const question = typeof input?.question === "string" && input.question.trim() ? input.question : "(no question)";
const context = typeof input?.context === "string" && input.context.trim() ? input.context : undefined; const context = typeof input?.context === "string" && input.context.trim() ? input.context : undefined;
@@ -114,7 +175,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
.join("\n\n"); .join("\n\n");
const fetchedUrls: string[] = []; const fetchedUrls: string[] = [];
const agent = createResearcherAgent(opts.model); const agent = createResearcherAgent(model);
try { try {
jobStore.updateJob(jobId, { activity: "researching" }); jobStore.updateJob(jobId, { activity: "researching" });
@@ -132,37 +193,22 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const brief = result.text.trim(); const brief = result.text.trim();
if (!brief) throw new Error("the researcher produced no text"); if (!brief) throw new Error("the researcher produced no text");
const research: ResearchResult = { brief, sources: parseSources(brief, fetchedUrls) }; const research: ResearchResult = { brief, sources: parseSources(brief, fetchedUrls) };
await finishJob(jobId, research);
fulfillPart(job.chatId, job.toolCallId, research);
jobStore.updateJob(jobId, { status: "done", result: research }); // activity line kept: the last tool call is the record
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
fulfillPart(job.chatId, job.toolCallId, { brief: `Research failed: ${reason}`, sources: [] }); jobStore.updateJob(jobId, { error: reason });
jobStore.updateJob(jobId, { status: "error", activity: "failed", error: reason }); await finishJob(jobId, { brief: `Research failed: ${reason}`, sources: [] });
} }
} }
// ─── demo + visual job wrappers ─────────────────────────────────────────── // ─── demo + visual ──────────────────────────────────────────────────────
/** Failure shape (W6 brief): html === "" with "FAILED: …" notes. */ async function runDemoJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> {
function isDemoFailure(result: DemoResult): boolean { const ctx = jobContext(jobId);
return typeof result.html !== "string" || result.html.length === 0; if (!ctx) return;
} const { job } = ctx;
function isVisualFailure(result: VisualResult): boolean { const input = (passedInput ?? resolveInput(job, passedInput)) as DemoToolInput | undefined;
return typeof result.svg !== "string" || result.svg.length === 0;
}
async function runDemoJob(jobId: string, opts: RunDemoJobOptions = {}): Promise<void> {
const job = jobStore.getJob(jobId);
if (!job || job.status !== "running") return;
const chat = chatStore.getChat(job.chatId);
if (!chat) {
jobStore.updateJob(jobId, { status: "error", error: `Chat not found: ${job.chatId}`, activity: "failed" });
return;
}
const input = findToolPart(chat.messages, job.toolCallId)?.input as DemoToolInput | undefined;
const title = typeof input?.title === "string" && input.title.trim() ? input.title : "Interactive demo"; const title = typeof input?.title === "string" && input.title.trim() ? input.title : "Interactive demo";
const brief = typeof input?.brief === "string" ? input.brief : ""; const brief = typeof input?.brief === "string" ? input.brief : "";
@@ -171,34 +217,25 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const result = await runDemoBuilder( const result = await runDemoBuilder(
{ title, brief }, { title, brief },
{ {
model: opts.model, model,
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }), onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
}, },
); );
fulfillPart(job.chatId, job.toolCallId, result); if (isDemoFailure(result)) jobStore.updateJob(jobId, { error: result.notes ?? "demo build failed" });
if (isDemoFailure(result)) { await finishJob(jobId, result);
jobStore.updateJob(jobId, { status: "error", error: result.notes ?? "demo build failed", activity: "failed" });
} else {
jobStore.updateJob(jobId, { status: "done", result });
}
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
const failure: DemoResult = { title, html: "", tested: true, notes: `FAILED: ${reason}` }; jobStore.updateJob(jobId, { error: reason });
fulfillPart(job.chatId, job.toolCallId, failure); await finishJob(jobId, { title, html: "", tested: true, notes: `FAILED: ${reason}` });
jobStore.updateJob(jobId, { status: "error", error: reason, activity: "failed" });
} }
} }
async function runVisualJob(jobId: string, opts: RunVisualJobOptions = {}): Promise<void> { async function runVisualJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> {
const job = jobStore.getJob(jobId); const ctx = jobContext(jobId);
if (!job || job.status !== "running") return; if (!ctx) return;
const { job } = ctx;
const chat = chatStore.getChat(job.chatId); const input = (passedInput ?? resolveInput(job, passedInput)) as VisualToolInput | undefined;
if (!chat) {
jobStore.updateJob(jobId, { status: "error", error: `Chat not found: ${job.chatId}`, activity: "failed" });
return;
}
const input = findToolPart(chat.messages, job.toolCallId)?.input as VisualToolInput | undefined;
const brief = typeof input?.brief === "string" ? input.brief : ""; const brief = typeof input?.brief === "string" ? input.brief : "";
try { try {
@@ -206,70 +243,33 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const result = await runVisualMaker( const result = await runVisualMaker(
{ brief }, { brief },
{ {
model: opts.model, model,
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }), onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
}, },
); );
fulfillPart(job.chatId, job.toolCallId, result); if (isVisualFailure(result)) jobStore.updateJob(jobId, { error: result.notes ?? "visual build failed" });
if (isVisualFailure(result)) { await finishJob(jobId, result);
jobStore.updateJob(jobId, { status: "error", error: result.notes ?? "visual build failed", activity: "failed" });
} else {
jobStore.updateJob(jobId, { status: "done", result });
}
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
const failure: VisualResult = { svg: "", notes: `FAILED: ${reason}` }; jobStore.updateJob(jobId, { error: reason });
fulfillPart(job.chatId, job.toolCallId, failure); await finishJob(jobId, { svg: "", notes: `FAILED: ${reason}` });
jobStore.updateJob(jobId, { status: "error", error: reason, activity: "failed" });
} }
} }
// ─── helpers ───────────────────────────────────────────────────────────── return { runJob };
function findToolPart(messages: UIMessage[], toolCallId: string) {
for (const m of messages) {
for (const part of m.parts) {
if (isToolUIPart(part) && part.toolCallId === toolCallId) return part;
}
}
return null;
}
/**
* Rewrite the tool part in the LATEST chat record to output-available.
* Synchronous read-modify-write (no awaits) — atomic within the event loop.
*/
function fulfillPart(chatId: string, toolCallId: string, output: ResearchResult | DemoResult | VisualResult): void {
const chat = chatStore.getChat(chatId);
if (!chat) return;
let found = false;
const messages = chat.messages.map((m) =>
m.role !== "assistant"
? m
: {
...m,
parts: m.parts.map((part) => {
if (isToolUIPart(part) && part.toolCallId === toolCallId && part.state === "input-available") {
found = true;
return { ...part, state: "output-available" as const, output };
}
return part;
}),
},
);
if (!found) return;
try {
chatStore.saveMessages(chatId, messages);
} catch (err) {
console.error(`[chat ${chatId}] fulfilling job part ${toolCallId} failed:`, err);
}
}
return { spawnPendingJobs, runResearchJob, runDemoJob, runVisualJob };
} }
// ─── module-level pure helpers ────────────────────────────────────────────── // ─── module-level pure helpers ──────────────────────────────────────────────
/** Failure shape (W6 brief): html === "" with "FAILED: …" notes. */
function isDemoFailure(result: DemoResult): boolean {
return typeof result.html !== "string" || result.html.length === 0;
}
function isVisualFailure(result: VisualResult): boolean {
return typeof result.svg !== "string" || result.svg.length === 0;
}
/** The activity line shown in the UI: `web_search: "query"` / `web_fetch: host`. */ /** The activity line shown in the UI: `web_search: "query"` / `web_fetch: host`. */
function activityLine(toolName: string, input: { query?: string; url?: string } | undefined): string { function activityLine(toolName: string, input: { query?: string; url?: string } | undefined): string {
if (toolName === "web_search") return `web_search: "${input?.query ?? ""}"`; if (toolName === "web_search") return `web_search: "${input?.query ?? ""}"`;
+70 -4
View File
@@ -49,8 +49,11 @@ composition, and above everything CORRECTNESS: the picture must not assert anyth
mark on the wrong corner, a vector pointing the wrong way, a point plotted at the wrong coordinate is a mark on the wrong corner, a vector pointing the wrong way, a point plotted at the wrong coordinate is a
failure even if it renders cleanly. failure even if it renders cleanly.
Your two tools: Your three tools:
- write_svg({ svg }) — write the complete <svg>…</svg> source (overwrites the previous version). - write_svg({ svg }) — write the complete <svg>…</svg> source (overwrites the previous version).
- edit_svg({ old_text, new_text }) — a surgical exact-match replacement in the current SVG:
old_text must appear EXACTLY ONCE; it is replaced by new_text. Call write_svg first. For anything
beyond a one-spot fix (new elements, restructured layout), write_svg the whole document again.
- render_svg({}) — rasterize the current SVG in headless Chrome. The render arrives as an image in the - render_svg({}) — rasterize the current SVG in headless Chrome. The render arrives as an image in the
message right after the tool result. message right after the tool result.
@@ -64,8 +67,8 @@ says nothing about whether the geometry is right or the picture is readable. Whe
- Would a learner instantly read the intended idea from this picture alone? - Would a learner instantly read the intended idea from this picture alone?
Workflow: plan the coordinate space (viewBox + margins, nothing touching the edges) → write_svg → Workflow: plan the coordinate space (viewBox + margins, nothing touching the edges) → write_svg →
render_svg → LOOK critically → fix (write_svg) and re-render until correct and clean. Never finish render_svg → LOOK critically → fix (edit_svg for one-spot fixes, write_svg otherwise) and re-render
without having looked at a render of your FINAL SVG. until correct and clean. Never finish without having looked at a render of your FINAL SVG.
SVG requirements: SVG requirements:
- A complete <svg> with explicit width/height AND a matching viewBox. - A complete <svg> with explicit width/height AND a matching viewBox.
@@ -86,6 +89,14 @@ export function createVisualTools() {
svg: z.string().describe('Complete <svg xmlns="…">…</svg> markup.'), svg: z.string().describe('Complete <svg xmlns="…">…</svg> markup.'),
}), }),
}), }),
edit_svg: tool({
description:
"Replace one exact spot in the current SVG: old_text must appear EXACTLY ONCE and is replaced by new_text. Call write_svg first. For larger changes, write_svg the whole document.",
inputSchema: z.object({
old_text: z.string().describe("The exact text to replace — must match exactly once in the current SVG."),
new_text: z.string().describe("The replacement text."),
}),
}),
render_svg: tool({ render_svg: tool({
description: "Render the current SVG to a PNG and show it to you for inspection. No arguments.", description: "Render the current SVG to a PNG and show it to you for inspection. No arguments.",
inputSchema: z.object({}), inputSchema: z.object({}),
@@ -164,6 +175,36 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
currentSvg = svg; currentSvg = svg;
opts.onActivity?.(`write_svg: ${(svg.length / 1024).toFixed(1)} KB`); opts.onActivity?.(`write_svg: ${(svg.length / 1024).toFixed(1)} KB`);
toolResults.push(toolText(call.toolCallId, call.toolName, `Saved (${svg.length} bytes). Call render_svg to look at it.`)); toolResults.push(toolText(call.toolCallId, call.toolName, `Saved (${svg.length} bytes). Call render_svg to look at it.`));
} else if (call.toolName === "edit_svg") {
const edit = call.input as { old_text?: unknown; new_text?: unknown };
if (!currentSvg) {
toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: nothing to edit — call write_svg first."));
continue;
}
if (typeof edit.old_text !== "string" || typeof edit.new_text !== "string" || !edit.old_text) {
toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: expected old_text (non-empty) and new_text strings."));
continue;
}
const occurrences = currentSvg.split(edit.old_text).length - 1;
if (occurrences === 0) {
toolResults.push(
toolText(
call.toolCallId,
call.toolName,
`ERROR: old_text not found. Closest line for reference:\n${snippetAround(currentSvg, edit.old_text)}`,
),
);
continue;
}
if (occurrences > 1) {
toolResults.push(
toolText(call.toolCallId, call.toolName, `ERROR: old_text appears ${occurrences} times — it must match EXACTLY ONCE. Include more surrounding context to make it unique.`),
);
continue;
}
currentSvg = currentSvg.replace(edit.old_text, edit.new_text);
opts.onActivity?.("edit_svg: applied");
toolResults.push(toolText(call.toolCallId, call.toolName, "OK — replaced 1 occurrence. Call render_svg to look at the edited SVG."));
} else if (call.toolName === "render_svg") { } else if (call.toolName === "render_svg") {
if (!currentSvg) { if (!currentSvg) {
toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: nothing to render — call write_svg first.")); toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: nothing to render — call write_svg first."));
@@ -181,7 +222,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
toolResults.push(toolText(call.toolCallId, call.toolName, `RENDER ERROR (the SVG may be malformed): ${msg}. Fix it with write_svg and render again.`)); toolResults.push(toolText(call.toolCallId, call.toolName, `RENDER ERROR (the SVG may be malformed): ${msg}. Fix it with write_svg and render again.`));
} }
} else { } else {
toolResults.push(toolText(call.toolCallId, call.toolName, `ERROR: unknown tool "${call.toolName}". Use write_svg or render_svg.`)); toolResults.push(toolText(call.toolCallId, call.toolName, `ERROR: unknown tool "${call.toolName}". Use write_svg, edit_svg, or render_svg.`));
} }
} }
// Message order: assistant tool-calls → tool results → render image. // Message order: assistant tool-calls → tool results → render image.
@@ -215,6 +256,31 @@ function toolText(toolCallId: string, toolName: string, value: string): ToolResu
return { type: "tool-result", toolCallId, toolName, output: { type: "text", value } }; return { type: "tool-result", toolCallId, toolName, output: { type: "text", value } };
} }
/** A 3-line numbered context window around the closest fuzzy match for a
* failed edit — enough for the model to see what went wrong and retry
* (the pi svg_tools snippetAround semantics). */
function snippetAround(svg: string, target: string): string {
const words = target.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "(empty old_text)";
const lines = svg.split("\n");
let bestAt = -1;
let bestScore = -1;
for (let i = 0; i < lines.length; i++) {
const score = words.filter((w) => lines[i]!.includes(w)).length;
if (score > bestScore) {
bestScore = score;
bestAt = i;
}
}
if (bestAt < 0) return "(no similar line found)";
const from = Math.max(0, bestAt - 1);
const to = Math.min(lines.length, bestAt + 2);
return lines
.slice(from, to)
.map((line, idx) => `${from + idx + 1} | ${line}`)
.join("\n");
}
function finish(svg: string | null, finalText: string): VisualResult { function finish(svg: string | null, finalText: string): VisualResult {
if (!svg) { if (!svg) {
return { return {
+18 -8
View File
@@ -2,8 +2,8 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { ToolLoopAgent, stepCountIs, type LanguageModel, type ToolSet } from "ai"; import { ToolLoopAgent, stepCountIs, type LanguageModel, type ToolSet } from "ai";
import { PATHS } from "../../config.ts"; import { PATHS } from "../../config.ts";
import { createTeacherTools, jobTools, questionTools } from "./tools.ts";
import { modelFor } from "./models.ts"; import { modelFor } from "./models.ts";
import { questionTools } from "./tools.ts";
/** /**
* The teacher agent. Everything pi used to provide (agent loop, tool calling, * The teacher agent. Everything pi used to provide (agent loop, tool calling,
@@ -20,16 +20,24 @@ import { modelFor } from "./models.ts";
* src/agent/prompts/*.md fragment in sorted filename order (workers add * src/agent/prompts/*.md fragment in sorted filename order (workers add
* fragments — nobody edits shared sections of the base prompt). Prefix * fragments — nobody edits shared sections of the base prompt). Prefix
* fragments with a numeric order key: 10-plan-tree.md, 20-jobs.md, … * fragments with a numeric order key: 10-plan-tree.md, 20-jobs.md, …
*
* ONLY numeric-prefix files load — the loader enforces the convention, so
* non-fragment documentation (README.md, …) can live in the directory
* without ever leaking into the model's prompt.
*/ */
const PROMPTS_DIR = path.join(PATHS.root, "src", "agent", "prompts"); const PROMPTS_DIR = path.join(PATHS.root, "src", "agent", "prompts");
function composeInstructions(): string { /** A prompt fragment: numeric order key + .md (e.g. 20-jobs.md). Nothing else loads. */
const FRAGMENT_RE = /^\d+-[\w.-]+\.md$/;
/** Exported for tests: assert what composes into the model's prompt. */
export function composeInstructions(): string {
const base = fs.readFileSync(PATHS.teacherPrompt, "utf-8"); const base = fs.readFileSync(PATHS.teacherPrompt, "utf-8");
if (!fs.existsSync(PROMPTS_DIR)) return base; if (!fs.existsSync(PROMPTS_DIR)) return base;
const fragments = fs const fragments = fs
.readdirSync(PROMPTS_DIR) .readdirSync(PROMPTS_DIR)
.filter((f) => f.endsWith(".md")) .filter((f) => FRAGMENT_RE.test(f))
.sort() .sort()
.map((f) => fs.readFileSync(path.join(PROMPTS_DIR, f), "utf-8").trim()) .map((f) => fs.readFileSync(path.join(PROMPTS_DIR, f), "utf-8").trim())
.filter(Boolean); .filter(Boolean);
@@ -56,13 +64,15 @@ export function createTeacherAgent(model?: LanguageModel, tools?: ToolSet, graph
// Reasoning tokens stream as reasoning parts into the UI's collapsible block. // Reasoning tokens stream as reasoning parts into the UI's collapsible block.
model: model ?? modelFor("teacher"), model: model ?? modelFor("teacher"),
instructions: withGraphContext(graphContext), instructions: withGraphContext(graphContext),
// Tools are per-request (plan tools close over the injected ctx); // Tools are per-request (plan tools close over the injected ctx; job
// chat-handler passes createTeacherTools({ chatId, chatStore }). The // tools close over the job store + spawn seam) — chat-handler passes
// createTeacherTools({ chatId, chatStore, jobStore, spawn }). The
// dependency-free default is the question-only set (client-side tools, // dependency-free default is the question-only set (client-side tools,
// no storage needed) — used when tests pass a bare mock model. // no storage needed) — used when tests pass a bare mock model.
tools: tools ?? { ...questionTools, ...jobTools() }, tools: tools ?? { ...questionTools },
// Question and job tools are client-side — a call ends the server turn // Question tools are client-side (a call ends the server turn);
// automatically. stepCountIs is just the safety net. // job tools resolve immediately (fire-and-forget ack). stepCountIs is
// just the safety net.
stopWhen: stepCountIs(20), stopWhen: stepCountIs(20),
}); });
} }
+19 -14
View File
@@ -1,10 +1,11 @@
import { tool, type ToolSet } from "ai"; import { tool, type ToolSet } from "ai";
import { askInputSchema, quizInputSchema } from "../core/question-core.ts"; import { askInputSchema, quizInputSchema } from "../core/question-core.ts";
import type { ChatStore } from "../core/ports.ts"; import type { JobRecord } from "../core/contracts.ts";
import { jobTools } from "./tools/jobs.ts"; import type { JobStore } from "../core/ports.ts";
import { jobTools, type DemoInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts";
import { planTools, type PlanToolContext } from "./tools/plan.ts";
export { jobTools }; export { jobTools };
import { planTools } from "./tools/plan.ts";
/** /**
* The teacher's tools, composed per request by createTeacherTools. * The teacher's tools, composed per request by createTeacherTools.
@@ -17,8 +18,10 @@ import { planTools } from "./tools/plan.ts";
* the prompt docs exactly; keep them in sync. * the prompt docs exactly; keep them in sync.
* - planTools (src/agent/tools/plan.ts): SERVER-SIDE, mutate the chat's plan * - planTools (src/agent/tools/plan.ts): SERVER-SIDE, mutate the chat's plan
* tree (needs the chatId closure — hence the per-request factory). * tree (needs the chatId closure — hence the per-request factory).
* - jobTools (src/agent/tools/jobs.ts): CLIENT-SIDE semantics, server-side * - jobTools (src/agent/tools/jobs.ts): SERVER-EXECUTED dispatch — the call
* fulfillment by background sub-agents (see src/core/contracts.ts). * resolves immediately with a fire-and-forget ack, the background runner
* does the work, and the result arrives later as a steer message
* (contracts.ts "THE ASYNC PROTOCOL").
*/ */
export const quizTool = tool({ export const quizTool = tool({
@@ -51,23 +54,25 @@ export const questionTools = {
ask_user_question: askUserQuestionTool, ask_user_question: askUserQuestionTool,
}; };
export interface TeacherToolContext { export interface TeacherToolContext extends PlanToolContext {
chatId: string; /** Job-tool wiring (sub-agent dispatch): the job store + the runner's
/** The storage port plan tools persist through — the composition root * spawn seam. The composition root injects both. */
* (chat-handler) injects the wired adapter; the agent layer never jobStore: JobStore;
* imports one. */ /** Fire the background runner for a freshly created job. The tool input
chatStore: ChatStore; * rides along — the spawn fires mid-stream, before the tool part is
* persisted to the transcript (a transcript scan would race it). */
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput) => void;
} }
/** /**
* Per-request tool registry. Plan tools close over the ctx (chatId + the * Per-request tool registry. Plan tools close over the ctx (chatId + the
* injected ChatStore) for persistence; question and job tools are * injected ChatStore) for persistence; question tools are context-free;
* context-free. * job tools close over the job store + spawn seam.
*/ */
export function createTeacherTools(ctx: TeacherToolContext): ToolSet { export function createTeacherTools(ctx: TeacherToolContext): ToolSet {
return { return {
...questionTools, ...questionTools,
...planTools(ctx), ...planTools(ctx),
...jobTools(), ...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }),
}; };
} }
+83 -42
View File
@@ -1,16 +1,29 @@
import { tool, type ToolSet } from "ai"; import { tool, type ToolSet } from "ai";
import { z } from "zod"; import { z } from "zod";
import type { JobRecord } from "../../core/contracts.ts";
import type { JobStore } from "../../core/ports.ts";
/** /**
* Async sub-agent job tools (W2: research; W6 adds demo + visual). * Async sub-agent job tools (vision §2.5; W2/W6 kinds + the pi-model
* CLIENT-SIDE semantics (no execute) — exactly like quiz/ask: the tool call * protocol). SERVER-EXECUTED: each call creates the job record, fires the
* streams to the browser in `input-available` state and the server turn ends * background runner (via the injected spawn seam — the agent layer never
* there. chat-handler's onFinish spawns a background runner that fulfills * imports one), and resolves IMMEDIATELY with a fire-and-forget ack. The
* the part server-side; the client polls /api/jobs and the continuation * teacher's turn does NOT end at the call; the result arrives later as a
* resumes the teacher with the result as a normal tool output. * steer message that wakes the teacher in a new turn. See contracts.ts
* See src/core/contracts.ts "THE ASYNC PROTOCOL". * "THE ASYNC PROTOCOL".
*/ */
/** What the job tools need at execute time (injected by the composition root). */
export interface JobToolContext {
chatId: string;
/** Creates the job record (deduped by toolCallId). */
jobStore: JobStore;
/** Fire the background runner for a fresh job — fire-and-forget. The
* input rides along because the spawn happens MID-STREAM, before
* onFinish persists the tool part — a transcript scan would race it. */
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput) => void;
}
export const researchInputSchema = z.object({ export const researchInputSchema = z.object({
question: z question: z
.string() .string()
@@ -21,15 +34,6 @@ export const researchInputSchema = z.object({
.describe("Why you need this / what the lesson needs it for — focuses the brief. One or two sentences."), .describe("Why you need this / what the lesson needs it for — focuses the brief. One or two sentences."),
}); });
export const researchTool = tool({
description:
"Dispatch a background web-research agent: fact verification, current data, or niche specifics you are not certain about. " +
"The call is ASYNC — your turn ENDS here, so say everything you want to say first; the research brief arrives later as " +
"this tool's output and the lesson resumes from it. Cite the brief's sources when you use them. " +
"NEVER use it for things you know cold, internal reasoning, or math.",
inputSchema: researchInputSchema,
});
export const demoInputSchema = z.object({ export const demoInputSchema = z.object({
title: z.string().describe("Short title for the demo card, e.g. 'Damped harmonic oscillator'."), title: z.string().describe("Short title for the demo card, e.g. 'Damped harmonic oscillator'."),
brief: z brief: z
@@ -40,16 +44,6 @@ export const demoInputSchema = z.object({
), ),
}); });
export const demoTool = tool({
description:
"Dispatch a background sub-agent that BUILDS and headlessly TESTS an interactive teaching instrument — a single-file HTML " +
"demo (parameter sweep, simulation, animation, sampling) rendered live in the lesson. Use it when the concept is DYNAMIC: " +
"something to sweep, animate, or sample is better experienced than described. " +
"The call is ASYNC — your turn ENDS here, so say everything you want to say first; the tested demo arrives later as this " +
"tool's output and the lesson resumes with it. Write a precise brief: concept, controls, interactions, style.",
inputSchema: demoInputSchema,
});
export const visualInputSchema = z.object({ export const visualInputSchema = z.object({
brief: z brief: z
.string() .string()
@@ -59,26 +53,73 @@ export const visualInputSchema = z.object({
), ),
}); });
export const visualTool = tool({ /**
description: * The per-call dispatch: create the job (idempotent by toolCallId), fire the
"Dispatch a background sub-agent that hand-authors ONE precise SVG figure, renders it, LOOKS at the render, and iterates " + * runner, and return the ack. Shared by all three tools — only kind differs.
"until correct. Use it for exact spatial pictures auto-layout diagrams can't do: coordinate geometry, number lines, vectors, " + */
"function plots, physical layouts. For flows/relations/taxonomies write mermaid inline instead. " + function dispatch(
"The call is ASYNC — your turn ENDS here, so say everything you want to say first; the verified figure arrives later as this " + ctx: JobToolContext,
"tool's output and the lesson resumes with it.", kind: JobRecord["kind"],
inputSchema: visualInputSchema, toolCallId: string,
}); ack: string,
input: ResearchInput | DemoInput | VisualInput,
): string {
const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId);
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId });
if (!existing) ctx.spawn(job, input);
return `Dispatched (job ${job.id}). ${ack}`;
}
const RESEARCH_ACK =
"This call is fire-and-forget: continue your turn and keep teaching — do not wait, poll, or mention waiting. " +
"The researched brief arrives as a new message when ready and the lesson resumes from it; cite its sources when you use them.";
const DEMO_ACK =
"This call is fire-and-forget: continue your turn and keep teaching — do not wait, poll, or mention waiting. " +
"The tested demo arrives as a new message when ready and is rendered in the lesson; teach with it then.";
const VISUAL_ACK =
"This call is fire-and-forget: continue your turn and keep teaching — do not wait, poll, or mention waiting. " +
"The verified figure arrives as a new message when ready and is rendered in the lesson; reference it then.";
const RESEARCH_DESCRIPTION =
"Dispatch a background web-research agent: fact verification, current data, or niche specifics you are not certain about. " +
"The call is ASYNC and fire-and-forget — it resolves immediately and your turn continues; the research brief arrives later " +
"as a new message and the lesson resumes from it. Cite the brief's sources when you use them. " +
"NEVER use it for things you know cold, internal reasoning, or math.";
const DEMO_DESCRIPTION =
"Dispatch a background sub-agent that BUILDS and headlessly TESTS an interactive teaching instrument — a single-file HTML " +
"demo (parameter sweep, simulation, animation, sampling) rendered live in the lesson. Use it when the concept is DYNAMIC: " +
"something to sweep, animate, or sample is better experienced than described. " +
"The call is ASYNC and fire-and-forget — it resolves immediately and your turn continues; the tested demo arrives later " +
"as a new message. Write a precise brief: concept, controls, interactions, style.";
const VISUAL_DESCRIPTION =
"Dispatch a background sub-agent that hand-authors ONE precise SVG figure, renders it, LOOKS at the render, and iterates " +
"until correct. Use it for exact spatial pictures auto-layout diagrams can't do: coordinate geometry, number lines, vectors, " +
"function plots, physical layouts. For flows/relations/taxonomies write mermaid inline instead. " +
"The call is ASYNC and fire-and-forget — it resolves immediately and your turn continues; the verified figure arrives later " +
"as a new message.";
/** /**
* Per-request job tool registry. Context-free (the runner resolves * Per-request job tool registry: each tool closes over the composition root's
* everything from the chat record by toolCallId). Takes no dependencies — * context (chatId + jobStore + the spawn seam), so concurrent chats never
* part of the dependency-free default registry in teacher-agent.ts. * share state. chat-handler wires it each turn; tests wire their own.
*/ */
export function jobTools(): ToolSet { export function jobTools(ctx: JobToolContext): ToolSet {
return { return {
research: researchTool, research: tool({
demo: demoTool, description: RESEARCH_DESCRIPTION,
visual: visualTool, inputSchema: researchInputSchema,
execute: async (input, { toolCallId }) => dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
}),
demo: tool({
description: DEMO_DESCRIPTION,
inputSchema: demoInputSchema,
execute: async (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input),
}),
visual: tool({
description: VISUAL_DESCRIPTION,
inputSchema: visualInputSchema,
execute: async (input, { toolCallId }) => dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
}),
}; };
} }
+10 -3
View File
@@ -1,6 +1,7 @@
import { tool, type ToolSet } from "ai"; import { tool, type ToolSet } from "ai";
import { z } from "zod"; import { z } from "zod";
import type { PlanToolResult, PlanTree } from "../../core/contracts.ts"; import type { PlanToolResult, PlanTree } from "../../core/contracts.ts";
import type { ChatStore } from "../../core/ports.ts";
import { import {
addEdge, addEdge,
addNode, addNode,
@@ -13,7 +14,13 @@ import {
setCurrent, setCurrent,
updateNode, updateNode,
} from "../../core/plan-tree.ts"; } from "../../core/plan-tree.ts";
import type { TeacherToolContext } from "../tools.ts";
/** What the plan tools close over (the plan-tree half of the tool context). */
export interface PlanToolContext {
chatId: string;
/** The storage port plan tools persist through. */
chatStore: ChatStore;
}
/** /**
* Plan-tree tools — W1 workstream (docs/plan.md Wave 1; src/core/contracts.ts * Plan-tree tools — W1 workstream (docs/plan.md Wave 1; src/core/contracts.ts
@@ -48,7 +55,7 @@ const edgeSchema = z.object({
* empty) tree, so the model can see what it's working with and fix the call. * empty) tree, so the model can see what it's working with and fix the call.
*/ */
async function runPlanOp( async function runPlanOp(
ctx: TeacherToolContext, ctx: PlanToolContext,
op: (tree: PlanTree) => PlanTree, op: (tree: PlanTree) => PlanTree,
): Promise<PlanToolResult> { ): Promise<PlanToolResult> {
const { chatId, chatStore } = ctx; const { chatId, chatStore } = ctx;
@@ -65,7 +72,7 @@ async function runPlanOp(
} }
} }
export function planTools(ctx: TeacherToolContext): ToolSet { export function planTools(ctx: PlanToolContext): ToolSet {
return { return {
plan_init: tool({ plan_init: tool({
description: description:
+139 -26
View File
@@ -282,39 +282,59 @@ export interface JobRecord {
activity: string; activity: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** The tool output payload when done (ResearchResult | DemoResult | VisualResult). */ /** The sub-agent's result (steer-artifact payload). */
result?: unknown; result?: unknown;
error?: string; error?: string;
} }
/** /**
* THE ASYNC PROTOCOL — "client-side-tool semantics, server-side fulfillment": * THE ASYNC PROTOCOL — "ack now, steer later" (the pi sub-agents model,
* vision §2.5):
* *
* 1. Job tools (research / demo / visual) are CLIENT-SIDE tools (no execute), * 1. Job tools (research / demo / visual) are SERVER-EXECUTED. The model
* exactly like quiz/ask. The model calls one; the server turn ends with the * calls one MID-TURN; execute creates the job record, fires the
* tool part in `input-available` state. (The teacher is prompted to say its * background runner, and resolves IMMEDIATELY with an acknowledgement
* piece BEFORE calling, since its turn ends at the call.) * ("dispatched — continue your turn"). The teacher's turn does NOT end
* 2. chat-handler's onFinish (the integration point) scans the new assistant * at the call: it keeps teaching, may dispatch other independent jobs
* message for job tool parts and spawns one background runner per part * (full parallelism), and ends whenever it ends. The tool part's output
* (src/agent/subagents/…, W2/W6). The job store (src/store/jobs.ts) is * is the ack, forever.
* keyed by toolCallId.
* 3. On completion the runner REWRITES the part in the stored chat record to
* `output-available` with the job result as output (read-modify-write of
* the LATEST record — never a stale copy).
* 4. The client polls GET /api/jobs?chatId=… while any job part is pending;
* when a part flips, it re-syncs messages from the server
* (ChatActionsContext.syncFromServer) and the existing
* `sendAutomaticallyWhen` continuation fires — the teacher receives the
* result as a normal tool output and resumes. No new stream machinery.
* *
* 5. The composer gate (findPendingToolCall) covers ONLY quiz/ask — but the * 2. The background runner (src/agent/subagents/runner.ts) runs the kind's
* protocol still forbids sending a user message while a job part is * sub-agent loop — isolated context, modelFor(role), the
* pending (an unanswered tool call in the outbound list breaks provider * verify-never-one-shot doctrine (headless-tested demos, looked-at SVGs,
* conversion). So job parts ALSO gate SENDING, with a distinct affordance * sourced briefs) — tracking a live activity line in the job record.
* ("waiting for <job>…" + live activity) — the learner can keep reading, * ONE central client poll of GET /api/jobs?chatId=… (in Chat) drives every
* scrolling, and typing notes in the field; the send unlocks on arrival. * job surface: the transcript dispatch cards (live activity by
* True mid-job interjection (concurrent turns) is a documented later * toolCallId, quiet one-liner after delivery), the sub-agents sidebar
* upgrade, not v1. * panel, and the single re-sync when a job settles.
*
* 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
* and hands it to the wake pipeline (src/server/jobs.ts): a user-role
* UIMessage carrying the result — a text part with what the MODEL needs
* (the research brief, the builder's notes), plus a data-subagent-artifact
* part with what the UI renders (the full result: brief + sources /
* SVG / demo HTML). Provider conversion drops data parts, so artifact
* payloads never enter the teacher's context. metadata.subagentResult
* identifies the job; the UI renders these as sub-agent result cards,
* NEVER as learner interjections.
*
* 4. The wake pipeline delivers the steer by firing a SERVER-SIDE teacher
* turn through the same turn composition as a client request
* (handleChatRequest) — write-ahead persist, turn liveness, extraction
* trigger: one code path. A per-chat serialized queue: a steer waits
* while a turn is in flight OR a question tool part is pending (an
* unanswered client-side tool call breaks provider conversion — the
* steer fires right after the learner's continuation), then fires; a 409
* (a client turn raced in) retries. Delivery is idempotent per jobId, so
* a crashed wake can be re-entered by the stale-job recovery sweep. The
* job's status flips to done/error only AFTER the wake turn completes —
* a polling client that sees the flip re-syncs once and gets the
* finished record.
*
* 5. No pending job parts ever exist, so the composer never gates on jobs
* (the learner can interject mid-job; the steer queues behind their
* turn) and job tool outputs never trigger the client-side
* sendAutomatically continuation — that mechanism stays quiz/ask-only.
*/ */
export interface ResearchToolInput { export interface ResearchToolInput {
@@ -354,6 +374,99 @@ export interface VisualResult {
notes?: string; notes?: string;
} }
// ─── Sub-agent steer messages (THE ASYNC PROTOCOL §3–4) ─────────────────────
/** Message metadata marking a sub-agent steer (contracts.ts §3). */
export interface SubagentResultMeta {
jobId: string;
kind: JobKind;
toolCallId: string;
}
/** The data-subagent-artifact part payload: what the UI renders. */
export interface SubagentArtifactData {
kind: JobKind;
result: ResearchResult | DemoResult | VisualResult;
}
/**
* The steer message: user-role on the wire (UIMessage roles are fixed), but
* NEVER a learner interjection — metadata.subagentResult is the marker the
* UI branches on, and the artifact rides the data part (stripped by provider
* conversion, so the model sees only the text part).
*/
export interface SubagentSteerMessage extends UIMessage {
role: "user";
metadata: { subagentResult: SubagentResultMeta };
parts: [
{ type: "text"; text: string },
{ type: "data-subagent-artifact"; data: SubagentArtifactData },
];
}
/** Whether a result is the failure shape (html/svg empty, failure brief). */
export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean {
if (kind === "research") {
const r = result as ResearchResult;
return r.sources.length === 0 && /^Research failed:/i.test(r.brief);
}
if (kind === "demo") {
const r = result as DemoResult;
return typeof r.html !== "string" || r.html.length === 0;
}
const r = result as VisualResult;
return typeof r.svg !== "string" || r.svg.length === 0;
}
/** The model-facing text of a steer: what the result IS, what to do with it. */
function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): string {
if (isSubagentFailure(kind, result)) {
if (kind === "research")
return `${(result as ResearchResult).brief}\n\nContinue with what you can establish and flag the uncertainty to the learner.`;
if (kind === "demo")
return `The demo "${(result as DemoResult).title}" could not be built: ${
(result as DemoResult).notes ?? "unknown reason"
}. Fall back to explaining in prose; if the brief was the problem, re-call demo with a tighter one.`;
return `The figure could not be made: ${(result as VisualResult).notes ?? "unknown reason"}. Fall back to prose or mermaid, or re-call visual with a tighter brief.`;
}
if (kind === "research")
return `Research complete — the researcher's full brief follows. Weave it into the lesson and cite its sources when you use its findings:\n\n${(result as ResearchResult).brief}`;
if (kind === "demo")
return `The demo "${(result as DemoResult).title}" is built, headlessly tested, and rendered in the lesson. Builder notes: ${
(result as DemoResult).notes ?? "(none)"
}.\nTeach with it — reference its controls by name and point the learner at what to try.`;
return `The figure is drawn, verified by inspection, and rendered in the lesson. Maker notes: ${
(result as VisualResult).notes ?? "(none)"
}.\nReference its elements as you teach.`;
}
/**
* Build the steer message for a finished job (pure — shared by the runner,
* the wake pipeline, and tests). Deterministic id per job makes re-delivery
* idempotent.
*/
export function buildSubagentSteer(
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
result: ResearchResult | DemoResult | VisualResult,
): SubagentSteerMessage {
return {
id: `steer_${job.id}`,
role: "user",
metadata: { subagentResult: { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId } },
parts: [
{ type: "text", text: steerText(job.kind, result) },
{ type: "data-subagent-artifact", data: { kind: job.kind, result } },
],
};
}
/** True when the transcript already carries this job's steer (idempotent delivery). */
export function steerDelivered(messages: UIMessage[], jobId: string): boolean {
return messages.some(
(m) => (m.metadata as { subagentResult?: { jobId?: string } } | undefined)?.subagentResult?.jobId === jobId,
);
}
// ─── Demo iframe bridge (W6) ──────────────────────────────────────────────── // ─── Demo iframe bridge (W6) ────────────────────────────────────────────────
// postMessage protocol between the sandboxed demo iframe and its parent card. // postMessage protocol between the sandboxed demo iframe and its parent card.
// The bridge script is auto-injected into every demo HTML by the demo-card // The bridge script is auto-injected into every demo HTML by the demo-card
+17 -6
View File
@@ -1,18 +1,29 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import type { DemoToolInput, ResearchToolInput, SubagentArtifactData, VisualToolInput } from "./contracts.ts";
import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question-core.ts"; import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question-core.ts";
/** /**
* The typed UI message for the teacher chat — gives the frontend typed tool * The typed UI message for the teacher chat — gives the frontend typed tool
* parts ("tool-quiz" / "tool-ask_user_question" with typed input/output) * parts ("tool-quiz" / "tool-ask_user_question" / "tool-research" / …) without
* without importing the server-side agent into the client bundle. * importing the server-side agent into the client bundle.
* *
* Declared explicitly (not via InferUITools) because a client-side tool has * Question tools are CLIENT-SIDE (no execute): their OUTPUT type is the
* no execute function, so its OUTPUT type lives only here: the structured * structured answer record the client builds and sends via addToolOutput.
* answer record the client builds and sends via addToolOutput. * Job tools are SERVER-EXECUTED: their output is the plain-string dispatch
* acknowledgement (the result later arrives as a steer message — see
* SubagentSteerMessage in contracts.ts, whose artifact rides the
* data-subagent-artifact part typed here as DATA_PARTS).
*/ */
export type TeacherUITools = { export type TeacherUITools = {
quiz: { input: QuizToolInput; output: QuestionAnswerData }; quiz: { input: QuizToolInput; output: QuestionAnswerData };
ask_user_question: { input: AskToolInput; output: QuestionAnswerData }; ask_user_question: { input: AskToolInput; output: QuestionAnswerData };
research: { input: ResearchToolInput; output: string };
demo: { input: DemoToolInput; output: string };
visual: { input: VisualToolInput; output: string };
}; };
export type TeacherUIMessage = UIMessage<unknown, never, TeacherUITools>; export type TeacherUIDataParts = {
"subagent-artifact": SubagentArtifactData;
};
export type TeacherUIMessage = UIMessage<unknown, TeacherUIDataParts, TeacherUITools>;
+36 -7
View File
@@ -3,9 +3,10 @@ import { createTeacherAgent, type TeacherAgent } from "../agent/teacher-agent.ts
import { createJobRunner } from "../agent/subagents/runner.ts"; import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts"; import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import { createTeacherTools } from "../agent/tools.ts"; import { createTeacherTools } from "../agent/tools.ts";
import { deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
import { stores } from "./stores.ts"; import { stores } from "./stores.ts";
import { beginTurn, endTurn } from "./turn-liveness.ts"; import { beginTurn, endTurn } from "./turn-liveness.ts";
import { buildTeacherContext, persistTurn, spawnTurnJobs, triggerExtraction } from "./turn-service.ts"; import { buildTeacherContext, persistTurn, triggerExtraction } from "./turn-service.ts";
/** /**
* One chat turn: run the teacher agent over the client's message list and * One chat turn: run the teacher agent over the client's message list and
@@ -14,14 +15,38 @@ import { buildTeacherContext, persistTurn, spawnTurnJobs, triggerExtraction } fr
* handler in app/api/chat is a one-line wrapper. * handler in app/api/chat is a one-line wrapper.
* *
* This module is the TURN COMPOSITION ROOT: it wires the storage ports, the * This module is the TURN COMPOSITION ROOT: it wires the storage ports, the
* job runner, the teacher tools, and the turn services (turn-service.ts) * job runner + wake pipeline, the teacher tools, and the turn services
* together — all actual work lives behind those seams. The background job * (turn-service.ts) together — all actual work lives behind those seams.
* runner closes over the same port objects the services see. * THE SAME COMPOSITION serves both entry points: client requests and the
* wake pipeline's server-side steers (src/server/jobs.ts) — one code path
* for write-ahead persistence, turn liveness, and the turn services.
*/ */
const jobRunner = createJobRunner(stores); const jobRunner = createJobRunner({
chatStore: stores.chatStore,
jobStore: stores.jobStore,
deliver: deliverJobResult,
});
const extraction = createExtractionPipeline(stores); const extraction = createExtractionPipeline(stores);
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
// fires its server-side teacher turns through THIS composition root, and
// crash recovery re-spawns orphaned sub-agent loops through THIS runner.
setTurnRunner(async (chatId, messages) => {
const res = await handleChatRequest(
new Request("http://localhost/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatId, messages }),
}),
);
// Drive the stream to completion server-side — nobody else will read it,
// and onFinish (persist, extraction, endTurn) only runs when it drains.
await res.arrayBuffer().catch(() => undefined);
return res.status;
});
setJobRespawn((jobId) => void jobRunner.runJob(jobId));
export async function handleChatRequest(req: Request, agent?: TeacherAgent): Promise<Response> { export async function handleChatRequest(req: Request, agent?: TeacherAgent): Promise<Response> {
let body: { chatId?: unknown; messages?: unknown }; let body: { chatId?: unknown; messages?: unknown };
try { try {
@@ -64,7 +89,12 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro
agent ?? agent ??
createTeacherAgent( createTeacherAgent(
undefined, undefined,
createTeacherTools({ chatId, chatStore: stores.chatStore }), createTeacherTools({
chatId,
chatStore: stores.chatStore,
jobStore: stores.jobStore,
spawn: (job, input) => void jobRunner.runJob(job.id, input),
}),
buildTeacherContext(stores.graphStore, chatId), buildTeacherContext(stores.graphStore, chatId),
), ),
uiMessages: body.messages as UIMessage[], uiMessages: body.messages as UIMessage[],
@@ -81,7 +111,6 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro
}, },
onFinish: ({ messages, isAborted }) => { onFinish: ({ messages, isAborted }) => {
persistTurn(stores.chatStore, chatId, messages as UIMessage[], isAborted); persistTurn(stores.chatStore, chatId, messages as UIMessage[], isAborted);
spawnTurnJobs(jobRunner, chatId, messages as UIMessage[]);
triggerExtraction(stores, extraction, chatId); triggerExtraction(stores, extraction, chatId);
// LAST: a poller that sees not-in-flight must read an already-persisted // LAST: a poller that sees not-in-flight must read an already-persisted
// record — endTurn only after persistTurn has done its job. // record — endTurn only after persistTurn has done its job.
+300 -7
View File
@@ -1,10 +1,303 @@
import { stores } from "./stores.ts";
/** /**
* Server-side job-status entry (GET /api/jobs?chatId=…, contracts.ts * THE WAKE PIPELINE (contracts.ts "THE ASYNC PROTOCOL" §4) — server-side
* "THE ASYNC PROTOCOL" §4): the client polls while any job tool part is * steer delivery for finished sub-agent jobs.
* pending, finds the job by toolCallId, renders its activity line, and *
* re-syncs the chat when the job flips to done/error. * deliverJobResult(steer) — the runner calls this on job completion. The
* steer joins a per-chat serialized queue and a pump drives it:
*
* 1. WAIT FOR IDLE: while a turn is in flight (turn-liveness) OR a
* question tool part is pending (an unanswered client-side tool
* call breaks provider conversion — the steer fires right after
* the learner's continuation), poll. A short grace after the gates
* open avoids stealing the turn a client is about to send.
* 2. FIRE: a server-side teacher turn through the SAME composition as
* a client request (the injected turn runner wraps
* handleChatRequest): write-ahead persist, liveness, extraction —
* one code path. A 409 means a client turn raced in — retry with
* backoff. Idempotent per jobId (steerDelivered): a crashed wake
* or a duplicate deliver is skipped, not duplicated.
* 3. FLIP: only now does the job go done/error — a polling client
* that sees the flip re-syncs once and gets the finished record
* (steer + the teacher's response to it).
*
* Crash recovery (recoverStaleJobs): a process restart can orphan jobs —
* "running" with a result (the wake never fired) or without one (the
* sub-agent loop itself died). The sweep re-delivers finished results
* (idempotently) and re-spawns unfinished loops, both via injected
* seams. It runs lazily off the /api/jobs poll, throttled.
*
* All mutable state lives on globalThis (the turn-liveness lesson: Next dev
* gives each route entry its own module instances — a module-level Map would
* be a different object in each).
*/ */
export const { listJobs } = stores.jobStore; import type { UIMessage } from "ai";
import {
buildSubagentSteer,
isSubagentFailure,
steerDelivered,
type JobRecord,
type SubagentSteerMessage,
} from "../core/contracts.ts";
import { findPendingToolCall } from "../core/pending.ts";
import { stores } from "./stores.ts";
import { isTurnRunning } from "./turn-liveness.ts";
/** Fires one server-side teacher turn: (chatId, messages) → HTTP-ish status. */
export type TurnRunner = (chatId: string, messages: UIMessage[]) => Promise<number>;
/** Re-runs a job's sub-agent loop (crash recovery for orphaned jobs). */
export type JobRespawn = (jobId: string) => void;
interface QueuedSteer {
steer: SubagentSteerMessage;
resolve: () => void;
reject: (reason: Error) => void;
}
interface WakeState {
turnRunner?: TurnRunner;
respawn?: JobRespawn;
/** chatId → steers awaiting delivery, in arrival order. */
queues: Map<string, QueuedSteer[]>;
/** Chats whose pump loop is currently active (serialization per chat). */
pumping: Set<string>;
/** Throttle bookkeeping for the stale-job sweep. */
lastSweepAt: number;
}
interface LhWakeGlobal {
__lhWakePipeline?: WakeState;
}
function state(): WakeState {
const g = globalThis as typeof globalThis & LhWakeGlobal;
return (g.__lhWakePipeline ??= { queues: new Map(), pumping: new Set(), lastSweepAt: 0 });
}
/** chat-handler wires this at module init: its adapter around handleChatRequest. */
export function setTurnRunner(run: TurnRunner): void {
state().turnRunner = run;
}
/** chat-handler wires this at module init: its job runner's runJob. */
export function setJobRespawn(respawn: JobRespawn): void {
state().respawn = respawn;
}
const POLL_MS = 400;
/** After the gates open, wait a beat before firing — a client that just
* answered a question is about to send its continuation; don't 409 it. */
const GRACE_MS = 1500;
/** 409 backoff: capped ramp so a busy client can't spin the pump. */
const RETRY_BACKOFF_MS = [500, 1000, 2000, 5000] as const;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function jobIdOf(steer: SubagentSteerMessage): string {
return (steer.metadata as { subagentResult: { jobId: string } }).subagentResult.jobId;
}
/**
* Hand a finished job's steer to the pipeline. Resolves when the wake turn
* has completed (and the job status flipped); rejects when delivery fails
* unrecoverably. Never rejects for queueing itself.
*/
export function deliverJobResult(steer: SubagentSteerMessage): Promise<void> {
return new Promise<void>((resolve, reject) => {
const s = state();
const chatId = chatIdOf(steer);
const queue = s.queues.get(chatId) ?? [];
queue.push({ steer, resolve, reject });
s.queues.set(chatId, queue);
void pump(chatId);
});
}
function chatIdOf(steer: SubagentSteerMessage): string {
// The chat is resolvable from the job record; the steer's metadata
// carries jobId, which indexes the store.
const jobId = jobIdOf(steer);
const job = stores.jobStore.getJob(jobId);
return job?.chatId ?? "";
}
/**
* The per-chat pump: delivers every queued steer in order, one wake turn
* each. Only one loop per chat at a time; later arrivals ride the queue.
*/
async function pump(chatId: string): Promise<void> {
if (!chatId) return;
const s = state();
if (s.pumping.has(chatId)) return;
s.pumping.add(chatId);
try {
for (let attempt = 0; ; ) {
const queue = s.queues.get(chatId);
if (!queue || queue.length === 0) {
s.queues.delete(chatId);
return;
}
const entry = queue[0]!;
const jobId = jobIdOf(entry.steer);
// Gone chat: nothing to deliver into.
const record = stores.chatStore.getChat(chatId);
if (!record) {
queue.shift();
entry.reject(new Error(`chat ${chatId} not found — cannot deliver job ${jobId}`));
continue;
}
// Idempotent delivery: the transcript already carries this steer
// (a previous wake completed, or a duplicate deliver raced in).
if (steerDelivered(record.messages, jobId)) {
queue.shift();
finalizeJob(jobId);
entry.resolve();
continue;
}
await waitForIdle(chatId);
// Re-fetch AFTER the gates open — waitForIdle can wait out a quiz
// answer (minutes), and sending the pre-wait snapshot would clobber
// the client's answer + continuation in the write-ahead persist.
const fresh = stores.chatStore.getChat(chatId);
if (!fresh) {
queue.shift();
entry.reject(new Error(`chat ${chatId} not found — cannot deliver job ${jobId}`));
continue;
}
// The runner is required — chat-handler sets it at init. If some
// module instance missed the wiring, fail loudly rather than hang.
if (!s.turnRunner) {
queue.shift();
const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)");
failJob(jobId, err);
entry.reject(err);
continue;
}
// Fire the server-side teacher turn. A 409 = a client turn raced
// in between idle and firing — back off and retry the same steer.
const status = await s.turnRunner(chatId, [...fresh.messages, entry.steer]).catch((err: unknown) => {
console.error(`[job ${jobId}] wake turn crashed:`, err);
return -1;
});
if (status === 409) {
await delay(RETRY_BACKOFF_MS[Math.min(attempt++, RETRY_BACKOFF_MS.length - 1)]!);
continue;
}
attempt = 0;
queue.shift();
if (status === 200) {
finalizeJob(jobId);
entry.resolve();
} else {
const err = new Error(`wake turn failed with status ${status}`);
failJob(jobId, err);
entry.reject(err);
}
}
} finally {
s.pumping.delete(chatId);
// A steer may have arrived between the last check and the finally —
// restart the pump rather than strand it.
if ((state().queues.get(chatId) ?? []).length > 0) void pump(chatId);
}
}
/** Flip the job to its final status now that the lesson has resumed. */
function finalizeJob(jobId: string): void {
const job = stores.jobStore.getJob(jobId);
if (!job || job.status !== "running") return;
const result = job.result;
const failed = result ? isSubagentFailure(job.kind, result as never) : true;
stores.jobStore.updateJob(jobId, {
status: failed ? "error" : "done",
activity: failed ? "failed" : "delivered",
});
}
function failJob(jobId: string, err: Error): void {
stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" });
}
/**
* Wait until the chat can take a wake turn: no turn in flight AND no
* unanswered question part (provider conversion would break). Poll; the
* learner may take arbitrarily long to answer, and that is correct — the
* steer fires right after their continuation completes.
*/
async function waitForIdle(chatId: string): Promise<void> {
for (;;) {
const record = stores.chatStore.getChat(chatId);
if (!record) return;
const blocked = isTurnRunning(chatId) || findPendingToolCall(record.messages) !== null;
if (!blocked) {
await delay(GRACE_MS);
const recheck = stores.chatStore.getChat(chatId);
if (
!isTurnRunning(chatId) &&
recheck &&
findPendingToolCall(recheck.messages) === null
) {
return;
}
}
await delay(POLL_MS);
}
}
// ─── crash recovery ──────────────────────────────────────────────────────────
/** Jobs this old with status "running" are orphans (crash/restart). */
const STALE_JOB_MS = 15 * 60_000;
const SWEEP_THROTTLE_MS = 30_000;
/**
* Lazy sweep (called off the /api/jobs poll, throttled): every stale
* "running" job either has a result (the wake never fired — re-deliver,
* idempotently) or doesn't (the loop itself died — re-spawn it).
*/
function recoverStaleJobs(): void {
const s = state();
const now = Date.now();
if (now - s.lastSweepAt < SWEEP_THROTTLE_MS) return;
s.lastSweepAt = now;
try {
for (const chat of stores.chatStore.listChats()) {
for (const job of stores.jobStore.listJobs(chat.id)) {
if (job.status !== "running") continue;
if (now - Date.parse(job.updatedAt) < STALE_JOB_MS) continue;
if (!stores.chatStore.getChat(job.chatId)) continue;
if (job.result) {
// The wake never fired (or crashed mid-flight): re-deliver.
const steer = buildSubagentSteer(job, job.result as never);
stores.jobStore.updateJob(job.id, { activity: "recovering: re-delivering" });
void deliverJobResult(steer).catch(() => {
/* deliverJobResult marks the job error on failure */
});
} else {
// The sub-agent loop itself died with the process: re-spawn.
if (!s.respawn) continue;
stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" });
s.respawn(job.id);
}
}
}
} catch (err) {
console.error("[jobs] stale-job recovery sweep failed:", err);
}
}
/** The /api/jobs entry: job list for the client poll (+ lazy recovery). */
export function listJobs(chatId: string): JobRecord[] {
if (process.env.NODE_ENV !== "test") recoverStaleJobs();
return stores.jobStore.listJobs(chatId);
}
+16 -16
View File
@@ -6,7 +6,6 @@
* *
* buildTeacherContext — graph → instructions block (the W8 read channel) * buildTeacherContext — graph → instructions block (the W8 read channel)
* persistTurn — the canonical state write (client + agent messages) * persistTurn — the canonical state write (client + agent messages)
* spawnTurnJobs — the async sub-agent protocol kick-off
* triggerExtraction — the graph write channel: fires the extraction * triggerExtraction — the graph write channel: fires the extraction
* pipeline (the sole graph writer) once a chat * pipeline (the sole graph writer) once a chat
* crosses the v1 quiz-answer threshold. The trigger * crosses the v1 quiz-answer threshold. The trigger
@@ -22,7 +21,6 @@ import { buildGraphContext } from "../core/graph-context.ts";
import { getGraph, pendingQuizAnswers } from "../core/graph.ts"; import { getGraph, pendingQuizAnswers } from "../core/graph.ts";
import type { ChatStore, GraphStore } from "../core/ports.ts"; import type { ChatStore, GraphStore } from "../core/ports.ts";
import { EXTRACTION } from "../../config.ts"; import { EXTRACTION } from "../../config.ts";
import type { JobRunner } from "../agent/subagents/runner.ts";
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts"; import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
/** /**
@@ -47,24 +45,26 @@ 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.
*/ */
export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void { /**
try { * The SDK assigns assistant-message ids CLIENT-side (useChat); the server's
chatStore.saveMessages(chatId, messages); * onFinish synthesis leaves them EMPTY. A message persisted only server-side
} catch (err) { * (the last turn's, or a wake turn's — nobody re-POSTs it with a client id)
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted }); * 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 {
* The async sub-agent protocol (contracts.ts): scan the last assistant
* message for pending job tool parts and fire the background runners —
* fire-and-forget, never awaited; the HTTP response is already gone.
*/
export function spawnTurnJobs(jobRunner: JobRunner, chatId: string, messages: UIMessage[]): void {
try { try {
jobRunner.spawnPendingJobs(chatId, messages); chatStore.saveMessages(chatId, withMessageIds(messages));
} catch (err) { } catch (err) {
console.error(`[chat ${chatId}] job spawn failed:`, err); console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
} }
} }
+256 -82
View File
@@ -11,9 +11,10 @@
* no-Chrome failure) * no-Chrome failure)
* 4. the visual-maker loop (fake model + fake headless): render-and-look * 4. the visual-maker loop (fake model + fake headless): render-and-look
* success, forced look on premature finish, never-writes failure * success, forced look on premature finish, never-writes failure
* 5. job wrappers + spawn-hook kind dispatch (demo part → demo job → * 5. job runners + dispatch (demo part → demo job → steer via a fake
* part fulfilled; visual failure path) * deliver; visual failure path; end-to-end dispatch through
* 6. tool registry wiring (client-side demo/visual tools, schemas) * handleChatRequest with the ack protocol)
* 6. tool registry wiring (server-executed dispatch tools, schemas)
* *
* Cleanup follows tests/jobs.test.ts idioms (created chat/job files removed * Cleanup follows tests/jobs.test.ts idioms (created chat/job files removed
* in the after() hook; overrides reset in finally blocks). * in the after() hook; overrides reset in finally blocks).
@@ -38,18 +39,46 @@ 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 { 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 { 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";
/** The runner with the same wired fs ports the chat-handler injects. */
const jobRunner = createJobRunner({ chatStore, jobStore });
import { handleChatRequest } from "../src/server/chat-handler.ts"; import { handleChatRequest } from "../src/server/chat-handler.ts";
import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
/**
* A fake deliver seam + a runner wired like the chat-handler's, per test:
* capture the steer and mimic the pipeline's final status flip so the job
* record reads exactly as production does after the wake turn completes.
*/
function testRunner() {
const steers: SubagentSteerMessage[] = [];
const runner = createJobRunner({
chatStore,
jobStore,
deliver: async (steer) => {
steers.push(steer);
const jobId = steer.metadata.subagentResult.jobId;
const job = jobStore.getJob(jobId);
if (job && job.status === "running") {
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
jobStore.updateJob(jobId, { status: failed ? "error" : "done", activity: failed ? "failed" : "delivered" });
}
},
});
return { steers, runner };
}
import { artifactRenderers } from "../components/artifacts/index.ts"; import { artifactRenderers } from "../components/artifacts/index.ts";
const JOBS_DIR = path.join(PATHS.data, "jobs"); const JOBS_DIR = path.join(PATHS.data, "jobs");
@@ -217,68 +246,152 @@ 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", () => { it("renders the verified svg inline (sanitized); the maker's notes are glue and stay hidden", () => {
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", () => {
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(
<VisualCard part={{ type: "tool-visual", state: "output-available", toolCallId: "c", output: { svg, notes: "The unit circle." } } as never} />, { id: "jv", kind: "visual", toolCallId: "cv" },
{ svg, notes: "Looking at the render, the diagram is clean and accurate." },
); );
assert.match(html, /The unit circle\./); const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
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/);
assert.match(html, /svg\]:w-full/, "svg scales to the display area's full width");
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
}); });
it("renders the failure note with no svg when the build FAILED", () => { it("renders a quiet Research-complete badge — the full brief stays out of the transcript (but persisted)", () => {
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, a very long brief that would clutter the transcript.",
sources: [
{ title: "Britannica", url: "https://britannica.com/plato" },
{ title: "IEP", url: "https://iep.utm.edu/plato/" },
],
},
); );
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
assert.match(html, /Research/);
assert.match(html, /complete/);
assert.match(html, /2 sources/);
assert.ok(!html.includes("founded the Academy"), "the long brief never renders inline");
assert.ok(!html.includes("britannica"), "source links stay out too — the wake turn cites them");
});
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={[]} />), "");
}); });
}); });
@@ -399,6 +512,37 @@ describe("visual maker (fake model + fake headless)", () => {
assert.equal(headless.svgRenders.length, 1, "the gate rendered the svg once itself"); assert.equal(headless.svgRenders.length, 1, "the gate rendered the svg once itself");
}); });
it("edit_svg: exact-match single replacement, with fuzzy context on a miss", async () => {
const headless = fakeHeadless();
const model = scriptedModel([
{ toolCalls: [{ toolCallId: "w1", toolName: "write_svg", input: { svg: SVG_1 } }] },
{ toolCalls: [{ toolCallId: "r1", toolName: "render_svg", input: {} }] },
// A miss: old_text not in the document (error fed back, nothing changed)
{ toolCalls: [{ toolCallId: "e1", toolName: "edit_svg", input: { old_text: "NOT-PRESENT", new_text: "x" } }] },
// A hit: exactly one occurrence — applied
{ toolCalls: [{ toolCallId: "e2", toolName: "edit_svg", input: { old_text: '<line x1="10"', new_text: '<line x1="20"' } }] },
{ toolCalls: [{ toolCallId: "r2", toolName: "render_svg", input: {} }] },
{ text: "Moved the start of the line." },
]);
const result = await runVisualMaker({ brief: "b" }, { model, headless });
assert.equal(result.svg, SVG_1.replace('<line x1="10"', '<line x1="20"'), "the edit was applied to the delivered svg");
assert.equal(result.notes, "Moved the start of the line.");
assert.equal(headless.svgRenders.length, 2, "re-rendered after the edit (the look gate saw the change)");
});
it("edit_svg before write_svg errors and never edits nothing into a deliverable", async () => {
const headless = fakeHeadless();
const model = scriptedModel([
// Nothing written yet — the edit must error, not crash the loop
{ toolCalls: [{ toolCallId: "e0", toolName: "edit_svg", input: { old_text: "svg", new_text: "x" } }] },
{ text: "Cannot proceed." },
]);
const result = await runVisualMaker({ brief: "b" }, { model, headless });
assert.equal(result.svg, "", "the failed edit produced no deliverable");
assert.match(result.notes ?? "", /never wrote an SVG/);
});
it("feeds render errors back to the model instead of crashing", async () => { it("feeds render errors back to the model instead of crashing", async () => {
const headless = fakeHeadless({ renderFailures: 1 }); const headless = fakeHeadless({ renderFailures: 1 });
const model = scriptedModel([ const model = scriptedModel([
@@ -427,7 +571,7 @@ describe("visual maker (fake model + fake headless)", () => {
}); });
}); });
// ─── 5. job wrappers + spawn dispatch ──────────────────────────────────────── // ─── 5. job runners + dispatch ───────────────────────────────────────────────
const DEMO_INPUT = { title: "Compound interest", brief: "Slider for rate, curve of growth over 30 years." }; const DEMO_INPUT = { title: "Compound interest", brief: "Slider for rate, curve of growth over 30 years." };
@@ -448,8 +592,9 @@ async function drain(res: Response): Promise<string> {
return res.text(); return res.text();
} }
describe("demo/visual job runners", () => { describe("demo/visual job runners", () => {
it("runDemoJob fulfills the part with a tested DemoResult and marks the job done", async () => { it("runJob completes a demo: result on the job, steer via deliver, ack stays on the part", async () => {
const chat = chatStore.createChat("Test: demo runner"); const chat = chatStore.createChat("Test: demo runner");
createdChats.push(chat.id); createdChats.push(chat.id);
chatStore.saveMessages(chat.id, [ chatStore.saveMessages(chat.id, [
@@ -457,42 +602,52 @@ describe("demo/visual job runners", () => {
{ {
id: "a1", id: "a1",
role: "assistant", role: "assistant",
parts: [{ type: "tool-demo", toolCallId: "call-d1", state: "input-available", input: DEMO_INPUT }], parts: [{ type: "tool-demo", toolCallId: "call-d1", state: "output-available", input: DEMO_INPUT, output: "Dispatched." }],
}, },
] as never); ] as never);
const job = jobStore.createJob({ kind: "demo", chatId: chat.id, toolCallId: "call-d1" }); const job = jobStore.createJob({ kind: "demo", chatId: chat.id, toolCallId: "call-d1" });
createdJobs.push(job.id); createdJobs.push(job.id);
// runDemoJob has no headless injection point — the module override is the seam. // runJob has no headless injection point — the module override is the seam.
headlessOverride.current = fakeHeadless(); headlessOverride.current = fakeHeadless();
demoModelOverride.current = scriptedModel([
{ toolCalls: [{ toolCallId: "w1", toolName: "write_demo", input: { html: GOOD_HTML } }] },
{ toolCalls: [{ toolCallId: "r1", toolName: "run_demo", input: {} }] },
{ text: "Sweep the rate slider." },
]);
const { steers, runner } = testRunner();
try { try {
await jobRunner.runDemoJob(job.id, { await runner.runJob(job.id);
model: scriptedModel([
{ toolCalls: [{ toolCallId: "w1", toolName: "write_demo", input: { html: GOOD_HTML } }] },
{ toolCalls: [{ toolCallId: "r1", toolName: "run_demo", input: {} }] },
{ text: "Sweep the rate slider." },
]),
});
} finally { } finally {
headlessOverride.current = undefined; headlessOverride.current = undefined;
demoModelOverride.current = undefined;
} }
const done = jobStore.getJob(job.id)!; const done = jobStore.getJob(job.id)!;
assert.equal(done.status, "done"); assert.equal(done.status, "done");
assert.equal(done.activity, "run_demo: clean", "final activity line recorded"); assert.equal(done.activity, "delivered", "the fake deliver flips status like the wake pipeline");
const result = done.result as { title: string; html: string; tested: boolean }; const result = done.result as { title: string; html: string; tested: boolean; notes?: string };
assert.equal(result.title, "Compound interest"); assert.equal(result.title, "Compound interest");
assert.equal(result.html, GOOD_HTML);
assert.equal(result.tested, true); assert.equal(result.tested, true);
// The steer carries the artifact for the renderer.
assert.equal(steers.length, 1, "exactly one steer");
const data = steers[0]!.parts[1];
assert.ok(data.type === "data-subagent-artifact");
assert.equal(data.data.kind, "demo");
assert.deepEqual(data.data.result, result);
// The tool part keeps the DISPATCH ACK forever — the result never
// rewrites it (the teacher gets the result once, via the steer).
const saved = chatStore.getChat(chat.id)!; const saved = chatStore.getChat(chat.id)!;
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo"); const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
assert.ok(part && part.type === "tool-demo", "demo tool part persisted"); assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
assert.equal(part.state, "output-available", "part fulfilled"); assert.equal(typeof (part as { output?: unknown }).output, "string", "part output is the ack, not the result");
assert.equal((part as { output?: { html?: string } }).output?.html, GOOD_HTML);
}); });
it("runVisualJob fulfills the part even on failure and marks the job error", async () => { it("runJob still steers on visual failure and the job lands error", async () => {
const chat = chatStore.createChat("Test: visual runner failure"); const chat = chatStore.createChat("Test: visual runner failure");
createdChats.push(chat.id); createdChats.push(chat.id);
chatStore.saveMessages(chat.id, [ chatStore.saveMessages(chat.id, [
@@ -500,33 +655,38 @@ describe("demo/visual job runners", () => {
{ {
id: "a1", id: "a1",
role: "assistant", role: "assistant",
parts: [{ type: "tool-visual", toolCallId: "call-v1", state: "input-available", input: { brief: "b" } }], parts: [{ type: "tool-visual", toolCallId: "call-v1", state: "output-available", input: { brief: "b" }, output: "Dispatched." }],
}, },
] as never); ] as never);
const job = jobStore.createJob({ kind: "visual", chatId: chat.id, toolCallId: "call-v1" }); const job = jobStore.createJob({ kind: "visual", chatId: chat.id, toolCallId: "call-v1" });
createdJobs.push(job.id); createdJobs.push(job.id);
await jobRunner.runVisualJob(job.id, { model: scriptedModel([{ text: "cannot" }]) }); visualModelOverride.current = scriptedModel([{ text: "cannot" }]);
const { steers, runner } = testRunner();
try {
await runner.runJob(job.id);
} finally {
visualModelOverride.current = undefined;
}
const errored = jobStore.getJob(job.id)!; const errored = jobStore.getJob(job.id)!;
assert.equal(errored.status, "error"); assert.equal(errored.status, "error");
assert.match(errored.error ?? "", /never wrote an SVG/); assert.match(errored.error ?? "", /never wrote an SVG/);
const saved = chatStore.getChat(chat.id)!; assert.ok(steers[0], "failure still delivers a steer — the teacher learns the outcome");
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-visual"); const data = steers[0]!.parts[1];
assert.ok(part && part.type === "tool-visual", "visual tool part persisted"); assert.ok(data.type === "data-subagent-artifact");
assert.equal(part.state, "output-available", "failure still fulfills the part"); const result = data.data.result as { svg: string; notes?: string };
const output = (part as { output?: { svg?: string; notes?: string } }).output!; assert.equal(result.svg, "");
assert.equal(output.svg, ""); assert.match(result.notes ?? "", /^FAILED:/);
assert.match(output.notes ?? "", /^FAILED:/);
}); });
it("the spawn hook dispatches a demo part to the demo runner (kind→runner map)", async () => { it("dispatch through a teacher turn: the call acks mid-turn and the runner completes in the background", async () => {
const chat = chatStore.createChat("Test: demo spawn dispatch"); const chat = chatStore.createChat("Test: demo spawn dispatch");
createdChats.push(chat.id); createdChats.push(chat.id);
// A teacher that calls demo on turn 1 (scripted stream, like jobs.test.ts). // A teacher that calls demo on turn 1, then continues in the SAME turn.
let turn = 0; let turn = 0;
const teacher = new MockLanguageModelV4({ const teacher = new MockLanguageModelV4({
doStream: async () => { doStream: async () => {
@@ -552,12 +712,15 @@ describe("demo/visual job runners", () => {
}, },
}); });
// The overrides must be live BEFORE the request — dispatch fires the
// runner mid-stream, inside the tool execute.
demoModelOverride.current = scriptedModel([ demoModelOverride.current = scriptedModel([
{ toolCalls: [{ toolCallId: "w1", toolName: "write_demo", input: { html: GOOD_HTML } }] }, { toolCalls: [{ toolCallId: "w1", toolName: "write_demo", input: { html: GOOD_HTML } }] },
{ toolCalls: [{ toolCallId: "r1", toolName: "run_demo", input: {} }] }, { toolCalls: [{ toolCallId: "r1", toolName: "run_demo", input: {} }] },
{ text: "Sweep it." }, { text: "Sweep it." },
]); ]);
headlessOverride.current = fakeHeadless(); headlessOverride.current = fakeHeadless();
const { steers, runner } = testRunner();
try { try {
await drain( await drain(
@@ -570,28 +733,38 @@ describe("demo/visual job runners", () => {
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me compounding" }] }], messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me compounding" }] }],
}), }),
}), }),
createTeacherAgent(teacher), createTeacherAgent(
teacher,
createTeacherTools({
chatId: chat.id,
chatStore,
jobStore,
spawn: (job, input) => void runner.runJob(job.id, input),
}),
),
), ),
); );
const job = jobStore.findJobByToolCallId(chat.id, "call-d2"); const job = jobStore.findJobByToolCallId(chat.id, "call-d2");
assert.ok(job, "spawn hook created a job for the demo part"); assert.ok(job, "dispatch created a job at tool-execute time");
assert.equal(job!.kind, "demo"); assert.equal(job!.kind, "demo");
createdJobs.push(job!.id); createdJobs.push(job!.id);
// The background runner (fire-and-forget) completes with the overrides. // The background runner (fire-and-forget) completes with the overrides.
await waitFor(() => jobStore.getJob(job!.id)!.status !== "running", 5000, "demo job completion"); await waitFor(() => jobStore.getJob(job!.id)!.status !== "running", 8000, "demo job completion");
const done = jobStore.getJob(job!.id)!; const done = jobStore.getJob(job!.id)!;
assert.equal(done.status, "done"); assert.equal(done.status, "done");
assert.equal(done.activity, "run_demo: clean");
const saved = chatStore.getChat(chat.id)!; const saved = chatStore.getChat(chat.id)!;
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo"); const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
assert.ok(part && part.type === "tool-demo", "demo tool part persisted"); assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
assert.equal(part.state, "output-available", "demo part fulfilled by the background runner"); assert.equal(part.state, "output-available", "the part resolved with the ack at dispatch time");
assert.match(String((part as { output?: unknown }).output), /fire-and-forget/);
// Idempotency: re-running the spawn hook creates no second job. assert.equal(steers.length, 1, "one job, one steer");
jobRunner.spawnPendingJobs(chat.id, saved.messages); assert.equal(steers[0]!.metadata.subagentResult.jobId, job!.id);
// Idempotency: dispatch dedupes by toolCallId — no second job.
assert.equal(jobStore.listJobs(chat.id).length, 1, "no duplicate job for the same tool part"); assert.equal(jobStore.listJobs(chat.id).length, 1, "no duplicate job for the same tool part");
} finally { } finally {
demoModelOverride.current = undefined; demoModelOverride.current = undefined;
@@ -603,11 +776,12 @@ describe("demo/visual job runners", () => {
// ─── 6. registry wiring ────────────────────────────────────────────────────── // ─── 6. registry wiring ──────────────────────────────────────────────────────
describe("tool + renderer registry wiring", () => { describe("tool + renderer registry wiring", () => {
it("demo and visual are CLIENT-SIDE tools with the contract schemas", () => { it("demo and visual are SERVER-EXECUTED dispatch tools with the contract schemas", () => {
const tools = jobTools(); const { runner } = testRunner();
const tools = jobTools({ chatId: "chat_registry", jobStore, spawn: (job) => void runner.runJob(job.id) });
assert.deepEqual(Object.keys(tools).sort(), ["demo", "research", "visual"]); assert.deepEqual(Object.keys(tools).sort(), ["demo", "research", "visual"]);
for (const name of ["demo", "visual"] as const) { for (const name of ["demo", "visual", "research"] as const) {
assert.equal(typeof (tools[name] as { execute?: unknown }).execute, "undefined", `${name} has no execute — client-side like quiz/ask`); assert.equal(typeof (tools[name] as { execute?: unknown }).execute, "function", `${name} executes server-side — fire-and-forget ack per THE ASYNC PROTOCOL`);
} }
assert.equal(demoInputSchema.safeParse({ title: "T", brief: "b" }).success, true); assert.equal(demoInputSchema.safeParse({ title: "T", brief: "b" }).success, true);
assert.equal(demoInputSchema.safeParse({ brief: "b" }).success, false, "demo requires a title"); assert.equal(demoInputSchema.safeParse({ brief: "b" }).success, false, "demo requires a title");
+430 -70
View File
@@ -2,15 +2,22 @@
* Job infrastructure tests — deterministic, zero network: * Job infrastructure tests — deterministic, zero network:
* *
* 1. job store CRUD (create → get → update → list → dedup by toolCallId) * 1. job store CRUD (create → get → update → list → dedup by toolCallId)
* 2. the spawn hook: drive handleChatRequest with a scripted mock teacher * 2. steer helpers (pure, contracts.ts): buildSubagentSteer shape per kind,
* that emits a research tool call → a job record appears (runner fires * isSubagentFailure, steerDelivered
* with the mock override, so no network) * 3. dispatch (async protocol §2): drive handleChatRequest with a scripted
* 3. runner completion: a fake researcher model (web_search call → final * mock teacher that emits a research tool call → the tool part resolves
* text brief, globalThis.fetch stubbed) → the stored chat's part flips * IMMEDIATELY with the fire-and-forget ack (the teacher's turn
* to output-available with a ResearchResult-shaped output, the job is * CONTINUES — no turn break at the call), a job record exists, and the
* done with the activity recorded * test runner (fake deliver) produces the steer
* 4. runner failure: a model that throws → the part is still fulfilled * 4. runner completion: a fake researcher model (web_search call → final
* with a failure brief and the job is marked error * text brief, globalThis.fetch stubbed) → the job stores the result,
* the steer carries the brief + sources, the fake deliver flips the
* job done
* 5. runner failure: a model that throws → the failure steer still fires
* (the teacher learns the outcome) and the job lands error
* 6. the wake pipeline (src/server/jobs.ts): server-side steer delivery —
* happy path (fires the injected turn runner, flips the job), pending-
* quiz deferral, 409 retry, idempotent redelivery
* *
* Cleanup follows tests/chat-flow.test.ts: created chat/job files removed in * Cleanup follows tests/chat-flow.test.ts: created chat/job files removed in
* finally blocks / the after() hook. * finally blocks / the after() hook.
@@ -22,17 +29,26 @@ import * as path from "node:path";
import { after, describe, it } from "node:test"; import { after, describe, it } from "node:test";
import { MockLanguageModelV4, simulateReadableStream } from "ai/test"; import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider"; import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; import {
buildSubagentSteer,
isSubagentFailure,
steerDelivered,
type DemoResult,
type ResearchResult,
type SubagentSteerMessage,
type VisualResult,
} from "../src/core/contracts.ts";
import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.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 { 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";
import { createResearcherAgent } from "../src/agent/subagents/researcher.ts"; import { createResearcherAgent } from "../src/agent/subagents/researcher.ts";
import { PATHS } from "../config.ts"; import { PATHS } from "../config.ts";
/** The runner with the same wired fs ports the chat-handler injects. */
const jobRunner = createJobRunner({ chatStore, jobStore });
const JOBS_DIR = path.join(PATHS.data, "jobs"); const JOBS_DIR = path.join(PATHS.data, "jobs");
const createdChats: string[] = []; const createdChats: string[] = [];
@@ -61,6 +77,7 @@ after(() => {
/* already gone */ /* already gone */
} }
} }
researchModelOverride.current = undefined;
}); });
function usage() { function usage() {
@@ -85,6 +102,18 @@ function post(chatId: string, messages: unknown[]): Request {
}); });
} }
function waitFor(cond: () => boolean, timeoutMs = 8000, what = "condition"): Promise<void> {
return new Promise((resolve, reject) => {
const started = Date.now();
const tick = () => {
if (cond()) return resolve();
if (Date.now() - started > timeoutMs) return reject(new Error(`timeout waiting for ${what}`));
setTimeout(tick, 25);
};
tick();
});
}
// ─── 1. job store CRUD ─────────────────────────────────────────────────────── // ─── 1. job store CRUD ───────────────────────────────────────────────────────
describe("job store", () => { describe("job store", () => {
@@ -118,14 +147,89 @@ describe("job store", () => {
}); });
}); });
// ─── scripted teacher: emits a research tool call ──────────────────────────── // ─── 2. steer helpers (pure) ─────────────────────────────────────────────────
describe("steer helpers (contracts.ts)", () => {
const research: ResearchResult = { brief: "BRIEF", sources: [{ title: "s", url: "https://x.test" }] };
const demo: DemoResult = { title: "T", html: "<html></html>", tested: true, notes: "ok" };
const visual: VisualResult = { svg: "<svg/>", notes: "ok" };
it("buildSubagentSteer: user-role, metadata-marked, artifact on the data part, brief on the text part", () => {
const job = { id: "job_x", kind: "research" as const, toolCallId: "call-x" };
const steer = buildSubagentSteer(job, research);
assert.equal(steer.id, "steer_job_x", "deterministic id per job — idempotent delivery");
assert.equal(steer.role, "user");
assert.deepEqual(steer.metadata, { subagentResult: { jobId: "job_x", kind: "research", toolCallId: "call-x" } });
const [text, data] = steer.parts;
assert.equal(text.type, "text");
assert.ok(text.type === "text" && text.text.includes("BRIEF"), "the model-facing text carries the brief");
assert.equal(data.type, "data-subagent-artifact");
assert.ok(data.type === "data-subagent-artifact");
assert.equal(data.data.kind, "research");
assert.deepEqual(data.data.result, research);
});
it("isSubagentFailure: failure shapes only (empty html/svg, failure brief)", () => {
assert.equal(isSubagentFailure("research", research), false);
assert.equal(isSubagentFailure("research", { brief: "Research failed: x", sources: [] }), true);
// a brief with sources is a success even if the text mentions failure
assert.equal(isSubagentFailure("research", { brief: "Research failed: x", sources: [{ title: "s", url: "https://x" }] }), false);
assert.equal(isSubagentFailure("demo", demo), false);
assert.equal(isSubagentFailure("demo", { ...demo, html: "" }), true);
assert.equal(isSubagentFailure("visual", visual), false);
assert.equal(isSubagentFailure("visual", { ...visual, svg: "" }), true);
});
it("steerDelivered: matches on metadata.subagentResult.jobId", () => {
const steer = buildSubagentSteer({ id: "job_y", kind: "demo", toolCallId: "c" }, demo);
assert.equal(steerDelivered([], "job_y"), false);
assert.equal(steerDelivered([steer], "job_y"), true);
assert.equal(steerDelivered([steer], "job_z"), false);
});
it("failure steers tell the teacher how to recover (research/demo/visual wording)", () => {
const failResearch = buildSubagentSteer({ id: "j1", kind: "research", toolCallId: "c" }, { brief: "Research failed: API down", sources: [] });
const failDemo = buildSubagentSteer({ id: "j2", kind: "demo", toolCallId: "c" }, { title: "T", html: "", tested: true, notes: "FAILED: boom" });
const failVisual = buildSubagentSteer({ id: "j3", kind: "visual", toolCallId: "c" }, { svg: "", notes: "FAILED: boom" });
for (const steer of [failResearch, failDemo, failVisual]) {
const text = steer.parts[0];
assert.ok(text.type === "text" && !text.text.includes("rendered in the lesson"), "failure text never claims the artifact landed");
}
assert.ok(failResearch.parts[0].type === "text" && failResearch.parts[0].text.includes("flag the uncertainty"));
});
});
// ─── fake deliver (the wake pipeline's stand-in for runner tests) ───────────
/**
* A fake deliver seam: capture the steer and mimic the pipeline's final
* status flip (done/error by isSubagentFailure) so the job record reads
* exactly as it will in production after the wake turn completes.
*/
function fakeDeliver() {
const steers: SubagentSteerMessage[] = [];
return {
steers,
deliver: async (steer: SubagentSteerMessage) => {
steers.push(steer);
const jobId = steer.metadata.subagentResult.jobId;
const job = jobStore.getJob(jobId);
if (job && job.status === "running") {
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
jobStore.updateJob(jobId, { status: failed ? "error" : "done", activity: failed ? "failed" : "delivered" });
}
},
};
}
// ─── 3. dispatch: the tool call acks mid-turn and spawns ─────────────────────
const RESEARCH_INPUT = { const RESEARCH_INPUT = {
question: "What is the latest stable sigma.js version?", question: "What is the latest stable sigma.js version?",
context: "Choosing a graph rendering library for the lesson.", context: "Choosing a graph rendering library for the lesson.",
}; };
/** A teacher that calls research on turn 1; plain text thereafter. */ /** A teacher that calls research (text → call), then plain text thereafter. */
function researchCallingTeacher(): MockLanguageModelV4 { function researchCallingTeacher(): MockLanguageModelV4 {
let turn = 0; let turn = 0;
return new MockLanguageModelV4({ return new MockLanguageModelV4({
@@ -156,31 +260,59 @@ function researchCallingTeacher(): MockLanguageModelV4 {
}); });
} }
describe("spawn hook (async protocol §2)", () => { describe("dispatch (async protocol §2)", () => {
it("a pending research part in the final assistant message spawns exactly one job", async () => { it("the research call resolves immediately with an ack; the turn continues and the runner steers", async () => {
const chat = chatStore.createChat("Test: research spawn"); const chat = chatStore.createChat("Test: research dispatch");
trackChat(chat.id); trackChat(chat.id);
const agent = createTeacherAgent(researchCallingTeacher()); const delivery = fakeDeliver();
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me graph rendering" }] }; const testRunner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
await drain(await handleChatRequest(post(chat.id, [firstUser]), agent)); const agent = createTeacherAgent(
researchCallingTeacher(),
createTeacherTools({
chatId: chat.id,
chatStore,
jobStore,
spawn: (job, input) => void testRunner.runJob(job.id, input),
}),
);
// The research part is pending (input-available) in the stored chat. const restoreFetch = stubFetch();
const saved = chatStore.getChat(chat.id)!; researchModelOverride.current = researcherModel();
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-research"); try {
assert.ok(part && part.type === "tool-research", "research tool part persisted"); const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me graph rendering" }] };
assert.equal(part.state, "input-available", "job tool part stays pending until the runner fulfills it"); const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
// A job record exists for it. // The teacher's SAME turn carried on past the tool call.
const job = jobStore.findJobByToolCallId(chat.id, "call-r1"); assert.ok(body.includes("Back with the brief."), "the turn continued after the dispatch");
assert.ok(job, "spawn hook created a job record");
assert.equal(job!.kind, "research");
assert.equal(job!.status, "running");
trackJob(job!.id);
// Re-running the spawn hook (idempotency) does not create a second job. const saved = chatStore.getChat(chat.id)!;
jobRunner.spawnPendingJobs(chat.id, saved.messages); const part = saved.messages[1]!.parts.find((p) => p.type === "tool-research");
assert.equal(jobStore.listJobs(chat.id).length, 1, "no duplicate job for the same tool part"); assert.ok(part && part.type === "tool-research", "research tool part persisted");
assert.equal(part.state, "output-available", "the part resolved at dispatch time (not held pending)");
assert.equal(typeof (part as { output?: unknown }).output, "string", "the output is the ack, not the result");
assert.match(String((part as { output?: unknown }).output), /fire-and-forget/);
// The job record exists and the background runner completed it.
const job = jobStore.findJobByToolCallId(chat.id, "call-r1");
assert.ok(job, "dispatch created a job record");
trackJob(job!.id);
await waitFor(() => jobStore.getJob(job!.id)!.status !== "running", 8000, "research job completion");
const done = jobStore.getJob(job!.id)!;
assert.equal(done.status, "done");
assert.equal(done.activity, "delivered");
// The steer carries the brief + artifact for the (UI) renderer.
assert.equal(delivery.steers.length, 1, "exactly one steer — one job, one result");
const steer = delivery.steers[0]!;
assert.equal(steer.metadata.subagentResult.jobId, job!.id);
const text = steer.parts[0];
assert.ok(text.type === "text" && text.text.includes("sigma.js v3 is current"), "steer text carries the brief");
} finally {
restoreFetch();
researchModelOverride.current = undefined;
}
}); });
}); });
@@ -249,59 +381,58 @@ function stubFetch() {
} }
describe("research runner (async protocol §3)", () => { describe("research runner (async protocol §3)", () => {
it("fulfills the part with a ResearchResult and marks the job done with activity recorded", async () => { it("runJob completes the job: result + sources on the job, brief on the steer, activity recorded", async () => {
const restoreFetch = stubFetch(); const restoreFetch = stubFetch();
try { try {
const chat = chatStore.createChat("Test: runner completion"); const chat = chatStore.createChat("Test: runner completion");
trackChat(chat.id); trackChat(chat.id);
// A pending research part, as the spawn hook would find it. // A dispatched research part in the stored chat (the input is also
const messages = [ // what the runner falls back to on a crash-recovery re-spawn).
chatStore.saveMessages(chat.id, [
{ id: "u1", role: "user", parts: [{ type: "text", text: "which sigma version?" }] }, { id: "u1", role: "user", parts: [{ type: "text", text: "which sigma version?" }] },
{ {
id: "a1", id: "a1",
role: "assistant", role: "assistant",
parts: [ parts: [
{ type: "text", text: "Checking." }, { type: "text", text: "Checking." },
{ type: "tool-research", toolCallId: "call-run1", state: "input-available", input: RESEARCH_INPUT }, { type: "tool-research", toolCallId: "call-run1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." },
], ],
}, },
] as never; ] as never);
// saveMessages requires the chat to exist; use the store directly.
chatStore.saveMessages(chat.id, messages);
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-run1" }); const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-run1" });
trackJob(job.id); trackJob(job.id);
await jobRunner.runResearchJob(job.id, { model: researcherModel() }); const delivery = fakeDeliver();
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
researchModelOverride.current = researcherModel();
try {
await runner.runJob(job.id);
} finally {
researchModelOverride.current = undefined;
}
// The job is done, with the activity line from the tool call. // The job is done, with the activity line from the tool call.
const done = jobStore.getJob(job.id)!; const done = jobStore.getJob(job.id)!;
assert.equal(done.status, "done"); assert.equal(done.status, "done");
assert.equal(done.activity, 'web_search: "sigma.js latest version"', "activity line recorded"); assert.equal(done.activity, "delivered", "the fake deliver flips the status like the pipeline");
assert.ok(done.result, "result stored on the job"); const result = done.result as ResearchResult;
assert.equal(result.brief, BRIEF);
assert.ok(result.sources.length > 0, "sources parsed from the brief");
assert.equal(result.sources[0]!.url, "https://www.sigmajs.org/docs");
// The stored chat's part flipped to output-available with a // The activity trail survives on... the last recorded activity is
// ResearchResult-shaped output. // the deliver flip, but the search line was recorded during the run:
const saved = chatStore.getChat(chat.id)!; // the job store keeps only the latest; the search fired before it.
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-research"); // (We ran the search; the runner recorded it — check the store's
assert.ok(part && part.type === "tool-research"); // history via the final state only.)
assert.equal(part.state, "output-available");
const output = part.output as { brief: string; sources: { title: string; url: string }[] };
assert.equal(output.brief, BRIEF);
assert.ok(Array.isArray(output.sources) && output.sources.length > 0, "sources parsed from the brief");
assert.equal(output.sources[0]!.url, "https://www.sigmajs.org/docs");
} finally { } finally {
restoreFetch(); restoreFetch();
} }
}); });
it("a failing runner still fulfills the part (the teacher learns the outcome) and marks the job error", async () => { it("a failing runner still steers (the teacher learns the outcome) and the job lands error", async () => {
const failing = new MockLanguageModelV4({
doGenerate: async () => {
throw new Error("model exploded");
},
});
const chat = chatStore.createChat("Test: runner failure"); const chat = chatStore.createChat("Test: runner failure");
trackChat(chat.id); trackChat(chat.id);
chatStore.saveMessages(chat.id, [ chatStore.saveMessages(chat.id, [
@@ -309,26 +440,71 @@ describe("research runner (async protocol §3)", () => {
{ {
id: "a1", id: "a1",
role: "assistant", role: "assistant",
parts: [{ type: "tool-research", toolCallId: "call-err1", state: "input-available", input: RESEARCH_INPUT }], parts: [{ type: "tool-research", toolCallId: "call-err1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
}, },
] as never); ] as never);
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-err1" }); const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-err1" });
trackJob(job.id); trackJob(job.id);
await jobRunner.runResearchJob(job.id, { model: failing }); const delivery = fakeDeliver();
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
researchModelOverride.current = new MockLanguageModelV4({
doGenerate: async () => {
throw new Error("model exploded");
},
});
try {
await runner.runJob(job.id);
} finally {
researchModelOverride.current = undefined;
}
const errored = jobStore.getJob(job.id)!; const errored = jobStore.getJob(job.id)!;
assert.equal(errored.status, "error"); assert.equal(errored.status, "error");
assert.match(errored.error ?? "", /model exploded/); assert.match(errored.error ?? "", /model exploded/);
const saved = chatStore.getChat(chat.id)!; const steer = delivery.steers[0]!;
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-research"); assert.ok(steer, "failure still delivers a steer");
assert.ok(part && part.type === "tool-research"); const text = steer.parts[0];
assert.equal(part.state, "output-available", "failure still fulfills the part"); assert.ok(text.type === "text" && text.text.includes("Research failed: model exploded"), "failure brief on the steer text");
const output = part.output as { brief: string; sources: unknown[] }; const data = steer.parts[1];
assert.match(output.brief, /^Research failed: /); assert.ok(data.type === "data-subagent-artifact");
assert.deepEqual(output.sources, []); assert.deepEqual((data.data.result as ResearchResult).sources, []);
});
it("runJob resolves the input from the transcript when dispatch-time input is absent (crash recovery)", async () => {
const restoreFetch = stubFetch();
try {
const chat = chatStore.createChat("Test: transcript input fallback");
trackChat(chat.id);
chatStore.saveMessages(chat.id, [
{ id: "u1", role: "user", parts: [{ type: "text", text: "go" }] },
{
id: "a1",
role: "assistant",
parts: [{ type: "tool-research", toolCallId: "call-fb1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
},
] as never);
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-fb1" });
trackJob(job.id);
const delivery = fakeDeliver();
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
researchModelOverride.current = researcherModel();
try {
// no input passed — as a recovery re-spawn would call it
await runner.runJob(job.id);
} finally {
researchModelOverride.current = undefined;
}
const done = jobStore.getJob(job.id)!;
assert.equal(done.status, "done", "the researcher ran from the transcript-resolved input");
assert.equal((done.result as ResearchResult).brief, BRIEF);
} finally {
restoreFetch();
}
}); });
}); });
@@ -344,3 +520,187 @@ describe("researcher agent", () => {
researchModelOverride.current = undefined; researchModelOverride.current = undefined;
}); });
}); });
// ─── 6. the wake pipeline (server-side steer delivery) ────────────────────────
/**
* Pipeline tests drive deliverJobResult with an injected fake turn runner
* (setTurnRunner) — the real wiring (chat-handler's handleChatRequest
* adapter) never fires here, so no teacher model is touched.
*
* Timing: the pump polls at 400ms with a 1.5s grace after the gates open —
* each happy-path test takes ~2s; waitFor tolerates it.
*/
describe("wake pipeline (src/server/jobs.ts)", () => {
/** A chat with a finished job ready for delivery. */
function setup(kind: "research" | "demo" = "research"): { chatId: string; jobId: string; steer: SubagentSteerMessage } {
const chat = chatStore.createChat("Test: wake pipeline");
trackChat(chat.id);
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] }] as never);
const job = jobStore.createJob({ kind, chatId: chat.id, toolCallId: `call-${jobStore.listJobs(chat.id).length}` });
trackJob(job.id);
const result: ResearchResult = { brief: "Done: it works.", sources: [{ title: "s", url: "https://x.test" }] };
jobStore.updateJob(job.id, { result, activity: "delivering result" });
const steer = buildSubagentSteer(job, result);
return { chatId: chat.id, jobId: job.id, steer };
}
it("delivers: fires the turn runner with the record + steer, then flips the job done", async () => {
const { chatId, jobId, steer } = setup();
let seen: { chatId: string; lastMessage: unknown } | null = null;
setTurnRunner(async (cid, messages) => {
seen = { chatId: cid, lastMessage: messages[messages.length - 1] };
return 200;
});
await deliverJobResult(steer);
await waitFor(() => jobStore.getJob(jobId)!.status !== "running", 8000, "job flip after delivery");
assert.equal(jobStore.getJob(jobId)!.status, "done");
assert.equal(jobStore.getJob(jobId)!.activity, "delivered");
assert.ok(seen, "the turn runner fired");
assert.equal((seen as { chatId: string }).chatId, chatId);
const last = (seen as { lastMessage: SubagentSteerMessage }).lastMessage;
assert.equal(last.id, steer.id, "the steer is the wake turn's final input message");
});
it("waits while a question part is pending, and fires once it clears", async () => {
const { chatId, jobId, steer } = setup();
let fired = 0;
setTurnRunner(async () => {
fired++;
return 200;
});
// An unanswered quiz part blocks the wake (provider conversion would
// break on an input-available tool call).
chatStore.saveMessages(chatId, [
{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] },
{
id: "a1",
role: "assistant",
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "input-available", input: { question: "q", choices: ["a", "b"] } }],
},
] as never);
// The delivery promise floats while the quiz blocks — awaiting it now
// would deadlock the test (the pump defers until the gate opens).
const delivery = deliverJobResult(steer);
await delay(1200);
assert.equal(fired, 0, "no wake while the quiz is pending");
assert.equal(jobStore.getJob(jobId)!.status, "running", "job stays running while blocked");
// The learner answers: the pending part clears (client adds output).
const record = chatStore.getChat(chatId)!;
const answered = record.messages.map((m) =>
m.id === "a1"
? {
...m,
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "output-available", input: { question: "q", choices: ["a", "b"] }, output: { answer: "a", correct: true, explanation: "" } }],
}
: m,
);
chatStore.saveMessages(chatId, answered as never);
await waitFor(() => fired > 0, 8000, "wake after the quiz answer");
await delivery; // now it resolves — the steer was delivered
await waitFor(() => jobStore.getJob(jobId)!.status !== "running", 8000, "job flip after the deferred wake");
assert.equal(jobStore.getJob(jobId)!.status, "done");
});
it("retries with backoff on a 409 (a client turn raced in) and delivers on the retry", async () => {
const { jobId, steer } = setup();
let calls = 0;
setTurnRunner(async () => {
calls++;
return calls === 1 ? 409 : 200;
});
await deliverJobResult(steer);
assert.ok(calls >= 2, "409 was retried");
await waitFor(() => jobStore.getJob(jobId)!.status !== "running", 8000, "job flip after the retry");
assert.equal(jobStore.getJob(jobId)!.status, "done");
});
it("is idempotent: a steer already in the transcript flips the job without firing a turn", async () => {
const { chatId, jobId, steer } = setup();
// Simulate the crash window: the wake completed but the flip didn't.
const record = chatStore.getChat(chatId)!;
chatStore.saveMessages(chatId, [...record.messages, steer] as never);
let fired = 0;
setTurnRunner(async () => {
fired++;
return 200;
});
await deliverJobResult(steer);
assert.equal(fired, 0, "no duplicate wake for an already-delivered steer");
assert.equal(jobStore.getJob(jobId)!.status, "done", "the job still flips — recovery completes");
});
it("fails the job (error status) when the wake turn errors unrecoverably", async () => {
const { jobId, steer } = setup();
setTurnRunner(async () => 500);
await deliverJobResult(steer).then(
() => assert.fail("expected delivery to reject"),
() => undefined,
);
const errored = jobStore.getJob(jobId)!;
assert.equal(errored.status, "error");
assert.match(errored.error ?? "", /status 500/);
});
});
function delay(ms: number): Promise<void> {
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");
});
});
// ─── prompt composition hygiene (the README leak) ─────────────────────────────
describe("prompt composition", () => {
it("loads ONLY numeric-prefix fragments — README.md never enters the system prompt", () => {
const instructions = composeInstructions();
assert.ok(!instructions.includes("Prompt fragments"), "README content must not leak");
assert.ok(!instructions.includes("Rules for adding a fragment"), "README content must not leak (rules)");
// every numeric fragment DOES compose, verbatim, after the base prompt
const base = fs.readFileSync("src/agent/system-prompt.md", "utf-8").trim();
assert.ok(instructions.startsWith(base), "base prompt first");
for (const f of fs.readdirSync("src/agent/prompts").filter((f) => /^\d+-[\w.-]+\.md$/.test(f)).sort()) {
const body = fs.readFileSync(`src/agent/prompts/${f}`, "utf-8").trim();
assert.ok(instructions.includes(body), `fragment ${f} must compose verbatim`);
}
});
});