mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
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:
commit
470db44ba2
3 changed files with 725 additions and 201 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue