diff --git a/apps/fabro-web/app/components/event-debug.tsx b/apps/fabro-web/app/components/event-debug.tsx index 30f77e04f..b83e5eee7 100644 --- a/apps/fabro-web/app/components/event-debug.tsx +++ b/apps/fabro-web/app/components/event-debug.tsx @@ -456,7 +456,7 @@ export interface ThreadDnaItem { const INSTANT_MARKER_PX = 4; const MIN_DURATION_PX = 3; -function selectionKey(s: ThreadDnaSelection): string { +export function threadSelectionKey(s: ThreadDnaSelection): string { return s.kind === "single" ? `s:${s.turnIndex}` : `g:${s.childTurnIndices.join(",")}`; @@ -516,7 +516,7 @@ export function ThreadDnaStrip({ const visibleItemByKey = useMemo( () => new Map( - visibleItems.map((item) => [selectionKey(item.selection), item]), + visibleItems.map((item) => [threadSelectionKey(item.selection), item]), ), [visibleItems], ); @@ -552,7 +552,7 @@ export function ThreadDnaStrip({ >
{visibleItems.map((item) => { - const key = selectionKey(item.selection); + const key = threadSelectionKey(item.selection); const isInstant = item.durationMs <= 0; const isSelected = selectionsEqual(item.selection, selection); const isHovered = hover?.key === key; diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index abe450314..fa7fa9a31 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -3,12 +3,21 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client"; import { buildThreadDnaItems, + displayItemSelection, + EVENT_KINDS, eventsTabLabel, eventsToActivity, + filterDisplayItems, formatStageModelUsageLabel, groupConsecutiveTools, + searchableText, selectStageRenderer, + turnSummary, + visibleTurnCount, + type DisplayItem, + type EventKind, } from "./run-stages"; +import { threadSelectionKey } from "../components/event-debug"; function envelope(seq: number, partial: Partial): EventEnvelope { return { @@ -59,6 +68,7 @@ describe("eventsToActivity", () => { content: "first visit reply", inputTokens: 0, outputTokens: 0, + toolCallCount: null, }, ]); @@ -71,6 +81,7 @@ describe("eventsToActivity", () => { content: "second visit reply", inputTokens: 0, outputTokens: 0, + toolCallCount: null, }, ]); }); @@ -329,6 +340,7 @@ describe("eventsToActivity", () => { content: "Refactored auth module", inputTokens: 120, outputTokens: 30, + toolCallCount: null, }, ]); }); @@ -376,6 +388,7 @@ describe("eventsToActivity", () => { content: "Done.", inputTokens: 10, outputTokens: 5, + toolCallCount: null, }, ]); }); @@ -402,6 +415,7 @@ describe("eventsToActivity", () => { content: "All clear.", inputTokens: 0, outputTokens: 4, + toolCallCount: null, }, ]); }); @@ -482,7 +496,7 @@ describe("groupConsecutiveTools", () => { }; } - function entry(turn: ReturnType | { kind: "system"; ts: string; content: string } | { kind: "assistant"; ts: string; content: string; inputTokens: number; outputTokens: number }, index: number): Filtered[number] { + function entry(turn: Filtered[number]["turn"], index: number): Filtered[number] { return { turn, index }; } @@ -515,7 +529,7 @@ describe("groupConsecutiveTools", () => { ]); }); - test("five consecutive same-tool successes form one group; durations summed; ts is first", () => { + test("five consecutive same-tool successes form one group spanning earliest start to latest end", () => { const turns = [0, 1, 2, 3, 4].map((i) => tool({ ts: `2026-04-09T12:00:0${i}Z`, @@ -530,11 +544,52 @@ describe("groupConsecutiveTools", () => { expect(item.kind).toBe("group"); if (item.kind === "group") { expect(item.ts).toBe("2026-04-09T12:00:00Z"); - expect(item.durationMs).toBe(15000); + // last child starts at 4s and runs 5s → ends at 9s. The summed 15s is + // not elapsed time; overlapping calls would double-count. + expect(item.durationMs).toBe(9000); expect(item.children.map((c) => c.turnIndex)).toEqual([0, 1, 2, 3, 4]); } }); + test("group bounds ignore array order and use the earliest start / latest end", () => { + // Children listed in completion order: the second one started first and + // the first one finished last. + const late = tool({ ts: "2026-04-09T12:00:05Z", toolName: "shell", durationMs: 4000 }); + const early = tool({ ts: "2026-04-09T12:00:02Z", toolName: "shell", durationMs: 500 }); + const result = groupConsecutiveTools([entry(late, 0), entry(early, 1)]); + expect(result).toHaveLength(1); + const item = result[0]; + if (item.kind === "group") { + expect(item.ts).toBe("2026-04-09T12:00:02Z"); + // earliest start 2s, latest end 5s + 4s = 9s → 7s elapsed. + expect(item.durationMs).toBe(7000); + } + }); + + test("parallel children collapse to their overlapping wall-clock span", () => { + const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 3000 }); + const b = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 2000 }); + const c = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 1000 }); + const result = groupConsecutiveTools([entry(a, 0), entry(b, 1), entry(c, 2)]); + const item = result[0]; + if (item.kind === "group") { + // Three calls issued together: elapsed is the slowest, not the sum. + expect(item.durationMs).toBe(3000); + } + }); + + test("a group of unparseable timestamps falls back to zero elapsed", () => { + const a = tool({ ts: "not-a-timestamp", toolName: "shell", durationMs: 10 }); + const b = tool({ ts: "also-bad", toolName: "shell", durationMs: 10 }); + const result = groupConsecutiveTools([entry(a, 0), entry(b, 1)]); + const item = result[0]; + expect(item.kind).toBe("group"); + if (item.kind === "group") { + expect(item.ts).toBe("not-a-timestamp"); + expect(item.durationMs).toBe(0); + } + }); + 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 }); @@ -589,6 +644,7 @@ describe("groupConsecutiveTools", () => { content: "thinking", inputTokens: 0, outputTokens: 0, + toolCallCount: null, }; const c = tool({ ts: "2026-04-09T12:00:03Z", toolName: "shell" }); const result = groupConsecutiveTools([ @@ -675,6 +731,7 @@ describe("buildThreadDnaItems", () => { content: "hi", inputTokens: 0, outputTokens: 0, + toolCallCount: null, }, }; } @@ -780,40 +837,39 @@ describe("buildThreadDnaItems", () => { }); }); - test("tool group spans first child's start to last child's end", () => { - const child1 = { - turnIndex: 0, - turn: { - kind: "tool" as const, - ts: "2026-04-09T12:00:10Z", - toolName: "shell", - input: "", - result: "", - isError: false, - durationMs: 1000, - }, - }; - const child2 = { - turnIndex: 1, - turn: { - kind: "tool" as const, - ts: "2026-04-09T12:00:12Z", - toolName: "shell", - input: "", - result: "", - isError: false, - durationMs: 2000, - }, - }; - const group = { - kind: "group" as const, + test("a group's bar reuses the same wall-clock bounds the row shows", () => { + // Children in completion order, so the group's start is not children[0]. + const late = { + kind: "tool" as const, + ts: "2026-04-09T12:00:12Z", toolName: "shell", - ts: "2026-04-09T12:00:10Z", - durationMs: 3000, - children: [child1, child2], + input: "", + result: "", + isError: false, + durationMs: 2000, }; - const items = buildThreadDnaItems([group], RUN_START); - // span = 12s + 2s − 10s = 4s, not the summed 3s. + const early = { + kind: "tool" as const, + ts: "2026-04-09T12:00:10Z", + toolName: "shell", + input: "", + result: "", + isError: false, + durationMs: 1000, + }; + const grouped = groupConsecutiveTools([ + { turn: late, index: 0 }, + { turn: early, index: 1 }, + ]); + const group = grouped[0]; + expect(group.kind).toBe("group"); + if (group.kind !== "group") return; + + // span = 12s + 2s − 10s = 4s, not the summed 3s and not children[0]'s ts. + expect(group.ts).toBe("2026-04-09T12:00:10Z"); + expect(group.durationMs).toBe(4000); + + const items = buildThreadDnaItems(grouped, RUN_START); expect(items[0]).toMatchObject({ category: "tool", startMs: 10_000, @@ -834,3 +890,284 @@ describe("buildThreadDnaItems", () => { expect(items[1]).toMatchObject({ startMs: 0, durationMs: 5000 }); }); }); + +describe("tool-call-only agent responses", () => { + test("retains an empty agent.message with its timestamp, billing, and tool-call count", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "agent.message", + ts: "2026-04-09T12:00:42Z", + stage_id: "code@1", + node_id: "code", + properties: { + text: "", + billing: { input_tokens: 4200, output_tokens: 96 }, + tool_call_count: 2, + }, + }), + ]; + + expect(eventsToActivity(events, "code@1")).toEqual([ + { + kind: "assistant", + ts: "2026-04-09T12:00:42Z", + content: "", + inputTokens: 4200, + outputTokens: 96, + toolCallCount: 2, + }, + ]); + }); + + test("does not synthesize a prompt.completed turn after an empty agent.message", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "agent.message", + stage_id: "code@1", + node_id: "code", + properties: { text: "", tool_call_count: 1 }, + }), + envelope(2, { + event: "prompt.completed", + stage_id: "code@1", + node_id: "code", + properties: { response: "", billing: { input_tokens: 1, output_tokens: 2 } }, + }), + ]; + + const turns = eventsToActivity(events, "code@1"); + expect(turns).toHaveLength(1); + expect(turns[0]).toMatchObject({ kind: "assistant", toolCallCount: 1 }); + }); + + test("empty responses get nonblank summary copy and stay searchable by it", () => { + const withTools = { + kind: "assistant" as const, + ts: "2026-04-09T12:00:00Z", + content: "", + inputTokens: 0, + outputTokens: 0, + toolCallCount: 3, + }; + const withOneTool = { ...withTools, toolCallCount: 1 }; + const withoutCount = { ...withTools, toolCallCount: null }; + + expect(turnSummary(withTools)).toBe("Requested 3 tool calls"); + expect(turnSummary(withOneTool)).toBe("Requested 1 tool call"); + expect(turnSummary(withoutCount)).toBe("Model response contained no text"); + + expect(searchableText(withTools)).toContain("Requested 3 tool calls"); + // Text-bearing responses keep searching their own content. + expect(searchableText({ ...withTools, content: "all done" })).toBe("all done"); + }); +}); + +describe("tool batch boundaries", () => { + const STAGE = "code@1"; + const RUN_START = "2026-04-09T12:00:00Z"; + + function modelResponse( + seq: number, + ts: string, + toolCallCount: number, + text = "", + ): EventEnvelope { + return envelope(seq, { + event: "agent.message", + ts, + stage_id: STAGE, + node_id: "code", + properties: { + text, + billing: { input_tokens: 1000, output_tokens: 20 }, + tool_call_count: toolCallCount, + }, + }); + } + + function shellCall( + seq: number, + callId: string, + startTs: string, + endTs: string, + command: string, + ): EventEnvelope[] { + return [ + envelope(seq, { + event: "agent.tool.started", + ts: startTs, + stage_id: STAGE, + node_id: "code", + properties: { + tool_call_id: callId, + tool_name: "shell", + arguments: { command }, + }, + }), + envelope(seq + 1, { + event: "agent.tool.completed", + ts: endTs, + stage_id: STAGE, + node_id: "code", + properties: { tool_call_id: callId, tool_name: "shell", output: "ok" }, + }), + ]; + } + + // Anonymized reproduction: eight sub-100ms shell calls issued across five + // model responses, each response separated by a minute or more of model + // time and carrying no text of its own. + const REPRO_EVENTS: EventEnvelope[] = [ + envelope(1, { + event: "stage.prompt", + ts: RUN_START, + stage_id: STAGE, + node_id: "code", + properties: { text: "investigate the failure" }, + }), + modelResponse(2, "2026-04-09T12:00:30Z", 2), + ...shellCall(3, "c1", "2026-04-09T12:00:30.010Z", "2026-04-09T12:00:30.060Z", "alpha"), + ...shellCall(5, "c2", "2026-04-09T12:00:30.070Z", "2026-04-09T12:00:30.140Z", "bravo"), + modelResponse(7, "2026-04-09T12:01:30Z", 1), + ...shellCall(8, "c3", "2026-04-09T12:01:30.010Z", "2026-04-09T12:01:30.050Z", "charlie"), + modelResponse(10, "2026-04-09T12:02:40Z", 1), + ...shellCall(11, "c4", "2026-04-09T12:02:40.010Z", "2026-04-09T12:02:40.090Z", "delta"), + modelResponse(13, "2026-04-09T12:03:50Z", 2), + ...shellCall(14, "c5", "2026-04-09T12:03:50.010Z", "2026-04-09T12:03:50.060Z", "echo"), + ...shellCall(16, "c6", "2026-04-09T12:03:50.070Z", "2026-04-09T12:03:50.130Z", "foxtrot"), + modelResponse(18, "2026-04-09T12:05:00Z", 2), + ...shellCall(19, "c7", "2026-04-09T12:05:00.010Z", "2026-04-09T12:05:00.060Z", "golf"), + ...shellCall(21, "c8", "2026-04-09T12:05:00.070Z", "2026-04-09T12:05:00.130Z", "hotel"), + modelResponse(23, "2026-04-09T12:06:00Z", 0, "Done."), + ]; + + function reproItems(): DisplayItem[] { + const turns = eventsToActivity(REPRO_EVENTS, STAGE); + return groupConsecutiveTools(turns.map((turn, index) => ({ turn, index }))); + } + + function visibleDna( + items: DisplayItem[], + kinds: readonly EventKind[], + search: string, + ) { + const all = buildThreadDnaItems(items, RUN_START); + const visible = new Set( + filterDisplayItems(items, kinds, search).map((item) => + threadSelectionKey(displayItemSelection(item)), + ), + ); + return all.filter((item) => visible.has(threadSelectionKey(item.selection))); + } + + function groupSizes(items: DisplayItem[]): (number | "single")[] { + return items + .filter( + (item) => item.kind === "group" || (item.kind === "single" && item.turn.kind === "tool"), + ) + .map((item) => (item.kind === "group" ? item.children.length : "single")); + } + + test("eight shell calls across five responses keep their original batches", () => { + const items = reproItems(); + expect(groupSizes(items)).toEqual([2, "single", "single", 2, 2]); + // The bug produced a single `Bash x8` group. + expect(items.some((item) => item.kind === "group" && item.children.length > 2)).toBe( + false, + ); + }); + + test("batches survive excluding Agent with the kind filter", () => { + const items = reproItems(); + const withoutAgent = EVENT_KINDS.filter((k) => k !== "assistant"); + const visible = filterDisplayItems(items, withoutAgent, ""); + + expect(groupSizes(visible)).toEqual([2, "single", "single", 2, 2]); + expect(visible.some((item) => item.kind === "single" && item.turn.kind === "assistant")).toBe( + false, + ); + // Eight tool turns remain, just spread across the same five items. + expect(visibleTurnCount(visible)).toBe(9); // 8 tool calls + the stage prompt + }); + + test("search matching one child keeps its whole group and merges nothing", () => { + const items = reproItems(); + const visible = filterDisplayItems(items, EVENT_KINDS, "alpha"); + + expect(visible).toHaveLength(1); + const only = visible[0]; + expect(only.kind).toBe("group"); + if (only.kind === "group") { + // "bravo" never matched the search but stays in the group for context. + expect(only.children).toHaveLength(2); + expect(only.children.map((c) => JSON.parse(c.turn.input).command)).toEqual([ + "alpha", + "bravo", + ]); + } + }); + + test("DNA charges the long gaps to Agent and keeps every tool batch sub-second", () => { + const bars = buildThreadDnaItems(reproItems(), RUN_START); + const agentBars = bars.filter((b) => b.category === "agent"); + const toolBars = bars.filter((b) => b.category === "tool"); + + expect(agentBars).toHaveLength(6); + expect(toolBars).toHaveLength(5); + for (const bar of toolBars) { + expect(bar.durationMs).toBeLessThan(1000); + } + // First response: 30s of model time from the stage prompt. + expect(agentBars[0]).toMatchObject({ startMs: 0, durationMs: 30_000 }); + // Second: from the end of the first batch (30.140s) to 90s. + expect(agentBars[1]).toMatchObject({ startMs: 30_140, durationMs: 59_860 }); + // The first batch itself is 130ms, not the six minutes of the whole stage. + expect(toolBars[0]).toMatchObject({ startMs: 30_010, durationMs: 130 }); + }); + + test("hiding tools does not inflate the adjacent Agent durations", () => { + const items = reproItems(); + const unfiltered = buildThreadDnaItems(items, RUN_START).filter( + (b) => b.category === "agent", + ); + const withoutTools = visibleDna( + items, + EVENT_KINDS.filter((k) => k !== "tool"), + "", + ).filter((b) => b.category === "agent"); + + expect(withoutTools).toEqual(unfiltered); + }); + + test("hiding Agent does not inflate or merge the tool bars", () => { + const items = reproItems(); + const unfiltered = buildThreadDnaItems(items, RUN_START).filter( + (b) => b.category === "tool", + ); + const withoutAgent = visibleDna( + items, + EVENT_KINDS.filter((k) => k !== "assistant"), + "", + ).filter((b) => b.category === "tool"); + + expect(withoutAgent).toEqual(unfiltered); + }); + + test("row and bar selection identifiers stay one-to-one", () => { + const items = reproItems(); + const bars = buildThreadDnaItems(items, RUN_START); + + expect(bars.map((b) => threadSelectionKey(b.selection))).toEqual( + items.map((item) => threadSelectionKey(displayItemSelection(item))), + ); + + const group = items.find((item) => item.kind === "group"); + expect(group).toBeDefined(); + if (group?.kind === "group") { + expect(displayItemSelection(group)).toEqual({ + kind: "group", + childTurnIndices: group.children.map((c) => c.turnIndex), + }); + } + }); +}); diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index b39b6bb72..765a5edd9 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -17,6 +17,7 @@ import { EventSearchInput, MultiSelectFilter, ThreadDnaStrip, + threadSelectionKey, } from "../components/event-debug"; import { debugCategory, @@ -81,7 +82,14 @@ type TurnType = | { kind: "interrupt"; ts: string; content: string } | { kind: "pair_user"; ts: string; content: string } | { kind: "pair_system"; ts: string; content: string } - | { kind: "assistant"; ts: string; content: string; inputTokens: number; outputTokens: number } + | { + kind: "assistant"; + ts: string; + content: string; + inputTokens: number; + outputTokens: number; + toolCallCount: number | null; + } | { kind: "tool"; ts: string; toolName: string; input: string; result: string; isError: boolean; durationMs: number } | { kind: "command"; @@ -109,7 +117,7 @@ type PanelSelection = ThreadDnaSelection; const STAGE_ACTIVITY_EVENT_SET = new Set(STAGE_ACTIVITY_EVENT_TYPES); -const EVENT_KINDS = [ +export const EVENT_KINDS = [ "system", "steer", "interrupt", @@ -119,7 +127,7 @@ const EVENT_KINDS = [ "tool", "command", ] as const; -type EventKind = (typeof EVENT_KINDS)[number]; +export type EventKind = (typeof EVENT_KINDS)[number]; const EVENT_KIND_LABEL: Record = { system: "System", @@ -254,17 +262,18 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn break; case "agent.message": { sawAssistantMessage = true; - const msg = getString(props, "text") ?? e.text ?? ""; - if (msg) { - const billing = (props.billing ?? {}) as UnknownRecord; - turns.push({ - kind: "assistant", - ts: e.ts, - content: msg, - inputTokens: getNumber(billing, "input_tokens") ?? 0, - outputTokens: getNumber(billing, "output_tokens") ?? 0, - }); - } + // A text-free message still marks the end of a model response — it is + // the boundary between two batches of tool calls. Dropping it would + // splice unrelated batches into one tool group. + const billing = (props.billing ?? {}) as UnknownRecord; + turns.push({ + kind: "assistant", + ts: e.ts, + content: getString(props, "text") ?? e.text ?? "", + inputTokens: getNumber(billing, "input_tokens") ?? 0, + outputTokens: getNumber(billing, "output_tokens") ?? 0, + toolCallCount: getNumber(props, "tool_call_count") ?? null, + }); break; } case "prompt.completed": { @@ -276,6 +285,7 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn content: getString(props, "response") ?? "", inputTokens: getNumber(billing, "input_tokens") ?? 0, outputTokens: getNumber(billing, "output_tokens") ?? 0, + toolCallCount: null, }); } break; @@ -390,8 +400,49 @@ export type DisplayItem = children: { turn: ToolTurn; turnIndex: number }[]; }; +// A group's elapsed time is the wall-clock envelope of its children — +// earliest start to latest end — not the sum of their durations. Parallel +// calls overlap, and completion order is not always start order, so neither +// the summed duration nor the last array element is the right answer. +export function toolGroupBounds(children: { turn: ToolTurn }[]): { + ts: string; + durationMs: number; +} { + let earliestTs = children[0].turn.ts; + let earliestStart: number | null = null; + let latestEnd: number | null = null; + + for (const { turn } of children) { + const startMs = Date.parse(turn.ts); + if (Number.isNaN(startMs)) continue; + const endMs = startMs + Math.max(0, turn.durationMs); + if (earliestStart == null || startMs < earliestStart) { + earliestStart = startMs; + earliestTs = turn.ts; + } + if (latestEnd == null || endMs > latestEnd) latestEnd = endMs; + } + + if (earliestStart == null || latestEnd == null) { + return { ts: earliestTs, durationMs: 0 }; + } + return { ts: earliestTs, durationMs: Math.max(0, latestEnd - earliestStart) }; +} + +export function displayItemSelection(item: DisplayItem): ThreadDnaSelection { + return item.kind === "single" + ? { kind: "single", turnIndex: item.turnIndex } + : { + kind: "group", + childTurnIndices: item.children.map((child) => child.turnIndex), + }; +} + +// Grouping runs over the complete turn stream, never a filtered one: any +// non-tool turn is a real boundary whether or not the current filters make it +// visible, and hiding one must not merge the tool batches on either side. export function groupConsecutiveTools( - filtered: { turn: TurnType; index: number }[], + turns: { turn: TurnType; index: number }[], ): DisplayItem[] { const out: DisplayItem[] = []; let buf: { turn: ToolTurn; turnIndex: number }[] = []; @@ -401,20 +452,19 @@ export function groupConsecutiveTools( 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); + const bounds = toolGroupBounds(buf); out.push({ kind: "group", - toolName: first.toolName, - ts: first.ts, - durationMs: totalMs, + toolName: buf[0].turn.toolName, + ts: bounds.ts, + durationMs: bounds.durationMs, children: buf, }); } buf = []; } - for (const { turn, index } of filtered) { + for (const { turn, index } of turns) { const groupable = turn.kind === "tool" && !turn.isError; if (groupable && (buf.length === 0 || buf[0].turn.toolName === turn.toolName)) { buf.push({ turn, turnIndex: index }); @@ -431,15 +481,47 @@ export function groupConsecutiveTools( return out; } -// Convert the event list / grouped tool view into bars for the Thread DNA +// Hide display items that the kind filter or search excludes. This is purely +// a visibility pass: it runs after grouping and after DNA timing, so it can +// never change group membership, timestamps, or durations. A group survives +// when Tool is selected and any child matches the search, and it is passed +// through whole so its context is preserved. +export function filterDisplayItems( + items: DisplayItem[], + selectedKinds: readonly EventKind[], + search: string, +): DisplayItem[] { + const kinds = new Set(selectedKinds); + const needle = search.toLowerCase(); + const matchesSearch = (turn: TurnType) => + !needle || searchableText(turn).toLowerCase().includes(needle); + + return items.filter((item) => { + if (item.kind === "single") { + return kinds.has(item.turn.kind) && matchesSearch(item.turn); + } + return kinds.has("tool") && item.children.some((c) => matchesSearch(c.turn)); + }); +} + +export function visibleTurnCount(items: DisplayItem[]): number { + return items.reduce( + (total, item) => total + (item.kind === "single" ? 1 : item.children.length), + 0, + ); +} + +// Convert the complete grouped display list into bars for the Thread DNA // strip. Each bar carries the same selection identifier the event list uses, // so clicking a bar opens the same side-panel entry as clicking its row. // // Duration semantics: // - tool / command turns use their explicit durationMs -// - tool groups span from the first child's start to the last child's end -// - assistant turns have no native duration; we treat the time from the -// previous activity's end to this message's ts as "thinking" time +// - tool groups use their wall-clock envelope (see toolGroupBounds) +// - assistant turns have no native duration; their bar covers the interval +// from the previous activity's end to the message's ts. That is the +// inferred model response time — provider queueing, network, streaming, +// and generation — not a reasoning trace. // - system / steer / interrupt are instants (durationMs = 0) export function buildThreadDnaItems( items: DisplayItem[], @@ -461,16 +543,19 @@ export function buildThreadDnaItems( const out: ThreadDnaItem[] = []; let prevEndMs: number | null = null; + // Overlapping or out-of-order tool completions must never move the + // previous-activity cursor backward, or the next Agent bar absorbs time + // that already belonged to a tool. + const advance = (endMs: number) => { + prevEndMs = prevEndMs == null ? endMs : Math.max(prevEndMs, endMs); + }; for (const item of items) { if (item.kind === "single") { const turn = item.turn; const tsMs = Date.parse(turn.ts); if (Number.isNaN(tsMs)) continue; - const selection: ThreadDnaSelection = { - kind: "single", - turnIndex: item.turnIndex, - }; + const selection = displayItemSelection(item); switch (turn.kind) { case "system": @@ -481,7 +566,7 @@ export function buildThreadDnaItems( durationMs: 0, selection, }); - prevEndMs = tsMs; + advance(tsMs); break; case "steer": out.push({ @@ -491,7 +576,7 @@ export function buildThreadDnaItems( durationMs: 0, selection, }); - prevEndMs = tsMs; + advance(tsMs); break; case "interrupt": out.push({ @@ -501,7 +586,7 @@ export function buildThreadDnaItems( durationMs: 0, selection, }); - prevEndMs = tsMs; + advance(tsMs); break; case "pair_user": out.push({ @@ -511,7 +596,7 @@ export function buildThreadDnaItems( durationMs: 0, selection, }); - prevEndMs = tsMs; + advance(tsMs); break; case "pair_system": out.push({ @@ -521,12 +606,13 @@ export function buildThreadDnaItems( durationMs: 0, selection, }); - prevEndMs = tsMs; + advance(tsMs); break; case "assistant": { // turn.ts is the moment the assistant message arrived (end of - // generation). Its bar represents the gap from the last activity - // to that moment, so the visual width approximates "thinking". + // generation). Its bar covers the gap from the last activity to + // that moment: the model's response time, tool-call-only responses + // included. const startSourceMs = prevEndMs ?? tsMs; const startMs = Math.max(0, startSourceMs - anchorMs); const durationMs = Math.max(0, tsMs - startSourceMs); @@ -537,7 +623,7 @@ export function buildThreadDnaItems( durationMs, selection, }); - prevEndMs = tsMs; + advance(tsMs); break; } case "tool": { @@ -550,7 +636,7 @@ export function buildThreadDnaItems( durationMs, selection, }); - prevEndMs = tsMs + durationMs; + advance(tsMs + durationMs); break; } case "command": { @@ -563,29 +649,24 @@ export function buildThreadDnaItems( durationMs, selection, }); - prevEndMs = tsMs + durationMs; + advance(tsMs + durationMs); break; } } } else { - const firstStart = Date.parse(item.ts); - const lastChild = item.children[item.children.length - 1].turn; - const lastEnd = Date.parse(lastChild.ts) + lastChild.durationMs; - if (Number.isNaN(firstStart) || Number.isNaN(lastEnd)) continue; - - const startMs = Math.max(0, firstStart - anchorMs); - const durationMs = Math.max(0, lastEnd - firstStart); + // item.ts / item.durationMs are already the group's wall-clock + // envelope, so the row, the details header, and this bar all agree. + const startTsMs = Date.parse(item.ts); + if (Number.isNaN(startTsMs)) continue; + const durationMs = Math.max(0, item.durationMs); out.push({ category: "tool", label: `${humanizeToolName(item.toolName)} ×${item.children.length}`, - startMs, + startMs: Math.max(0, startTsMs - anchorMs), durationMs, - selection: { - kind: "group", - childTurnIndices: item.children.map((c) => c.turnIndex), - }, + selection: displayItemSelection(item), }); - prevEndMs = lastEnd; + advance(startTsMs + durationMs); } } @@ -706,8 +787,18 @@ export function turnSummary(turn: TurnType): string { case "interrupt": case "pair_user": case "pair_system": - case "assistant": return oneLine(turn.content); + case "assistant": { + const line = oneLine(turn.content); + if (line) return line; + // A model response that only requested tools has no text of its own; + // describe what it did instead of rendering a blank row. + const count = turn.toolCallCount ?? 0; + if (count > 0) { + return `Requested ${count} tool call${count === 1 ? "" : "s"}`; + } + return "Model response contained no text"; + } case "tool": return humanizeToolName(turn.toolName); case "command": @@ -748,8 +839,10 @@ export function searchableText(turn: TurnType): string { case "interrupt": case "pair_user": case "pair_system": - case "assistant": return turn.content; + case "assistant": + // Text-free responses are findable by the copy the thread shows. + return turn.content || turnSummary(turn); case "tool": return `${humanizeToolName(turn.toolName)} ${turn.toolName} ${turn.input} ${turn.result}`; case "command": @@ -904,13 +997,35 @@ function EventDetails({ turn.kind === "steer" || turn.kind === "interrupt" || turn.kind === "pair_user" || - turn.kind === "pair_system" || - turn.kind === "assistant") && ( + turn.kind === "pair_system") && ( )} + {turn.kind === "assistant" && ( + <> + + {turn.content ? ( + + ) : ( + {turnSummary(turn)} + )} + + {turn.toolCallCount != null && turn.toolCallCount > 0 && ( + + {turn.toolCallCount} + + )} + {(turn.inputTokens > 0 || turn.outputTokens > 0) && ( + + {formatTokenCount(turn.inputTokens)} in ·{" "} + {formatTokenCount(turn.outputTokens)} out + + )} + + )} + {turn.kind === "tool" && ( <> {!hideMeta && ( @@ -1471,8 +1586,7 @@ function StageActivityBody({ effectiveTab, renderer, turns, - filteredTurns, - displayItems, + visibleItems, panelSelection, onPanelSelectionChange, runStart, @@ -1490,8 +1604,7 @@ function StageActivityBody({ effectiveTab: EventsTab; renderer: StageRenderer; turns: TurnType[]; - filteredTurns: { turn: TurnType; index: number }[]; - displayItems: DisplayItem[]; + visibleItems: DisplayItem[]; panelSelection: PanelSelection | null; onPanelSelectionChange: (selection: PanelSelection | null) => void; runStart: string | undefined; @@ -1510,12 +1623,12 @@ function StageActivityBody({
{effectiveTab === "primary" ? ( renderer === "agent" ? ( - turns.length > 0 && filteredTurns.length === 0 ? ( + turns.length > 0 && visibleItems.length === 0 ? (
No events match these filters.
) : ( - displayItems.map((item) => { + visibleItems.map((item) => { if (item.kind === "single") { return ( (null); const [openDebugSeq, setOpenDebugSeq] = useState(null); - const filteredTurns = useMemo<{ turn: TurnType; index: number }[]>(() => { - const kindSet = new Set(selectedKinds); - const needle = search.toLowerCase(); - const out: { turn: TurnType; index: number }[] = []; - turns.forEach((turn, i) => { - if (!kindSet.has(turn.kind)) return; - if (needle && !searchableText(turn).toLowerCase().includes(needle)) return; - out.push({ turn, index: i }); - }); - return out; - }, [turns, selectedKinds, search]); + // Semantics first, visibility second: grouping and DNA timing are derived + // from the complete turn stream, and the kind/search filters only decide + // which of those items are shown. const displayItems = useMemo( - () => groupConsecutiveTools(filteredTurns), - [filteredTurns], + () => groupConsecutiveTools(turns.map((turn, index) => ({ turn, index }))), + [turns], ); - const threadDnaItems = useMemo( + const visibleItems = useMemo( + () => filterDisplayItems(displayItems, selectedKinds, search), + [displayItems, selectedKinds, search], + ); + const allDnaItems = useMemo( () => buildThreadDnaItems(displayItems, runStart), [displayItems, runStart], ); + const threadDnaItems = useMemo(() => { + if (visibleItems.length === displayItems.length) return allDnaItems; + const visibleKeys = new Set( + visibleItems.map((item) => threadSelectionKey(displayItemSelection(item))), + ); + return allDnaItems.filter((item) => + visibleKeys.has(threadSelectionKey(item.selection)), + ); + }, [allDnaItems, displayItems, visibleItems]); const openTurn = panelSelection?.kind === "single" ? turns[panelSelection.turnIndex] ?? null : null; + // Resolve against the complete group list so changing a filter cannot + // corrupt or drop the identity of an open selection. const openGroup = useMemo | null>(() => { if (panelSelection?.kind !== "group") return null; const wanted = panelSelection.childTurnIndices; @@ -1764,7 +1884,7 @@ function RunStageActivityStage({ onSearchChange={onSearchChange} filteredCount={ effectiveTab === "primary" - ? filteredTurns.length + ? visibleTurnCount(visibleItems) : filteredDebugEvents.length } totalCount={ @@ -1800,8 +1920,7 @@ function RunStageActivityStage({ effectiveTab={effectiveTab} renderer={renderer} turns={turns} - filteredTurns={filteredTurns} - displayItems={displayItems} + visibleItems={visibleItems} panelSelection={panelSelection} onPanelSelectionChange={setPanelSelection} runStart={runStart}