fix(web): correct tool-group boundaries and DNA timeline attribution

Empty-text `agent.message` events were discarded, erasing the boundaries
between batches of tool calls. Eight short shell calls issued across five
model responses collapsed into one `Bash x8` group whose DNA bar spanned
the model-response gaps between them, showing a misleading six-minute
duration. Filtering could recreate the same artificial adjacency.

- Always emit an assistant turn for `agent.message`, carrying
  `tool_call_count` so a text-free response renders as
  "Requested N tool calls" instead of a blank row.
- Derive grouping and DNA timing from the complete turn stream, then
  apply kind/search filters as a pure visibility pass over display
  items. Hiding a tool can no longer inflate an adjacent Agent bar, and
  hiding an Agent can no longer merge the tool groups on either side.
- Give a tool group the wall-clock envelope of its children (earliest
  start to latest end) rather than the sum of their durations or the
  span to the last array element. Row, details header, DNA bar, and
  tooltip all read the same values.
- Advance the DNA previous-activity cursor by the maximum observed end
  so out-of-order or overlapping completions cannot move it backward.

Frontend only: no event, persistence, or API schema changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-07-24 13:56:57 -04:00
parent 280afbb649
commit 110058bb4b
No known key found for this signature in database
3 changed files with 573 additions and 117 deletions

View file

@ -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({
>
<div className="relative h-full">
{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;

View file

@ -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>): 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<typeof tool> | { 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),
});
}
});
});

View file

@ -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<string>(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<EventKind, string> = {
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<string>(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") && (
<DetailField label="Content">
<Markdown content={turn.content} />
</DetailField>
)}
{turn.kind === "assistant" && (
<>
<DetailField label="Content">
{turn.content ? (
<Markdown content={turn.content} />
) : (
<span className="text-fg-muted">{turnSummary(turn)}</span>
)}
</DetailField>
{turn.toolCallCount != null && turn.toolCallCount > 0 && (
<DetailField label="Tool calls" mono>
{turn.toolCallCount}
</DetailField>
)}
{(turn.inputTokens > 0 || turn.outputTokens > 0) && (
<DetailField label="Tokens" mono>
{formatTokenCount(turn.inputTokens)} in ·{" "}
{formatTokenCount(turn.outputTokens)} out
</DetailField>
)}
</>
)}
{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({
<div className="min-h-0 flex-1 overflow-y-auto pt-6 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
{effectiveTab === "primary" ? (
renderer === "agent" ? (
turns.length > 0 && filteredTurns.length === 0 ? (
turns.length > 0 && visibleItems.length === 0 ? (
<div className="px-2 py-6 text-sm text-fg-muted">
No events match these filters.
</div>
) : (
displayItems.map((item) => {
visibleItems.map((item) => {
if (item.kind === "single") {
return (
<EventRow
@ -1652,28 +1765,35 @@ function RunStageActivityStage({
const [panelSelection, setPanelSelection] = useState<PanelSelection | null>(null);
const [openDebugSeq, setOpenDebugSeq] = useState<number | null>(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<Extract<DisplayItem, { kind: "group" }> | 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}