/** `task` — spawn subagents: batch shape, streamed progress, per-agent results. */ import type { ReactNode } from "react"; import { AgentLink, Badge, Note, Output, ResultText, Row } from "../parts"; import type { ToolRenderer, ToolRenderHost, ToolRenderProps } from "../types"; import { detailsRecord, isRecord, normalizeWs, num, str, truncate } from "../util"; const MISSING_YIELD_PREFIX = "SYSTEM WARNING: Subagent exited without calling yield tool"; /** One spawned unit of work, normalized across the batch and flat/legacy arg shapes. */ interface TaskItemView { id: string | null; description: string | null; assignment: string | null; isolated: boolean; } function taskItems(args: Record): TaskItemView[] { const raw = args.tasks; if (Array.isArray(raw)) { const items: TaskItemView[] = []; for (const entry of raw) { if (!isRecord(entry)) continue; items.push({ id: str(entry.id), description: str(entry.description), assignment: str(entry.assignment), isolated: entry.isolated === true, }); } return items; } const flat: TaskItemView = { id: str(args.id), description: str(args.description), assignment: str(args.assignment), isolated: args.isolated === true, }; return flat.id || flat.description || flat.assignment ? [flat] : []; } /** "Anna.Bob" nesting → "Anna>Bob" breadcrumb (mirrors the TUI's formatTaskId). */ function taskIdLabel(id: string): string { return id.includes(".") ? id.split(".").join(">") : id; } function fmtDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; const s = ms / 1000; if (s < 60) return `${s < 10 ? s.toFixed(1) : Math.round(s)}s`; return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`; } function fmtCount(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); } /** Outcome of one agent, mirroring the TUI's aborted / merge-failed / done / failed split. */ function resultStatus(res: Record): { label: string; tone: "ok" | "err" | "warn" } { if (res.aborted === true) return { label: "aborted", tone: "err" }; if (num(res.exitCode) === 0) { return str(res.error) ? { label: "merge failed", tone: "warn" } : { label: "done", tone: "ok" }; } return { label: "failed", tone: "err" }; } function Summary({ args }: ToolRenderProps): ReactNode { const agent = str(args.agent); const resume = str(args.resume); const tasks = taskItems(args); const first = tasks.length > 0 ? tasks[0] : null; const label = first ? (first.description ?? first.id) : null; return ( <> {agent && {agent}} {!agent && resume && resume {resume}} {label && {truncate(normalizeWs(label), 72)}} {tasks.length > 1 && {tasks.length} tasks} ); } /** Final snapshot for one agent: status row, output preview, error/abort notes. */ function AgentResult({ res, host }: { res: Record; host?: ToolRenderHost }): ReactNode { const { label, tone } = resultStatus(res); const id = str(res.id) ?? "agent"; const description = str(res.description); const stats: string[] = []; const tokens = num(res.tokens); if (tokens) stats.push(`${fmtCount(tokens)} tok`); const requests = num(res.requests); if (requests) stats.push(`${requests} req`); const durationMs = num(res.durationMs); if (durationMs != null) stats.push(fmtDuration(durationMs)); const model = str(res.resolvedModel); if (model) stats.push(model); // The runtime prepends a one-line warning when a subagent never called // yield; lift it out of the output preview like the TUI does. let output = str(res.output) ?? ""; let warning: string | null = null; const nl = output.indexOf("\n"); const firstLine = (nl === -1 ? output : output.slice(0, nl)).trim(); if (firstLine.startsWith(MISSING_YIELD_PREFIX)) { warning = firstLine; output = nl === -1 ? "" : output.slice(nl + 1).replace(/^\s*\n+/, ""); } const error = str(res.error); const aborted = res.aborted === true; const abortReason = str(res.abortReason); const patchPath = str(res.patchPath); const branchName = str(res.branchName); return ( <> {taskIdLabel(id)} } > {label} {res.truncated === true && truncated}{" "} {description && {truncate(normalizeWs(description), 96)}}{" "} {stats.length > 0 && {stats.join(" · ")}} {warning && {warning}} {aborted && abortReason && {abortReason}} {output.trim() !== "" && } {error && !aborted && error !== abortReason && {error}} {patchPath &&
patch: {patchPath}
} {!patchPath && branchName &&
branch: {branchName}
} ); } /** Live (still-running) snapshot for one agent. */ function AgentProgressRow({ p, host }: { p: Record; host?: ToolRenderHost }): ReactNode { const status = str(p.status) ?? "running"; const tone = status === "completed" ? ("ok" as const) : status === "failed" || status === "aborted" ? ("err" as const) : status === "running" ? ("accent" as const) : undefined; const id = str(p.id) ?? "agent"; const description = str(p.description); const intent = str(p.lastIntent) ?? str(p.currentTool); const bits: string[] = []; const toolCount = num(p.toolCount); if (toolCount) bits.push(`${toolCount} tools`); const tokens = num(p.tokens); if (tokens) bits.push(`${fmtCount(tokens)} tok`); const durationMs = num(p.durationMs); if (durationMs) bits.push(fmtDuration(durationMs)); return ( {taskIdLabel(id)} } > {status} {description && {truncate(normalizeWs(description), 96)}}{" "} {intent && {truncate(normalizeWs(intent), 64)}}{" "} {bits.length > 0 && {bits.join(" · ")}} ); } function Body({ args, result, host }: ToolRenderProps): ReactNode { const resume = str(args.resume); const context = str(args.context); const tasks = taskItems(args); const details = detailsRecord(result); const results = details && Array.isArray(details.results) ? details.results.filter(isRecord) : []; const progress = details && Array.isArray(details.progress) ? details.progress.filter(isRecord) : []; const showProgress = results.length === 0 && progress.length > 0; // Run footer: outcome counts + total wall time (mirrors the TUI's bracket line). let footer: ReactNode = null; if (results.length > 0) { let ok = 0; let mergeFailed = 0; let aborted = 0; let failed = 0; for (const res of results) { const { label } = resultStatus(res); if (label === "done") ok++; else if (label === "merge failed") mergeFailed++; else if (label === "aborted") aborted++; else failed++; } const total = details ? num(details.totalDurationMs) : null; footer = ( {ok > 0 && {ok} succeeded}{" "} {mergeFailed > 0 && {mergeFailed} merge failed}{" "} {failed > 0 && {failed} failed}{" "} {aborted > 0 && {aborted} aborted}{" "} {total != null && {fmtDuration(total)}} ); } // Finished agents first, by runtime ascending — same order as the TUI. const ordered = [...results].sort( (a, b) => (num(a.durationMs) ?? 0) - (num(b.durationMs) ?? 0) || (num(a.index) ?? 0) - (num(b.index) ?? 0), ); return ( <> {resume && resume {resume}} {context && } {tasks.length > 0 && (
{tasks.map((t, i) => (
{taskIdLabel(t.id)} ) : ( {`#${i + 1}`} ) } > {t.isolated && isolated}{" "} {t.description && {truncate(normalizeWs(t.description), 120)}} {t.assignment && }
))}
)} {ordered.length > 0 && (
{ordered.map((res, i) => ( ))} {footer}
)} {showProgress && (
{progress.map((p, i) => ( ))}
)} {ordered.length === 0 && !showProgress && } ); } export const taskRenderer: ToolRenderer = { Summary, Body };