"use client"; /* The canvas Test panel: messages a workflow over SSE and lights up nodes as the graph executes (one backend thread per session so the checkpointer holds prior turns). */ import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../icons"; import { Tile } from "../primitives"; import { api, openSSE, type Workflow, type ComponentT } from "@/lib/api"; import { fmtUSD } from "@/lib/data"; import { ComponentRenderer } from "../component-renderer"; import { Markdown } from "../markdown"; import { ReplyAccumulator, type Part, type ComponentInstance } from "@/lib/chat-parts"; import Mustache from "mustache"; interface TestMsg { role: "user" | "assistant"; content?: string; parts?: Part[] } export function WorkflowTestPanel({ project, workflow, running, onRunningChange, onBeforeRun, onClose, onResetRun, onNodeStep, onFinalDebug, }: { project: any; workflow: Workflow; running: boolean; onRunningChange: (running: boolean) => void; onBeforeRun: () => Promise; onClose: () => void; onResetRun: () => void; onNodeStep: (nodeId: string, status?: "idle" | "running" | "done" | "error", output?: any) => void; onFinalDebug: (debugNodes: Record) => void; }) { const [input, setInput] = useState(""); const [msgs, setMsgs] = useState([]); const [streaming, setStreaming] = useState(""); const [meter, setMeter] = useState<{ tokens: number; cost: number } | null>(null); const [compDefs, setCompDefs] = useState>({}); const [liveParts, setLiveParts] = useState([]); // in-flight assistant reply parts (audit H3) const activeRef = useRef(true); const scrollRef = useRef(null); // Folded sub-agents aren't graph nodes, so node_start never fires for them - map each // sub-agent NAME to its canvas node id so the live `activity` stream can light it up. const subNameToId = useMemo(() => { const m: Record = {}; const nodes = (workflow as any)?.executable?.nodes || (workflow as any)?.canvas?.nodes || []; for (const n of nodes) { const name = n?.config?.name ?? n?.data?.config?.name; const type = n?.type ?? n?.data?.nodeType; if (type === "agent" || type === "deep_agent") { if (name) m[name] = n.id; m[n.id] = n.id; // sub-agent name falls back to the node id when the agent is unnamed } } return m; }, [workflow]); const actNameRef = useRef>({}); // activity id -> sub-agent name // One backend thread per test session - the checkpointer holds prior turns. const threadRef = useRef(null); useEffect(() => { threadRef.current = null; setMsgs([]); if (project?.id) api.listComponents(project.id).then((cs) => setCompDefs(Object.fromEntries(cs.map((c) => [c.id, c])))).catch(() => {}); }, [workflow?.id, project?.id]); useEffect(() => { activeRef.current = true; return () => { activeRef.current = false; onRunningChange(false); }; }, [onRunningChange]); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [msgs, streaming, meter, liveParts]); async function send(textArg?: string) { const text = (typeof textArg === "string" ? textArg : input).trim(); if (!text || running || !project?.id || !workflow?.id) return; if (typeof textArg !== "string") setInput(""); setMsgs((m) => [...m, { role: "user", content: text }]); setStreaming(""); setMeter(null); setLiveParts([]); actNameRef.current = {}; onResetRun(); onRunningChange(true); let finalAnswer = ""; let lastNode: string | null = null; // Components are positioned by the [[forge:component:ID]] markers the agent writes into its // reply (not by frame-arrival order), so a widget lands in its natural place, not at the top. const acc = new ReplyAccumulator(); try { await onBeforeRun(); if (!activeRef.current) return; const run = await api.createRun( project.id, workflow.id, { messages: [{ role: "user", content: text }] }, threadRef.current || undefined, ); threadRef.current = run.thread_id; await openSSE(api.runStreamUrl(project.id, workflow.id, run.id), (f) => { if (!activeRef.current) return; if (f.event === "messages" && f.data?.content) { acc.addText(f.data.content); setStreaming(acc.text); setLiveParts(acc.parts({ streaming: true })); } else if (f.event === "node_start" && f.data?.node) { const node = f.data.node; lastNode = node; onNodeStep(node, "running"); } else if (f.event === "node_error" && f.data?.node) { const node = f.data.node; onNodeStep(node, "error"); finalAnswer = `⚠ ${f.data?.message || `${node} failed`}`; } else if (f.event === "updates" && f.data && typeof f.data === "object") { const node = Object.keys(f.data)[0]; if (!node) return; const output = f.data[node]; onNodeStep(node, "done", output); if (lastNode === node) lastNode = null; } else if (f.event === "activity" && f.data?.id) { // Light up a folded sub-agent's canvas node from the live activity stream (it has no // graph node_start of its own). Tools inside the sub-agent aren't nodes, so skip them. const a = f.data; if (a.phase === "start" && a.kind === "subagent") { actNameRef.current[a.id] = a.name; const nid = subNameToId[a.name]; if (nid) onNodeStep(nid, "running"); } else if (a.phase === "end") { const nid = subNameToId[actNameRef.current[a.id]]; if (nid) onNodeStep(nid, "done", a.output); } } else if (f.event === "custom" && f.data?.channel === "component" && f.data?.payload) { acc.addComponent(f.data.payload as ComponentInstance); setLiveParts(acc.parts({ streaming: true })); } else if (f.event === "done") { finalAnswer = f.data?.answer || ""; setMeter({ tokens: f.data?.total_tokens ?? 0, cost: f.data?.total_cost_usd ?? 0 }); onFinalDebug(f.data?.debug?.nodes || {}); if (lastNode) onNodeStep(lastNode, "done"); } else if (f.event === "interrupt") { finalAnswer = "⏸ This run paused for approval. Open the full Playground to resume it."; if (lastNode) onNodeStep(lastNode, "done"); } else if (f.event === "error") { finalAnswer = `⚠ ${f.data?.message || "run failed"}`; if (lastNode) onNodeStep(lastNode, "error"); } }); } catch (e: any) { if (!activeRef.current) return; finalAnswer = `⚠ ${e.message || e}`; if (lastNode) onNodeStep(lastNode, "error"); } finally { if (activeRef.current) { setStreaming(""); onRunningChange(false); setLiveParts([]); } } if (activeRef.current) { // resolveText reconciles the streamed buffer with the authoritative answer / error so it's // never dropped (audit H2); markers in it splice components into place. const finalText = acc.resolveText(finalAnswer); if (acc.hasComponents()) { setMsgs((m) => [...m, { role: "assistant", parts: acc.parts({ finalText }) }]); } else { setMsgs((m) => [...m, { role: "assistant", content: finalText || "(no output)" }]); } } } function handleComponentAction(inst: ComponentInstance, action: string, fields: Record) { const def = (inst.actions || []).find((a: any) => a.id === action) || {}; let msg = (def as any).message || (def as any).label || action; try { msg = Mustache.render(String(msg), { props: inst.props || {}, fields, action }); } catch {} if (msg) send(msg); } return (
Test
{workflow.name}
{msgs.length === 0 && !streaming && !running && (
Message this workflow
Nodes highlight as the graph executes. Hover the debug chips on nodes for output and cost.
)} {msgs.map((m, i) => ( ))} {(running || liveParts.length > 0) && ( )} {meter && {meter.tokens} tok · {fmtUSD(meter.cost)}}
setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} placeholder="Ask this workflow..." disabled={running} style={{ flex: 1, minWidth: 0, border: "none", background: "none", outline: "none", fontSize: 13, color: "var(--fg-0)", fontFamily: "var(--font-ui)" }} />
); } /* One test-panel turn: a single avatar + a column. The assistant reply is an ordered list of parts (text + components interleaved in produced order), matching the Playground so a rendered component sits in its correct place within the reply. */ function TestMessage({ role, content, streaming, parts, compDefs, onAction }: { role: "user" | "assistant"; content?: string; streaming?: boolean; parts?: Part[]; compDefs: Record; onAction: (inst: ComponentInstance, action: string, fields: Record) => void; }) { const user = role === "user"; if (user) { return (
{content}
); } const renderComp = (inst: ComponentInstance, key: number | string) => { const def = compDefs[inst.component_id]; if (!def) { return
Component “{inst.name || inst.component_id}” unavailable.
; } return ( onAction(inst, a, f)} /> ); }; const list: Part[] = parts && parts.length ? parts : (content && content.trim() ? [{ kind: "text", text: content }] : []); let lastText = -1; for (let k = list.length - 1; k >= 0; k--) { if (list[k].kind === "text") { lastText = k; break; } } return (
{list.map((p, j) => (p.kind === "text" ? (
{p.text} {streaming && j === lastText && }
) : renderComp(p.inst, j)))} {streaming && lastText === -1 && }
); }