agent-ecosystem/src/main/services/analysis/SemanticStepGrouper.ts

114 lines
3.4 KiB
TypeScript

/**
* SemanticStepGrouper - Groups semantic steps for UI presentation.
*
* Groups steps by their source assistant message for collapsible UI.
* Steps from the same assistant message share the message UUID.
*/
import type { SemanticStep, SemanticStepGroup } from '@main/types';
/**
* Build semantic step groups from steps.
* Groups steps by their source assistant message for collapsible UI.
*/
export function buildSemanticStepGroups(steps: SemanticStep[]): SemanticStepGroup[] {
const groups: SemanticStepGroup[] = [];
let groupIdCounter = 0;
// Group steps by assistant message or standalone type
const stepsByGroup = new Map<string | null, SemanticStep[]>();
for (const step of steps) {
const messageId = extractMessageIdFromStep(step);
const existingSteps = stepsByGroup.get(messageId) ?? [];
existingSteps.push(step);
stepsByGroup.set(messageId, existingSteps);
}
// Build groups
for (const [messageId, groupSteps] of stepsByGroup) {
const startTime = groupSteps[0].startTime;
const endTimes = groupSteps
.map((s) => s.endTime ?? new Date(s.startTime.getTime() + s.durationMs))
.map((d) => d.getTime());
const endTime = new Date(Math.max(...endTimes));
const totalDuration = groupSteps.reduce((sum, s) => sum + s.durationMs, 0);
groups.push({
id: `group-${++groupIdCounter}`,
label: buildGroupLabel(groupSteps),
steps: groupSteps,
isGrouped: messageId !== null && groupSteps.length > 1,
sourceMessageId: messageId ?? undefined,
startTime,
endTime,
totalDuration,
});
}
// Sort by startTime
return groups.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
}
/**
* Extract the assistant message ID from a step, or null if standalone.
* Steps from the same assistant message share the message UUID.
* Subagents, tool results, and interruptions are standalone (null).
*/
function extractMessageIdFromStep(step: SemanticStep): string | null {
// Use sourceMessageId if available
if (step.sourceMessageId) {
return step.sourceMessageId;
}
// Standalone steps (not grouped)
if (step.type === 'subagent') return null;
if (step.type === 'tool_result') return null;
if (step.type === 'interruption') return null;
if (step.type === 'tool_call') return null; // Tool calls are standalone
return null;
}
/**
* Build a descriptive label for a group.
*/
function buildGroupLabel(steps: SemanticStep[]): string {
if (steps.length === 1) {
const step = steps[0];
switch (step.type) {
case 'thinking':
return 'Thinking';
case 'tool_call':
return `Tool: ${step.content.toolName ?? 'Unknown'}`;
case 'tool_result':
return `Result: ${step.content.isError ? 'Error' : 'Success'}`;
case 'subagent':
return step.content.subagentDescription ?? 'Subagent';
case 'output':
return 'Output';
case 'interruption':
return 'Interruption';
}
}
// Multiple steps grouped together
const hasThinking = steps.some((s) => s.type === 'thinking');
const hasOutput = steps.some((s) => s.type === 'output');
const toolCalls = steps.filter((s) => s.type === 'tool_call');
if (toolCalls.length > 0) {
return `Tools (${toolCalls.length})`;
}
if (hasThinking && hasOutput) {
return 'Assistant Response';
}
if (hasThinking) {
return 'Thinking';
}
if (hasOutput) {
return 'Output';
}
return `Response (${steps.length} steps)`;
}