import type { AgentState } from '@/types/api' import type { AgentEvent, ChatBlock, NormalizedMessage, UsageData } from '@/chat/types' import type { ThreadGoal } from '@/types/api' import { traceMessages, type TracedMessage } from '@/chat/tracer' import { dedupeAgentEvents, foldApiErrorEvents } from '@/chat/reducerEvents' import { collectTitleChanges, collectToolIdsFromMessages, ensureToolBlock, getPermissions } from '@/chat/reducerTools' import { reduceTimeline } from '@/chat/reducerTimeline' // Calculate context size from usage data function calculateContextSize(usage: UsageData): number { if (typeof usage.context_tokens === 'number') { return usage.context_tokens } return (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + usage.input_tokens } function isUsageVisibleInParentContext(usage: UsageData): boolean { return usage.scope_role !== 'child' } export type LatestUsage = { inputTokens: number outputTokens: number cacheCreation: number cacheRead: number contextSize: number contextWindow: number | null timestamp: number } function getLatestThreadGoal(normalized: NormalizedMessage[]): ThreadGoal | null { let sawNewerNonGoalUserMessage = false for (let i = normalized.length - 1; i >= 0; i--) { const msg = normalized[i] if (msg.role === 'user') { if (!/^\s*\/goal(?:\s|$)/i.test(msg.content.text)) { sawNewerNonGoalUserMessage = true } continue } if (msg.role !== 'event') continue const event = msg.content as AgentEvent if (event.type === 'thread-goal-cleared') return null if (event.type === 'thread-goal-updated') { const goal = (event as { goal?: ThreadGoal }).goal ?? null if (goal?.status === 'complete' && sawNewerNonGoalUserMessage) { return null } return goal } } return null } export function reduceChatBlocks( normalized: NormalizedMessage[], agentState: AgentState | null | undefined ): { blocks: ChatBlock[]; hasReadyEvent: boolean; latestUsage: LatestUsage | null; latestGoal: ThreadGoal | null } { const permissionsById = getPermissions(agentState) const toolIdsInMessages = collectToolIdsFromMessages(normalized) const titleChangesByToolUseId = collectTitleChanges(normalized) const traced = traceMessages(normalized) const groups = new Map() const root: TracedMessage[] = [] for (const msg of traced) { if (msg.sidechainId) { const existing = groups.get(msg.sidechainId) ?? [] existing.push(msg) groups.set(msg.sidechainId, existing) } else { root.push(msg) } } const consumedGroupIds = new Set() const emittedTitleChangeToolUseIds = new Set() const reducerContext = { permissionsById, groups, consumedGroupIds, titleChangesByToolUseId, emittedTitleChangeToolUseIds } const rootResult = reduceTimeline(root, reducerContext) let hasReadyEvent = rootResult.hasReadyEvent // Only create permission-only tool cards when there is no tool call/result in the transcript. // Also skip if the permission is older than the oldest message in the current view, // to avoid mixing old tool cards with newer messages when paginating. const oldestMessageTime = normalized.length > 0 ? Math.min(...normalized.map(m => m.createdAt)) : null for (const [id, entry] of permissionsById) { if (toolIdsInMessages.has(id)) continue if (rootResult.toolBlocksById.has(id)) continue const createdAt = entry.permission.createdAt ?? Date.now() // Skip permissions that are older than the oldest message in the current view. // These will be shown when the user loads older messages. if (oldestMessageTime !== null && createdAt < oldestMessageTime) { continue } const block = ensureToolBlock(rootResult.blocks, rootResult.toolBlocksById, id, { createdAt, localId: null, name: entry.toolName, input: entry.input, description: null, permission: entry.permission }) if (entry.permission.status === 'approved') { block.tool.state = 'completed' block.tool.completedAt = entry.permission.completedAt ?? createdAt if (block.tool.result === undefined) { block.tool.result = 'Approved' } } else if (entry.permission.status === 'denied' || entry.permission.status === 'canceled') { block.tool.state = 'error' block.tool.completedAt = entry.permission.completedAt ?? createdAt if (block.tool.result === undefined && entry.permission.reason) { block.tool.result = { error: entry.permission.reason } } } } // Calculate latest usage from messages (find the most recent message with usage data) let latestUsage: LatestUsage | null = null for (let i = normalized.length - 1; i >= 0; i--) { const msg = normalized[i] if (msg.usage && isUsageVisibleInParentContext(msg.usage)) { latestUsage = { inputTokens: msg.usage.input_tokens, outputTokens: msg.usage.output_tokens, cacheCreation: msg.usage.cache_creation_input_tokens ?? 0, cacheRead: msg.usage.cache_read_input_tokens ?? 0, contextSize: calculateContextSize(msg.usage), contextWindow: msg.usage.context_window ?? null, timestamp: msg.createdAt } break } } return { blocks: dedupeAgentEvents(foldApiErrorEvents(rootResult.blocks)), hasReadyEvent, latestUsage, latestGoal: getLatestThreadGoal(normalized) } }