mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user