agent-ecosystem/src/renderer/utils/displayItemBuilder.ts
2026-04-18 12:20:37 +03:00

587 lines
19 KiB
TypeScript

/**
* Display Item Builder - Build display items from semantic steps or messages
*
* Creates a flat chronological list of display items for the AI Group UI.
*/
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
import { estimateTokens, formatToolInput, formatToolResult, toDate } from './aiGroupHelpers';
import { extractSlashes, type PrecedingSlashInfo } from './slashCommandExtractor';
import { linkToolCallsToResults } from './toolLinkingEngine';
import type { ParsedMessage, Process, SemanticStep } from '../types/data';
import type { AIGroupDisplayItem, AIGroupLastOutput, LinkedToolItem } from '../types/groups';
/**
* Get the timestamp from a display item for sorting.
*/
function getDisplayItemTimestamp(item: AIGroupDisplayItem): Date {
switch (item.type) {
case 'thinking':
case 'output':
return toDate(item.timestamp);
case 'tool':
return toDate(item.tool.startTime);
case 'subagent':
return toDate(item.subagent.startTime);
case 'slash':
return toDate(item.slash.timestamp);
case 'teammate_message':
return toDate(item.teammateMessage.timestamp);
case 'subagent_input':
case 'compact_boundary':
return toDate(item.timestamp);
}
}
/**
* Sort display items chronologically.
*/
function sortDisplayItemsChronologically(items: AIGroupDisplayItem[]): void {
items.sort((a, b) => getDisplayItemTimestamp(a).getTime() - getDisplayItemTimestamp(b).getTime());
}
/**
* Link TeammateMessages to their triggering SendMessage calls.
* For each TeammateMessage, scans backwards through chronologically sorted items
* to find the most recent SendMessage to that teammate.
* Only matches type: "message" or "broadcast" (not shutdown_request/shutdown_response).
* Proactive messages (no preceding SendMessage) get no badge.
*/
function linkTeammateReplies(items: AIGroupDisplayItem[]): void {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type !== 'teammate_message') continue;
const tmMsg = item.teammateMessage;
// Scan backwards for the most recent SendMessage to this teammate
for (let j = i - 1; j >= 0; j--) {
const prev = items[j];
if (prev.type !== 'tool') continue;
if (prev.tool.name !== 'SendMessage') continue;
const input = prev.tool.input;
// Only match outbound messages (not shutdown_request, shutdown_response, etc.)
if (input.type !== 'message' && input.type !== 'broadcast') continue;
// Match by recipient (broadcast goes to all, so always matches)
if (input.type === 'message' && input.recipient !== tmMsg.teammateId) continue;
tmMsg.replyToSummary = (input.summary as string) || 'message';
tmMsg.replyToToolId = prev.tool.id;
break;
}
}
}
/**
* Build a flat chronological list of display items for the AI Group.
*
* Strategy:
* 1. Skip the step that represents lastOutput (to avoid duplication)
* 2. For tool_call steps, use the LinkedToolItem (which includes the result)
* 3. Skip standalone tool_result steps (already linked to calls)
* 4. Skip Task tool_call steps that have associated subagents (avoid duplication)
* 5. Include thinking, subagent, and output steps
* 6. Return items in chronological order
*
* @param steps - Semantic steps from the AI Group
* @param lastOutput - The last output to skip
* @param subagents - Subagents associated with this group
* @param responses - Optional raw messages for extracting slash instructions
* @param precedingSlash - Optional slash info from the preceding UserGroup
* @returns Flat array of display items
*/
export function buildDisplayItems(
steps: SemanticStep[],
lastOutput: AIGroupLastOutput | null,
subagents: Process[],
responses?: ParsedMessage[],
precedingSlash?: PrecedingSlashInfo
): AIGroupDisplayItem[] {
const displayItems: AIGroupDisplayItem[] = [];
const linkedTools = linkToolCallsToResults(steps, responses);
// Build set of Task IDs that have associated subagents
// This prevents duplicate display of Task tool calls when subagents are shown
const taskIdsWithSubagents = new Set<string>(
subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id)
);
// Find the exact lastOutput step to skip it without accidentally
// dropping the paired tool_call, which shares the same step id.
let lastOutputStepRef:
| {
id: string;
type: SemanticStep['type'];
}
| undefined;
if (lastOutput) {
for (let i = steps.length - 1; i >= 0; i--) {
const step = steps[i];
if (
lastOutput.type === 'text' &&
step.type === 'output' &&
step.content.outputText === lastOutput.text
) {
lastOutputStepRef = { id: step.id, type: step.type };
break;
}
if (
lastOutput.type === 'tool_result' &&
step.type === 'tool_result' &&
step.content.toolResultContent === lastOutput.toolResult
) {
lastOutputStepRef = { id: step.id, type: step.type };
break;
}
if (
lastOutput.type === 'interruption' &&
step.type === 'interruption' &&
step.content.interruptionText === lastOutput.interruptionMessage
) {
lastOutputStepRef = { id: step.id, type: step.type };
break;
}
}
}
// Build display items
for (const step of steps) {
// Skip the last output step
if (step.id === lastOutputStepRef?.id && step.type === lastOutputStepRef.type) {
continue;
}
switch (step.type) {
case 'thinking':
if (step.content.thinkingText) {
displayItems.push({
type: 'thinking',
content: step.content.thinkingText,
timestamp: step.startTime,
tokenCount: estimateTokens(step.content.thinkingText),
});
}
break;
case 'tool_call': {
const linkedTool = linkedTools.get(step.id);
if (linkedTool) {
// Skip Task tool calls that have associated subagents
// The subagent will be shown separately, so showing the Task call is redundant
const isTaskWithSubagent =
linkedTool.name === 'Task' && taskIdsWithSubagents.has(step.id);
if (!isTaskWithSubagent) {
displayItems.push({
type: 'tool',
tool: linkedTool,
});
}
}
break;
}
case 'tool_result':
// Skip - these are already included in LinkedToolItem
break;
case 'subagent': {
const subagentId = step.content.subagentId;
const subagent = subagents.find((s) => s.id === subagentId);
if (subagent) {
displayItems.push({
type: 'subagent',
subagent: subagent,
});
}
break;
}
case 'output':
if (step.content.outputText) {
displayItems.push({
type: 'output',
content: step.content.outputText,
timestamp: step.startTime,
tokenCount: estimateTokens(step.content.outputText),
});
}
break;
case 'interruption':
if (step.content.interruptionText) {
displayItems.push({
type: 'output',
content: step.content.interruptionText,
timestamp: step.startTime,
tokenCount: estimateTokens(step.content.interruptionText),
});
}
break;
}
}
// Add slashes as display items
if (responses) {
const slashes = extractSlashes(responses, precedingSlash);
for (const slash of slashes) {
displayItems.push({
type: 'slash',
slash,
});
}
}
// Add teammate messages from responses (one user message may contain multiple blocks)
if (responses) {
for (const msg of responses) {
if (msg.type !== 'user' || msg.isMeta) continue;
const rawText =
typeof msg.content === 'string'
? msg.content
: Array.isArray(msg.content)
? msg.content
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('')
: '';
const parsedBlocks = parseAllTeammateMessages(rawText);
for (const parsed of parsedBlocks) {
displayItems.push({
type: 'teammate_message',
teammateMessage: {
id: `${msg.uuid}-${parsed.teammateId}-${displayItems.length}`,
teammateId: parsed.teammateId,
color: parsed.color,
summary: parsed.summary,
content: parsed.content,
timestamp: toDate(msg.timestamp),
tokenCount: estimateTokens(parsed.content),
},
});
}
}
}
// Sort all items chronologically to ensure slashes appear in correct order
sortDisplayItemsChronologically(displayItems);
// Link TeammateMessages to their triggering SendMessage calls
linkTeammateReplies(displayItems);
return displayItems;
}
/**
* Build display items from raw ParsedMessages (used by subagents).
* This mirrors the logic of buildDisplayItems but works with messages instead of SemanticSteps.
*
* Strategy:
* 1. Extract thinking blocks from assistant messages
* 2. Extract tool_use blocks from assistant messages -> collect in a Map by ID
* 3. Extract text output blocks from assistant messages
* 4. Extract tool_result blocks from user messages (isMeta or toolResults exist)
* 5. Link tool calls with their results using LinkedToolItem structure
* 6. Filter Task tool calls that have matching subagents
* 7. Include subagents as separate items
* 8. Sort all items chronologically
*
* @param messages - Raw ParsedMessages to process
* @param subagents - Subagents associated with these messages
* @returns Flat array of display items
*/
export function buildDisplayItemsFromMessages(
messages: ParsedMessage[],
subagents: Process[] = []
): AIGroupDisplayItem[] {
const displayItems: AIGroupDisplayItem[] = [];
// Maps for tool call/result linking
const toolCallsById = new Map<
string,
{
id: string;
name: string;
input: Record<string, unknown>;
timestamp: Date;
sourceMessageId: string;
sourceModel?: string;
}
>();
const toolResultsById = new Map<
string,
{
content: string | unknown[];
isError: boolean;
toolUseResult?: Record<string, unknown>;
timestamp: Date;
}
>();
// Map to collect skill instructions by source tool use ID
// Skill tools have follow-up isMeta:true messages with instructions starting with "Base directory for this skill:"
const skillInstructionsById = new Map<string, string>();
// Build set of Task IDs that have associated subagents
// This prevents duplicate display of Task tool calls when subagents are shown
const taskIdsWithSubagents = new Set<string>(
subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id)
);
// Track compaction events for compact_boundary display items
let compactionCount = 0;
// Helper to get the last assistant's total input tokens before a given index
// Note: don't filter by isSidechain — subagent messages all have isSidechain=true
function getLastAssistantInputTokens(idx: number): number {
for (let i = idx - 1; i >= 0; i--) {
const m = messages[i];
if (m.type === 'assistant' && m.usage && m.model !== '<synthetic>') {
return (
(m.usage.input_tokens ?? 0) +
(m.usage.cache_read_input_tokens ?? 0) +
(m.usage.cache_creation_input_tokens ?? 0)
);
}
}
return 0;
}
// Helper to get the first assistant's total input tokens after a given index
function getFirstAssistantInputTokens(idx: number): number {
for (let i = idx + 1; i < messages.length; i++) {
const m = messages[i];
if (m.type === 'assistant' && m.usage && m.model !== '<synthetic>') {
return (
(m.usage.input_tokens ?? 0) +
(m.usage.cache_read_input_tokens ?? 0) +
(m.usage.cache_creation_input_tokens ?? 0)
);
}
}
return 0;
}
// First pass: collect tool calls and tool results from messages
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
const msg = messages[messageIndex];
const msgTimestamp = toDate(msg.timestamp);
// Detect compact boundary (before regular user message handling)
if (msg.isCompactSummary) {
const preTokens = getLastAssistantInputTokens(messageIndex);
const postTokens = getFirstAssistantInputTokens(messageIndex);
const rawText =
typeof msg.content === 'string'
? msg.content
: Array.isArray(msg.content)
? msg.content
.filter((b: { type: string; text?: string }) => b.type === 'text')
.map((b: { type: string; text?: string }) => b.text ?? '')
.join('\n\n')
: '';
displayItems.push({
type: 'compact_boundary',
content: rawText,
timestamp: msgTimestamp,
tokenDelta:
preTokens > 0
? {
preCompactionTokens: preTokens,
postCompactionTokens: postTokens,
delta: postTokens - preTokens,
}
: undefined,
phaseNumber: compactionCount + 2,
});
compactionCount++;
continue;
}
// Check for teammate messages (non-meta user messages with <teammate-message> content)
// One user message may contain multiple <teammate-message> blocks
if (msg.type === 'user' && !msg.isMeta) {
const rawText =
typeof msg.content === 'string'
? msg.content
: Array.isArray(msg.content)
? msg.content
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('')
: '';
const parsedBlocks = parseAllTeammateMessages(rawText);
if (parsedBlocks.length > 0) {
for (const parsed of parsedBlocks) {
displayItems.push({
type: 'teammate_message',
teammateMessage: {
id: `${msg.uuid}-${parsed.teammateId}-${displayItems.length}`,
teammateId: parsed.teammateId,
color: parsed.color,
summary: parsed.summary,
content: parsed.content,
timestamp: msgTimestamp,
tokenCount: estimateTokens(parsed.content),
},
});
}
continue;
}
// Only treat as subagent input if there are NO tool_result blocks in this message
const hasToolResults =
Array.isArray(msg.content) && msg.content.some((b) => b.type === 'tool_result');
if (rawText.trim() && !hasToolResults) {
displayItems.push({
type: 'subagent_input',
content: rawText.trim(),
timestamp: msgTimestamp,
tokenCount: estimateTokens(rawText),
});
continue;
}
// Fall through to tool result processing below if message has tool_results
}
if (msg.type === 'assistant' && Array.isArray(msg.content)) {
// Process assistant message content blocks
for (const block of msg.content) {
if (block.type === 'thinking' && block.thinking) {
// Add thinking block
displayItems.push({
type: 'thinking',
content: block.thinking,
timestamp: msgTimestamp,
tokenCount: estimateTokens(block.thinking),
});
} else if (block.type === 'tool_use' && block.id && block.name) {
// Collect tool call for later linking
toolCallsById.set(block.id, {
id: block.id,
name: block.name,
input: block.input ?? {},
timestamp: msgTimestamp,
sourceMessageId: msg.uuid,
sourceModel: msg.model,
});
} else if (block.type === 'text' && block.text) {
// Add text output
displayItems.push({
type: 'output',
content: block.text,
timestamp: msgTimestamp,
tokenCount: estimateTokens(block.text),
});
}
}
} else if (msg.type === 'user' && (msg.isMeta || msg.toolResults.length > 0)) {
// Process tool results from internal user messages
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === 'tool_result' && block.tool_use_id) {
// Collect tool result for linking
toolResultsById.set(block.tool_use_id, {
content: block.content ?? '',
isError: block.is_error ?? false,
toolUseResult: msg.toolUseResult,
timestamp: msgTimestamp,
});
}
// Check for skill instructions: isMeta:true messages with sourceToolUseID
// containing text starting with "Base directory for this skill:"
if (block.type === 'text' && block.text && msg.sourceToolUseID) {
const text = block.text;
if (text.startsWith('Base directory for this skill:')) {
skillInstructionsById.set(msg.sourceToolUseID, text);
}
}
}
}
// Also check msg.toolResults array (pre-extracted results)
for (const result of msg.toolResults) {
if (!toolResultsById.has(result.toolUseId)) {
toolResultsById.set(result.toolUseId, {
content: result.content,
isError: result.isError,
toolUseResult: msg.toolUseResult,
timestamp: msgTimestamp,
});
}
}
}
}
// Second pass: Build LinkedToolItems by matching calls with results
for (const [toolId, call] of toolCallsById.entries()) {
const result = toolResultsById.get(toolId);
// Skip Task tool calls that have associated subagents
// The subagent will be shown separately, so showing the Task call is redundant
const isTaskWithSubagent = call.name === 'Task' && taskIdsWithSubagents.has(toolId);
if (isTaskWithSubagent) {
continue;
}
// Get skill instructions for Skill tool calls
const skillInstructions = call.name === 'Skill' ? skillInstructionsById.get(toolId) : undefined;
const linkedItem: LinkedToolItem = {
id: toolId,
name: call.name,
input: call.input,
result: result
? {
content: result.content,
isError: result.isError,
toolUseResult: result.toolUseResult,
}
: undefined,
inputPreview: formatToolInput(call.input),
outputPreview: result ? formatToolResult(result.content) : undefined,
startTime: call.timestamp,
endTime: result?.timestamp,
durationMs: result?.timestamp
? result.timestamp.getTime() - call.timestamp.getTime()
: undefined,
isOrphaned: !result,
sourceModel: call.sourceModel,
skillInstructions,
skillInstructionsTokenCount: skillInstructions
? estimateTokens(skillInstructions)
: undefined,
};
displayItems.push({
type: 'tool',
tool: linkedItem,
});
}
// Add subagents as display items
for (const subagent of subagents) {
displayItems.push({
type: 'subagent',
subagent: subagent,
});
}
// Add slashes as display items
const slashes = extractSlashes(messages);
for (const slash of slashes) {
displayItems.push({
type: 'slash',
slash,
});
}
// Sort all items chronologically
sortDisplayItemsChronologically(displayItems);
// Link TeammateMessages to their triggering SendMessage calls
linkTeammateReplies(displayItems);
return displayItems;
}