fix: scope turn-activity panel metrics to per-agent events in sequential workflows

In sequential workflows, multiple collapsible turn-activity panels shared
the same SessionRunRecord and displayed identical aggregate metrics (tool
calls, approvals, etc.) instead of per-agent counts.

Root cause: ChatPane grouped thinking messages correctly per agent, but
passed the full unfiltered run object to every TurnActivityPanel, so
summarizeActivity() counted all events from the entire run for each panel.

Changes:
- Extract filterEventsByAgent() and summarizeActivity() to
  runTimelineFormatting.ts as pure, testable helpers
- Add agentNames (derived from thinking message authors) and isLastRunPanel
  to the DisplayItem turn-activity variant in ChatPane
- TurnActivityPanel now filters run.events through filterEventsByAgent()
  so each panel only counts events belonging to its agents
- Git summary / discard actions only render on the last panel of a run
- Per-agent timing derived from scoped events instead of run-level start

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-07 12:25:01 +02:00
co-authored by Copilot
parent 778c3b4a5b
commit 0a5fa81111
4 changed files with 292 additions and 40 deletions
+51 -3
View File
@@ -46,7 +46,16 @@ import {
type DisplayItem =
| { type: 'message'; message: ChatMessageRecord }
| { type: 'turn-activity'; thinkingMessages: ChatMessageRecord[]; run?: SessionRunRecord; turnStartedAt?: string };
| {
type: 'turn-activity';
thinkingMessages: ChatMessageRecord[];
run?: SessionRunRecord;
turnStartedAt?: string;
/** Agent names in this turn group, derived from thinking message authors. Used to scope run events. */
agentNames?: ReadonlySet<string>;
/** True when this is the last turn-activity panel that shares a given run (controls git summary / discard). */
isLastRunPanel?: boolean;
};
interface ChatPaneProps {
project: ProjectRecord;
@@ -147,6 +156,15 @@ export function ChatPane({
// can detect orphaned runs that need their own panel.
const consumedRunIds = new Set<string>();
/** Collect unique author names from a batch of thinking messages. */
function collectAgentNames(msgs: ChatMessageRecord[]): Set<string> | undefined {
const names = new Set<string>();
for (const m of msgs) {
if (m.authorName) names.add(m.authorName);
}
return names.size > 0 ? names : undefined;
}
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const isLast = i === messages.length - 1;
@@ -176,7 +194,13 @@ export function ChatPane({
if (pendingThinking.length > 0) {
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
if (run) consumedRunIds.add(run.id);
items.push({ type: 'turn-activity', thinkingMessages: pendingThinking, run, turnStartedAt: run?.startedAt });
items.push({
type: 'turn-activity',
thinkingMessages: pendingThinking,
run,
turnStartedAt: run?.startedAt,
agentNames: collectAgentNames(pendingThinking),
});
pendingThinking = [];
}
items.push({ type: 'message', message });
@@ -188,7 +212,13 @@ export function ChatPane({
if (pendingThinking.length > 0) {
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
if (run) consumedRunIds.add(run.id);
items.push({ type: 'turn-activity', thinkingMessages: pendingThinking, run, turnStartedAt: run?.startedAt });
items.push({
type: 'turn-activity',
thinkingMessages: pendingThinking,
run,
turnStartedAt: run?.startedAt,
agentNames: collectAgentNames(pendingThinking),
});
}
// If the session is busy but no turn-activity was emitted for the
@@ -201,6 +231,22 @@ export function ChatPane({
}
}
// Tag the last turn-activity panel for each run so only it shows
// run-level metadata (git summary, discard button, etc.).
const lastPanelIndexByRunId = new Map<string, number>();
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type === 'turn-activity' && item.run) {
lastPanelIndexByRunId.set(item.run.id, i);
}
}
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type === 'turn-activity' && item.run) {
item.isLastRunPanel = lastPanelIndexByRunId.get(item.run.id) === i;
}
}
return items;
}, [session.messages, session.runs, session.status]);
@@ -547,6 +593,8 @@ export function ChatPane({
isActive={isTurnActive(item, itemIndex)}
turnStartedAt={item.turnStartedAt}
sessionId={session.id}
agentNames={item.agentNames}
isLastRunPanel={item.isLastRunPanel}
onDiscard={onDiscardRunChanges}
onOpenCommitComposer={onOpenCommitComposer}
/>
@@ -16,7 +16,7 @@ import {
import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer';
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
import { formatEventLabel, truncateContent } from '@renderer/lib/runTimelineFormatting';
import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting';
import type { ChatMessageRecord } from '@shared/domain/session';
import type { ProjectGitFileReference } from '@shared/domain/project';
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
@@ -40,6 +40,10 @@ export interface TurnActivityPanelProps {
isActive: boolean;
turnStartedAt?: string;
sessionId: string;
/** Agent names in this turn group — used to scope run events in multi-agent runs. */
agentNames?: ReadonlySet<string>;
/** True when this panel is the last one sharing a given run (controls git summary / discard). */
isLastRunPanel?: boolean;
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
onOpenCommitComposer?: () => void;
}
@@ -78,36 +82,6 @@ function buildActivityStream(
return items;
}
interface ActivitySummary {
thinkingSteps: number;
toolCalls: number;
handoffs: number;
approvals: number;
hasError: boolean;
}
function summarizeActivity(
thinkingMessages: ChatMessageRecord[],
run?: SessionRunRecord,
): ActivitySummary {
const thinkingSteps = thinkingMessages.filter((m) => m.content).length;
let toolCalls = 0;
let handoffs = 0;
let approvals = 0;
let hasError = false;
if (run) {
for (const e of run.events) {
if (e.kind === 'tool-call') toolCalls++;
else if (e.kind === 'handoff') handoffs++;
else if (e.kind === 'approval') approvals++;
else if (e.kind === 'run-failed') hasError = true;
}
}
return { thinkingSteps, toolCalls, handoffs, approvals, hasError };
}
function formatSummaryParts(summary: ActivitySummary): string[] {
const parts: string[] = [];
if (summary.toolCalls > 0) {
@@ -267,6 +241,8 @@ export function TurnActivityPanel({
isActive,
turnStartedAt,
sessionId,
agentNames,
isLastRunPanel,
onDiscard,
onOpenCommitComposer,
}: TurnActivityPanelProps) {
@@ -286,19 +262,43 @@ export function TurnActivityPanel({
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
// When the run is shared across multiple panels (multi-agent sequential),
// scope events to only those belonging to this panel's agents.
const scopedEvents = useMemo(
() => filterEventsByAgent(run?.events ?? [], agentNames),
[run?.events, agentNames],
);
// Derive per-agent timing from the scoped events when agent names are set
// (multi-agent run). For single-agent runs, use the run-level start time.
const effectiveTurnStartedAt = useMemo(() => {
if (!agentNames || agentNames.size === 0 || scopedEvents.length === 0) {
return turnStartedAt;
}
// Use the earliest scoped event as the start time for this agent's panel.
let earliest = turnStartedAt;
for (const e of scopedEvents) {
if (!earliest || e.occurredAt < earliest) {
earliest = e.occurredAt;
break; // events are already in insertion order (chronological)
}
}
return earliest;
}, [agentNames, scopedEvents, turnStartedAt]);
const elapsed = useElapsedTimer(
thinkingMessages.length > 0 || run ? turnStartedAt : undefined,
thinkingMessages.length > 0 || run ? effectiveTurnStartedAt : undefined,
isActive,
);
const summary = useMemo(
() => summarizeActivity(thinkingMessages, run),
[thinkingMessages, run],
() => summarizeActivity(thinkingMessages, scopedEvents),
[thinkingMessages, scopedEvents],
);
const activityStream = useMemo(
() => buildActivityStream(thinkingMessages, run?.events ?? []),
[thinkingMessages, run?.events],
() => buildActivityStream(thinkingMessages, scopedEvents),
[thinkingMessages, scopedEvents],
);
// Nothing to show — no thinking messages, no run, and not active
@@ -312,7 +312,8 @@ export function TurnActivityPanel({
const isFailed = runStatus === 'error';
const isCancelled = runStatus === 'cancelled';
const isTerminated = isCompleted || isFailed || isCancelled;
const showGitSummary = run && isTerminated && run.postRunGitSummary && onDiscard;
// Only show git summary and discard on the last panel for a given run
const showGitSummary = run && isTerminated && run.postRunGitSummary && onDiscard && (isLastRunPanel !== false);
// Build the summary label
let summaryLabel: string;
+65
View File
@@ -168,3 +168,68 @@ export function truncateContent(content: string | undefined, maxLength = 80): st
export function findLatestRun(runs: readonly SessionRunRecord[]): SessionRunRecord | undefined {
return runs.length > 0 ? runs[0] : undefined;
}
/* ── Agent-scoped event filtering ──────────────────────────── */
/** Run-level events that are not scoped to any specific agent. */
const RUN_LEVEL_EVENT_KINDS = new Set<RunTimelineEventKind>([
'run-started',
'run-completed',
'run-cancelled',
'run-failed',
]);
/**
* Filter run events to only those belonging to the given agents.
* When `agentNames` is undefined or empty (single-agent run), all events pass through.
* Run-level events (start/complete/fail) are excluded from agent-scoped panels
* because they represent the entire run, not a specific agent's work.
*/
export function filterEventsByAgent(
events: readonly RunTimelineEventRecord[],
agentNames: ReadonlySet<string> | undefined,
): RunTimelineEventRecord[] {
if (!agentNames || agentNames.size === 0) return events.slice();
return events.filter((e) => {
if (RUN_LEVEL_EVENT_KINDS.has(e.kind)) return false;
if (e.kind === 'handoff') {
return (e.sourceAgentName != null && agentNames.has(e.sourceAgentName))
|| (e.targetAgentName != null && agentNames.has(e.targetAgentName));
}
if (e.agentName) return agentNames.has(e.agentName);
return false;
});
}
/** Counts of different activity types within a set of run timeline events. */
export interface ActivitySummary {
thinkingSteps: number;
toolCalls: number;
handoffs: number;
approvals: number;
hasError: boolean;
}
/**
* Summarize activity from thinking messages and (already-filtered) run events.
*/
export function summarizeActivity(
thinkingMessages: ReadonlyArray<{ content: string }>,
events: readonly RunTimelineEventRecord[],
): ActivitySummary {
const thinkingSteps = thinkingMessages.filter((m) => m.content).length;
let toolCalls = 0;
let handoffs = 0;
let approvals = 0;
let hasError = false;
for (const e of events) {
if (e.kind === 'tool-call') toolCalls++;
else if (e.kind === 'handoff') handoffs++;
else if (e.kind === 'approval') approvals++;
else if (e.kind === 'run-failed') hasError = true;
}
return { thinkingSteps, toolCalls, handoffs, approvals, hasError };
}
@@ -2,10 +2,12 @@ import { describe, expect, test } from 'bun:test';
import {
collapseTimelineEvents,
filterEventsByAgent,
formatEventLabel,
formatRunDuration,
formatRunStatusLabel,
formatRunTimestamp,
summarizeActivity,
truncateContent,
} from '@renderer/lib/runTimelineFormatting';
import type { RunTimelineEventRecord } from '@shared/domain/runTimeline';
@@ -134,3 +136,139 @@ describe('run timeline formatting', () => {
]);
});
});
/* ── filterEventsByAgent ──────────────────────────────────── */
describe('filterEventsByAgent', () => {
const sharedEvents: RunTimelineEventRecord[] = [
createEvent({ id: 'e-start', kind: 'run-started' }),
createEvent({ id: 'e-tc-w1', kind: 'tool-call', agentName: 'Writer', toolName: 'edit' }),
createEvent({ id: 'e-tc-w2', kind: 'tool-call', agentName: 'Writer', toolName: 'grep' }),
createEvent({ id: 'e-ap-w', kind: 'approval', agentName: 'Writer' }),
createEvent({ id: 'e-hoff', kind: 'handoff', sourceAgentName: 'Writer', targetAgentName: 'Reviewer' }),
createEvent({ id: 'e-tc-r1', kind: 'tool-call', agentName: 'Reviewer', toolName: 'view' }),
createEvent({ id: 'e-tc-r2', kind: 'tool-call', agentName: 'Reviewer', toolName: 'grep' }),
createEvent({ id: 'e-msg-r', kind: 'message', agentName: 'Reviewer', content: 'Done.' }),
createEvent({ id: 'e-end', kind: 'run-completed' }),
];
test('returns all events when agentNames is undefined (single-agent)', () => {
const result = filterEventsByAgent(sharedEvents, undefined);
expect(result).toHaveLength(sharedEvents.length);
});
test('returns all events when agentNames is an empty set', () => {
const result = filterEventsByAgent(sharedEvents, new Set());
expect(result).toHaveLength(sharedEvents.length);
});
test('filters to Writer agent events only', () => {
const result = filterEventsByAgent(sharedEvents, new Set(['Writer']));
const ids = result.map((e) => e.id);
expect(ids).toEqual(['e-tc-w1', 'e-tc-w2', 'e-ap-w', 'e-hoff']);
});
test('filters to Reviewer agent events only', () => {
const result = filterEventsByAgent(sharedEvents, new Set(['Reviewer']));
const ids = result.map((e) => e.id);
expect(ids).toEqual(['e-hoff', 'e-tc-r1', 'e-tc-r2', 'e-msg-r']);
});
test('excludes run-level events from agent-scoped results', () => {
const result = filterEventsByAgent(sharedEvents, new Set(['Writer']));
const kinds = result.map((e) => e.kind);
expect(kinds).not.toContain('run-started');
expect(kinds).not.toContain('run-completed');
});
test('excludes events with no agentName in agent-scoped mode', () => {
const events: RunTimelineEventRecord[] = [
createEvent({ id: 'e1', kind: 'tool-call', agentName: undefined, toolName: 'unknown' }),
createEvent({ id: 'e2', kind: 'tool-call', agentName: 'Writer', toolName: 'edit' }),
];
const result = filterEventsByAgent(events, new Set(['Writer']));
expect(result.map((e) => e.id)).toEqual(['e2']);
});
test('includes handoff events for both source and target agents', () => {
const handoff = sharedEvents.find((e) => e.kind === 'handoff')!;
const writerResult = filterEventsByAgent([handoff], new Set(['Writer']));
const reviewerResult = filterEventsByAgent([handoff], new Set(['Reviewer']));
expect(writerResult).toHaveLength(1);
expect(reviewerResult).toHaveLength(1);
});
});
/* ── summarizeActivity ────────────────────────────────────── */
describe('summarizeActivity', () => {
test('counts thinking steps from messages with content', () => {
const messages = [
{ content: 'step 1' },
{ content: '' },
{ content: 'step 2' },
];
const summary = summarizeActivity(messages, []);
expect(summary.thinkingSteps).toBe(2);
expect(summary.toolCalls).toBe(0);
});
test('counts tool calls, handoffs, and approvals from events', () => {
const events: RunTimelineEventRecord[] = [
createEvent({ id: 'e1', kind: 'tool-call', agentName: 'A' }),
createEvent({ id: 'e2', kind: 'tool-call', agentName: 'A' }),
createEvent({ id: 'e3', kind: 'handoff', sourceAgentName: 'A', targetAgentName: 'B' }),
createEvent({ id: 'e4', kind: 'approval', agentName: 'A' }),
];
const summary = summarizeActivity([], events);
expect(summary).toMatchObject({
thinkingSteps: 0,
toolCalls: 2,
handoffs: 1,
approvals: 1,
hasError: false,
});
});
test('detects run-failed events', () => {
const events: RunTimelineEventRecord[] = [
createEvent({ id: 'e1', kind: 'run-failed' }),
];
const summary = summarizeActivity([], events);
expect(summary.hasError).toBe(true);
});
test('produces correct per-agent summary when combined with filterEventsByAgent', () => {
const allEvents: RunTimelineEventRecord[] = [
createEvent({ id: 'e-start', kind: 'run-started' }),
createEvent({ id: 'e1', kind: 'tool-call', agentName: 'Writer' }),
createEvent({ id: 'e2', kind: 'tool-call', agentName: 'Writer' }),
createEvent({ id: 'e3', kind: 'tool-call', agentName: 'Writer' }),
createEvent({ id: 'e4', kind: 'approval', agentName: 'Writer' }),
createEvent({ id: 'e5', kind: 'handoff', sourceAgentName: 'Writer', targetAgentName: 'Reviewer' }),
createEvent({ id: 'e6', kind: 'tool-call', agentName: 'Reviewer' }),
createEvent({ id: 'e7', kind: 'approval', agentName: 'Reviewer' }),
createEvent({ id: 'e-end', kind: 'run-completed' }),
];
const writerEvents = filterEventsByAgent(allEvents, new Set(['Writer']));
const writerSummary = summarizeActivity([{ content: 'think1' }, { content: 'think2' }], writerEvents);
expect(writerSummary).toMatchObject({
thinkingSteps: 2,
toolCalls: 3,
handoffs: 1,
approvals: 1,
hasError: false,
});
const reviewerEvents = filterEventsByAgent(allEvents, new Set(['Reviewer']));
const reviewerSummary = summarizeActivity([{ content: 'think3' }], reviewerEvents);
expect(reviewerSummary).toMatchObject({
thinkingSteps: 1,
toolCalls: 1,
handoffs: 1,
approvals: 1,
hasError: false,
});
});
});