From 0a5fa811113b04e1cbb5271c75ff8ea4f948e4c5 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 7 Apr 2026 12:25:01 +0200 Subject: [PATCH] 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> --- src/renderer/components/ChatPane.tsx | 54 ++++++- .../components/chat/TurnActivityPanel.tsx | 75 +++++----- src/renderer/lib/runTimelineFormatting.ts | 65 +++++++++ tests/renderer/runTimelineFormatting.test.ts | 138 ++++++++++++++++++ 4 files changed, 292 insertions(+), 40 deletions(-) diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index b99dd5b..73bb69b 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -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; + /** 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(); + /** Collect unique author names from a batch of thinking messages. */ + function collectAgentNames(msgs: ChatMessageRecord[]): Set | undefined { + const names = new Set(); + 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(); + 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} /> diff --git a/src/renderer/components/chat/TurnActivityPanel.tsx b/src/renderer/components/chat/TurnActivityPanel.tsx index 4aaa5e6..08077f4 100644 --- a/src/renderer/components/chat/TurnActivityPanel.tsx +++ b/src/renderer/components/chat/TurnActivityPanel.tsx @@ -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; + /** 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; 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; diff --git a/src/renderer/lib/runTimelineFormatting.ts b/src/renderer/lib/runTimelineFormatting.ts index 490cc72..64da621 100644 --- a/src/renderer/lib/runTimelineFormatting.ts +++ b/src/renderer/lib/runTimelineFormatting.ts @@ -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([ + '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 | 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 }; +} diff --git a/tests/renderer/runTimelineFormatting.test.ts b/tests/renderer/runTimelineFormatting.test.ts index a83ddfc..f6e19e3 100644 --- a/tests/renderer/runTimelineFormatting.test.ts +++ b/tests/renderer/runTimelineFormatting.test.ts @@ -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, + }); + }); +});