587 lines
19 KiB
TypeScript
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;
|
|
}
|