Merge pull request #624 from fabro-sh/fix/tool-group-boundaries-dna-attribution

fix(web): correct tool-group boundaries and DNA timeline attribution
This commit is contained in:
Bryan Helmkamp 2026-07-24 17:35:28 -04:00 • committed by GitHub
commit 470db44ba2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 725 additions and 201 deletions

View file

@ -443,7 +443,10 @@ const THREAD_CATEGORY_COLOR: Record<ThreadCategory, string> = {
export type ThreadDnaSelection =
| { kind: "single"; turnIndex: number }
| { kind: "group"; childTurnIndices: number[] };
| {
kind: "group";
childTurnIndices: readonly [number, number, ...number[]];
};
export interface ThreadDnaItem {
category: ThreadCategory;
@ -456,13 +459,15 @@ export interface ThreadDnaItem {
const INSTANT_MARKER_PX = 4;
const MIN_DURATION_PX = 3;
function selectionKey(s: ThreadDnaSelection): string {
return s.kind === "single"
? `s:${s.turnIndex}`
: `g:${s.childTurnIndices.join(",")}`;
export function threadSelectionId(selection: ThreadDnaSelection): number {
const turnIndex =
selection.kind === "single"
? selection.turnIndex
: selection.childTurnIndices[0];
return turnIndex * 2 + (selection.kind === "group" ? 1 : 0);
}
function selectionsEqual(
export function threadSelectionsEqual(
a: ThreadDnaSelection,
b: ThreadDnaSelection | null,
): boolean {
@ -504,19 +509,19 @@ export function ThreadDnaStrip({
selection: ThreadDnaSelection | null;
onSelect: (s: ThreadDnaSelection) => void;
}) {
const [hover, setHover] = useState<{ key: string; rect: DOMRect } | null>(
const [hover, setHover] = useState<{ id: number; rect: DOMRect } | null>(
null,
);
const visibleItems = useMemo(
() => sampleStripItems(items, STRIP_MAX_MARKERS, (item) =>
selectionsEqual(item.selection, selection)
threadSelectionsEqual(item.selection, selection)
),
[items, selection],
);
const visibleItemByKey = useMemo(
const visibleItemById = useMemo(
() =>
new Map(
visibleItems.map((item) => [selectionKey(item.selection), item]),
visibleItems.map((item) => [threadSelectionId(item.selection), item]),
),
[visibleItems],
);
@ -542,7 +547,7 @@ export function ThreadDnaStrip({
const hoveredItem =
hover != null
? visibleItemByKey.get(hover.key) ?? null
? visibleItemById.get(hover.id) ?? null
: null;
return (
@ -552,10 +557,10 @@ export function ThreadDnaStrip({
>
<div className="relative h-full">
{visibleItems.map((item) => {
const key = selectionKey(item.selection);
const id = threadSelectionId(item.selection);
const isInstant = item.durationMs <= 0;
const isSelected = selectionsEqual(item.selection, selection);
const isHovered = hover?.key === key;
const isSelected = threadSelectionsEqual(item.selection, selection);
const isHovered = hover?.id === id;
const leftPct = (item.startMs / totalMs) * 100;
const baseColor = THREAD_CATEGORY_COLOR[item.category];
@ -585,18 +590,18 @@ export function ThreadDnaStrip({
return (
<button
key={key}
key={id}
type="button"
aria-label={`${THREAD_CATEGORY_LABEL[item.category]} · ${item.label}`}
aria-pressed={isSelected}
onMouseEnter={(e) =>
setHover({
key,
id,
rect: e.currentTarget.getBoundingClientRect(),
})
}
onMouseLeave={() =>
setHover((cur) => (cur?.key === key ? null : cur))
setHover((cur) => (cur?.id === id ? null : cur))
}
onClick={() => onSelect(item.selection)}
className="absolute cursor-pointer rounded-[2px] border-0 p-0 transition-all duration-100 ease-out"

View file

@ -3,12 +3,21 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client";
import {
buildThreadDnaItems,
EVENT_KINDS,
eventsTabLabel,
eventsToActivity,
filterDisplayItems,
filterThreadDnaItems,
formatStageModelUsageLabel,
groupConsecutiveTools,
searchableText,
selectStageRenderer,
turnSummary,
visibleTurnCount,
type DisplayItem,
type EventKind,
} from "./run-stages";
import { threadSelectionId } from "../components/event-debug";
function envelope(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
return {
@ -21,6 +30,26 @@ function envelope(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
} as EventEnvelope;
}
function expectToolGroup(
item: DisplayItem | undefined,
): Extract<DisplayItem, { kind: "group" }> {
expect(item?.kind).toBe("group");
if (item?.kind !== "group") {
throw new Error("expected a tool group");
}
return item;
}
function expectSingleItem(
item: DisplayItem | undefined,
): Extract<DisplayItem, { kind: "single" }> {
expect(item?.kind).toBe("single");
if (item?.kind !== "single") {
throw new Error("expected a single display item");
}
return item;
}
describe("eventsToActivity", () => {
test("filters events by stage_id (verify@1 vs verify@2 do not cross-contaminate)", () => {
const events: EventEnvelope[] = [
@ -59,6 +88,7 @@ describe("eventsToActivity", () => {
content: "first visit reply",
inputTokens: 0,
outputTokens: 0,
toolCallCount: null,
},
]);
@ -71,6 +101,7 @@ describe("eventsToActivity", () => {
content: "second visit reply",
inputTokens: 0,
outputTokens: 0,
toolCallCount: null,
},
]);
});
@ -329,6 +360,7 @@ describe("eventsToActivity", () => {
content: "Refactored auth module",
inputTokens: 120,
outputTokens: 30,
toolCallCount: null,
},
]);
});
@ -376,6 +408,7 @@ describe("eventsToActivity", () => {
content: "Done.",
inputTokens: 10,
outputTokens: 5,
toolCallCount: null,
},
]);
});
@ -402,6 +435,7 @@ describe("eventsToActivity", () => {
content: "All clear.",
inputTokens: 0,
outputTokens: 4,
toolCallCount: null,
},
]);
});
@ -482,7 +516,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 };
}
@ -493,7 +527,12 @@ describe("groupConsecutiveTools", () => {
test("single tool turn becomes a single, not a group", () => {
const t = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 100 });
expect(groupConsecutiveTools([entry(t, 0)])).toEqual([
{ kind: "single", turn: t, turnIndex: 0 },
{
kind: "single",
turn: t,
turnIndex: 0,
selection: { kind: "single", turnIndex: 0 },
},
]);
});
@ -511,11 +550,12 @@ describe("groupConsecutiveTools", () => {
{ turn: a, turnIndex: 0 },
{ turn: b, turnIndex: 1 },
],
selection: { kind: "group", childTurnIndices: [0, 1] },
},
]);
});
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`,
@ -526,13 +566,44 @@ describe("groupConsecutiveTools", () => {
const filtered = turns.map((t, i) => entry(t, i));
const result = groupConsecutiveTools(filtered);
expect(result).toHaveLength(1);
const item = result[0];
expect(item.kind).toBe("group");
if (item.kind === "group") {
expect(item.ts).toBe("2026-04-09T12:00:00Z");
expect(item.durationMs).toBe(15000);
expect(item.children.map((c) => c.turnIndex)).toEqual([0, 1, 2, 3, 4]);
}
const item = expectToolGroup(result[0]);
expect(item.ts).toBe("2026-04-09T12:00:00Z");
// 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 = expectToolGroup(result[0]);
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 = expectToolGroup(result[0]);
// 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 = expectToolGroup(result[0]);
expect(item.ts).toBe("not-a-timestamp");
expect(item.durationMs).toBe(0);
});
test("a different tool between same-tool calls breaks the group boundary", () => {
@ -549,15 +620,13 @@ describe("groupConsecutiveTools", () => {
entry(e, 4),
]);
expect(result.map((r) => r.kind)).toEqual(["group", "single", "group"]);
if (result[0].kind === "group") {
expect(result[0].children.map((c) => c.turnIndex)).toEqual([0, 1]);
}
if (result[1].kind === "single") {
expect(result[1].turnIndex).toBe(2);
}
if (result[2].kind === "group") {
expect(result[2].children.map((c) => c.turnIndex)).toEqual([3, 4]);
}
expect(expectToolGroup(result[0]).children.map((c) => c.turnIndex)).toEqual([
0, 1,
]);
expect(expectSingleItem(result[1]).turnIndex).toBe(2);
expect(expectToolGroup(result[2]).children.map((c) => c.turnIndex)).toEqual([
3, 4,
]);
});
test("an errored tool call is never grouped and breaks the run", () => {
@ -572,12 +641,10 @@ describe("groupConsecutiveTools", () => {
entry(d, 3),
]);
expect(result.map((r) => r.kind)).toEqual(["single", "single", "group"]);
if (result[1].kind === "single") {
expect(result[1].turn).toBe(errored);
}
if (result[2].kind === "group") {
expect(result[2].children.map((c) => c.turnIndex)).toEqual([2, 3]);
}
expect(expectSingleItem(result[1]).turn).toBe(errored);
expect(expectToolGroup(result[2]).children.map((c) => c.turnIndex)).toEqual([
2, 3,
]);
});
test("non-tool turns flush the buffer correctly", () => {
@ -589,6 +656,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([
@ -598,12 +666,10 @@ describe("groupConsecutiveTools", () => {
entry(c, 3),
]);
expect(result.map((r) => r.kind)).toEqual(["group", "single", "single"]);
if (result[0].kind === "group") {
expect(result[0].children.map((c) => c.turnIndex)).toEqual([0, 1]);
}
if (result[2].kind === "single") {
expect(result[2].turnIndex).toBe(3);
}
expect(expectToolGroup(result[0]).children.map((c) => c.turnIndex)).toEqual([
0, 1,
]);
expect(expectSingleItem(result[2]).turnIndex).toBe(3);
});
});
@ -662,6 +728,7 @@ describe("buildThreadDnaItems", () => {
kind: "single" as const,
turnIndex,
turn: { kind: "system" as const, ts, content },
selection: { kind: "single" as const, turnIndex },
};
}
@ -675,7 +742,9 @@ describe("buildThreadDnaItems", () => {
content: "hi",
inputTokens: 0,
outputTokens: 0,
toolCallCount: null,
},
selection: { kind: "single" as const, turnIndex },
};
}
@ -697,6 +766,7 @@ describe("buildThreadDnaItems", () => {
isError: false,
durationMs,
},
selection: { kind: "single" as const, turnIndex },
};
}
@ -705,6 +775,7 @@ describe("buildThreadDnaItems", () => {
kind: "single" as const,
turnIndex,
turn: { kind: "steer" as const, ts, content: "do this" },
selection: { kind: "single" as const, turnIndex },
};
}
@ -780,40 +851,37 @@ 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 = expectToolGroup(grouped[0]);
// 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 +902,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 };
const whitespaceOnly = { ...withTools, content: " \n\t" };
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(turnSummary(whitespaceOnly)).toBe("Requested 3 tool calls");
expect(searchableText(withTools)).toContain("Requested 3 tool calls");
expect(searchableText(whitespaceOnly)).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 = filterDisplayItems(items, kinds, search);
return filterThreadDnaItems(all, visible);
}
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("the default visibility pass reuses the grouped item list", () => {
const items = reproItems();
expect(filterDisplayItems(items, EVENT_KINDS, "")).toBe(items);
});
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 = expectToolGroup(visible[0]);
// "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) => threadSelectionId(b.selection))).toEqual(
items.map((item) => threadSelectionId(item.selection)),
);
const group = expectToolGroup(
items.find((item) => item.kind === "group"),
);
expect(group.selection).toEqual({
kind: "group",
childTurnIndices: group.children.map((c) => c.turnIndex),
});
});
});

View file

@ -17,6 +17,8 @@ import {
EventSearchInput,
MultiSelectFilter,
ThreadDnaStrip,
threadSelectionId,
threadSelectionsEqual,
} from "../components/event-debug";
import {
debugCategory,
@ -59,6 +61,7 @@ import {
formatDurationMs,
formatTokenCount,
} from "../lib/format";
import { plural } from "../lib/plural";
import {
useRun,
useRunEventsList,
@ -81,7 +84,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 +119,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 +129,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 +264,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 +287,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;
@ -379,42 +391,112 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn
}
type ToolTurn = Extract<TurnType, { kind: "tool" }>;
type ToolGroupChild = { turn: ToolTurn; turnIndex: number };
type ToolGroupChildren = readonly [
ToolGroupChild,
ToolGroupChild,
...ToolGroupChild[],
];
export type DisplayItem =
| { kind: "single"; turn: TurnType; turnIndex: number }
| {
kind: "single";
turn: TurnType;
turnIndex: number;
selection: Extract<ThreadDnaSelection, { kind: "single" }>;
}
| {
kind: "group";
toolName: string;
ts: string;
durationMs: number;
children: { turn: ToolTurn; turnIndex: number }[];
children: ToolGroupChildren;
selection: Extract<ThreadDnaSelection, { kind: "group" }>;
};
// 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.
function toolGroupBounds(children: ToolGroupChildren): {
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) };
}
function singleDisplayItem(turn: TurnType, turnIndex: number): DisplayItem {
return {
kind: "single",
turn,
turnIndex,
selection: { kind: "single", turnIndex },
};
}
function toolGroupDisplayItem(
first: ToolGroupChild,
second: ToolGroupChild,
rest: ToolGroupChild[],
): Extract<DisplayItem, { kind: "group" }> {
const children: ToolGroupChildren = [first, second, ...rest];
const bounds = toolGroupBounds(children);
return {
kind: "group",
toolName: first.turn.toolName,
ts: bounds.ts,
durationMs: bounds.durationMs,
children,
selection: {
kind: "group",
childTurnIndices: [
first.turnIndex,
second.turnIndex,
...rest.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 }[] = [];
function flush() {
if (buf.length === 0) return;
if (buf.length === 1) {
out.push({ kind: "single", turn: buf[0].turn, turnIndex: buf[0].turnIndex });
} else {
const first = buf[0].turn;
const totalMs = buf.reduce((sum, b) => sum + b.turn.durationMs, 0);
out.push({
kind: "group",
toolName: first.toolName,
ts: first.ts,
durationMs: totalMs,
children: buf,
});
}
const [first, second, ...rest] = buf;
buf = [];
if (!first) return;
out.push(
second
? toolGroupDisplayItem(first, second, rest)
: singleDisplayItem(first.turn, first.turnIndex),
);
}
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 });
@ -424,22 +506,74 @@ export function groupConsecutiveTools(
if (groupable) {
buf.push({ turn, turnIndex: index });
} else {
out.push({ kind: "single", turn, turnIndex: index });
out.push(singleDisplayItem(turn, index));
}
}
flush();
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[] {
if (
search.length === 0 &&
selectedKinds.length === EVENT_KINDS.length &&
EVENT_KINDS.every((kind) => selectedKinds.includes(kind))
) {
return items;
}
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,
);
}
export function filterThreadDnaItems(
items: ThreadDnaItem[],
visibleItems: DisplayItem[],
): ThreadDnaItem[] {
const visibleIds = new Set(
visibleItems.map((item) => threadSelectionId(item.selection)),
);
return items.filter((item) =>
visibleIds.has(threadSelectionId(item.selection)),
);
}
// 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 +595,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 = item.selection;
switch (turn.kind) {
case "system":
@ -481,7 +618,7 @@ export function buildThreadDnaItems(
durationMs: 0,
selection,
});
prevEndMs = tsMs;
advance(tsMs);
break;
case "steer":
out.push({
@ -491,7 +628,7 @@ export function buildThreadDnaItems(
durationMs: 0,
selection,
});
prevEndMs = tsMs;
advance(tsMs);
break;
case "interrupt":
out.push({
@ -501,7 +638,7 @@ export function buildThreadDnaItems(
durationMs: 0,
selection,
});
prevEndMs = tsMs;
advance(tsMs);
break;
case "pair_user":
out.push({
@ -511,7 +648,7 @@ export function buildThreadDnaItems(
durationMs: 0,
selection,
});
prevEndMs = tsMs;
advance(tsMs);
break;
case "pair_system":
out.push({
@ -521,12 +658,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 +675,7 @@ export function buildThreadDnaItems(
durationMs,
selection,
});
prevEndMs = tsMs;
advance(tsMs);
break;
}
case "tool": {
@ -550,7 +688,7 @@ export function buildThreadDnaItems(
durationMs,
selection,
});
prevEndMs = tsMs + durationMs;
advance(tsMs + durationMs);
break;
}
case "command": {
@ -563,29 +701,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: item.selection,
});
prevEndMs = lastEnd;
advance(startTsMs + durationMs);
}
}
@ -674,6 +807,12 @@ function oneLine(text: string): string {
return `${collapsed.slice(0, SUMMARY_MAX_CHARS - 1)}…`;
}
function nonBlankAssistantContent(
turn: Extract<TurnType, { kind: "assistant" }>,
): string | null {
return turn.content.trim() ? turn.content : null;
}
const TOOL_NAME_DISPLAY: Record<string, string> = {
read_file: "Read",
write_file: "Write",
@ -706,8 +845,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(nonBlankAssistantContent(turn) ?? "");
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} ${plural(count, "tool call", "tool calls")}`;
}
return "Model response contained no text";
}
case "tool":
return humanizeToolName(turn.toolName);
case "command":
@ -748,8 +897,12 @@ 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.
const content = nonBlankAssistantContent(turn);
return content ?? turnSummary(turn);
}
case "tool":
return `${humanizeToolName(turn.toolName)} ${turn.toolName} ${turn.input} ${turn.result}`;
case "command":
@ -891,6 +1044,8 @@ function EventDetails({
if (Number.isNaN(ms)) return turn.ts;
return new Date(ms).toLocaleString();
})();
const assistantContent =
turn.kind === "assistant" ? nonBlankAssistantContent(turn) : null;
return (
<div className="space-y-5">
@ -904,13 +1059,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">
{assistantContent ? (
<Markdown content={assistantContent} />
) : (
<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 +1648,7 @@ function StageActivityBody({
effectiveTab,
renderer,
turns,
filteredTurns,
displayItems,
visibleItems,
panelSelection,
onPanelSelectionChange,
runStart,
@ -1490,8 +1666,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,49 +1685,36 @@ 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) => {
const selection = item.selection;
const selectionId = threadSelectionId(selection);
const isSelected = threadSelectionsEqual(
selection,
panelSelection,
);
if (item.kind === "single") {
return (
<EventRow
key={`turn-${item.turnIndex}`}
key={selectionId}
turn={item.turn}
runStart={runStart}
selected={
panelSelection?.kind === "single" &&
panelSelection.turnIndex === item.turnIndex
}
onSelect={() =>
onPanelSelectionChange({
kind: "single",
turnIndex: item.turnIndex,
})
}
selected={isSelected}
onSelect={() => onPanelSelectionChange(selection)}
/>
);
}
const childIndices = item.children.map((c) => c.turnIndex);
const groupKey = `group-${childIndices.join("-")}`;
const isSelected =
panelSelection?.kind === "group" &&
panelSelection.childTurnIndices.length === childIndices.length &&
panelSelection.childTurnIndices.every((v, i) => v === childIndices[i]);
return (
<ToolGroupRow
key={groupKey}
key={selectionId}
group={item}
runStart={runStart}
selected={isSelected}
onSelect={() =>
onPanelSelectionChange({
kind: "group",
childTurnIndices: childIndices,
})
}
onSelect={() => onPanelSelectionChange(selection)}
/>
);
})
@ -1652,37 +1814,46 @@ 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 visibleCount = useMemo(
() => visibleTurnCount(visibleItems),
[visibleItems],
);
const allDnaItems = useMemo(
() => buildThreadDnaItems(displayItems, runStart),
[displayItems, runStart],
);
const threadDnaItems = useMemo(
() =>
visibleItems === displayItems
? allDnaItems
: filterThreadDnaItems(allDnaItems, visibleItems),
[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;
for (const item of displayItems) {
if (item.kind !== "group") continue;
const matches =
item.children.length === wanted.length &&
item.children.every((c, i) => c.turnIndex === wanted[i]);
if (matches) return item;
if (
item.kind === "group" &&
threadSelectionsEqual(item.selection, panelSelection)
) {
return item;
}
}
return null;
}, [displayItems, panelSelection]);
@ -1764,7 +1935,7 @@ function RunStageActivityStage({
onSearchChange={onSearchChange}
filteredCount={
effectiveTab === "primary"
? filteredTurns.length
? visibleCount
: filteredDebugEvents.length
}
totalCount={
@ -1800,8 +1971,7 @@ function RunStageActivityStage({
effectiveTab={effectiveTab}
renderer={renderer}
turns={turns}
filteredTurns={filteredTurns}
displayItems={displayItems}
visibleItems={visibleItems}
panelSelection={panelSelection}
onPanelSelectionChange={setPanelSelection}
runStart={runStart}