- Added support for rendering 'subagent_input' and 'compact_boundary' types in the chat display components. - Introduced a new `MarkdownViewer` for displaying content in both item types. - Enhanced the `MetricsPill` and `SubagentItem` components to include phase breakdowns and isolated usage metrics. - Updated the `AIGroupDisplayItem` type to accommodate new item types and their properties. - Implemented logic to compute and display token consumption across multiple phases for subagents.
208 lines
6.3 KiB
TypeScript
208 lines
6.3 KiB
TypeScript
/**
|
|
* AI Group Helpers - Utility functions for AI Group enhancement
|
|
*
|
|
* Small, focused utility functions used across the AI Group enhancement modules.
|
|
*/
|
|
|
|
import { createLogger } from '@shared/utils/logger';
|
|
import { estimateTokens } from '@shared/utils/tokenFormatting';
|
|
|
|
import type { ParsedMessage, PhaseTokenBreakdown, Process } from '../types/data';
|
|
import type { LinkedToolItem } from '../types/groups';
|
|
|
|
const logger = createLogger('Util:aiGroupHelpers');
|
|
|
|
// Re-export for backwards compatibility
|
|
export { estimateTokens };
|
|
|
|
/**
|
|
* Safely converts a timestamp to a Date object.
|
|
* Handles both Date objects and ISO string timestamps (from IPC serialization).
|
|
*/
|
|
export function toDate(timestamp: Date | string | number): Date {
|
|
if (timestamp instanceof Date) {
|
|
return timestamp;
|
|
}
|
|
return new Date(timestamp);
|
|
}
|
|
|
|
/**
|
|
* Truncates text to a maximum length and adds ellipsis if needed.
|
|
*/
|
|
export function truncateText(text: string, maxLength: number): string {
|
|
if (text.length <= maxLength) {
|
|
return text;
|
|
}
|
|
return text.substring(0, maxLength) + '...';
|
|
}
|
|
|
|
/**
|
|
* Converts tool input object to a preview string.
|
|
*/
|
|
export function formatToolInput(input: Record<string, unknown>): string {
|
|
try {
|
|
const json = JSON.stringify(input, null, 2);
|
|
return truncateText(json, 100);
|
|
} catch (error) {
|
|
logger.debug('formatToolInput failed:', error);
|
|
return '[Invalid JSON]';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts tool result content to a preview string.
|
|
*/
|
|
export function formatToolResult(content: string | unknown[]): string {
|
|
try {
|
|
if (typeof content === 'string') {
|
|
return truncateText(content, 200);
|
|
}
|
|
const json = JSON.stringify(content, null, 2);
|
|
return truncateText(json, 200);
|
|
} catch (error) {
|
|
logger.debug('formatToolResult failed:', error);
|
|
return '[Invalid content]';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attaches main session impact tokens to subagents.
|
|
* For each subagent with a parentTaskId, finds the matching Task tool
|
|
* and extracts the callTokens and resultTokens that affect the main session.
|
|
*
|
|
* This allows SubagentItem to display both:
|
|
* - Main session impact: tokens consumed by the Task tool_call + tool_result in the parent session
|
|
* - Subagent isolated context: the subagent's internal token usage
|
|
*
|
|
* @param subagents - Array of subagents to enhance
|
|
* @param linkedTools - Map of tool IDs to LinkedToolItem (includes Task tools)
|
|
* @returns The same subagents array with mainSessionImpact populated
|
|
*/
|
|
export function attachMainSessionImpact(
|
|
subagents: Process[],
|
|
linkedTools: Map<string, LinkedToolItem>
|
|
): Process[] {
|
|
for (const subagent of subagents) {
|
|
if (subagent.parentTaskId) {
|
|
const taskTool = linkedTools.get(subagent.parentTaskId);
|
|
if (taskTool) {
|
|
const callTokens = taskTool.callTokens ?? 0;
|
|
const resultTokens = taskTool.result?.tokenCount ?? 0;
|
|
subagent.mainSessionImpact = {
|
|
callTokens,
|
|
resultTokens,
|
|
totalTokens: callTokens + resultTokens,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return subagents;
|
|
}
|
|
|
|
/**
|
|
* Computes multi-phase context breakdown for a subagent session.
|
|
* Mirrors the algorithm in src/main/utils/jsonl.ts:500-576.
|
|
*
|
|
* Tracks assistant input tokens across compaction events to compute
|
|
* per-phase contribution and total consumption across all phases.
|
|
*
|
|
* @param messages - Subagent's ParsedMessages
|
|
* @returns Phase breakdown with total consumption, or null if no usage data
|
|
*/
|
|
export function computeSubagentPhaseBreakdown(messages: ParsedMessage[]): {
|
|
phases: PhaseTokenBreakdown[];
|
|
totalConsumption: number;
|
|
compactionCount: number;
|
|
} | null {
|
|
let lastMainAssistantInputTokens = 0;
|
|
let awaitingPostCompaction = false;
|
|
const compactionPhases: { pre: number; post: number }[] = [];
|
|
|
|
for (const msg of messages) {
|
|
// Track assistant input tokens.
|
|
// Unlike jsonl.ts, we don't filter by isSidechain here because subagent messages
|
|
// all have isSidechain=true (from the parent session's perspective).
|
|
if (msg.type === 'assistant' && msg.model !== '<synthetic>') {
|
|
const inputTokens =
|
|
(msg.usage?.input_tokens ?? 0) +
|
|
(msg.usage?.cache_read_input_tokens ?? 0) +
|
|
(msg.usage?.cache_creation_input_tokens ?? 0);
|
|
if (inputTokens > 0) {
|
|
if (awaitingPostCompaction && compactionPhases.length > 0) {
|
|
compactionPhases[compactionPhases.length - 1].post = inputTokens;
|
|
awaitingPostCompaction = false;
|
|
}
|
|
lastMainAssistantInputTokens = inputTokens;
|
|
}
|
|
}
|
|
|
|
// Detect compaction events
|
|
if (msg.isCompactSummary) {
|
|
compactionPhases.push({ pre: lastMainAssistantInputTokens, post: 0 });
|
|
awaitingPostCompaction = true;
|
|
}
|
|
}
|
|
|
|
if (lastMainAssistantInputTokens <= 0) {
|
|
return null;
|
|
}
|
|
|
|
let phaseBreakdown: PhaseTokenBreakdown[];
|
|
|
|
if (compactionPhases.length === 0) {
|
|
// No compaction: single phase
|
|
phaseBreakdown = [
|
|
{
|
|
phaseNumber: 1,
|
|
contribution: lastMainAssistantInputTokens,
|
|
peakTokens: lastMainAssistantInputTokens,
|
|
},
|
|
];
|
|
return {
|
|
phases: phaseBreakdown,
|
|
totalConsumption: lastMainAssistantInputTokens,
|
|
compactionCount: 0,
|
|
};
|
|
}
|
|
|
|
phaseBreakdown = [];
|
|
let total = 0;
|
|
|
|
// Phase 1: tokens up to first compaction
|
|
const phase1Contribution = compactionPhases[0].pre;
|
|
total += phase1Contribution;
|
|
phaseBreakdown.push({
|
|
phaseNumber: 1,
|
|
contribution: phase1Contribution,
|
|
peakTokens: compactionPhases[0].pre,
|
|
postCompaction: compactionPhases[0].post,
|
|
});
|
|
|
|
// Middle phases: contribution = pre[i] - post[i-1]
|
|
for (let i = 1; i < compactionPhases.length; i++) {
|
|
const contribution = compactionPhases[i].pre - compactionPhases[i - 1].post;
|
|
total += contribution;
|
|
phaseBreakdown.push({
|
|
phaseNumber: i + 1,
|
|
contribution,
|
|
peakTokens: compactionPhases[i].pre,
|
|
postCompaction: compactionPhases[i].post,
|
|
});
|
|
}
|
|
|
|
// Last phase: final tokens - last post-compaction
|
|
const lastPhase = compactionPhases[compactionPhases.length - 1];
|
|
const lastContribution = lastMainAssistantInputTokens - lastPhase.post;
|
|
total += lastContribution;
|
|
phaseBreakdown.push({
|
|
phaseNumber: compactionPhases.length + 1,
|
|
contribution: lastContribution,
|
|
peakTokens: lastMainAssistantInputTokens,
|
|
});
|
|
|
|
return {
|
|
phases: phaseBreakdown,
|
|
totalConsumption: total,
|
|
compactionCount: compactionPhases.length,
|
|
};
|
|
}
|