218 lines
7.8 KiB
TypeScript
218 lines
7.8 KiB
TypeScript
/**
|
|
* SemanticStepExtractor - Extracts semantic steps from AI chunks.
|
|
*
|
|
* Semantic steps represent logical units of work within AI responses:
|
|
* - thinking: Claude's reasoning process
|
|
* - tool_call: Tool invocation
|
|
* - tool_result: Tool execution result
|
|
* - output: Text output from Claude
|
|
* - subagent: Nested agent execution
|
|
* - interruption: User interruption
|
|
*/
|
|
|
|
import { countContentTokens } from '@main/utils/tokenizer';
|
|
|
|
import type { AIChunk, ContentBlock, EnhancedAIChunk, SemanticStep } from '@main/types';
|
|
|
|
function normalizeAssistantContent(content: ContentBlock[] | string): ContentBlock[] {
|
|
if (typeof content === 'string') {
|
|
return content ? [{ type: 'text', text: content }] : [];
|
|
}
|
|
|
|
return Array.isArray(content) ? content : [];
|
|
}
|
|
|
|
/**
|
|
* Extract semantic steps from AI chunk responses.
|
|
* Semantic steps represent logical units of work within responses.
|
|
*
|
|
* Note: ALL tool calls are included, including Task tools with subagents.
|
|
* Task tools are filtered in the renderer's buildDisplayItems,
|
|
* but they are kept here for accurate context token tracking in aggregateToolOutputs.
|
|
*/
|
|
export function extractSemanticStepsFromAIChunk(chunk: AIChunk | EnhancedAIChunk): SemanticStep[] {
|
|
const steps: SemanticStep[] = [];
|
|
let stepIdCounter = 0;
|
|
|
|
// Note: Task tool calls are included in semantic steps for context token tracking.
|
|
// The renderer's buildDisplayItems filters Task tools with subagents.
|
|
|
|
// Process only AI responses (no user message in AIChunk)
|
|
for (const msg of chunk.responses) {
|
|
if (msg.type === 'assistant') {
|
|
// Extract from content blocks
|
|
const content = normalizeAssistantContent(msg.content);
|
|
|
|
for (const block of content) {
|
|
if (block.type === 'thinking' && block.thinking) {
|
|
// Calculate tokens for thinking content (output from Claude)
|
|
const thinkingTokens = countContentTokens(block.thinking);
|
|
|
|
steps.push({
|
|
id: `${msg.uuid}-thinking-${stepIdCounter++}`,
|
|
type: 'thinking',
|
|
startTime: new Date(msg.timestamp),
|
|
durationMs: 0, // Estimated from token count
|
|
content: {
|
|
thinkingText: block.thinking,
|
|
tokenCount: thinkingTokens, // Pre-computed token count
|
|
},
|
|
tokens: {
|
|
input: 0,
|
|
output: thinkingTokens, // Thinking is output from Claude
|
|
},
|
|
context: msg.agentId ? 'subagent' : 'main',
|
|
agentId: msg.agentId,
|
|
sourceMessageId: msg.uuid,
|
|
});
|
|
}
|
|
|
|
if (block.type === 'tool_use' && block.id && block.name) {
|
|
// Include ALL tool calls in semantic steps, including Task tools with processes.
|
|
// Task tools with processes are filtered from DISPLAY in the renderer's buildDisplayItems,
|
|
// but they should be included here for accurate context token tracking.
|
|
// The renderer's aggregateToolOutputs will correctly count Task tool tokens
|
|
// as part of the main session's context consumption.
|
|
|
|
// Calculate tool call tokens directly from name + input
|
|
// This reflects what actually enters the context window
|
|
const callTokens = countContentTokens(block.name + JSON.stringify(block.input));
|
|
|
|
steps.push({
|
|
id: block.id,
|
|
type: 'tool_call',
|
|
startTime: new Date(msg.timestamp),
|
|
durationMs: 0,
|
|
content: {
|
|
toolName: block.name,
|
|
toolInput: block.input,
|
|
sourceModel: msg.model,
|
|
},
|
|
tokens: {
|
|
input: callTokens,
|
|
output: 0,
|
|
},
|
|
context: msg.agentId ? 'subagent' : 'main',
|
|
agentId: msg.agentId,
|
|
sourceMessageId: msg.uuid,
|
|
});
|
|
}
|
|
|
|
if (block.type === 'text' && block.text) {
|
|
// Calculate tokens for text output (Claude's generated text)
|
|
const textTokens = countContentTokens(block.text);
|
|
|
|
steps.push({
|
|
id: `${msg.uuid}-output-${stepIdCounter++}`,
|
|
type: 'output',
|
|
startTime: new Date(msg.timestamp),
|
|
durationMs: 0,
|
|
content: {
|
|
outputText: block.text,
|
|
tokenCount: textTokens, // Pre-computed token count for consistency
|
|
},
|
|
tokens: {
|
|
input: 0, // Text output is generated by Claude, not input
|
|
output: textTokens,
|
|
},
|
|
context: msg.agentId ? 'subagent' : 'main',
|
|
agentId: msg.agentId,
|
|
sourceMessageId: msg.uuid,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tool results from internal user messages
|
|
// Note: isMeta can be true or null in JSONL, so check for toolResults presence directly
|
|
if (msg.type === 'user' && msg.toolResults && msg.toolResults.length > 0) {
|
|
for (const result of msg.toolResults) {
|
|
steps.push({
|
|
id: result.toolUseId,
|
|
type: 'tool_result',
|
|
startTime: new Date(msg.timestamp),
|
|
durationMs: 0,
|
|
content: {
|
|
toolResultContent:
|
|
typeof result.content === 'string' ? result.content : JSON.stringify(result.content),
|
|
isError: result.isError,
|
|
toolUseResult: msg.toolUseResult, // Enriched data from message
|
|
tokenCount: countContentTokens(result.content), // Pre-computed token count
|
|
},
|
|
context: msg.agentId ? 'subagent' : 'main',
|
|
agentId: msg.agentId,
|
|
});
|
|
}
|
|
}
|
|
|
|
// User interruption messages
|
|
// These are user messages with array content containing text like "[Request interrupted by user]"
|
|
if (msg.type === 'user' && Array.isArray(msg.content)) {
|
|
let foundInterruption = false;
|
|
for (const block of msg.content) {
|
|
if (block.type === 'text' && block.text) {
|
|
const textContent = block.text;
|
|
// Check for interruption patterns
|
|
if (
|
|
textContent.includes('[Request interrupted by user]') ||
|
|
textContent.includes('[Request interrupted by user for tool use]')
|
|
) {
|
|
steps.push({
|
|
id: `${msg.uuid}-interruption-${stepIdCounter++}`,
|
|
type: 'interruption',
|
|
startTime: new Date(msg.timestamp),
|
|
durationMs: 0,
|
|
content: {
|
|
interruptionText: textContent,
|
|
},
|
|
context: msg.agentId ? 'subagent' : 'main',
|
|
agentId: msg.agentId,
|
|
});
|
|
foundInterruption = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// User-rejected tool use (toolUseResult field is "User rejected tool use")
|
|
if (!foundInterruption && (msg.toolUseResult as unknown) === 'User rejected tool use') {
|
|
steps.push({
|
|
id: `${msg.uuid}-interruption-${stepIdCounter++}`,
|
|
type: 'interruption',
|
|
startTime: new Date(msg.timestamp),
|
|
durationMs: 0,
|
|
content: {
|
|
interruptionText: 'Request interrupted by user',
|
|
},
|
|
context: msg.agentId ? 'subagent' : 'main',
|
|
agentId: msg.agentId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Link processes as steps
|
|
for (const process of chunk.processes) {
|
|
steps.push({
|
|
id: process.id,
|
|
type: 'subagent',
|
|
startTime: process.startTime,
|
|
endTime: process.endTime,
|
|
durationMs: process.durationMs,
|
|
content: {
|
|
subagentId: process.id,
|
|
subagentDescription: process.description,
|
|
},
|
|
tokens: {
|
|
input: process.metrics.inputTokens,
|
|
output: process.metrics.outputTokens,
|
|
cached: process.metrics.cacheReadTokens,
|
|
},
|
|
isParallel: process.isParallel,
|
|
context: 'subagent',
|
|
agentId: process.id,
|
|
});
|
|
}
|
|
|
|
// Sort by startTime
|
|
return steps.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
|
}
|