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}