diff --git a/apps/clickhouse-builder-docs/src/sidebar-icons.tsx b/apps/clickhouse-builder-docs/src/sidebar-icons.tsx index 35013e6d2..8e86fb6b2 100644 --- a/apps/clickhouse-builder-docs/src/sidebar-icons.tsx +++ b/apps/clickhouse-builder-docs/src/sidebar-icons.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react" // Nucleo geometry from Maple’s existing icon set (apps/web/src/components/icons). -const icons: Record = { +const icons = { "branch-fork": ( <> {" "} @@ -297,10 +297,16 @@ const icons: Record = { ))}{" "} ), +} satisfies Record + +type SidebarIconName = keyof typeof icons + +function isSidebarIconName(name: string): name is SidebarIconName { + return name in icons } export function sidebarIcon(name: string | undefined) { - const icon = name ? icons[name] : undefined + const icon = name !== undefined && isSidebarIconName(name) ? icons[name] : undefined if (!icon) return undefined return (
- {/* The verdict and its findings are one reading, so no rule divides - them; each section below answers a different question, and the - hairline is what keeps the verdict from reading as a header over - all of them. */} -
- + {/* One rhythm down the column: every section answers a different + question, so every boundary is the same hairline with the same + air either side of it. */} +
+ {/* A session that completed with findings has no verdict line: the + findings below are the verdict, and a headline counting them + only said it twice. Failed and clean sessions do carry one — + there the line is the only place the outcome is stated. */} + {report.verdict.status !== "attention" && ( + <> + + + + )} + + - +
@@ -123,12 +130,10 @@ type OpenSpan = (spanId: string) => void function Verdict({ verdict, - findingCount, turns, onOpenSpan, }: { verdict: SessionVerdict - findingCount: number turns: readonly SessionTurn[] onOpenSpan: OpenSpan }) { @@ -159,15 +164,6 @@ function Verdict({ The final {turnWord} did not close cleanly.

- ) : verdict.status === "attention" ? ( - // No subline: the findings right below are the explanation, and a - // sentence pointing at them said nothing the layout doesn't. -

- - - Completed, with {findingCount} {findingCount === 1 ? "finding" : "findings"} - -

) : ( <>

@@ -205,8 +201,8 @@ function VerdictDot({ className }: { className: string }) { function Findings({ findings, onOpenSpan }: { findings: readonly SessionFinding[]; onOpenSpan: OpenSpan }) { return ( -

-
+
+

Findings

@@ -225,7 +221,7 @@ function Findings({ findings, onOpenSpan }: { findings: readonly SessionFinding[
{findings.length === 0 ? ( -

No findings.

+

No findings.

) : ( findings.map((finding) => ( @@ -242,7 +238,7 @@ function FindingRow({ finding, onOpenSpan }: { finding: SessionFinding; onOpenSp aria-haspopup="dialog" onClick={() => onOpenSpan(finding.spanId)} className={cn( - "group flex w-full items-start gap-3 border-border border-t px-3 py-3.5 text-left hover:bg-accent/40", + "group flex w-full items-start gap-3 border-border border-t px-3 py-2.5 text-left hover:bg-accent/40", finding.severity === "failure" && "border-l-2 border-l-destructive bg-destructive/[0.06] pl-2.5", )} @@ -251,7 +247,7 @@ function FindingRow({ finding, onOpenSpan }: { finding: SessionFinding; onOpenSp aria-hidden className={cn("mt-[0.4rem] size-1.5 shrink-0 rounded-full", SEVERITY_DOT[finding.severity])} /> - + band.percent >= 0.5) return ( -
+

Where the time went @@ -312,21 +308,27 @@ function TimeComposition({ summary }: { summary: SessionSummary }) { to be inferred from the bar — and the fan-out that makes them differ, which is the one number the bar itself cannot show. */}
- + {peakParallel > 1 && ( - {peakParallel}× + + {peakParallel}× + agents in parallel )}

-
+
{bands.map((band) => (
-
+
{legend.map((band) => { const Icon = AGENT_TIME_ICON[band.kind] return ( - + {AGENT_TIME_LABEL[band.kind]} {formatSessionDuration(band.ms)} · {formatPercent(band.percent / 100)} @@ -376,7 +382,6 @@ function Rail({ summary }: { summary: SessionSummary }) { const detect = useDetectedModels(summary.models.map((model) => model.model)) const tokenBuckets = TOKEN_BUCKETS.filter((bucket) => summary.tokens[bucket.key] > 0) const topModelCost = Math.max(...summary.models.map((model) => model.cost ?? 0), 0) - const topToolCalls = summary.tools[0]?.calls ?? 0 return (
)} + + ) +} - - {summary.tools.length === 0 ? ( -

no tool calls

- ) : ( - summary.tools.map((tool) => ( - - )) +/* -------------------------------------------------------------------------- */ +/* Tools */ +/* -------------------------------------------------------------------------- */ + +/** + * What the session reached for, as a ledger rather than a ranking. + * + * The bar chart this replaced answered only "which tool was called most", a + * question nobody asks, and spent a row of the page on each answer. The columns + * here are the ones an engineer actually arrives with — which tool burned the + * time, which is flaky, which the agent kept re-running — and the lane beside + * them puts every call on the session's own clock, so a row also says *when*. A + * mark is a call: clicking it opens that span. + */ +function ToolUsage({ summary, onOpenSpan }: { summary: SessionSummary; onOpenSpan: OpenSpan }) { + const [expanded, setExpanded] = useState(undefined) + const axis = useMemo( + () => buildSessionAxis({ startMs: summary.startMs, endMs: summary.endMs, collapsedGaps: [] }), + [summary.startMs, summary.endMs], + ) + + const toggle = (key: string) => setExpanded((current) => (current === key ? undefined : key)) + + return ( +
+ + + {summary.tools.length === 0 ? ( +

no tool calls

+ ) : ( + <> +
+ + {summary.tools.map((tool) => ( + toggle(tool.name)} + onOpenSpan={onOpenSpan} + /> + ))} +
+ + )} +
+ ) +} + +function ToolLedgerHeader({ summary }: { summary: SessionSummary }) { + const calls = summary.tools.reduce((total, tool) => total + tool.calls, 0) + const failed = summary.tools.reduce((total, tool) => total + tool.failed, 0) + const toolMs = summary.tools.reduce((total, tool) => total + tool.totalMs, 0) + + return ( +
+

+ Tools +

+ {summary.tools.length > 0 && ( +
+ + + + {failed > 0 && ( + + + {failed} + + failed + + )} +
+ )} +
+ ) +} + +function LedgerStat({ label, value, tone }: { label: string; value: string; tone?: string }) { + return ( + + + {label} + + + {value} + + + ) +} + +/** The ledger's lanes, shared by the column header and every row: a column is + * read downwards, so the widths are fixed rather than content-driven. */ +const LEDGER_NAME = "w-44 min-w-0 shrink-0" +const LEDGER_COUNT = "w-11 shrink-0 text-right" +const LEDGER_TIME = "w-16 shrink-0 text-right" + +function ToolLedgerColumns({ axis }: { axis: SessionAxis }) { + return ( +
+ Tool + Calls + Fail + Total + Slowest + + {axis.ticks.map((tick) => + tick.fraction === 0 ? null : ( + + {tick.label} + + ), )} - - + +
) } /** - * One tool in the rail. Where the instrumentation stamped a - * `gen_ai.tool.description`, the row discloses it in place — the definition the - * model saw is a fact about the session, and the rail is where the tool is - * already named. A tool without one has nothing to open and stays a plain row. + * One tool. Expanding it discloses what the rail used to hide — the definition + * the model was given, and every call that failed, with its error and a way into + * the span — so the row is both the summary and the way in. */ -function ToolUsageRow({ tool, topToolCalls }: { tool: SessionToolUsage; topToolCalls: number }) { - const [open, setOpen] = useState(false) - const disclosable = tool.description !== undefined - - const row = ( - <> - - {disclosable && ( - void + onOpenSpan: OpenSpan +}) { + const failures = tool.events.filter((event) => event.failed) + const disclosable = tool.description !== undefined || failures.length > 0 + + return ( +
0 && "bg-destructive/[0.06]")}> +
+ {disclosable ? ( + + ) : ( + + {tool.name} + )} - - {tool.name} + {tool.calls} + 0 ? "text-destructive" : "text-muted-foreground/50", + )} + > + {tool.failed > 0 ? tool.failed : "."} - - {/* One bar, two parts: the length is how often the tool was reached - for, the red head how much of that failed. A tool called twenty - times and failing every time reads nothing like one that never - failed, and the bar is where that difference belongs. */} - - - - + + {formatToolDuration(tool.totalMs)} - - {/* A fixed slot, empty on a tool that never failed: a count only some - rows carry would shorten their bars, and bar lengths across the - rail are the whole reason the bars are there. */} - 0 ? `${tool.failed} failed` : undefined} - > - {tool.failed > 0 && ( - <> - - {tool.failed} - failed - - )} - - - {tool.calls} - - + + {formatToolDuration(tool.slowestMs)} + + +
+ + {expanded && ( +
+ {tool.description !== undefined && ( +

+ {tool.description} +

+ )} + {failures.map((event) => ( + + ))} +
+ )} +
) +} - if (!disclosable) return
{row}
+/** Every call of a tool on the session's clock. The hairline is the session, a + * mark is a call; a mark thinner than 3px would otherwise vanish. */ +function CallLane({ + events, + axis, + sessionStartMs, + toolName, + muted = false, + onOpenSpan, +}: { + events: readonly SessionToolCall[] + axis: SessionAxis + sessionStartMs: number + toolName: string + muted?: boolean + onOpenSpan: OpenSpan +}) { + return ( + + + {events.map((event) => ( + + ))} + + ) +} +/** A failed call, spelled out under its tool: what the instrumentation called + * it, where in the session it happened, and the way into the span. */ +function FailedCallRow({ + event, + sessionStartMs, + onOpenSpan, +}: { + event: SessionToolCall + sessionStartMs: number + onOpenSpan: OpenSpan +}) { return ( -
- - {open && ( -

- {tool.description} -

+
+ + {event.errorLabel ?? "error"} + + {callWhen(event, sessionStartMs)} + {event.errorDetail !== undefined && ( + {event.errorDetail} )} +
) } +function callWhen(event: SessionToolCall, sessionStartMs: number): string { + const at = `${formatSessionDuration(event.startMs - sessionStartMs)} in, ${formatToolDuration(event.durationMs)}` + return event.turnIndex === undefined ? at : `turn ${event.turnIndex}, ${at}` +} + +function callTitle(toolName: string, event: SessionToolCall, sessionStartMs: number): string { + const where = `${toolName} — ${callWhen(event, sessionStartMs)}` + return event.failed ? `${where} — ${event.errorLabel ?? "error"}` : where +} + +/** + * Tool durations run from a tenth of a second to minutes and the ledger compares + * them column-wise, so seconds keep a decimal and minutes drop it. + */ +function formatToolDuration(ms: number): string { + return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatSessionDuration(ms) +} + function RailSection({ title, aside, children }: { title: string; aside?: ReactNode; children: ReactNode }) { return (
diff --git a/apps/web/src/lib/agent-sessions/session-findings.ts b/apps/web/src/lib/agent-sessions/session-findings.ts index dccb42a58..41d98f396 100644 --- a/apps/web/src/lib/agent-sessions/session-findings.ts +++ b/apps/web/src/lib/agent-sessions/session-findings.ts @@ -11,8 +11,10 @@ import { formatNumber } from "@maple/ui/lib/format" import { formatSessionDuration } from "@maple/ui/lib/replay-format" import { + clipDetail, failureEvents, findIdleGaps, + firstProse, shadowedAncestorIds, spanTokenBuckets, type SessionFailureKind, @@ -199,62 +201,6 @@ function failureDetail(spans: readonly AiSessionSpan[], label: string): string | return undefined } -function clipDetail(text: string): string { - return text.length > 140 ? `${text.slice(0, 139)}…` : text -} - -/** - * Keys an error payload's human message hides under, tried before anything - * else so a structured result yields its message rather than its first field. - * `result` and `prefix` are Maple's own `toolCallJson` wrappers — a bare error - * string is recorded as `{result}`, an over-budget one as `{truncated, prefix}`. - */ -const PROSE_KEYS = [ - "error", - "message", - "error_message", - "errorMessage", - "reason", - "detail", - "result", - "prefix", - "text", -] - -/** - * The first human-readable line inside a captured payload. Maple's own tool - * errors are plain strings; other vendors wrap the message in an object or an - * MCP-style content array, so this walks tolerantly and gives up rather than - * serialising structure into the row. - */ -function firstProse(value: unknown, depth = 0): string | undefined { - if (depth > 4) return undefined - if (typeof value === "string") { - const line = value - .split("\n") - .map((raw) => raw.trim()) - .find((raw) => raw.length > 0) - return line - } - if (Array.isArray(value)) { - for (const entry of value) { - const prose = firstProse(entry, depth + 1) - if (prose !== undefined) return prose - } - return undefined - } - if (typeof value !== "object" || value === null) return undefined - const record = value as Record - for (const key of PROSE_KEYS) { - if (key in record) { - const prose = firstProse(record[key], depth + 1) - if (prose !== undefined) return prose - } - } - // `content` last and on its own: MCP results nest their text parts there. - return "content" in record ? firstProse(record.content, depth + 1) : undefined -} - /** * How the prompt grew over the session's model calls — the story behind a * context-window death. Prompt size is the input-side buckets (uncached input diff --git a/apps/web/src/lib/agent-sessions/session-summary.test.ts b/apps/web/src/lib/agent-sessions/session-summary.test.ts index 549624ab4..1310b4db7 100644 --- a/apps/web/src/lib/agent-sessions/session-summary.test.ts +++ b/apps/web/src/lib/agent-sessions/session-summary.test.ts @@ -992,28 +992,61 @@ describe("per-model cost, tools and failure groups", () => { expect(summary.cost).toBeCloseTo(0.3) }) - it("counts tools by name, busiest first", () => { + // Busiest first, with what each cost alongside it: how often the agent + // reached for a tool is the ledger's own order. + it("orders tools by how often they were called, and totals what they cost", () => { const summary = summarize([ - agentSpan({ spanId: "a1", startMs: 0, durationMs: 10 * SECOND }), + agentSpan({ spanId: "a1", startMs: 0, durationMs: 30 * SECOND }), toolSpan({ spanId: "t1", parentSpanId: "a1", startMs: 0, durationMs: 100 }), toolSpan({ spanId: "t2", parentSpanId: "a1", startMs: 200, durationMs: 100 }), toolSpan({ spanId: "t3", parentSpanId: "a1", startMs: 400, - durationMs: 100, + durationMs: 5 * SECOND, toolName: "run_tests", }), ]) - expect(summary.tools).toEqual([ - { name: "read_file", calls: 2, failed: 0 }, - { name: "run_tests", calls: 1, failed: 0 }, + expect(summary.tools.map((tool) => [tool.name, tool.calls, tool.totalMs, tool.slowestMs])).toEqual([ + ["read_file", 2, 200, 100], + ["run_tests", 1, 5 * SECOND, 5 * SECOND], + ]) + }) + + // The rail drew one bar per tool, so a failure only ever showed as a share of + // it. The ledger opens the call itself, which means carrying the call. + it("carries every call of a tool: when it ran, how long, and the span behind it", () => { + const summary = summarize([ + agentSpan({ spanId: "a1", startMs: 0, durationMs: 10 * SECOND }), + toolSpan({ spanId: "t1", parentSpanId: "a1", startMs: 1000, durationMs: 100 }), + toolSpan({ spanId: "t2", parentSpanId: "a1", startMs: 2000, durationMs: 300 }), + ]) + + expect( + summary.tools[0]?.events.map((event) => ({ ...event, startMs: event.startMs - summary.startMs })), + ).toEqual([ + { + spanId: "t1", + startMs: 1000, + durationMs: 100, + failed: false, + errorLabel: undefined, + errorDetail: undefined, + turnIndex: 1, + }, + { + spanId: "t2", + startMs: 2000, + durationMs: 300, + failed: false, + errorLabel: undefined, + errorDetail: undefined, + turnIndex: 1, + }, ]) }) - // The rail draws the failed share inside the tool's bar, so the count has to - // be per tool — a session-wide error count cannot say which tool broke. it("counts the failed calls of each tool alongside its total", () => { const summary = summarize([ agentSpan({ spanId: "a1", startMs: 0, durationMs: 10 * SECOND }), @@ -1036,13 +1069,50 @@ describe("per-model cost, tools and failure groups", () => { }), ]) - expect(summary.tools).toEqual([ - { name: "read_file", calls: 2, failed: 1 }, - { name: "run_tests", calls: 1, failed: 1 }, + expect(summary.tools.map((tool) => [tool.name, tool.calls, tool.failed])).toEqual([ + ["read_file", 2, 1], + ["run_tests", 1, 1], + ]) + }) + + // A failed call is only actionable if it says what went wrong in the row the + // reader expanded, rather than sending them to the span to find out. + it("names a failed call's error, and its message wherever the framework put it", () => { + const summary = summarize([ + agentSpan({ spanId: "a1", startMs: 0, durationMs: 10 * SECOND }), + toolSpan({ + spanId: "t1", + parentSpanId: "a1", + startMs: 0, + durationMs: 100, + statusCode: "Error", + statusMessage: "shard 3 is locked by a running merge", + genAi: { errorType: "SHARD_LOCKED" }, + }), + // Maple's own agent reports a failed call as a value on an Ok span: the + // recorded result IS the error. + toolSpan({ + spanId: "t2", + parentSpanId: "a1", + startMs: 200, + durationMs: 100, + toolName: "run_tests", + genAi: { errorType: "tool_error", toolCallResult: { error: "exit 1" } }, + }), + ]) + + expect( + summary.tools.flatMap((tool) => + tool.events.map((event) => [event.errorLabel, event.errorDetail]), + ), + ).toEqual([ + ["SHARD_LOCKED", "shard 3 is locked by a running merge"], + ["tool_error", "exit 1"], ]) }) - // The Overview's rail discloses the description, so it rides the usage row. + // The Overview discloses the description under the tool, so it rides the + // usage row rather than being re-read off a span. it("keeps the first stamped tool description for the tool's usage row", () => { const summary = summarize([ agentSpan({ spanId: "a1", startMs: 0, durationMs: 10 * SECOND }), @@ -1056,9 +1126,7 @@ describe("per-model cost, tools and failure groups", () => { }), ]) - expect(summary.tools).toEqual([ - { name: "read_file", calls: 2, failed: 0, description: "Read a file from the repository." }, - ]) + expect(summary.tools.map((tool) => tool.description)).toEqual(["Read a file from the repository."]) }) // The counts and the breakdown are two readings of one list, so a failure diff --git a/apps/web/src/lib/agent-sessions/session-summary.ts b/apps/web/src/lib/agent-sessions/session-summary.ts index f1ab09aa9..6d06934d7 100644 --- a/apps/web/src/lib/agent-sessions/session-summary.ts +++ b/apps/web/src/lib/agent-sessions/session-summary.ts @@ -100,7 +100,22 @@ export interface SessionModelUsage { readonly cost: number | undefined } -/** One tool, and how many times the session called it. */ +/** One call of a tool: when it ran, what it cost, and how to open it. */ +export interface SessionToolCall { + readonly spanId: string + readonly startMs: number + readonly durationMs: number + readonly failed: boolean + /** The instrumentation's own word for what went wrong, on a failed call. */ + readonly errorLabel: string | undefined + /** The failure's message — the status message, or the recorded result for a + * framework that reports a failed call as a value on an `Ok` span. */ + readonly errorDetail: string | undefined + /** The `Turn n` the call ran in, or nothing for a call outside every turn. */ + readonly turnIndex: number | undefined +} + +/** One tool, and every call the session made to it. */ export interface SessionToolUsage { readonly name: string readonly calls: number @@ -108,6 +123,12 @@ export interface SessionToolUsage { readonly failed: number /** `gen_ai.tool.description`, from the first span that stamped one. */ readonly description: string | undefined + /** What the tool's own calls cost, summed — overlapping calls are counted + * once each, so this is agent time rather than wall clock. */ + readonly totalMs: number + readonly slowestMs: number + /** Every call, in start order. */ + readonly events: readonly SessionToolCall[] } /** How a failure is named on the page — the bucket it counts in, and the label @@ -175,7 +196,7 @@ export interface SessionSummary { /** The same failures those counts tally, grouped by what they say went wrong * and ordered busiest first. */ readonly failureGroups: readonly SessionFailureGroup[] - /** Tools by call count, busiest first. */ + /** Tools by how often they were called, busiest first. */ readonly tools: readonly SessionToolUsage[] readonly spanCount: number readonly traceCount: number @@ -236,7 +257,7 @@ export function buildSessionSummary({ }, failures: countFailures(ordered), failureGroups: groupFailures(failureEvents(ordered)), - tools: toolUsage(ordered), + tools: toolUsage(ordered, turns), spanCount: ordered.length, traceCount: new Set(ordered.map((span) => span.traceId)).size, } @@ -720,27 +741,118 @@ function modelUsage( * framework that skips the attribute still gets a histogram rather than * disappearing from a column whose total says 63. */ -function toolUsage(spans: readonly AiSessionSpan[]): readonly SessionToolUsage[] { - const calls = new Map() +function toolUsage( + spans: readonly AiSessionSpan[], + turns: readonly SessionTurn[], +): readonly SessionToolUsage[] { + const turnIndexBySpan = new Map() + for (const turn of turns) { + for (const span of turn.spans) turnIndexBySpan.set(span.spanId, turn.index) + } + + const byName = new Map() for (const span of spans) { if (classifyAiSpan(span) !== "tool") continue const name = span.genAi.toolName ?? span.spanName - const entry = calls.get(name) ?? { count: 0, failed: 0, description: undefined } - entry.count += 1 - if (spanFailed(span)) entry.failed += 1 + const entry = byName.get(name) ?? { description: undefined, events: [] } // The first stamped description speaks for the tool: emitters send the // same definition on every call, so later ones only repeat it. entry.description ??= span.genAi.toolDescription - calls.set(name, entry) + const failed = spanFailed(span) + entry.events.push({ + spanId: span.spanId, + startMs: spanStartMs(span), + durationMs: spanEndMs(span) - spanStartMs(span), + failed, + errorLabel: failed ? (span.genAi.errorType ?? "error") : undefined, + errorDetail: failed ? toolCallErrorDetail(span) : undefined, + turnIndex: turnIndexBySpan.get(span.spanId), + }) + byName.set(name, entry) } - return [...calls] - .map(([name, entry]) => ({ - name, - calls: entry.count, - failed: entry.failed, - description: entry.description, - })) - .sort((a, b) => b.calls - a.calls || a.name.localeCompare(b.name)) + + return ( + [...byName] + .map(([name, entry]) => ({ + name, + calls: entry.events.length, + failed: entry.events.filter((event) => event.failed).length, + description: entry.description, + totalMs: entry.events.reduce((total, event) => total + event.durationMs, 0), + slowestMs: Math.max(...entry.events.map((event) => event.durationMs)), + events: entry.events, + })) + // Reach leads, time breaks the tie: what the agent kept going back to is + // the first thing a reader scans the ledger for. + .sort((a, b) => b.calls - a.calls || b.totalMs - a.totalMs || a.name.localeCompare(b.name)) + ) +} + +/** + * A failed call's message: the span's status message, and where the framework + * recorded the failure as a value on an `Ok` span, the recorded result itself. + */ +function toolCallErrorDetail(span: AiSessionSpan): string | undefined { + const message = span.statusMessage.trim() + if (message !== "" && message !== span.genAi.errorType) return clipDetail(message) + const result = span.genAi.toolCallResult + return result === undefined ? undefined : firstProse(result) +} + +export function clipDetail(text: string): string { + return text.length > 140 ? `${text.slice(0, 139)}…` : text +} + +/** + * Keys an error payload's human message hides under, tried before anything + * else so a structured result yields its message rather than its first field. + * `result` and `prefix` are Maple's own `toolCallJson` wrappers — a bare error + * string is recorded as `{result}`, an over-budget one as `{truncated, prefix}`. + */ +const PROSE_KEYS = [ + "error", + "message", + "error_message", + "errorMessage", + "reason", + "detail", + "result", + "prefix", + "text", +] + +/** + * The first human-readable line inside a captured payload. Maple's own tool + * errors are plain strings; other vendors wrap the message in an object or an + * MCP-style content array, so this walks tolerantly and gives up rather than + * serialising structure into the row. + */ +export function firstProse(value: unknown, depth = 0): string | undefined { + if (depth > 4) return undefined + if (typeof value === "string") { + const line = value + .split("\n") + .map((raw) => raw.trim()) + .find((raw) => raw.length > 0) + return line + } + if (Array.isArray(value)) { + for (const entry of value) { + const prose = firstProse(entry, depth + 1) + if (prose !== undefined) return prose + } + return undefined + } + if (typeof value !== "object" || value === null) return undefined + const record = value as Record + for (const key of PROSE_KEYS) { + if (key in record) { + const prose = firstProse(record[key], depth + 1) + if (prose !== undefined) return prose + } + } + // `content` last and on its own: MCP results nest their text parts there. + return "content" in record ? firstProse(record.content, depth + 1) : undefined } /* -------------------------------------------------------------------------- */