diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index d64b47972..dd8282d23 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; -import { eventsToActivity, extractStageModel, turnsToStageKind } from "./run-stages"; +import { + eventsToActivity, + extractStageModel, + groupConsecutiveTools, + turnsToStageKind, +} from "./run-stages"; function envelope(seq: number, partial: Partial): EventEnvelope { return { @@ -256,6 +261,153 @@ describe("eventsToActivity", () => { }); }); +describe("groupConsecutiveTools", () => { + type Filtered = Parameters[0]; + + function tool(opts: { + ts: string; + toolName: string; + durationMs?: number; + isError?: boolean; + input?: string; + result?: string; + }) { + return { + kind: "tool" as const, + ts: opts.ts, + toolName: opts.toolName, + input: opts.input ?? "", + result: opts.result ?? "", + isError: opts.isError ?? false, + durationMs: opts.durationMs ?? 0, + }; + } + + function entry(turn: ReturnType | { kind: "system"; ts: string; content: string } | { kind: "assistant"; ts: string; content: string; inputTokens: number; outputTokens: number }, index: number): Filtered[number] { + return { turn, index }; + } + + test("empty input returns empty output", () => { + expect(groupConsecutiveTools([])).toEqual([]); + }); + + test("single tool turn becomes a single, not a group", () => { + const t = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 100 }); + expect(groupConsecutiveTools([entry(t, 0)])).toEqual([ + { kind: "single", turn: t, turnIndex: 0 }, + ]); + }); + + test("two consecutive same-tool successes form a group of 2", () => { + const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 1000 }); + const b = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell", durationMs: 2000 }); + const result = groupConsecutiveTools([entry(a, 0), entry(b, 1)]); + expect(result).toEqual([ + { + kind: "group", + toolName: "shell", + ts: "2026-04-09T12:00:00Z", + durationMs: 3000, + children: [ + { turn: a, turnIndex: 0 }, + { turn: b, turnIndex: 1 }, + ], + }, + ]); + }); + + test("five consecutive same-tool successes form one group; durations summed; ts is first", () => { + const turns = [0, 1, 2, 3, 4].map((i) => + tool({ + ts: `2026-04-09T12:00:0${i}Z`, + toolName: "shell", + durationMs: (i + 1) * 1000, + }), + ); + const filtered = turns.map((t, i) => entry(t, i)); + const result = groupConsecutiveTools(filtered); + expect(result).toHaveLength(1); + const item = result[0]; + expect(item.kind).toBe("group"); + if (item.kind === "group") { + expect(item.ts).toBe("2026-04-09T12:00:00Z"); + expect(item.durationMs).toBe(15000); + expect(item.children.map((c) => c.turnIndex)).toEqual([0, 1, 2, 3, 4]); + } + }); + + test("a different tool between same-tool calls breaks the group boundary", () => { + const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 1 }); + const b = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell", durationMs: 1 }); + const c = tool({ ts: "2026-04-09T12:00:02Z", toolName: "read_file", durationMs: 1 }); + const d = tool({ ts: "2026-04-09T12:00:03Z", toolName: "shell", durationMs: 1 }); + const e = tool({ ts: "2026-04-09T12:00:04Z", toolName: "shell", durationMs: 1 }); + const result = groupConsecutiveTools([ + entry(a, 0), + entry(b, 1), + entry(c, 2), + entry(d, 3), + entry(e, 4), + ]); + expect(result.map((r) => r.kind)).toEqual(["group", "single", "group"]); + if (result[0].kind === "group") { + expect(result[0].children.map((c) => c.turnIndex)).toEqual([0, 1]); + } + if (result[1].kind === "single") { + expect(result[1].turnIndex).toBe(2); + } + if (result[2].kind === "group") { + expect(result[2].children.map((c) => c.turnIndex)).toEqual([3, 4]); + } + }); + + test("an errored tool call is never grouped and breaks the run", () => { + const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell" }); + const errored = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell", isError: true }); + const c = tool({ ts: "2026-04-09T12:00:02Z", toolName: "shell" }); + const d = tool({ ts: "2026-04-09T12:00:03Z", toolName: "shell" }); + const result = groupConsecutiveTools([ + entry(a, 0), + entry(errored, 1), + entry(c, 2), + entry(d, 3), + ]); + expect(result.map((r) => r.kind)).toEqual(["single", "single", "group"]); + if (result[1].kind === "single") { + expect(result[1].turn).toBe(errored); + } + if (result[2].kind === "group") { + expect(result[2].children.map((c) => c.turnIndex)).toEqual([2, 3]); + } + }); + + test("non-tool turns flush the buffer correctly", () => { + const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell" }); + const b = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell" }); + const msg = { + kind: "assistant" as const, + ts: "2026-04-09T12:00:02Z", + content: "thinking", + inputTokens: 0, + outputTokens: 0, + }; + const c = tool({ ts: "2026-04-09T12:00:03Z", toolName: "shell" }); + const result = groupConsecutiveTools([ + entry(a, 0), + entry(b, 1), + entry(msg, 2), + entry(c, 3), + ]); + expect(result.map((r) => r.kind)).toEqual(["group", "single", "single"]); + if (result[0].kind === "group") { + expect(result[0].children.map((c) => c.turnIndex)).toEqual([0, 1]); + } + if (result[2].kind === "single") { + expect(result[2].turnIndex).toBe(3); + } + }); +}); + describe("turnsToStageKind", () => { test("classifies a stage with only command events as command", () => { const events: EventEnvelope[] = [ diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 8258311a5..4d502fce3 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -9,6 +9,8 @@ import { import { XMarkIcon } from "@heroicons/react/24/outline"; import { CheckIcon, + ChevronDownIcon, + ChevronRightIcon, ChevronUpDownIcon, CpuChipIcon, FunnelIcon, @@ -53,6 +55,10 @@ type TurnType = type CommandTurn = Extract; type StageKind = "agent" | "command"; +type PanelSelection = + | { kind: "single"; turnIndex: number } + | { kind: "group"; childTurnIndices: number[] }; + const STAGE_ACTIVITY_EVENT_SET = new Set(STAGE_ACTIVITY_EVENT_TYPES); const EVENT_KINDS = ["system", "assistant", "tool", "command"] as const; @@ -229,6 +235,59 @@ export function turnsToStageKind(turns: TurnType[]): StageKind { return hasCommand ? "command" : "agent"; } +type ToolTurn = Extract; + +export type DisplayItem = + | { kind: "single"; turn: TurnType; turnIndex: number } + | { + kind: "group"; + toolName: string; + ts: string; + durationMs: number; + children: { turn: ToolTurn; turnIndex: number }[]; + }; + +export function groupConsecutiveTools( + filtered: { turn: TurnType; index: number }[], +): DisplayItem[] { + const out: DisplayItem[] = []; + let buf: { turn: ToolTurn; turnIndex: number }[] = []; + + function flush() { + if (buf.length === 0) return; + if (buf.length === 1) { + out.push({ kind: "single", turn: buf[0].turn, turnIndex: buf[0].turnIndex }); + } else { + const first = buf[0].turn; + const totalMs = buf.reduce((sum, b) => sum + b.turn.durationMs, 0); + out.push({ + kind: "group", + toolName: first.toolName, + ts: first.ts, + durationMs: totalMs, + children: buf, + }); + } + buf = []; + } + + for (const { turn, index } of filtered) { + const groupable = turn.kind === "tool" && !turn.isError; + if (groupable && (buf.length === 0 || buf[0].turn.toolName === turn.toolName)) { + buf.push({ turn, turnIndex: index }); + continue; + } + flush(); + if (groupable) { + buf.push({ turn, turnIndex: index }); + } else { + out.push({ kind: "single", turn, turnIndex: index }); + } + } + flush(); + return out; +} + const STAGE_MODEL_EVENT_NAMES = new Set([ "stage.prompt", "agent.session.activated", @@ -467,6 +526,50 @@ function EventRow({ ); } +const TOOL_GROUP_TONE = "bg-mint/15 text-mint"; + +function ToolGroupRow({ + group, + runStart, + selected, + onSelect, +}: { + group: Extract; + runStart: string | undefined; + selected: boolean; + onSelect: () => void; +}) { + const metric = group.durationMs > 0 ? formatDurationMs(group.durationMs) : null; + return ( + + ); +} + function DebugRow({ event, runStart, @@ -868,6 +971,152 @@ function EventDetailsPanel({ ); } +const TOOL_INPUT_PREVIEW_KEYS = ["command", "path", "pattern", "url", "query", "script"]; + +function toolInputPreview(turn: ToolTurn): string { + const raw = turn.input; + if (!raw) return ""; + try { + const parsed = JSON.parse(raw); + if (typeof parsed === "string") return oneLine(parsed); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const obj = parsed as Record; + for (const k of TOOL_INPUT_PREVIEW_KEYS) { + const v = obj[k]; + if (typeof v === "string" && v) return oneLine(v); + } + } + } catch { + // input wasn't valid JSON; fall through to oneLine of the raw string + } + return oneLine(raw); +} + +function ToolGroupChildRow({ + child, + runStart, + expanded, + onToggle, +}: { + child: { turn: ToolTurn; turnIndex: number }; + runStart: string | undefined; + expanded: boolean; + onToggle: () => void; +}) { + const { turn } = child; + const metric = turn.durationMs > 0 ? formatDurationMs(turn.durationMs) : null; + const elapsed = formatElapsed(turn.ts, runStart); + const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon; + return ( + + ); +} + +function ToolGroupDetails({ + group, + runStart, +}: { + group: Extract; + runStart: string | undefined; +}) { + const [expandedIndex, setExpandedIndex] = useState(null); + useEffect(() => { + setExpandedIndex(null); + }, [group]); + + const elapsed = formatElapsed(group.ts, runStart); + const totalDuration = group.durationMs > 0 ? formatDurationMs(group.durationMs) : null; + + return ( +
+
+ + Tool + + + {humanizeToolName(group.toolName)}{" "} + x{group.children.length} + + + {elapsed} + {totalDuration && ( + <> + + +
+
    + {group.children.map((child, i) => ( +
  • + + setExpandedIndex((current) => (current === i ? null : i)) + } + /> + {expandedIndex === i && ( +
    + +
    + )} +
  • + ))} +
+
+ ); +} + +function ToolGroupDetailsPanel({ + group, + runStart, + onClose, +}: { + group: Extract | null; + runStart: string | undefined; + onClose: () => void; +}) { + return ( + + {group ? : null} + + ); +} + function DebugEventDetails({ event }: { event: EventEnvelope }) { const text = useMemo(() => JSON.stringify(event, null, 2), [event]); const tokens = useMemo(() => highlightJson(text), [text]); @@ -1149,13 +1398,12 @@ export default function RunStages() { return null; }, [turns]); - const [openIndex, setOpenIndex] = useState(null); + const [panelSelection, setPanelSelection] = useState(null); const [openDebugSeq, setOpenDebugSeq] = useState(null); useEffect(() => { - setOpenIndex(null); + setPanelSelection(null); setOpenDebugSeq(null); }, [selectedStageId]); - const openTurn = openIndex != null ? turns[openIndex] ?? null : null; const [tab, setTab] = useState("transcript"); const [selectedKinds, setSelectedKinds] = useState([ @@ -1174,6 +1422,24 @@ export default function RunStages() { }); return out; }, [turns, selectedKinds, search]); + const displayItems = useMemo( + () => groupConsecutiveTools(filteredTurns), + [filteredTurns], + ); + + const openTurn = + panelSelection?.kind === "single" ? turns[panelSelection.turnIndex] ?? null : null; + const openGroup = useMemo | null>(() => { + if (panelSelection?.kind !== "group") return null; + const wanted = panelSelection.childTurnIndices; + for (const item of displayItems) { + if (item.kind !== "group") continue; + if (item.children.length !== wanted.length) continue; + const matches = item.children.every((c, i) => c.turnIndex === wanted[i]); + if (matches) return item; + } + return null; + }, [displayItems, panelSelection]); const debugEvents = useMemo(() => { if (!selectedStageId) return []; @@ -1277,15 +1543,44 @@ export default function RunStages() { No events match these filters. ) : ( - filteredTurns.map(({ turn, index }) => ( - setOpenIndex(index)} - /> - )) + displayItems.map((item) => { + if (item.kind === "single") { + return ( + + setPanelSelection({ kind: "single", turnIndex: item.turnIndex }) + } + /> + ); + } + const childIndices = item.children.map((c) => c.turnIndex); + const groupKey = `group-${childIndices.join("-")}`; + const isSelected = + panelSelection?.kind === "group" && + panelSelection.childTurnIndices.length === childIndices.length && + panelSelection.childTurnIndices.every((v, i) => v === childIndices[i]); + return ( + + setPanelSelection({ + kind: "group", + childTurnIndices: childIndices, + }) + } + /> + ); + }) ) ) : debugEvents.length > 0 && filteredDebugEvents.length === 0 ? (
@@ -1306,11 +1601,17 @@ export default function RunStages() {
{tab === "transcript" ? ( - stageKind === "command" ? null : ( + stageKind === "command" ? null : panelSelection?.kind === "group" ? ( + setPanelSelection(null)} + /> + ) : ( setOpenIndex(null)} + onClose={() => setPanelSelection(null)} /> ) ) : (