From 3d5fa22eaa0bfb1a79e737d3ca9b8bcbabe0e521 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 8 Sep 2026 15:20:15 +0200 Subject: [PATCH 1/5] feat(agent-sessions): make the overview's tools a ledger you can act on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tools section was a rank-ordered bar chart: one row per tool, the bar its call count, a red head where calls failed. It spent 400px of the overview answering "which tool was called most" — a question nobody arrives with — and never said when anything happened or how to reach the call that broke. It is now a ledger. The columns are the questions an engineer actually has (calls, failures, total time, slowest call), sorted by time spent, so the tool that burned the session is the first row. Beside them every call sits on the session's own clock, one mark per call at its start time and sized by its duration: a row says *when*, and clicking a mark opens that call's span in the inspection overlay. Expanding a tool discloses what the rail used to hide — the definition the model was given — together with each failed call, its error, where in the session it happened, and a way straight into the span. The cheap tail folds into one row, and a tool that failed keeps its own row however little time it cost, so a session reaching for forty tools still fits. `SessionToolUsage` carries the calls behind it to make that possible: each call's span id, start, duration, turn, and error. The prose extraction helpers move down into `session-summary` so both the findings list and a tool row can name a failure the same way. --- .../session-detail/session-detail.test.tsx | 105 +++- .../session-detail/session-overview.tsx | 582 +++++++++++++++--- .../lib/agent-sessions/session-findings.ts | 58 +- .../agent-sessions/session-summary.test.ts | 98 ++- .../src/lib/agent-sessions/session-summary.ts | 146 ++++- 5 files changed, 806 insertions(+), 183 deletions(-) diff --git a/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx b/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx index 385aa381f..59dbd559e 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx @@ -493,16 +493,103 @@ describe("SessionOverview", () => { expect(screen.getByText("No findings.")).toBeTruthy() }) - // A tool called ten times and failing every time reads nothing like one that - // never failed; the rail used to draw both as the same bar. - it("separates a tool's failed calls from its successful ones", () => { - render() + // The ledger's row is a summary; the calls behind it are the point. A mark is + // one call, and it opens that span rather than describing it. + it("puts every call on the session's clock and opens the span behind a mark", () => { + const onSelectSpan = vi.fn() + render() + + fireEvent.click(screen.getByRole("button", { name: /^run_tests — turn 1, 14s in, 20.0s/ })) + expect(onSelectSpan).toHaveBeenCalledWith("tool-3") + }) + + // The description and the failure used to live in two different places — the + // rail disclosed one, the findings list carried the other. Expanding the tool + // is where a reader asks about the tool. + it("discloses a tool's definition and its failed calls when the row is expanded", () => { + const onSelectSpan = vi.fn() + const described = sessionOf([ + agentSpan({ spanId: "d-agent", startMs: 0, durationMs: 30 * SECOND }), + toolSpan({ + spanId: "d-tool", + parentSpanId: "d-agent", + startMs: SECOND, + durationMs: 4 * SECOND, + toolName: "reindex_shard", + statusCode: "Error", + statusMessage: "shard 3 is locked by a running merge", + genAi: { errorType: "SHARD_LOCKED", toolDescription: "Rebuild a shard's index." }, + }), + ]) + render() + + expect(screen.queryByText("Rebuild a shard's index.")).toBeNull() + + fireEvent.click(screen.getByRole("button", { name: "reindex_shard" })) + + expect(screen.getByText("Rebuild a shard's index.")).toBeTruthy() + // The findings list names the same failure; the disclosure is where a + // reader asking about this tool finds it. + expect(screen.getAllByText("SHARD_LOCKED").length).toBeGreaterThan(0) + expect(screen.getAllByText("shard 3 is locked by a running merge").length).toBe(2) + + fireEvent.click(screen.getByRole("button", { name: /Open span/ })) + expect(onSelectSpan).toHaveBeenCalledWith("d-tool") + }) + + // A session reaching for twenty tools would otherwise spend twenty rows on + // the ones that cost nothing; a tool that failed keeps its row whatever it + // cost, because that is the row a reader came for. + it("folds the cheap tail into one row, and keeps the expensive and the failed", () => { + const many = sessionOf([ + agentSpan({ spanId: "m-agent", startMs: 0, durationMs: 60 * SECOND }), + toolSpan({ + spanId: "m-slow", + parentSpanId: "m-agent", + startMs: 0, + durationMs: 30 * SECOND, + toolName: "run_tests", + }), + toolSpan({ + spanId: "m-cheap-1", + parentSpanId: "m-agent", + startMs: 31 * SECOND, + durationMs: 200, + toolName: "list_shards", + }), + toolSpan({ + spanId: "m-cheap-2", + parentSpanId: "m-agent", + startMs: 32 * SECOND, + durationMs: 200, + toolName: "query_data", + }), + toolSpan({ + spanId: "m-cheap-3", + parentSpanId: "m-agent", + startMs: 33 * SECOND, + durationMs: 200, + toolName: "search_logs", + }), + toolSpan({ + spanId: "m-failed", + parentSpanId: "m-agent", + startMs: 34 * SECOND, + durationMs: 100, + toolName: "git_diff", + statusCode: "Error", + }), + ]) + render() + + expect(screen.getByText("run_tests")).toBeTruthy() + // Cheapest of all, but it failed, so it stays a row of its own. + expect(screen.getByText("git_diff")).toBeTruthy() + expect(screen.queryByText("query_data")).toBeNull() - // run_tests: one call, and it errored. - expect(screen.getByTitle("1 failed")).toBeTruthy() - expect(screen.getByTitle("0 ok · 1 errored")).toBeTruthy() - // read_file and grep_repo ran clean, and say so by having nothing to say. - expect(screen.getAllByTitle("1 ok · 0 errored").length).toBe(2) + const fold = screen.getByRole("button", { name: "3 tools, 1 call each" }) + fireEvent.click(fold) + expect(screen.getByText("query_data")).toBeTruthy() }) it("says no cost was reported rather than pricing tokens itself", () => { diff --git a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx index 1ac19b57e..59196697b 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, type ReactNode } from "react" -import { ArrowRightIcon, ChevronRightIcon, CircleXmarkIcon } from "@/components/icons" +import { ArrowRightIcon, ChevronRightIcon } from "@/components/icons" import { Button } from "@maple/ui/components/ui/button" import { Separator } from "@maple/ui/components/ui/separator" import { formatNumber, formatPercent } from "@maple/ui/lib/format" @@ -16,8 +16,10 @@ import { import { formatCost, type SessionSummary, + type SessionToolCall, type SessionToolUsage, } from "@/lib/agent-sessions/session-summary" +import { buildSessionAxis, type SessionAxis } from "@/lib/agent-sessions/session-axis" import type { SessionTurn } from "@/lib/agent-sessions/session-turns" import { TOKEN_BUCKETS } from "@/lib/agent-sessions/token-buckets" import type { SessionToolResults } from "@/lib/agent-sessions/span-detail" @@ -97,6 +99,8 @@ export function SessionOverview({ + + @@ -312,14 +316,20 @@ 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 )} @@ -341,7 +351,11 @@ function TimeComposition({ summary }: { summary: SessionSummary }) { const Icon = AGENT_TIME_ICON[band.kind] return ( - + {AGENT_TIME_LABEL[band.kind]} {formatSessionDuration(band.ms)} · {formatPercent(band.percent / 100)} @@ -376,7 +390,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 */ +/* -------------------------------------------------------------------------- */ + +/** The cheap tail folds into one summary row from this many up: below it the + * fold costs a row to save the same row. */ +const TOOL_FOLD_MIN = 3 + +/** How much of the session's tool time the folded tail may add up to. Past it a + * tool is part of where the time went and keeps its row. */ +const TOOL_FOLD_MAX_SHARE = 0.1 + +const FOLD_KEY = " fold" + +/** + * 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 fold = foldableTail(summary.tools) + const listed = summary.tools.filter((tool) => !fold.includes(tool)) + + const toggle = (key: string) => setExpanded((current) => (current === key ? undefined : key)) + + return ( +
+ + + {summary.tools.length === 0 ? ( +

no tool calls

+ ) : ( + <> +
+ + {listed.map((tool) => ( + toggle(tool.name)} + onOpenSpan={onOpenSpan} + /> + ))} + {fold.length > 0 && ( + toggle(FOLD_KEY)} + onOpenSpan={onOpenSpan} + /> + )} +
+ +
+ One mark per call, at its start time and sized by how long it took. + Sorted by time spent +
+ + )} +
+ ) +} + +/** + * The run of tools at the bottom worth one row between them: no failures, and + * together a rounding error against the session's tool time. Tools are ordered + * by time spent, so this is always the cheap end — a tool called once that took + * twelve seconds sits high in the list and keeps its own row. + */ +function foldableTail(tools: readonly SessionToolUsage[]): readonly SessionToolUsage[] { + const budget = tools.reduce((total, tool) => total + tool.totalMs, 0) * TOOL_FOLD_MAX_SHARE + const fold: SessionToolUsage[] = [] + let spent = 0 + // Cheapest first, and a tool that failed is passed over rather than stopping + // the walk: a failure keeps its row however little time it took. + for (const tool of [...tools].reverse()) { + if (tool.failed > 0) continue + if (spent + tool.totalMs > budget) break + spent += tool.totalMs + fold.push(tool) + } + return fold.length >= TOOL_FOLD_MIN ? fold : [] +} + +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)} + + + {formatToolDuration(tool.slowestMs)} + + +
+ + {expanded && ( +
+ {tool.description !== undefined && ( +

+ {tool.description} +

+ )} + {failures.map((event) => ( + + ))} +
+ )} +
+ ) +} + +/** The tail of the ledger: tools called once that worked. Their calls still ride + * the lane, so folding them hides a row rather than a fact. */ +function ToolFoldRow({ + tools, + axis, + sessionStartMs, + expanded, + onToggle, + onOpenSpan, +}: { + tools: readonly SessionToolUsage[] + axis: SessionAxis + sessionStartMs: number + expanded: boolean + onToggle: () => void + onOpenSpan: OpenSpan +}) { + const events = tools.flatMap((tool) => tool.events) + const totalMs = tools.reduce((total, tool) => total + tool.totalMs, 0) + const slowestMs = Math.max(...tools.map((tool) => tool.slowestMs)) + + return ( +
+
+ + + {tools.reduce((total, tool) => total + tool.calls, 0)} - - {/* 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(totalMs)} + + + {formatToolDuration(slowestMs)} + + +
+ + {expanded && + tools.map((tool) => ( +
+ + {tool.name} + + + {tool.calls} + + + . + + + {formatToolDuration(tool.totalMs)} + + + {formatToolDuration(tool.slowestMs)} + + +
+ ))} +
) +} - 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} )} +
) } +/** `6 tools, 1 call each` where that is the whole story, and a plain count + * where it is not. */ +function foldLabel(tools: readonly SessionToolUsage[]): string { + return tools.every((tool) => tool.calls === 1) + ? `${tools.length} tools, 1 call each` + : `${tools.length} more tools` +} + +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..742cc7057 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", () => { + // The ledger sorts by what a tool cost, not how often it was reached for: a + // tool called twice for a minute is where the session's time went. + it("orders tools by the time their calls took, and totals it", () => { 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([ + ["run_tests", 1, 5 * SECOND, 5 * SECOND], + ["read_file", 2, 200, 100], + ]) + }) + + // 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..f247ccea7 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 the time their calls took, most expensive 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, + })) + // Time spent leads: a tool called twice for a minute matters more to a + // reader than one called twenty times for a millisecond. + .sort((a, b) => b.totalMs - a.totalMs || b.calls - a.calls || 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 } /* -------------------------------------------------------------------------- */ From 76917ededb469b6384b6688c6d590493898fc0f3 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 8 Sep 2026 23:01:06 +0200 Subject: [PATCH 2/5] fix(agent-sessions): order the tool ledger by reach, and give its rows room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Time spent was the wrong first question for the ledger: how often the agent went back to a tool is what a reader scans the column for, and the cost of each stays one column over. Rows go to 24px, and the caption under the section goes — the marks explain themselves. --- .../session-detail/session-overview.tsx | 23 ++++++++----------- .../agent-sessions/session-summary.test.ts | 8 +++---- .../src/lib/agent-sessions/session-summary.ts | 8 +++---- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx index 59196697b..c4c1b5d2c 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx @@ -579,11 +579,6 @@ function ToolUsage({ summary, onOpenSpan }: { summary: SessionSummary; onOpenSpa /> )}
- -
- One mark per call, at its start time and sized by how long it took. - Sorted by time spent -
)} @@ -593,8 +588,8 @@ function ToolUsage({ summary, onOpenSpan }: { summary: SessionSummary; onOpenSpa /** * The run of tools at the bottom worth one row between them: no failures, and * together a rounding error against the session's tool time. Tools are ordered - * by time spent, so this is always the cheap end — a tool called once that took - * twelve seconds sits high in the list and keeps its own row. + * by reach, so this is the least-called end — and a tool that cost real time + * keeps its own row however rarely it was reached for. */ function foldableTail(tools: readonly SessionToolUsage[]): readonly SessionToolUsage[] { const budget = tools.reduce((total, tool) => total + tool.totalMs, 0) * TOOL_FOLD_MAX_SHARE @@ -718,7 +713,7 @@ function ToolLedgerRow({ return (
0 && "bg-destructive/[0.06]")}> -
+
{disclosable ? ( - - {tools.reduce((total, tool) => total + tool.calls, 0)} - - - . - - - {formatToolDuration(totalMs)} - - - {formatToolDuration(slowestMs)} - - -
- - {expanded && - tools.map((tool) => ( -
- - {tool.name} - - - {tool.calls} - - - . - - - {formatToolDuration(tool.totalMs)} - - - {formatToolDuration(tool.slowestMs)} - - -
- ))} -
- ) -} - /** 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({ @@ -976,14 +826,6 @@ function FailedCallRow({ ) } -/** `6 tools, 1 call each` where that is the whole story, and a plain count - * where it is not. */ -function foldLabel(tools: readonly SessionToolUsage[]): string { - return tools.every((tool) => tool.calls === 1) - ? `${tools.length} tools, 1 call each` - : `${tools.length} more tools` -} - 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}` From 1d99b1d6663d37e03953185beb95407def0ddbd6 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 9 Sep 2026 11:34:04 +0200 Subject: [PATCH 4/5] fix(agent-sessions): one vertical rhythm on the overview, no verdict headline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boundaries alternated 28px and 28-rule-28, header-to-content gaps ran 0, 14 and 12 across three sections, and a finding row carried twice the air of a tool row — the column read as dead space in some places and dense in others. Every boundary is now the same hairline with 24px either side, every section opens on the same 12px gap, and the finding rows tighten towards the density of the rest. "Completed, with N findings" goes with it. The findings list is directly below, counting itself in its own header; the headline said it twice and took the top of the page to do it. A failed or clean session still leads with its verdict — there the line is the only place the outcome is stated. The breakdown moves above the findings, so the page opens on the shape of the session before its faults. --- .../session-detail/session-detail.test.tsx | 13 +++-- .../session-detail/session-overview.tsx | 56 ++++++++----------- 2 files changed, 32 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx b/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx index 07a35208d..f55bc5ac1 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx @@ -437,12 +437,15 @@ describe("SessionOverview", () => { }) // A mid-session failure the session recovered from is not a failed session — - // but it is exactly what the findings list exists to surface. - it("completes-with-findings when something failed mid-session, and opens it", () => { + // but it is exactly what the findings list exists to surface. There is no + // verdict line above it: the findings ARE the verdict, and a headline + // counting them said it twice. + it("leads with the findings when something failed mid-session, and opens one", () => { const onSelectSpan = vi.fn() render() - expect(screen.getByText(/Completed, with 1 finding/)).toBeTruthy() + expect(screen.queryByText(/^Completed/)).toBeNull() + expect(screen.getByText("Findings")).toBeTruthy() fireEvent.click(screen.getByText("error · run_tests")) expect(onSelectSpan).toHaveBeenCalledWith("tool-3") }) @@ -1233,12 +1236,12 @@ describe("SessionViews", () => { // height — which is what sent "Open in Traces view" nowhere near its row. it("takes the view being left out of the page, not just out of sight", () => { render() - expect(screen.getByText(/Completed, with/)).toBeTruthy() + expect(screen.getByText("Where the time went")).toBeTruthy() fireEvent.click(screen.getByRole("tab", { name: /Traces/ })) expect(screen.getByText("Model / target")).toBeTruthy() - expect(screen.queryByText(/Completed, with/)).toBeNull() + expect(screen.queryByText("Where the time went")).toBeNull() }) // The tab choice lives beside the other cross-view state in SessionViews: diff --git a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx index 1984f1005..301bb77c8 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx @@ -85,21 +85,24 @@ export function SessionOverview({ 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" && ( + <> + + + + )} + +
@@ -127,12 +130,10 @@ type OpenSpan = (spanId: string) => void function Verdict({ verdict, - findingCount, turns, onOpenSpan, }: { verdict: SessionVerdict - findingCount: number turns: readonly SessionTurn[] onOpenSpan: OpenSpan }) { @@ -163,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"} - -

) : ( <>

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

-
+
+

Findings

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

No findings.

+

No findings.

) : ( findings.map((finding) => ( @@ -246,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", )} @@ -255,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 @@ -336,7 +328,7 @@ function TimeComposition({ summary }: { summary: SessionSummary }) {

-
+
{bands.map((band) => (
-
+
{legend.map((band) => { const Icon = AGENT_TIME_ICON[band.kind] return ( From 281d002f8ceee4856630ae0426d3ec1dfcc3aab0 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 9 Sep 2026 11:39:22 +0200 Subject: [PATCH 5/5] fix(clickhouse-builder-docs): keep the sidebar icon map's own key type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Record` on the literal threw away the one thing the map knows — which icons exist — and `maple/no-record-string-any`'s open-dictionary rule fails the lint on it, which is what has had main red since the docs app landed. Inference plus `satisfies` keeps the check on the values, and the lookup narrows an arbitrary name to a key it holds rather than indexing an open dictionary. --- apps/clickhouse-builder-docs/src/sidebar-icons.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 (