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
@@ -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,
});
});
});