Files
hapi/web/src/chat/reducer.ts
T

151 lines
5.9 KiB
TypeScript

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<string, TracedMessage[]>()
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<string>()
const emittedTitleChangeToolUseIds = new Set<string>()
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)
}
}