fix: live sub-agent observability — dispatch cards, steer artifacts, sidebar panel
The pi-style ack protocol (previous commit) left the client describing the
old world: artifact cards read the ack string as a result ("could not be
displayed"), the only job poller rendered on input-available parts that no
longer exist (so wake turns landed invisibly until re-navigation), steers
rendered as learner bubbles with no SVG anywhere, and server-persisted
assistant messages carried empty SDK ids (duplicate React keys).
- persistTurn (turn-service): fill empty message ids at the canonical write
— server-only messages (last turn's, wake turns') now key uniquely;
client-authored ids untouched.
- chat.tsx: ONE central /api/jobs poll (fast while jobs run, slow discovery
cadence, off for job-less sessions) drives every job surface, re-syncs
once when a job settles (the record already holds the steer + wake
response at flip time), and adopts server-side wake turns via the same
turnInFlight machinery as returning mid-turn. Steers branch to the
artifact card — never a bubble. Composer no longer gates on jobs.
- /api/jobs now reports turnInFlight alongside the job list.
- dispatch-card: the tool-call box — live activity by toolCallId while
running, quiet "delivered"/"failed" one-liner after; the ack is visible
observability, the artifact lives in the steer.
- steer-card: renders the delivered artifact by kind (research brief /
sanitized SVG / sandboxed demo iframe) from the data-subagent-artifact
part; result renderers extracted and exported from the artifact cards.
- sub-agents-panel: right-sidebar section above the plan tree listing
running sub-agents (kind, elapsed, live activity) — vanishes when idle.
- tests: dispatch/steer/panel render tests re-target the new protocol;
persistTurn id-hygiene suite. 229/229 green, build green.
Out-of-lane touches for master review: chat.tsx + components/** (cleared
this wave), app/api/jobs/route.ts, src/server/turn-service.ts, contracts
protocol doc §2. Live-verified on :3003 (full loop, SSR DOM checked).
This commit is contained in:
@@ -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
|
||||
* client polls GET /api/jobs?chatId=… while any job tool part is pending,
|
||||
* finds the job by toolCallId, renders its activity line, and re-syncs the
|
||||
* chat when the job flips to done/error.
|
||||
* client's ONE central poller fetches this while any job runs (and slowly
|
||||
* otherwise) to drive the dispatch cards, the sub-agents panel, and the
|
||||
* 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) {
|
||||
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)) {
|
||||
return Response.json({ error: "Invalid chatId" }, { status: 400 });
|
||||
}
|
||||
return Response.json({ jobs: listJobs(chatId) });
|
||||
return Response.json({ jobs: listJobs(chatId), turnInFlight: isTurnRunning(chatId) });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
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
|
||||
@@ -66,93 +67,18 @@ export function reduceDemoBridgeState(state: DemoBridgeState, data: unknown): De
|
||||
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 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) {
|
||||
if (part.state === "output-available") {
|
||||
return <DemoOutputCard output={part.output} />;
|
||||
}
|
||||
return <JobActivityCard toolCallId={part.toolCallId} label="Building demo…" testId="demo-activity-card" />;
|
||||
if (part.state !== "output-available") return null;
|
||||
return <DispatchCard toolCallId={part.toolCallId} kind="demo" />;
|
||||
}
|
||||
|
||||
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;
|
||||
if (!result || typeof result.html !== "string" || typeof result.title !== "string") {
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import { ChatActionsContext } from "@/components/chat";
|
||||
import type { ArtifactPartProps, JobRecord, ResearchResult } from "@/src/core";
|
||||
import type { ArtifactPartProps, 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
|
||||
* 2s, find the job by toolCallId, show a pulsing dot + "Researching…" + the
|
||||
* job's live activity line. When the job flips to done/error, call
|
||||
* syncFromServer() ONCE (fresh messages replace local state; the part
|
||||
* re-renders as the brief card and the sendAutomaticallyWhen continuation
|
||||
* 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.
|
||||
* The part's output is the fire-and-forget dispatch ACK — the box itself is
|
||||
* the DispatchCard (live status via the central jobs poll; quiet one-liner
|
||||
* once delivered). The research brief itself no longer renders from the
|
||||
* tool part at all: it rides the steer message (the user-role delivery
|
||||
* injected after the wake turn) and renders there via ResearchBriefCard.
|
||||
*/
|
||||
export function JobCard({ part }: ArtifactPartProps) {
|
||||
if (part.state === "output-available") {
|
||||
return <ResearchBriefCard output={part.output as ResearchResult} />;
|
||||
}
|
||||
return <ResearchActivityCard toolCallId={part.toolCallId} />;
|
||||
if (part.state !== "output-available") return null;
|
||||
return <DispatchCard toolCallId={part.toolCallId} kind="research" />;
|
||||
}
|
||||
|
||||
function ResearchActivityCard({ toolCallId }: { toolCallId: 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="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 }) {
|
||||
/**
|
||||
* The research brief — markdown body + a small source link list. Exported:
|
||||
* the steer card (sub-agent result delivery) renders the same artifact.
|
||||
*/
|
||||
export function ResearchBriefCard({ output }: { output: unknown }) {
|
||||
const result = output as ResearchResult | undefined;
|
||||
if (!result || typeof result.brief !== "string") {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { SubagentArtifactData, TeacherUIMessage } from "@/src/core";
|
||||
import { DemoOutputCard } from "@/components/artifacts/demo-card";
|
||||
import { ResearchBriefCard } from "@/components/artifacts/job-card";
|
||||
import { VisualOutputCard } from "@/components/artifacts/visual-card";
|
||||
|
||||
/**
|
||||
* The sub-agent result delivery card (contracts.ts "THE ASYNC PROTOCOL" §4).
|
||||
*
|
||||
* When a background job completes, the wake pipeline injects a STEER message
|
||||
* into the transcript: a user-role UIMessage whose data part
|
||||
* ("data-subagent-artifact") carries the result. It must NEVER render as a
|
||||
* learner bubble: the model-facing text part is protocol glue, and the
|
||||
* artifact itself renders here, by kind, reusing the same artifact cards
|
||||
* the result always used.
|
||||
*/
|
||||
export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
|
||||
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
|
||||
const data = artifactPart?.data as SubagentArtifactData | undefined;
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||
A sub-agent finished, but its result could not be displayed.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
switch (data.kind) {
|
||||
case "research":
|
||||
return <ResearchBriefCard output={data.result} />;
|
||||
case "demo":
|
||||
return <DemoOutputCard output={data.result} />;
|
||||
case "visual":
|
||||
return <VisualOutputCard output={data.result} />;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
||||
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
|
||||
* card; when output-available, VisualResult.svg renders INLINE. 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.
|
||||
* Visual artifact card (W6 → ack protocol): the tool part's output is the
|
||||
* dispatch ack, so the tool-call box is the DispatchCard (live status via
|
||||
* the central jobs poll). The SVG itself renders from the steer message
|
||||
* (sub-agent result delivery) via VisualOutputCard.
|
||||
*
|
||||
* 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. */
|
||||
@@ -22,13 +26,12 @@ export function sanitizeSvg(svg: string): string {
|
||||
}
|
||||
|
||||
export function VisualCard({ part }: ArtifactPartProps) {
|
||||
if (part.state === "output-available") {
|
||||
return <VisualOutputCard output={part.output} />;
|
||||
}
|
||||
return <JobActivityCard toolCallId={part.toolCallId} label="Drawing figure…" testId="visual-activity-card" />;
|
||||
if (part.state !== "output-available") return null;
|
||||
return <DispatchCard toolCallId={part.toolCallId} kind="visual" />;
|
||||
}
|
||||
|
||||
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;
|
||||
if (!result || typeof result.svg !== "string") {
|
||||
return (
|
||||
|
||||
+100
-19
@@ -1,6 +1,6 @@
|
||||
"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 { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
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 { AnsweredCard } from "@/components/answered-card";
|
||||
import { artifactRenderers } from "@/components/artifacts";
|
||||
import { SubagentSteerCard } from "@/components/artifacts/steer-card";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { JobsContext } from "@/components/jobs-context";
|
||||
import { QuestionDock } from "@/components/question-dock";
|
||||
import { SidebarToggle } from "@/components/sidebar";
|
||||
import { SubAgentsPanel } from "@/components/sub-agents-panel";
|
||||
import { TreePanel } from "@/components/tree-panel";
|
||||
import type { ArtifactPartProps, QuestionAnswerData, TeacherUIMessage } from "@/src/core";
|
||||
import { findPendingJobCall, findPendingToolCall, phaseAt } from "@/src/core";
|
||||
import type { ArtifactPartProps, JobRecord, QuestionAnswerData, TeacherUIMessage } from "@/src/core";
|
||||
import { findPendingToolCall, phaseAt } from "@/src/core";
|
||||
|
||||
/** One width for everything: transcript, cards, and the bottom blob share this column. */
|
||||
const COLUMN = "mx-auto w-full max-w-3xl px-4";
|
||||
|
||||
/**
|
||||
* Server-sync channel for artifact renderers (job cards): when a background
|
||||
* job flips its tool part to output-available server-side, the card polls
|
||||
* /api/jobs, then calls syncFromServer() — fresh messages replace local state
|
||||
* and the sendAutomaticallyWhen continuation resumes the teacher.
|
||||
* Server-sync channel for artifact renderers: the CENTRAL jobs poll in Chat
|
||||
* calls syncFromServer() when a background job settles — the wake turn's
|
||||
* steer + response are already persisted by then, so fresh messages replace
|
||||
* local state in one pull. sendText feeds the demo error-fix loop.
|
||||
* (contracts.ts "THE ASYNC PROTOCOL" §4.)
|
||||
*/
|
||||
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). */
|
||||
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 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);
|
||||
|
||||
const [text, setText] = useState("");
|
||||
// Jobs gate sending too (an unanswered tool call in the outbound list would
|
||||
// break provider conversion — contracts.ts "THE ASYNC PROTOCOL" §5), but
|
||||
// they don't dock: the running job's activity card sits in the transcript.
|
||||
const pendingJob = findPendingJobCall(messages);
|
||||
const canSend = text.trim().length > 0 && !pending && !pendingJob && !turnInFlight;
|
||||
// Jobs never gate the composer (contracts.ts "THE ASYNC PROTOCOL" §4): job
|
||||
// tools resolve with an ack mid-turn, so the learner can interject while
|
||||
// sub-agents run — a feature, not a race: the wake pipeline queues steers
|
||||
// until the current turn ends. Only a QUESTION pending or a turn in
|
||||
// flight (client or server-side wake turn) gates sending.
|
||||
const canSend = text.trim().length > 0 && !pending && !turnInFlight;
|
||||
|
||||
const syncFromServer = useCallback(async () => {
|
||||
const res = await fetch(`/api/chats/${chatId}`);
|
||||
@@ -158,6 +168,65 @@ export function Chat({
|
||||
}
|
||||
}, [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
|
||||
// the orphaned turn finishes (turnInFlight flips false server-side in
|
||||
// onFinish, AFTER persist), then adopt the completed transcript — the
|
||||
@@ -190,13 +259,14 @@ export function Chat({
|
||||
|
||||
const onSend = () => {
|
||||
const t = text.trim();
|
||||
if (!t || busy || pending || pendingJob) return;
|
||||
if (!t || busy || pending) return;
|
||||
setText("");
|
||||
void sendMessage({ text: t });
|
||||
};
|
||||
|
||||
return (
|
||||
<ChatActionsContext.Provider value={{ chatId, syncFromServer, sendText: (t: string) => void sendMessage({ text: t }) }}>
|
||||
<JobsContext.Provider value={jobs}>
|
||||
<div className="flex h-dvh flex-col">
|
||||
<header className="flex items-center gap-3 px-3 py-2">
|
||||
<SidebarToggle />
|
||||
@@ -221,11 +291,17 @@ export function Chat({
|
||||
<Message key={m.id} from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
|
||||
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
|
||||
{m.role === "user" ? (
|
||||
// User text renders plain: the model's LaTeX pipeline is not
|
||||
// applied to learner-authored text ($ stays $, no surprises).
|
||||
<div className="whitespace-pre-wrap">
|
||||
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
||||
</div>
|
||||
isSubagentSteer(m) ? (
|
||||
// Sub-agent delivery (wake-pipeline steer): render the
|
||||
// artifact, never a learner bubble (contracts.ts §4).
|
||||
<SubagentSteerCard message={m} />
|
||||
) : (
|
||||
// User text renders plain: the model's LaTeX pipeline is not
|
||||
// applied to learner-authored text ($ stays $, no surprises).
|
||||
<div className="whitespace-pre-wrap">
|
||||
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
m.parts.map((part, pi) => {
|
||||
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
|
||||
@@ -303,9 +379,14 @@ export function Chat({
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</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>
|
||||
</JobsContext.Provider>
|
||||
</ChatActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[]>([]);
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -303,7 +303,10 @@ export interface JobRecord {
|
||||
* sub-agent loop — isolated context, modelFor(role), the
|
||||
* verify-never-one-shot doctrine (headless-tested demos, looked-at SVGs,
|
||||
* sourced briefs) — tracking a live activity line in the job record.
|
||||
* The client polls GET /api/jobs?chatId=… and renders the activity card.
|
||||
* ONE central client poll of GET /api/jobs?chatId=… (in Chat) drives every
|
||||
* job surface: the transcript dispatch cards (live activity by
|
||||
* toolCallId, quiet one-liner after delivery), the sub-agents sidebar
|
||||
* panel, and the single re-sync when a job settles.
|
||||
*
|
||||
* 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
|
||||
* and hands it to the wake pipeline (src/server/jobs.ts): a user-role
|
||||
|
||||
@@ -45,9 +45,24 @@ export function buildTeacherContext(graphStore: GraphStore, chatId: string): str
|
||||
* even when aborted — the partial transcript (including any pending
|
||||
* question part) is what makes resume work.
|
||||
*/
|
||||
/**
|
||||
* The SDK assigns assistant-message ids CLIENT-side (useChat); the server's
|
||||
* onFinish synthesis leaves them EMPTY. A message persisted only server-side
|
||||
* (the last turn's, or a wake turn's — nobody re-POSTs it with a client id)
|
||||
* would otherwise land in the record with id "" — and two of those in one
|
||||
* transcript break React keys (the chat map) and steer-dedup lookups. Fill any
|
||||
* missing id here, at the single canonical write: client-authored ids are
|
||||
* NEVER touched (the client's next POST still carries them verbatim).
|
||||
*/
|
||||
export function withMessageIds(messages: UIMessage[]): UIMessage[] {
|
||||
return messages.map((m) =>
|
||||
m.id ? m : { ...m, id: `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}` },
|
||||
);
|
||||
}
|
||||
|
||||
export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void {
|
||||
try {
|
||||
chatStore.saveMessages(chatId, messages);
|
||||
chatStore.saveMessages(chatId, withMessageIds(messages));
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
||||
}
|
||||
|
||||
+116
-38
@@ -39,12 +39,18 @@ import {
|
||||
reduceDemoBridgeState,
|
||||
} from "../components/artifacts/demo-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 { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
|
||||
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
|
||||
import { isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts";
|
||||
import { buildSubagentSteer, isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts";
|
||||
import type { JobRecord } from "../src/core/contracts.ts";
|
||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
@@ -240,68 +246,140 @@ function part(state: string, output?: unknown) {
|
||||
return { type: "tool-demo", state, toolCallId: "call-1", input: { title: "Oscillator", brief: "b" }, output };
|
||||
}
|
||||
|
||||
describe("demo card rendering", () => {
|
||||
it("renders a sandboxed iframe (allow-scripts, NO allow-same-origin) with the bridge injected", () => {
|
||||
const html = renderToStaticMarkup(<DemoCard part={part("output-available", { title: "Damped oscillator", html: DEMO_HTML, tested: true }) as never} />);
|
||||
describe("dispatch card rendering (the tool-call ack box)", () => {
|
||||
const ackPart = { type: "tool-research", state: "output-available", toolCallId: "call-1", input: { question: "q" }, output: "Dispatched (job j1)." };
|
||||
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: ("|")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.ok(!html.includes("allow-same-origin"), "never allow-same-origin");
|
||||
assert.match(html, /demo-resize/);
|
||||
assert.match(html, /demo-error/);
|
||||
assert.match(html, /Damped oscillator/);
|
||||
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", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<DemoCard part={part("output-available", { title: "T", html: "", tested: true, notes: "FAILED: never passed verification" }) as never} />,
|
||||
it("renders the demo failure note with no iframe when the build FAILED", () => {
|
||||
const steer = buildSubagentSteer(
|
||||
{ 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.ok(!html.includes("<iframe"), "no iframe for a failed build");
|
||||
});
|
||||
|
||||
it("renders notes in muted small text", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<DemoCard part={part("output-available", { title: "T", html: DEMO_HTML, tested: true, notes: "Drag the damping slider." }) as never} />,
|
||||
);
|
||||
assert.match(html, /Drag the damping slider\./);
|
||||
});
|
||||
|
||||
it("renders the activity card while the job runs", () => {
|
||||
const html = renderToStaticMarkup(<DemoCard part={part("input-available") as never} />);
|
||||
assert.match(html, /Building demo…/);
|
||||
assert.ok(!html.includes("<iframe"));
|
||||
});
|
||||
|
||||
it("renders the error chip with a Send to teacher button", () => {
|
||||
const html = renderToStaticMarkup(<DemoErrorChip title="T" message="boom is not defined" onSend={() => {}} />);
|
||||
assert.match(html, /This demo threw an error/);
|
||||
assert.match(html, /boom is not defined/);
|
||||
assert.match(html, /Send to teacher/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visual card rendering", () => {
|
||||
it("renders the verified svg inline (sanitized) with notes", () => {
|
||||
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(
|
||||
<VisualCard part={{ type: "tool-visual", state: "output-available", toolCallId: "c", output: { svg, notes: "The unit circle." } } as never} />,
|
||||
);
|
||||
const steer = buildSubagentSteer({ id: "jv", kind: "visual", toolCallId: "cv" }, { svg, notes: "The unit circle." });
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
assert.match(html, /The unit circle\./);
|
||||
assert.ok(html.includes("circle"), "svg content rendered inline");
|
||||
assert.match(html, /Figure/);
|
||||
});
|
||||
|
||||
it("renders the failure note with no svg when the build FAILED", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<VisualCard part={{ type: "tool-visual", state: "output-available", toolCallId: "c", output: { svg: "", notes: "FAILED: iteration limit" } } as never} />,
|
||||
it("renders the research brief with source links", () => {
|
||||
const steer = buildSubagentSteer(
|
||||
{ id: "jr", kind: "research", toolCallId: "cr" },
|
||||
{ brief: "Plato (c. 428–348 BCE) founded the Academy.", sources: [{ title: "Britannica", url: "https://britannica.com/plato" }] },
|
||||
);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
assert.match(html, /Research brief/);
|
||||
assert.match(html, /britannica\.com\/plato/);
|
||||
assert.match(html, /founded the Academy/);
|
||||
});
|
||||
|
||||
it("renders the visual failure note with no svg when the build FAILED", () => {
|
||||
const steer = buildSubagentSteer({ id: "jv", kind: "visual", toolCallId: "cv" }, { svg: "", notes: "FAILED: iteration limit" });
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
assert.match(html, /FAILED: iteration limit/);
|
||||
assert.ok(!html.includes("circle") && !html.includes("<svg"), "no svg for a failed build");
|
||||
});
|
||||
|
||||
it("renders the activity card while the job runs", () => {
|
||||
const html = renderToStaticMarkup(<VisualCard part={{ type: "tool-visual", state: "input-available", toolCallId: "c" } as never} />);
|
||||
assert.match(html, /Drawing figure…/);
|
||||
it("renders a fallback note when the artifact part is missing", () => {
|
||||
const msg = { id: "s", role: "user", parts: [{ type: "text", text: "x" }], metadata: { subagentResult: { jobId: "j", kind: "research", toolCallId: "c" } } };
|
||||
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: ("|")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={[]} />), "");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { createTeacherTools } from "../src/agent/tools.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 { jobStore } from "../src/store/jobs.ts";
|
||||
import { createJobRunner, researchModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
@@ -657,3 +658,32 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
function delay(ms: number): Promise<void> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user